Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ jobs:
with:
go-version-file: 'go.mod' # 💡 修复点 1:让 Action 自动跟随项目真实的 Go 版本

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Build web dist
working-directory: web
run: |
npm ci
npm run build

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
Expand All @@ -35,4 +46,4 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 💡 修复点 2:补回本仓库的内置鉴权令牌
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
14 changes: 2 additions & 12 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ builds:
- id: neocode
env:
- CGO_ENABLED=0
tags:
- webembed
ldflags:
- -s -w -X 'neo-code/internal/version.Version={{.Version}}'
goos:
Expand Down Expand Up @@ -52,18 +54,6 @@ archives:
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
files:
- web/package.json
- web/package-lock.json
- web/index.html
- web/components.json
- web/tsconfig.json
- web/tsconfig.app.json
- web/tsconfig.node.json
- web/vite.config.ts
- web/src/**/*
- web/scripts/**/*
- web/vite-plugins/**/*

- id: neocode-gateway
ids:
Expand Down
8 changes: 8 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ Then start with your workspace:
neocode --workdir /path/to/your/project
```

To launch the browser-based Web UI:

```bash
neocode web
```

Tagged release builds already embed `web/dist` into the `neocode` binary, so the target machine does not need Node.js or npm. When running from source, missing `web/dist` still triggers the local frontend build path.

### 4. Common commands

```text
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ neocode --workdir /path/to/your/project
neocode web
```

标签发布版会在缺少 `web/dist` 时自动使用发布包内的 `web/` 源码执行 `npm install` 和 `npm run build`。这要求用户机器已安装 Node.js npm;如果你使用源码仓库运行,也保留相同的自动构建行为
标签发布版已经将 Web UI 的 `web/dist` 内嵌进 `neocode` 二进制,执行 `neocode web` 时不再要求用户机器安装 Node.js npm。如果你在源码仓库里运行 `go run ./cmd/neocode web`,当本地缺少 `web/dist` 时仍会自动尝试构建前端

### 4. 常用命令

Expand Down
9 changes: 6 additions & 3 deletions internal/cli/gateway_runtime_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -2109,10 +2109,13 @@ func (b *gatewayRuntimePortBridge) resolveEffectiveProviderModel(
}
session, err := b.loadStoredSession(ctx, sessionID)
if err != nil {
return "", "", err
if !isRuntimeNotFoundError(err) {
return "", "", err
}
} else {
sessionProviderID = strings.TrimSpace(session.Provider)
sessionModelID = strings.TrimSpace(session.Model)
}
sessionProviderID = strings.TrimSpace(session.Provider)
sessionModelID = strings.TrimSpace(session.Model)
}

cfg := b.currentConfig()
Expand Down
82 changes: 82 additions & 0 deletions internal/cli/gateway_runtime_bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -1782,6 +1783,52 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) {
}
}

func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *testing.T) {
tmpDir := t.TempDir()
runGitTestCommand(t, tmpDir, "init")
runGitTestCommand(t, tmpDir, "config", "user.name", "NeoCode Test")
runGitTestCommand(t, tmpDir, "config", "user.email", "test@example.com")
runGitTestCommand(t, tmpDir, "commit", "--allow-empty", "-m", "init")

if err := os.MkdirAll(filepath.Join(tmpDir, "handwrite_res", "nested"), 0o755); err != nil {
t.Fatalf("mkdir handwrite_res: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "handwrite_res", "a.txt"), []byte("a\n"), 0o644); err != nil {
t.Fatalf("write a.txt: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "handwrite_res", "nested", "b.txt"), []byte("b\n"), 0o644); err != nil {
t.Fatalf("write b.txt: %v", err)
}

bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore)
defer bridge.Close()

result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{
SubjectID: testBridgeSubjectID,
Workdir: tmpDir,
})
if err != nil {
t.Fatalf("ListGitDiffFiles() error = %v", err)
}
if !result.InGitRepo || result.TotalCount != 2 || len(result.Files) != 2 {
t.Fatalf("unexpected git diff summary: %+v", result)
}
gotPaths := []string{result.Files[0].Path, result.Files[1].Path}
wantPaths := []string{"handwrite_res/a.txt", "handwrite_res/nested/b.txt"}
if strings.Join(gotPaths, ",") != strings.Join(wantPaths, ",") {
t.Fatalf("paths = %#v, want %#v", gotPaths, wantPaths)
}
}

func runGitTestCommand(t *testing.T, workdir string, args ...string) {
t.Helper()
command := exec.Command("git", append([]string{"-C", workdir}, args...)...)
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(output))
}
}

func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) {
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "main.go")
Expand Down Expand Up @@ -2583,6 +2630,41 @@ func TestGatewayRuntimePortBridgeListModelsUsesSessionProvider(t *testing.T) {
}
}

func TestGatewayRuntimePortBridgeListModelsSessionNotFoundFallsBackToGlobal(t *testing.T) {
store := &bridgeSessionStoreWithLoader{
bridgeSessionStoreStub: bridgeSessionStoreStub{},
loadErr: agentsession.ErrSessionNotFound,
}
cfgMgr := &configManagerStub{
cfg: config.Config{
SelectedProvider: "gemini",
CurrentModel: "gemini-2.5-pro",
},
}
ps := &providerSelectionStub{
listOptions: []configstate.ProviderOption{
{ID: "openai", Models: []providertypes.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}},
{ID: "gemini", Models: []providertypes.ModelDescriptor{{ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"}}},
},
}
bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, store, cfgMgr, ps)
defer bridge.Close()

models, err := bridge.ListModels(context.Background(), gateway.ListModelsInput{
SubjectID: testBridgeSubjectID,
SessionID: "session-startup-probe-1",
})
if err != nil {
t.Fatalf("ListModels() error = %v", err)
}
if len(models) != 1 {
t.Fatalf("models len = %d, want 1", len(models))
}
if models[0].Provider != "gemini" || models[0].ID != "gemini-2.5-pro" {
t.Fatalf("models = %+v, want gemini/gemini-2.5-pro only", models)
}
}

func TestGatewayRuntimePortBridgeGetSessionModelFallsBackToEffectiveSelection(t *testing.T) {
store := &bridgeSessionStoreWithLoader{
bridgeSessionStoreStub: bridgeSessionStoreStub{},
Expand Down
92 changes: 92 additions & 0 deletions internal/cli/release_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package cli

import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

type goreleaserBuild struct {
ID string `yaml:"id"`
Tags []string `yaml:"tags"`
}

type goreleaserConfig struct {
Builds []goreleaserBuild `yaml:"builds"`
}

// repoRootForReleaseConfigTest 解析仓库根目录,供发布配置回归测试读取工作流与 GoReleaser 文件。
func repoRootForReleaseConfigTest(t *testing.T) string {
t.Helper()
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", ".."))
}

func TestGoReleaserEmbedsWebAssetsOnlyForNeoCode(t *testing.T) {
repoRoot := repoRootForReleaseConfigTest(t)
raw, err := os.ReadFile(filepath.Join(repoRoot, ".goreleaser.yaml"))
if err != nil {
t.Fatalf("read .goreleaser.yaml: %v", err)
}

var cfg goreleaserConfig
if err := yaml.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("unmarshal .goreleaser.yaml: %v", err)
}

builds := make(map[string]goreleaserBuild, len(cfg.Builds))
for _, build := range cfg.Builds {
builds[build.ID] = build
}

neocodeBuild, ok := builds["neocode"]
if !ok {
t.Fatal("missing neocode build in .goreleaser.yaml")
}
if !slicesContains(neocodeBuild.Tags, "webembed") {
t.Fatalf("neocode build tags = %v, want webembed", neocodeBuild.Tags)
}

gatewayBuild, ok := builds["neocode-gateway"]
if !ok {
t.Fatal("missing neocode-gateway build in .goreleaser.yaml")
}
if slicesContains(gatewayBuild.Tags, "webembed") {
t.Fatalf("neocode-gateway build tags = %v, want no webembed", gatewayBuild.Tags)
}
}

func TestReleaseWorkflowBuildsWebDistBeforeGoReleaser(t *testing.T) {
repoRoot := repoRootForReleaseConfigTest(t)
raw, err := os.ReadFile(filepath.Join(repoRoot, ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("read release workflow: %v", err)
}
content := string(raw)

for _, expected := range []string{
"actions/setup-node@v4",
"npm ci",
"npm run build",
} {
if !strings.Contains(content, expected) {
t.Fatalf("release workflow missing %q", expected)
}
}
}

func slicesContains(values []string, target string) bool {
for _, value := range values {
if strings.TrimSpace(value) == target {
return true
}
}
return false
}
63 changes: 39 additions & 24 deletions internal/cli/web_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ var (
webCommandStartGatewayServer = startGatewayServer
webCommandBuildFrontend = buildFrontend
webCommandLookPath = exec.LookPath
webCommandEmbeddedAssets = func() (fs.FS, bool) {
if !webassets.IsAvailable() {
return nil, false
}
return webassets.FS, true
}
)

type webCommandOptions struct {
Expand Down Expand Up @@ -57,7 +63,7 @@ func newWebCommand() *cobra.Command {
cmd.Flags().StringVar(&options.LogLevel, "log-level", "info", "gateway log level: debug|info|warn|error")
cmd.Flags().StringVar(&options.StaticDir, "static-dir", "", "frontend static files directory override")
cmd.Flags().BoolVar(&options.OpenBrowser, "open-browser", true, "open browser automatically")
cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip frontend build (error if dist/ missing)")
cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip local frontend build (still works with embedded assets)")
cmd.Flags().StringVar(&options.TokenFile, "token-file", "", "gateway auth token file path")

return cmd
Expand All @@ -66,6 +72,7 @@ func newWebCommand() *cobra.Command {
// runWebCommand 执行 web 子命令:解析前端目录 → 构建前端(可选) → 启动 Gateway → 打开浏览器。
func runWebCommand(ctx context.Context, options webCommandOptions) error {
logger := log.New(os.Stderr, "neocode-web: ", log.LstdFlags)
embeddedAssets, embeddedAssetsAvailable := webCommandEmbeddedAssets()

// 如果未指定 workdir,默认使用当前工作目录
if strings.TrimSpace(options.Workdir) == "" {
Expand All @@ -76,47 +83,55 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error {

// 1. 解析前端静态文件目录
staticDir, err := resolveWebStaticDir(options.StaticDir)
needBuild := err != nil

// 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时)
if err == nil && options.StaticDir == "" {
webDir := findWebSourceDir()
if webDir != "" && isStaleFrontendBuild(webDir) {
logger.Println("frontend source is newer than build output, rebuilding...")
needBuild = true
switch {
case options.StaticDir != "":
if err != nil {
return fmt.Errorf("invalid --static-dir: %w", err)
}
}

if needBuild {
case err != nil && embeddedAssetsAvailable:
logger.Println("frontend dist not found, falling back to embedded assets")
staticDir = ""
case err != nil:
if options.SkipBuild {
return fmt.Errorf("frontend needs rebuild and --skip-build is set")
return fmt.Errorf("frontend assets missing and --skip-build is set")
}
webDir := findWebSourceDir()
if webDir == "" {
if err != nil {
return fmt.Errorf(
"frontend assets unavailable: %w; release packages must include the web/ source directory, or source builds must run from the project root or use --static-dir",
err,
)
}
return fmt.Errorf(
"web source directory not found; release packages must include web/, or source builds must run from project root or set --static-dir",
"frontend assets unavailable: %w; source builds must run from the project root, provide --static-dir, or use a release binary with embedded web assets",
err,
)
}
if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil {
return fmt.Errorf("frontend build failed on this machine after detecting bundled web source: %w", buildErr)
return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr)
}
// 构建后重新解析
staticDir, err = resolveWebStaticDir(options.StaticDir)
if err != nil {
return fmt.Errorf("frontend dist not found after build: %w", err)
}
default:
// 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时)
webDir := findWebSourceDir()
if webDir != "" && isStaleFrontendBuild(webDir) {
logger.Println("frontend source is newer than build output, rebuilding...")
if options.SkipBuild {
return fmt.Errorf("frontend needs rebuild and --skip-build is set")
}
if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil {
return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr)
}
staticDir, err = resolveWebStaticDir(options.StaticDir)
if err != nil {
return fmt.Errorf("frontend dist not found after build: %w", err)
}
}
}

// 2. 确定静态文件来源:外部目录优先,找不到时回退到嵌入资源
var staticFileFS fs.FS
if staticDir == "" {
if webassets.IsAvailable() {
staticFileFS = webassets.FS
if embeddedAssetsAvailable {
staticFileFS = embeddedAssets
logger.Println("serving web UI from embedded assets")
} else {
logger.Println("warning: no web UI assets found (external dist missing and embedded assets not compiled)")
Expand Down
Loading
Loading