diff --git a/AGENTS.md b/AGENTS.md index 4666bd6d..79843c05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,9 +54,9 @@ internal/ tui/ # BubbleTea v2 TUI components web/ # HTTP server (REST + WS + PTY) + embedded React dist web/ # React 18 + Vite + RTK product UI (embedded in the binary / Tauri) -packages/ # pnpm workspace: the reusable jcode-ui component library - jcode-ui/ # published styled React chat components (→ npm: jcode-ui) - jcode-ui-core/ # framework-agnostic core: types, ChatRuntime, headless primitives +packages/ # publish source for the jcode-ui npm packages (still in root pnpm workspace) + jcode-ui/ # styled React chat components → npm: jcode-ui + jcode-ui-core/ # types, ChatRuntime, headless primitives → npm: jcode-ui-core site/ # React + Vite marketing/docs site → www.j-code.net (docs markdown in site/docs/) desktop/ # Tauri 2 desktop shell; the Go binary runs as a sidecar extension/ # jcode Browser Bridge Chrome extension (MV3) for the browser-use extension backend @@ -67,13 +67,39 @@ agent-eval/ # Agent evaluation harness + showcase generation ### Frontend (React) -The product UI is React 18 (`web/`) built on `packages/jcode-ui` + `jcode-ui-core`. +The product UI is React 18 (`web/`) and **consumes `jcode-ui` / `jcode-ui-core` from the npm registry** (not `file:` / `workspace:*` links). `packages/` is the source tree used to publish those packages. -- `make build-web` builds packages + the React app → `internal/web/dist/` (production embed). +- `make build-web` typechecks/builds package `dist/` then builds the React app → `internal/web/dist/` (production embed). - `make lint-web` typechecks the React app + both packages. - Go `//go:embed dist/*` and Tauri `frontendDist` both point at `internal/web/dist/`. +- Consumers (`web/`, `site/`, `examples/*`) declare registry versions, e.g. `"jcode-ui": "^0.1.1"`, `"jcode-ui-core": "^0.1.0"`. +- Inside `packages/jcode-ui`, depend on core via a version range (`"jcode-ui-core": "^0.1.0"`) — **never** `file:../jcode-ui-core` (that broke `jcode-ui@0.1.0` on npm; use `0.1.1+`). -See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as `jcode-ui` (styled) + `jcode-ui-core` (headless). The runtime abstraction (`ChatRuntime` + `createExternalStoreRuntime`) is the seam that lets the components render from any Redux-shaped store. +See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published: [jcode-ui](https://www.npmjs.com/package/jcode-ui) (styled) + [jcode-ui-core](https://www.npmjs.com/package/jcode-ui-core) (headless). The runtime abstraction (`ChatRuntime` + `createExternalStoreRuntime`) is the seam that lets the components render from any Redux-shaped store. + +#### Publishing jcode-ui to npm + +Order matters: **publish `jcode-ui-core` first, then `jcode-ui`**. + +```bash +# 1) bump versions in packages/*/package.json as needed +# 2) build +cd packages/jcode-ui-core && pnpm build +cd ../jcode-ui && pnpm build + +# 3) publish (2FA/OTP may be required) +cd packages/jcode-ui-core && npm publish --access public --otp=XXXXXX +cd ../jcode-ui && npm publish --access public --otp=XXXXXX +``` + +After a successful publish, bump consumer deps (`web/`, `site/`, `examples/*`) to the new range and run `pnpm install` (and `cd site && pnpm install` for the site workspace). Also refresh `minimumReleaseAgeExclude` entries in every `pnpm-workspace.yaml` (root, `site/`, `examples/*`) so the newly published versions are not blocked by release-age checks. Local edits under `packages/` do **not** reach `web`/`site` until a new version is published and the dependency range is updated. + +Checklist before publish: + +1. `jcode-ui` → `jcode-ui-core` is a registry range (`^x.y.z`), not `file:` +2. Both packages have fresh `dist/` (`pnpm build`) +3. Smoke: `npm install jcode-ui@` in a temp dir imports both packages and pulls core transitively +4. After publish: update `minimumReleaseAgeExclude` in all `pnpm-workspace.yaml` files to the new `jcode-ui` / `jcode-ui-core` versions (drop stale entries) ### Key Design Decisions @@ -176,6 +202,7 @@ See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as - **Don't store mutable state in tool closures.** Use `*Env` or pass state explicitly. Tools may be re-created across mode transitions (normal ↔ plan). - **Don't skip `env.ResolvePath()`.** Raw path concatenation can escape the working directory without warning. - **Don't import `internal/tui` from non-TUI packages.** The handler interface is the decoupling boundary. +- **Don't depend on `jcode-ui` / `jcode-ui-core` via `file:` or `workspace:*`.** Consumers and `packages/jcode-ui`→core must use registry version ranges so publish and local installs match. --- @@ -190,11 +217,12 @@ See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as ## Frontend (web/) — React (production) -- **Stack:** React 18 + TypeScript + Vite + Redux Toolkit + `jcode-ui` / `jcode-ui-core` -- **Build:** `make build-web` (packages + `cd web && npx vite build`) +- **Stack:** React 18 + TypeScript + Vite + Redux Toolkit + npm `jcode-ui` / `jcode-ui-core` +- **Build:** `make build-web` (package typecheck/build + `cd web && npx vite build`) - **Output:** builds to `internal/web/dist/`, embedded in the Go binary via `//go:embed` - **Lint:** `make lint-web` (tsc for web + packages) -- Changes to the frontend require rebuilding via `make build-web` for the Go binary to pick them up +- Changes to the product UI (`web/`) require rebuilding via `make build-web` for the Go binary to pick them up +- Changes to the chat library (`packages/jcode-ui*`) require **npm publish + consumer version bump + `pnpm install`** before `web`/`site` see them (registry deps, not workspace links) - **Don't confuse `web/` with `site/`:** `web/` is the product UI embedded in the binary and reused by the desktop app; `site/` is the public website + docs at www.j-code.net and is deployed separately (`cd site && pnpm build`). ### Icons & Styling @@ -203,4 +231,4 @@ See `packages/jcode-ui/README.md` and `site/docs/chat-ui/`. Published to npm as - **Icon sizing:** use Tailwind `h-N w-N` classes (e.g. `className="h-3.5 w-3.5"`). - **Colors:** every color must come from a CSS custom property (jcode-ui tokens / `tokens.generated.css`). Never hardcode hex/rgb/`#fff`/`white` in components. - **Themes:** edit `internal/theme/palette.go` and run `make generate` — never edit `tokens.generated.css` or `themes.generated.ts` by hand. -- **Reusable chat UI:** prefer components from `packages/jcode-ui` over one-off markup in `web/`. +- **Reusable chat UI:** import from the `jcode-ui` package (registry); implement/fix library code under `packages/jcode-ui` and publish. diff --git a/examples/jcode-ui-minimal/package.json b/examples/jcode-ui-minimal/package.json index aa78ccc0..3099afd4 100644 --- a/examples/jcode-ui-minimal/package.json +++ b/examples/jcode-ui-minimal/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "dependencies": { - "jcode-ui": "file:../../packages/jcode-ui", - "jcode-ui-core": "file:../../packages/jcode-ui-core", + "jcode-ui": "^0.1.1", + "jcode-ui-core": "^0.1.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/examples/jcode-ui-minimal/pnpm-workspace.yaml b/examples/jcode-ui-minimal/pnpm-workspace.yaml index 43dae909..77d9ba5d 100644 --- a/examples/jcode-ui-minimal/pnpm-workspace.yaml +++ b/examples/jcode-ui-minimal/pnpm-workspace.yaml @@ -3,5 +3,8 @@ packages: - '.' allowBuilds: esbuild: true +minimumReleaseAgeExclude: + - jcode-ui-core@0.1.0 + - jcode-ui@0.1.1 onlyBuiltDependencies: - esbuild diff --git a/examples/jcode-ui-zustand/package.json b/examples/jcode-ui-zustand/package.json index 55e4b199..0e4a7cef 100644 --- a/examples/jcode-ui-zustand/package.json +++ b/examples/jcode-ui-zustand/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "dependencies": { - "jcode-ui": "file:../../packages/jcode-ui", - "jcode-ui-core": "file:../../packages/jcode-ui-core", + "jcode-ui": "^0.1.1", + "jcode-ui-core": "^0.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^5.0.3" diff --git a/examples/jcode-ui-zustand/pnpm-workspace.yaml b/examples/jcode-ui-zustand/pnpm-workspace.yaml index 43dae909..77d9ba5d 100644 --- a/examples/jcode-ui-zustand/pnpm-workspace.yaml +++ b/examples/jcode-ui-zustand/pnpm-workspace.yaml @@ -3,5 +3,8 @@ packages: - '.' allowBuilds: esbuild: true +minimumReleaseAgeExclude: + - jcode-ui-core@0.1.0 + - jcode-ui@0.1.1 onlyBuiltDependencies: - esbuild diff --git a/internal-doc/dynamic-workflow-design.md b/internal-doc/dynamic-workflow-design.md index 57224b5c..b698ac60 100644 --- a/internal-doc/dynamic-workflow-design.md +++ b/internal-doc/dynamic-workflow-design.md @@ -51,7 +51,7 @@ | **进度** | stdout 行 | `/workflows` 面板(仿 team panel) | WS `flow_progress` → WorkflowsView 树 | ACP session/update 文本 | **接线点(verbatim 已勘):** -- Web 事件:`internal/handler/web.go` 加 `OnFlowProgress(data)` → `h.emit("flow_progress", WebFlowProgressData{...})`;`internal/web/engine.go:startPump` 自动 `WSBroker.Broadcast(WSEvent{Type,TaskID,Data})`;前端 `web/src/composables/ws.ts` handlerMap 加 `flow_progress`。 +- Web 事件:`internal/handler/web.go` 加 `OnFlowProgress(data)` → `h.emit("flow_progress", WebFlowProgressData{...})`;`internal/web/engine.go:startPump` 自动 `WSBroker.Broadcast(WSEvent{Type,TaskID,Data})`;前端 `web/src/lib/ws.ts` handlerMap 加 `flow_progress`。 - Web API:`internal/web/server.go` 加 `GET/POST /api/workflows`、`GET /api/workflows/runs`、`POST /api/workflows/{name}/run`(仿 automations)。Vue:`stores/workflow.ts` + `components/WorkflowsView.vue` + App.vue `activeView` 加 `'workflows'` + Sidebar 入口。 - TUI:`internal/tui/messages.go` 加 `FlowProgressMsg`;`internal/tui/update.go` 加 `case FlowProgressMsg`(仿 `SubagentProgressMsg`);`internal/command/interactive.go` 加 `s.flowProgress(...)` → `s.p.Send(...)`;斜杠复用 `skillSlashCommands` 模式,新增 flow slash 源。 - 命令:`internal/command/workflow.go` 新建 `NewWorkflowCmd()`(`Use:"workflow" Aliases:["workflows","flow"]`),`cmd/jcode/main.go:47` 注册。 diff --git a/internal/web/approval.go b/internal/web/approval.go new file mode 100644 index 00000000..78d995e7 --- /dev/null +++ b/internal/web/approval.go @@ -0,0 +1,189 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/mode" + "github.com/cnjack/jcode/internal/tools" +) + +func (s *Server) handleGetTodos(w http.ResponseWriter, r *http.Request) { + eng := s.activeEngine() + if eng == nil || eng.todoStore == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + writeJSON(w, http.StatusOK, eng.todoStore.Items()) +} + +// handleGetGoal returns the current session goal (or null when none is set). +func (s *Server) handleGetGoal(w http.ResponseWriter, _ *http.Request) { + eng := s.activeEngine() + if eng == nil || eng.env == nil || eng.env.GoalStore == nil { + writeJSON(w, http.StatusOK, nil) + return + } + writeJSON(w, http.StatusOK, eng.env.GoalStore.Get()) +} + +// handleSetGoal sets (or replaces) the session goal. Unless start=false, it also +// kicks off an agent run so work begins immediately. +func (s *Server) handleSetGoal(w http.ResponseWriter, r *http.Request) { + eng := s.activeEngine() + if eng == nil || eng.env == nil || eng.env.GoalStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "goals not available"}) + return + } + var req struct { + Objective string `json:"objective"` + Start *bool `json:"start,omitempty"` // default true + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + objective, err := tools.ValidateGoalObjective(req.Objective) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + g := eng.env.GoalStore.Set(objective) + + if req.Start == nil || *req.Start { + // Start working immediately when idle; if busy, the continuation guard + // will pick the goal up after the current run finishes. Targets the active + // task. + if eng.running.CompareAndSwap(false, true) { + s.submitMessage(eng, tools.GoalKickoffPrompt(objective), eng.curMode(), "", "", nil) + } + } + writeJSON(w, http.StatusOK, g) +} + +// handleClearGoal removes the session goal. +func (s *Server) handleClearGoal(w http.ResponseWriter, _ *http.Request) { + if eng := s.activeEngine(); eng != nil && eng.env != nil && eng.env.GoalStore != nil { + eng.env.GoalStore.Clear() + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) +} + +func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Approved bool `json:"approved"` + ApproveAll bool `json:"approve_all"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + // Route the resolve to the requesting task's handler. resolveEngine maps an + // empty task_id to the active task (legacy clients) but a NON-empty unknown id + // to nil — so a stray id can't resolve against the active task's handler-local + // approval ids. + reng := s.resolveEngine(req.TaskID) + if reng == nil || reng.handler == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"}) + return + } + if err := reng.handler.ResolveApproval(req.ID, req.Approved, req.ApproveAll); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + // "Allow all" promotes that task to auto-approve (the runner flips its + // ApprovalState on resolve). Mirror it onto that task's mode + selector. + s.syncModeAfterApproval(reng, req.Approved, req.ApproveAll) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// syncModeAfterApproval reflects an approve-all promotion onto the server's +// user-facing mode state and notifies connected clients. A plain single approve +// (or a deny) leaves the mode untouched. The runner's ApprovalState is the +// source of truth for the approval axis; this only projects it onto the unified +// selector the frontend renders. +func (s *Server) syncModeAfterApproval(eng *Engine, approved, approveAll bool) { + if !approved || !approveAll || eng == nil { + return + } + sm := mode.FullAccess + eng.applyModeSwitch(sm.String(), nil) + s.wsBroker.Broadcast(WSEvent{Type: "mode_changed", TaskID: eng.taskID, Data: map[string]string{ + "mode": sm.String(), + }}) +} + +// handlePendingApproval returns approval requests still awaiting a decision. +// The frontend pulls this after rebuilding the timeline (page reload / session +// resume / WS reconnect) so an in-flight approval is re-attached as a card +// instead of leaving the agent blocked forever. +func (s *Server) handlePendingApproval(w http.ResponseWriter, r *http.Request) { + // Empty task_id → active task; non-empty unknown → empty (don't leak another + // task's pending requests under a stray id). + eng := s.resolveEngine(r.URL.Query().Get("task_id")) + if eng == nil || eng.handler == nil { + writeJSON(w, http.StatusOK, []handler.WebApprovalRequestData{}) + return + } + writeJSON(w, http.StatusOK, eng.handler.PendingApprovalRequests()) +} + +// handleAskUser resolves a pending ask_user request with the user's answers, +// routed back to the blocked tool via WebHandler.ResolveAskUser. The "answers" +// array is parallel to the questions the frontend received in ask_user_request: +// each carries the question header plus either a free-text answer or selected +// option labels. +func (s *Server) handleAskUser(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Answers []struct { + QuestionHeader string `json:"question_header"` + Answer string `json:"answer"` + Selected []string `json:"selected"` + } `json:"answers"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + resp := tools.AskUserBatchResponse{} + for _, a := range req.Answers { + resp.Answers = append(resp.Answers, tools.AskUserAnswer{ + QuestionHeader: a.QuestionHeader, + Answer: a.Answer, + Selected: a.Selected, + }) + } + + // Route the answer to the requesting task's handler. Empty task_id → active; + // non-empty unknown → reject (ids are handler-local). + eng := s.resolveEngine(req.TaskID) + if eng == nil || eng.handler == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"}) + return + } + if err := eng.handler.ResolveAskUser(req.ID, resp); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handlePendingAskUser returns ask_user questions still awaiting an answer. +// The frontend pulls this after rebuilding the timeline (page reload / session +// resume) so an in-flight question is re-attached to its tool card instead of +// leaving the agent blocked forever. +func (s *Server) handlePendingAskUser(w http.ResponseWriter, r *http.Request) { + eng := s.resolveEngine(r.URL.Query().Get("task_id")) + if eng == nil || eng.handler == nil { + writeJSON(w, http.StatusOK, []handler.WebAskUserRequestData{}) + return + } + writeJSON(w, http.StatusOK, eng.handler.PendingAskUserRequests()) +} diff --git a/internal/web/chat.go b/internal/web/chat.go new file mode 100644 index 00000000..640ce61f --- /dev/null +++ b/internal/web/chat.go @@ -0,0 +1,310 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/flow" + "github.com/cnjack/jcode/internal/hooks" + "github.com/cnjack/jcode/internal/runner" + "github.com/cnjack/jcode/internal/session" +) + +func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + if s.needsSetup { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "setup required: please configure a provider first"}) + return + } + + var req struct { + Message string `json:"message"` + Images []chatImage `json:"images,omitempty"` // optional: base64-encoded images + Mode string `json:"mode,omitempty"` // "build" or "plan" + SessionID string `json:"session_id,omitempty"` // optional: the task (session) to run + } + if err := json.NewDecoder(io.LimitReader(r.Body, 20<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if strings.TrimSpace(req.Message) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"}) + return + } + + modeStr := req.Mode + if modeStr == "" { + modeStr = s.activeMode() + } + + // Resolve (or lazily create) the engine for this task. Different tasks run + // concurrently; the per-task running flag only blocks double-running the SAME + // task. + eng, err := s.engineForChat(req.SessionID, modeStr) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !eng.running.CompareAndSwap(false, true) { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "this task is already processing a request", + }) + return + } + + sessionID := s.submitMessage(eng, req.Message, modeStr, "", req.SessionID, req.Images) + writeJSON(w, http.StatusAccepted, map[string]string{"status": "processing", "session_id": sessionID}) +} + +// engineForChat resolves the engine a chat request targets. An empty task id (or +// one matching the active task) uses the active engine; a known live task uses +// its engine; an unknown id lazily spins up a fresh engine for it (a new task or +// the first message of a not-yet-live task), rooted at the active task's pwd. +func (s *Server) engineForChat(taskID, modeStr string) (*Engine, error) { + if eng := s.resolveEngine(taskID); eng != nil { + return eng, nil + } + pwd := "" + if a := s.activeEngine(); a != nil { + pwd = a.pwd + } + return s.buildLocalEngine(taskID, pwd, modeStr) +} + +// chatImage represents a base64-encoded image in a chat request. +type chatImage struct { + Data string `json:"data"` // base64 data (without data: prefix) + MimeType string `json:"media_type"` // e.g. "image/png", "image/jpeg" +} + +// SubmitMessage submits a message for agent processing from an external source +// (e.g. WeChat inbound message). Returns false if the agent is busy. +func (s *Server) SubmitMessage(message, source string) bool { + eng := s.activeEngine() + if eng == nil { + return false + } + if !eng.running.CompareAndSwap(false, true) { + return false + } + s.submitMessage(eng, message, eng.curMode(), source, "", nil) + return true +} + +// submitMessage is the shared implementation for starting an agent run. +// source is an optional label (e.g. "wechat") for the user_message event. +// sessionID is an optional session identifier from the client to ensure +// continuity — if the current recorder has a different UUID, resume the +// correct session instead of creating a new one. +// images is an optional list of base64-encoded images to include in the message. +// The caller must have already set eng.running to true (via CompareAndSwap). +// Returns the session_id of the recorder used. +func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID string, images []chatImage) string { + // Slash command rewrite: if the original message starts with "/", check for + // skill slash commands and rewrite to load_skill instruction (same pattern as + // ACP/TUI). This must happen BEFORE the plan-mode prefix is applied, otherwise + // HasPrefix("/"…) would fail against the prefixed string. + agentMsg := message + if strings.HasPrefix(message, "/") { + cmd := strings.TrimPrefix(message, "/") + parts := strings.SplitN(cmd, " ", 2) + cmdName := parts[0] + userInput := "" + if len(parts) > 1 { + userInput = parts[1] + } + matchedSkill := false + if s.skillLoader != nil { + if sk := s.skillLoader.GetBySlash("/" + cmdName); sk != nil { + var sb strings.Builder + fmt.Fprintf(&sb, "Use the load_skill tool with name=%q and follow its instructions.", sk.Name) + if userInput != "" { + sb.WriteString("\n\nAdditional context: ") + sb.WriteString(userInput) + } + agentMsg = sb.String() + matchedSkill = true + } + } + // Otherwise check workflow slash commands (e.g. /repo-audit) against this + // task's project loader so its .jcode/workflows resolve. + if fl := s.flowLoaderFor(eng); !matchedSkill && fl != nil { + if wf, ok := fl.GetBySlash("/" + cmdName); ok { + agentMsg = flow.SlashRunPrompt(wf.Meta.Name, userInput) + } + } + } + + // Plan mode no longer needs an inline prompt prefix: the agent is rebuilt with + // the read-only plan system prompt + tool set on mode switch (handleSwitchMode), + // matching TUI/ACP. The mode arg is retained for the recorder/event context. + _ = mode + + // Emit user_message event for external sources (e.g. WeChat) so web clients see it. + // Web-originated messages are already added by the frontend's sendMessage(). + if source != "" { + eng.handler.Emit("user_message", map[string]string{ + "content": message, + "source": source, + }) + } + + // Ensure a recorder exists (lazy creation on first message). + // If the client provided a session_id and the current recorder differs, + // resume the client's session to prevent creating a duplicate. + eng.emu.Lock() + if eng.recorder == nil { + rec, _ := session.NewRecorder(eng.pwd, eng.providerName, eng.modelName) + if sessionID != "" { + rec.SetUUID(sessionID) + } + eng.recorder = rec + } else if sessionID != "" && eng.recorder.UUID() != sessionID { + // Client is continuing a session that doesn't match the current recorder. + // Resume the client's session to keep all messages together. + eng.recorder.Close() + rec, _ := session.NewRecorder(eng.pwd, eng.providerName, eng.modelName) + rec.SetUUID(sessionID) + eng.recorder = rec + } + recorder := eng.recorder + eng.emu.Unlock() + + // Record user message. + if recorder != nil { + var entryImages []session.EntryImage + for _, img := range images { + entryImages = append(entryImages, session.EntryImage{ + MimeType: img.MimeType, + Data: img.Data, + }) + } + recorder.RecordUser(agentMsg, entryImages...) + } + + // Build the user message — include images as multimodal content if provided. + var userMsg *schema.Message + if len(images) > 0 { + parts := make([]schema.MessageInputPart, 0, len(images)+1) + parts = append(parts, schema.MessageInputPart{ + Type: schema.ChatMessagePartTypeText, + Text: agentMsg, + }) + for _, img := range images { + data := img.Data + parts = append(parts, schema.MessageInputPart{ + Type: schema.ChatMessagePartTypeImageURL, + Image: &schema.MessageInputImage{ + MessagePartCommon: schema.MessagePartCommon{ + MIMEType: img.MimeType, + Base64Data: &data, + }, + }, + }) + } + userMsg = &schema.Message{ + Role: schema.User, + Content: agentMsg, + UserInputMultiContent: parts, + } + } else { + userMsg = schema.UserMessage(agentMsg) + } + + eng.emu.Lock() + eng.history = append(eng.history, userMsg) + history := make([]adk.Message, len(eng.history)) + copy(history, eng.history) + agent := eng.agent + eng.emu.Unlock() + + // Stream response via WebSocket — run agent in background. Each task derives + // its own cancellable context so /stop cancels only that task. Fall back to + // Background if a run is somehow submitted before Start set the root context. + base := s.rootCtx() + if base == nil { + base = context.Background() + } + runCtx, runCancel := context.WithCancel(base) + eng.emu.Lock() + eng.runGen++ + gen := eng.runGen + eng.runCancel = runCancel + eng.emu.Unlock() + + go func() { + s.setTaskStatus(eng, true) + defer func() { + // Tear down only if this run is still the current one. If a newer turn + // on the same engine has already taken over (runGen advanced) it now + // owns running/runCancel — leave them so /stop still reaches the live + // run and we don't broadcast a spurious idle for it. Releasing running + // inside the same emu section that clears runCancel also closes the + // gate↔cancel interleave window the run-start CAS relies on. + eng.emu.Lock() + superseded := eng.runGen != gen + if !superseded { + eng.runCancel = nil + eng.running.Store(false) + } + eng.emu.Unlock() + if !superseded { + s.setTaskStatus(eng, false) + } + }() + + // Take a git snapshot before the agent run for session diff tracking. + s.takeSessionSnapshot(eng) + + // Inject the hook dispatcher so PreToolUse/PostToolUse/Stop hooks run on the + // Web surface too (parity with the TUI); reloaded per turn for hot-apply. + hookCtx := hooks.WithDispatcher(runCtx, hooks.NewSessionDispatcher(config.ConfigDir(), eng.env.Pwd(), recorder.UUID(), config.Logger().Printf)) + resp := runner.Run(hookCtx, agent, history, eng.eventHandler, recorder, eng.todoStore, eng.env.GoalStore, s.tracer, eng.tokenUsage) + if resp != "" { + eng.emu.Lock() + eng.history = append(eng.history, &schema.Message{Role: schema.Assistant, Content: resp}) + eng.emu.Unlock() + } + }() + + return recorder.UUID() +} + +// --- Stop handler --- + +func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { + // Cancel only the targeted task. task_id comes via query or JSON body; absent, + // fall back to the active task (legacy clients). + taskID := r.URL.Query().Get("task_id") + if taskID == "" { + var req struct { + TaskID string `json:"task_id"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req) + taskID = req.TaskID + } + + eng := s.resolveEngine(taskID) + if eng == nil || !eng.running.Load() { + writeJSON(w, http.StatusOK, map[string]string{"status": "not_running"}) + return + } + + eng.emu.Lock() + cancel := eng.runCancel + eng.emu.Unlock() + if cancel != nil { + cancel() + } + + // Notify clients on that task's channel. + eng.handler.OnAgentDone(fmt.Errorf("stopped by user")) + + writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"}) +} diff --git a/internal/web/files.go b/internal/web/files.go new file mode 100644 index 00000000..2d2c691d --- /dev/null +++ b/internal/web/files.go @@ -0,0 +1,361 @@ +package web + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + utils "github.com/cnjack/jcode/internal/util" +) + +// withinWorkspace reports whether abs is the workspace root or strictly inside +// it. Uses filepath.Rel rather than strings.HasPrefix so a sibling like /repo2 +// can't escape /repo, and an empty root rejects everything. +func withinWorkspace(root, abs string) bool { + if root == "" { + return false + } + rel, err := filepath.Rel(root, abs) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) { + pwd := s.activePwd() + dir := r.URL.Query().Get("path") + if dir == "" { + dir = pwd + } else if !filepath.IsAbs(dir) { + dir = filepath.Join(pwd, dir) + } + + // Prevent path traversal / sibling escape. + abs := filepath.Clean(dir) + if !withinWorkspace(pwd, abs) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + entries, err := os.ReadDir(abs) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + type fileItem struct { + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + } + + items := make([]fileItem, 0, len(entries)) + for _, e := range entries { + info, _ := e.Info() + size := int64(0) + if info != nil { + size = info.Size() + } + items = append(items, fileItem{ + Name: e.Name(), + IsDir: e.IsDir(), + Size: size, + }) + } + writeJSON(w, http.StatusOK, items) +} + +func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"}) + return + } + + pwd := s.activePwd() + abs := path + if !filepath.IsAbs(abs) { + abs = filepath.Join(pwd, abs) + } + + // Prevent path traversal / sibling escape. + abs = filepath.Clean(abs) + if !withinWorkspace(pwd, abs) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path outside workspace"}) + return + } + + // Reject oversized files before loading them into memory. + if info, err := os.Stat(abs); err == nil && info.Size() > 1<<20 { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{ + "error": "file too large (>1MB)", + }) + return + } + + content, err := os.ReadFile(abs) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "path": abs, + "content": string(content), + }) +} + +func (s *Server) handleExec(w http.ResponseWriter, r *http.Request) { + var req struct { + Command string `json:"command"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if strings.TrimSpace(req.Command) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "command is required"}) + return + } + + ctx, cancel := context.WithTimeout(s.rootCtx(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-c", req.Command) + cmd.Dir = s.activePwd() + + output, err := cmd.CombinedOutput() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + } + + // Truncate output to 256KB + out := string(output) + if len(out) > 256*1024 { + out = out[:256*1024] + "\n... (truncated)" + } + + writeJSON(w, http.StatusOK, map[string]any{ + "output": out, + "exit_code": exitCode, + }) +} + +func (s *Server) handleDiff(w http.ResponseWriter, r *http.Request) { + mode := r.URL.Query().Get("mode") + if mode == "" { + mode = "working" + } + + // "session" mode: diff between snapshot taken at agent run start and current state. + if mode == "session" { + s.handleSessionDiff(w, r) + return + } + + var args []string + switch mode { + case "staged": + args = []string{"diff", "--cached", "--no-color"} + case "branch": + args = []string{"diff", "HEAD~1", "--no-color"} + default: // "working" + args = []string{"diff", "--no-color"} + } + + cmd := exec.CommandContext(s.rootCtx(), "git", args...) + cmd.Dir = s.activePwd() + cmd.Env = utils.ScrubbedGitEnv() + output, _ := cmd.CombinedOutput() + + // Parse diff into structured entries + type diffEntry struct { + File string `json:"file"` + Patch string `json:"patch"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Status string `json:"status"` // "M", "A", "D" + } + + var entries []diffEntry + rawDiff := string(output) + + // Parse unified diff into per-file entries + sections := splitDiffByFile(rawDiff) + for _, sec := range sections { + adds, dels := countDiffLines(sec.patch) + entries = append(entries, diffEntry{ + File: sec.file, + Patch: sec.patch, + Additions: adds, + Deletions: dels, + Status: sec.status, + }) + } + + if entries == nil { + entries = []diffEntry{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "mode": mode, + "entries": entries, + }) +} + +type diffSection struct { + file string + patch string + status string +} + +func splitDiffByFile(raw string) []diffSection { + var sections []diffSection + lines := strings.Split(raw, "\n") + var current *diffSection + var patchLines []string + + for _, line := range lines { + if strings.HasPrefix(line, "diff --git ") { + // Flush previous + if current != nil { + current.patch = strings.Join(patchLines, "\n") + sections = append(sections, *current) + } + // Parse file name from "diff --git a/foo b/foo" + parts := strings.SplitN(line, " b/", 2) + file := "" + if len(parts) == 2 { + file = parts[1] + } + current = &diffSection{file: file, status: "M"} + patchLines = []string{line} + } else if current != nil { + patchLines = append(patchLines, line) + if strings.HasPrefix(line, "new file") { + current.status = "A" + } else if strings.HasPrefix(line, "deleted file") { + current.status = "D" + } + } + } + if current != nil { + current.patch = strings.Join(patchLines, "\n") + sections = append(sections, *current) + } + return sections +} + +func countDiffLines(patch string) (adds, dels int) { + scanner := bufio.NewScanner(strings.NewReader(patch)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + adds++ + } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + dels++ + } + } + return +} + +// takeSessionSnapshot records the current git working tree state +// so that session-scoped diffs can be computed later. +func (s *Server) takeSessionSnapshot(eng *Engine) { + if eng == nil { + return + } + // Use "git stash create" to get a tree-ish of the current state without + // actually stashing. If there are no changes, use HEAD. + cmd := exec.CommandContext(s.rootCtx(), "git", "stash", "create") + cmd.Dir = eng.pwd + cmd.Env = utils.ScrubbedGitEnv() + out, err := cmd.Output() + snapshot := strings.TrimSpace(string(out)) + if err != nil || snapshot == "" { + // No local changes — use HEAD as baseline + cmd2 := exec.CommandContext(s.rootCtx(), "git", "rev-parse", "HEAD") + cmd2.Dir = eng.pwd + cmd2.Env = utils.ScrubbedGitEnv() + out2, _ := cmd2.Output() + snapshot = strings.TrimSpace(string(out2)) + } + eng.emu.Lock() + eng.sessionSnapshot = snapshot + eng.emu.Unlock() +} + +// handleSessionDiff computes the diff between the session start snapshot and current state. +func (s *Server) handleSessionDiff(w http.ResponseWriter, _ *http.Request) { + // Capture the active engine ONCE so the snapshot and the working dir come + // from the same task's repo even if the active engine is swapped between the + // two reads (otherwise we could diff engine A's snapshot against engine B's + // tree). eng.pwd is immutable after creation, so reading it bare is safe. + eng := s.activeEngine() + snapshot := "" + pwd := "" + if eng != nil { + eng.emu.Lock() + snapshot = eng.sessionSnapshot + eng.emu.Unlock() + pwd = eng.pwd + } + + type diffEntry struct { + File string `json:"file"` + Patch string `json:"patch"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Status string `json:"status"` + } + + if snapshot == "" { + writeJSON(w, http.StatusOK, map[string]any{ + "mode": "session", + "entries": []diffEntry{}, + }) + return + } + + // Diff from snapshot to current working tree + cmd := exec.CommandContext(s.rootCtx(), "git", "diff", snapshot, "--no-color") + cmd.Dir = pwd + cmd.Env = utils.ScrubbedGitEnv() + output, _ := cmd.CombinedOutput() + + var entries []diffEntry + sections := splitDiffByFile(string(output)) + for _, sec := range sections { + adds, dels := countDiffLines(sec.patch) + entries = append(entries, diffEntry{ + File: sec.file, + Patch: sec.patch, + Additions: adds, + Deletions: dels, + Status: sec.status, + }) + } + + if entries == nil { + entries = []diffEntry{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "mode": "session", + "entries": entries, + }) +} diff --git a/internal/web/mcp.go b/internal/web/mcp.go new file mode 100644 index 00000000..8d5570e4 --- /dev/null +++ b/internal/web/mcp.go @@ -0,0 +1,469 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/tools" +) + +// mcpLoginState tracks an in-progress or finished OAuth login for a server. +type mcpLoginState struct { + Status string `json:"status"` // pending | authorized | error | needs_client_id + AuthURL string `json:"auth_url,omitempty"` + Message string `json:"message,omitempty"` +} + +// mcpServerView is the JSON shape returned for one MCP server in the list and +// CRUD responses — enough for the management UI's status badges and edit form. +type mcpServerView struct { + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env []string `json:"env,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Timeout int `json:"timeout,omitempty"` + Enabled bool `json:"enabled"` + OAuth bool `json:"oauth"` // OAuth enabled for this server + HasAuth bool `json:"has_auth"` // a token is stored + Status string `json:"status"` // connected | needs_auth | error | disabled | configured + Error string `json:"error,omitempty"` +} + +// mcpServerReq is the request body for creating/updating an MCP server. +type mcpServerReq struct { + Name string `json:"name"` + Type string `json:"type"` // local|stdio|http|sse + URL string `json:"url"` + Command string `json:"command"` + Args []string `json:"args"` + Env []string `json:"env"` + Headers map[string]string `json:"headers"` + Timeout int `json:"timeout"` + OAuth *struct { + Enabled bool `json:"enabled"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Scopes []string `json:"scopes"` + } `json:"oauth"` +} + +// serverFromReq builds a config.MCPServer from a request body, normalizing the +// transport ("local" → "stdio") and preserving any existing OAuth token state. +func serverFromReq(req *mcpServerReq) (*config.MCPServer, error) { + srv := &config.MCPServer{ + Headers: req.Headers, + TimeoutSeconds: req.Timeout, + } + t := req.Type + if t == "local" { + t = "stdio" + } + switch t { + case "http", "sse": + if req.URL == "" { + return nil, fmt.Errorf("url is required for %s servers", t) + } + srv.Type = t + srv.URL = req.URL + case "stdio", "": + if req.Command == "" { + return nil, fmt.Errorf("command is required for local servers") + } + srv.Type = "stdio" + srv.Command = req.Command + srv.Args = req.Args + srv.Env = req.Env + default: + return nil, fmt.Errorf("unknown server type %q (use local, http, or sse)", req.Type) + } + if req.OAuth != nil && (req.OAuth.Enabled || req.OAuth.ClientID != "" || len(req.OAuth.Scopes) > 0) { + srv.OAuth = &config.MCPOAuthConfig{ + Enabled: req.OAuth.Enabled || req.OAuth.ClientID != "", + ClientID: req.OAuth.ClientID, + ClientSecret: req.OAuth.ClientSecret, + Scopes: req.OAuth.Scopes, + } + } + return srv, nil +} + +func cloneMCPServers(in map[string]*config.MCPServer) map[string]*config.MCPServer { + if len(in) == 0 { + return nil + } + out := make(map[string]*config.MCPServer, len(in)) + for name, srv := range in { + if srv == nil { + out[name] = nil + continue + } + cp := *srv + cp.Args = append([]string(nil), srv.Args...) + cp.Env = append([]string(nil), srv.Env...) + if srv.Headers != nil { + cp.Headers = make(map[string]string, len(srv.Headers)) + for k, v := range srv.Headers { + cp.Headers[k] = v + } + } + if srv.OAuth != nil { + oa := *srv.OAuth + oa.Scopes = append([]string(nil), srv.OAuth.Scopes...) + cp.OAuth = &oa + } + out[name] = &cp + } + return out +} + +// ReloadMCPInBackground connects configured MCP servers without blocking web +// startup. Slow or unreachable MCP servers should update settings/tool state +// when they finish, never delay /api/health or the desktop window. +func (s *Server) ReloadMCPInBackground() { + if s.reloadMCP == nil { + return + } + go func() { + config.Logger().Printf("[web] loading MCP tools in background") + if err := s.reloadMCPAndRebuild(); err != nil { + config.Logger().Printf("[web] background MCP reload failed: %v", err) + } else { + config.Logger().Printf("[web] background MCP reload finished") + } + s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"source": "startup"}}) + }() +} + +// reloadMCPAndRebuild reconnects MCP servers from the current config and +// rebuilds the live agent so new tools take effect without a restart. +func (s *Server) reloadMCPAndRebuild() error { + if s.reloadMCP != nil { + s.mu.RLock() + servers := cloneMCPServers(s.cfg.MCPServers) + s.mu.RUnlock() + statuses, err := s.reloadMCP(servers) + if err != nil { + return err + } + s.mu.Lock() + s.mcpStatuses = make(map[string]tools.MCPStatus, len(statuses)) + for _, st := range statuses { + s.mcpStatuses[st.Name] = st + } + s.mu.Unlock() + } + if !s.needsSetup { + // Rebuild the foreground task's agent so the new MCP tools take effect. + if eng := s.activeEngine(); eng != nil && eng.createAgent != nil { + prov, mod, _ := eng.modelSnapshot() + ag, err := eng.createAgent(prov, mod) + if err != nil { + return err + } + eng.setAgent(ag) + } + } + return nil +} + +// mcpServerStatus derives the UI status string for a server from its config and +// last-known connection status. +func (s *Server) mcpServerStatus(name string, srv *config.MCPServer) (status, errMsg string) { + if srv.Disabled { + return "disabled", "" + } + st, ok := s.mcpStatuses[name] + switch { + case !ok: + return "configured", "" + case st.NeedsAuth: + return "needs_auth", "" + case st.Running: + return "connected", "" + case st.Error != nil: + return "error", st.Error.Error() + default: + return "configured", "" + } +} + +func (s *Server) handleListMCP(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + servers := make(map[string]mcpServerView) + if s.cfg != nil { + for name, srv := range s.cfg.MCPServers { + status, errMsg := s.mcpServerStatus(name, srv) + servers[name] = mcpServerView{ + Name: name, + Type: srv.Type, + URL: srv.URL, + Command: srv.Command, + Args: srv.Args, + Env: srv.Env, + Headers: srv.Headers, + Timeout: srv.TimeoutSeconds, + Enabled: !srv.Disabled, + OAuth: srv.OAuth != nil && srv.OAuth.Enabled, + HasAuth: tools.HasMCPOAuthToken(name), + Status: status, + Error: errMsg, + } + } + } + writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) +} + +func (s *Server) handleCreateMCP(w http.ResponseWriter, r *http.Request) { + var req mcpServerReq + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<18)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + srv, err := serverFromReq(&req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + s.mu.Lock() + if s.cfg.MCPServers == nil { + s.cfg.MCPServers = make(map[string]*config.MCPServer) + } + if _, exists := s.cfg.MCPServers[req.Name]; exists { + s.mu.Unlock() + writeJSON(w, http.StatusConflict, map[string]string{"error": "a server with that name already exists"}) + return + } + s.cfg.MCPServers[req.Name] = srv + if err := config.SaveConfig(s.cfg); err != nil { + s.mu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + s.mu.Unlock() + + if err := s.reloadMCPAndRebuild(); err != nil { + config.Logger().Printf("[web] mcp create reload failed: %v", err) + } + s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": req.Name}}) + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": req.Name}) +} + +func (s *Server) handleUpdateMCP(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + var req mcpServerReq + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<18)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + srv, err := serverFromReq(&req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + s.mu.Lock() + existing, ok := s.cfg.MCPServers[name] + if !ok { + s.mu.Unlock() + writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) + return + } + // Preserve disabled flag and any already-obtained OAuth client id/secret so + // editing other fields doesn't drop a working registration. + srv.Disabled = existing.Disabled + if srv.OAuth != nil && existing.OAuth != nil { + if srv.OAuth.ClientID == "" { + srv.OAuth.ClientID = existing.OAuth.ClientID + } + if srv.OAuth.ClientSecret == "" { + srv.OAuth.ClientSecret = existing.OAuth.ClientSecret + } + } + s.cfg.MCPServers[name] = srv + if err := config.SaveConfig(s.cfg); err != nil { + s.mu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + s.mu.Unlock() + + if err := s.reloadMCPAndRebuild(); err != nil { + config.Logger().Printf("[web] mcp update reload failed: %v", err) + } + s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": name}}) + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": name}) +} + +func (s *Server) handleDeleteMCP(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + s.mu.Lock() + if _, ok := s.cfg.MCPServers[name]; !ok { + s.mu.Unlock() + writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) + return + } + delete(s.cfg.MCPServers, name) + delete(s.mcpStatuses, name) + delete(s.mcpLogins, name) + if err := config.SaveConfig(s.cfg); err != nil { + s.mu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + s.mu.Unlock() + + _ = tools.DeleteMCPOAuthToken(name) + if err := s.reloadMCPAndRebuild(); err != nil { + config.Logger().Printf("[web] mcp delete reload failed: %v", err) + } + s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": name}}) + writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) +} + +func (s *Server) handleToggleMCP(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + s.mu.Lock() + srv, ok := s.cfg.MCPServers[name] + if !ok { + s.mu.Unlock() + writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) + return + } + srv.Disabled = !req.Enabled + if err := config.SaveConfig(s.cfg); err != nil { + s.mu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + s.mu.Unlock() + + if err := s.reloadMCPAndRebuild(); err != nil { + config.Logger().Printf("[web] mcp toggle reload failed: %v", err) + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": name, "enabled": req.Enabled}) +} + +// handleMCPLogin starts the OAuth authorization flow for an HTTP/SSE server in +// the background and opens the user's browser. Progress is polled via +// handleMCPLoginStatus. +func (s *Server) handleMCPLogin(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + s.mu.Lock() + srv, ok := s.cfg.MCPServers[name] + if !ok { + s.mu.Unlock() + writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) + return + } + if srv.URL == "" || (srv.Type != "http" && srv.Type != "sse") { + s.mu.Unlock() + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "OAuth login only applies to http/sse servers"}) + return + } + if existing := s.mcpLogins[name]; existing != nil && existing.Status == "pending" { + s.mu.Unlock() + writeJSON(w, http.StatusConflict, map[string]string{"error": "a login is already in progress"}) + return + } + if srv.OAuth == nil { + srv.OAuth = &config.MCPOAuthConfig{Enabled: true} + } + s.mcpLogins[name] = &mcpLoginState{Status: "pending"} + s.mu.Unlock() + + go s.runMCPLogin(name) + writeJSON(w, http.StatusOK, map[string]any{"status": "pending"}) +} + +func (s *Server) setMCPLogin(name, status, msg string) { + s.mu.Lock() + st := s.mcpLogins[name] + if st == nil { + st = &mcpLoginState{} + s.mcpLogins[name] = st + } + st.Status = status + st.Message = msg + s.mu.Unlock() +} + +func (s *Server) runMCPLogin(name string) { + ctx, cancel := context.WithTimeout(s.rootCtx(), 5*time.Minute) + defer cancel() + + s.mu.RLock() + srv := s.cfg.MCPServers[name] + s.mu.RUnlock() + if srv == nil { + s.setMCPLogin(name, "error", "server not found") + return + } + + err := tools.PerformMCPOAuthLogin(ctx, name, srv, func(authURL string) { + s.mu.Lock() + if st := s.mcpLogins[name]; st != nil { + st.AuthURL = authURL + } + s.mu.Unlock() + s.wsBroker.Broadcast(WSEvent{Type: "mcp_login", Data: map[string]string{"name": name, "auth_url": authURL}}) + openBrowser(authURL) + }) + if err != nil { + status := "error" + if errors.Is(err, tools.ErrOAuthNeedsClientID) { + status = "needs_client_id" + } + s.setMCPLogin(name, status, err.Error()) + config.Logger().Printf("[web] mcp login %q failed: %v", name, err) + return + } + + // Persist the (possibly dynamically registered) client id and enabled flag. + s.mu.Lock() + if saveErr := config.SaveConfig(s.cfg); saveErr != nil { + config.Logger().Printf("[web] mcp login %q: save config failed: %v", name, saveErr) + } + s.mu.Unlock() + + if reErr := s.reloadMCPAndRebuild(); reErr != nil { + config.Logger().Printf("[web] mcp login %q: reload failed: %v", name, reErr) + } + s.setMCPLogin(name, "authorized", "") + s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": name}}) +} + +func (s *Server) handleMCPLoginStatus(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + s.mu.RLock() + st := s.mcpLogins[name] + s.mu.RUnlock() + if st == nil { + writeJSON(w, http.StatusOK, map[string]string{"status": "idle"}) + return + } + writeJSON(w, http.StatusOK, st) +} diff --git a/internal/web/models.go b/internal/web/models.go new file mode 100644 index 00000000..7fc23f79 --- /dev/null +++ b/internal/web/models.go @@ -0,0 +1,349 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/cloudwego/eino/adk" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/mode" + "github.com/cnjack/jcode/internal/model" +) + +func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { + curProvider, curModel := "", "" + if eng := s.activeEngine(); eng != nil { + curProvider, curModel, _ = eng.modelSnapshot() + } + // Snapshot pointers under cfgMu — setup/provider handlers reassign s.cfg and + // s.registry under that lock after SaveConfig. + s.cfgMu.Lock() + cfg := s.cfg + s.cfgMu.Unlock() + if cfg == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "current": map[string]string{"provider": curProvider, "model": curModel}, + "providers": []any{}, + }) + return + } + + type modelInfo struct { + ID string `json:"id"` + Name string `json:"name"` + ToolCall bool `json:"tool_call"` + ContextLimit int `json:"context_limit,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Recommended bool `json:"recommended,omitempty"` + DefaultEnabled bool `json:"default_enabled,omitempty"` + Enabled bool `json:"enabled"` + ImageSupport bool `json:"image_support,omitempty"` + ReasoningOptions []model.ReasoningOption `json:"reasoning_options,omitempty"` + } + type providerInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Custom bool `json:"custom,omitempty"` + Models []modelInfo `json:"models"` + } + + modelState, _ := config.LoadModelState() + + // Rebuild the registry from the live config so models added at runtime + // (custom models saved via the providers API) appear immediately in the chat + // model picker. The startup registry (s.registry) is a snapshot and would not + // reflect these additions until a restart. + registry := model.NewModelRegistryWithConfig(cfg) + + var result []providerInfo + configuredProviders := cfg.GetProviders() + for _, rp := range registry.ListProviders() { + if _, configured := configuredProviders[rp.ID]; !configured { + continue + } + models := registry.ListProviderModels(rp.ID, true) + if len(models) == 0 { + continue + } + pi := providerInfo{ID: rp.ID, Name: rp.Name, Custom: rp.Custom} + for _, m := range models { + ctx := 0 + if m.Limit != nil { + ctx = m.Limit.Context + } + ref := config.ModelRef{Provider: rp.ID, Model: m.ID} + enabled := modelState.IsModelEnabled(ref, m.DefaultEnabled) + imageSupport := false + if m.Modalities != nil { + for _, mod := range m.Modalities.Input { + if mod == "image" { + imageSupport = true + break + } + } + } + pi.Models = append(pi.Models, modelInfo{ + ID: m.ID, Name: m.Name, ToolCall: m.ToolCall, ContextLimit: ctx, + Reasoning: m.Reasoning, Recommended: m.Recommended, + DefaultEnabled: m.DefaultEnabled, Enabled: enabled, + ImageSupport: imageSupport, + ReasoningOptions: m.ReasoningOptions, + }) + } + result = append(result, pi) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "current": map[string]string{"provider": curProvider, "model": curModel}, + "providers": result, + }) +} + +func (s *Server) handleSwitchModel(w http.ResponseWriter, r *http.Request) { + eng := s.activeEngine() + if eng == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + return + } + // No running gate: applyModelSwitch swaps eng.agent under eng.emu (the lock the + // run reads it under), so a mid-run switch is safe and takes effect next turn — + // consistent with mode/approval switching. + + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + + // Rebuild THIS task's agent for the new model and swap it in under eng.emu + // (the same lock submitMessage uses to read the agent). Keep history. + ag, err := eng.createAgent(req.Provider, req.Model) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + eng.applyModelSwitch(ag, req.Provider, req.Model) + + // Track in recent models. + if state, err := config.LoadModelState(); err == nil { + state.AddRecent(config.ModelRef{Provider: req.Provider, Model: req.Model}) + _ = config.SaveModelState(state) + } + + s.wsBroker.Broadcast(WSEvent{Type: "model_changed", TaskID: eng.taskID, Data: map[string]string{ + "provider": req.Provider, + "model": req.Model, + }}) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleSwitchMode(w http.ResponseWriter, r *http.Request) { + var req struct { + Mode string `json:"mode"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + // Accept only the three canonical unified mode ids. + switch req.Mode { + case "approval", "plan", "full_access": + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "mode must be 'approval', 'plan', or 'full_access'"}) + return + } + sm := mode.Parse(req.Mode) + + eng := s.activeEngine() + if eng == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + return + } + // No running gate: applyModeSwitch writes eng.agent under eng.emu, the same + // lock submitMessage reads it under, so a mid-run switch is safe and simply + // takes effect on the next turn (matching TUI/ACP and the "Allow all" path). + + // Rebuild this task's agent FIRST. If the rebuild fails, abort without + // changing the mode/approval axis — otherwise plan mode could be reported while + // a write-capable agent stays live. + var newAg *adk.ChatModelAgent + if eng.rebuildForMode != nil { + ag, err := eng.rebuildForMode(sm.IsPlan()) + if err != nil { + config.Logger().Printf("[web] mode switch agent rebuild error: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to switch mode"}) + return + } + newAg = ag + } + if eng.approvalState != nil { + eng.approvalState.SetSessionMode(sm) // approval axis (Full access → auto) + } + eng.applyModeSwitch(sm.String(), newAg) + + s.wsBroker.Broadcast(WSEvent{Type: "mode_changed", TaskID: eng.taskID, Data: map[string]string{ + "mode": sm.String(), + }}) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "mode": sm.String()}) +} + +func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + // Return safe subset: no API keys. + providerName, modelName := cfg.GetProviderModel() + writeJSON(w, http.StatusOK, map[string]any{ + "provider": providerName, + "model": modelName, + "max_iterations": cfg.MaxIterations, + }) +} + +// handleGetModelState returns the recent, favorite, and visibility settings. +func (s *Server) handleGetModelState(w http.ResponseWriter, r *http.Request) { + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + type modelRefJSON struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + + recent := make([]modelRefJSON, 0, len(state.Recent)) + for _, r := range state.Recent { + recent = append(recent, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + favorites := make([]modelRefJSON, 0, len(state.Favorite)) + for _, r := range state.Favorite { + favorites = append(favorites, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + enabledModels := make([]modelRefJSON, 0, len(state.EnabledModels)) + for _, r := range state.EnabledModels { + enabledModels = append(enabledModels, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + disabledModels := make([]modelRefJSON, 0, len(state.DisabledModels)) + for _, r := range state.DisabledModels { + disabledModels = append(disabledModels, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "recent": recent, + "favorite": favorites, + "enabled_models": enabledModels, + "disabled_models": disabledModels, + "effort_overrides": state.EffortOverrides, + }) +} + +// handleToggleFavorite toggles a model in the favorites list. +func (s *Server) handleToggleFavorite(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + nowFavorite := state.ToggleFavorite(config.ModelRef{Provider: req.Provider, Model: req.Model}) + if err := config.SaveModelState(state); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "favorite": nowFavorite, + }) +} + +// handleToggleModelEnabled toggles whether a model is shown in the model selector. +func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + state.SetModelEnabled(config.ModelRef{Provider: req.Provider, Model: req.Model}, req.Enabled) + if err := config.SaveModelState(state); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": req.Enabled, + }) +} + +// handleSetModelEffort records the user's reasoning-effort choice for a single +// model (set from the chat model picker). An empty effort clears the override, +// restoring the provider-level default. The agent is rebuilt so the change +// takes effect on the next turn. +func (s *Server) handleSetModelEffort(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + Effort string `json:"effort"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + if req.Effort != "" && !validReasoningEffort(req.Effort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid effort"}) + return + } + + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + state.SetEffortOverride(config.ModelRef{Provider: req.Provider, Model: req.Model}, req.Effort) + if err := config.SaveModelState(state); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "effort": req.Effort, + }) +} diff --git a/internal/web/project.go b/internal/web/project.go new file mode 100644 index 00000000..62381dd3 --- /dev/null +++ b/internal/web/project.go @@ -0,0 +1,241 @@ +package web + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/handler" + "github.com/cnjack/jcode/internal/mode" +) + +func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { + dir := r.URL.Query().Get("path") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + dir = home + } + + abs, err := filepath.Abs(dir) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + entries, err := os.ReadDir(abs) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + type folderItem struct { + Name string `json:"name"` + Path string `json:"path"` + } + + var folders []folderItem + for _, e := range entries { + if !e.IsDir() { + continue + } + // Skip hidden folders + if strings.HasPrefix(e.Name(), ".") { + continue + } + folders = append(folders, folderItem{ + Name: e.Name(), + Path: filepath.Join(abs, e.Name()), + }) + } + if folders == nil { + folders = []folderItem{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "current": abs, + "folders": folders, + }) +} + +// handleValidatePaths reports which of the given local paths no longer exist (or +// are not directories). The web UI keeps its workspace list in localStorage and +// can't stat the disk itself, so it calls this to prune dead workspaces from the +// picker instead of letting the user click one and hit "path does not exist". +// Callers send local paths only; ssh:// labels can't be stat'd here and would be +// wrongly reported missing, so they must be filtered out client-side. +func (s *Server) handleValidatePaths(w http.ResponseWriter, r *http.Request) { + var req struct { + Paths []string `json:"paths"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + missing := []string{} + for _, p := range req.Paths { + if p == "" { + continue + } + info, err := os.Stat(p) + if err != nil { + // Only a confirmed not-exist means the workspace is gone. Transient + // errors (permission, NFS hiccup) are inconclusive — keep the path + // rather than silently dropping a still-valid workspace from the picker. + if os.IsNotExist(err) { + missing = append(missing, p) + } + continue + } + if !info.IsDir() { + missing = append(missing, p) + } + } + + writeJSON(w, http.StatusOK, map[string]any{"missing": missing}) +} + +func (s *Server) handleSwitchProject(w http.ResponseWriter, r *http.Request) { + // No running gate: "switch project" builds a NEW independent engine and leaves + // the previous task running in the background — switching to another task while + // one is chatting is the whole point of concurrent tasks. + if s.newEngine == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{ + "error": "project switching is not supported", + }) + return + } + + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if req.Path == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"}) + return + } + + // Validate path exists and is a directory. + info, err := os.Stat(req.Path) + if err != nil || !info.IsDir() { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path does not exist or is not a directory"}) + return + } + + // Snapshot the outgoing task once, build the new engine BEFORE tearing down its + // PTYs — a failed build must not kill the current task's terminals. + prevTaskID, curMode := "", "" + if cur := s.activeEngine(); cur != nil { + prevTaskID, curMode = cur.taskID, cur.curMode() + } + + // "Switch project" = build a fresh engine rooted at the new path and make it + // active. This replaces in-place env mutation, so no other live task's + // execution context is disturbed. + eng, err := s.buildLocalEngine("", req.Path, curMode) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("failed to switch project: %v", err), + }) + return + } + s.ptyMgr.closeForTask(prevTaskID) // outgoing task's PTYs only + s.setActiveEngine(eng) + + // Reset todos for the (now empty) active task view. + if eng.todoStore != nil { + eng.todoStore.Update(nil) + } + + // Broadcast project change to clients. + s.wsBroker.Broadcast(WSEvent{ + Type: "project_switched", + Data: map[string]string{ + "pwd": req.Path, + }, + }) + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "pwd": req.Path, + }) +} + +func (s *Server) handleGetApprovalMode(w http.ResponseWriter, r *http.Request) { + autoApprove := false + if eng := s.activeEngine(); eng != nil && eng.approvalState != nil { + autoApprove = eng.approvalState.GetMode() == handler.ModeAuto + } + writeJSON(w, http.StatusOK, map[string]any{"auto_approve": autoApprove}) +} + +func (s *Server) handleSetApprovalMode(w http.ResponseWriter, r *http.Request) { + eng := s.activeEngine() + if eng == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + return + } + // No running gate: the rebuild is emu-safe and applies next turn, consistent + // with the "Allow all" approval path which also flips full_access mid-run. + var req struct { + AutoApprove bool `json:"auto_approve"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + // Legacy endpoint: auto-approve now maps onto the unified mode (Full access vs + // Approval). Both are non-plan, so rebuild to the full tool set for consistency. + sm := mode.Approval + if req.AutoApprove { + sm = mode.FullAccess + } + // Rebuild first; abort the toggle if the rebuild fails (don't desync the + // reported mode from the live agent). + var newAg *adk.ChatModelAgent + if eng.rebuildForMode != nil { + ag, err := eng.rebuildForMode(false) + if err != nil { + config.Logger().Printf("[web] approval mode agent rebuild error: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to set approval mode"}) + return + } + newAg = ag + } + if eng.approvalState != nil { + eng.approvalState.SetSessionMode(sm) + } + eng.applyModeSwitch(sm.String(), newAg) + // Persist as the default startup mode so the preference survives restarts — + // resolveStartupMode reads cfg.DefaultMode. cfgMu serializes the config RMW. + s.cfgMu.Lock() + if s.cfg != nil { + s.cfg.DefaultMode = sm.String() + if err := config.SaveConfig(s.cfg); err != nil { + config.Logger().Printf("[web] approval mode save config failed: %v", err) + } + } + s.cfgMu.Unlock() + + s.wsBroker.Broadcast(WSEvent{ + Type: "approval_mode_changed", + TaskID: eng.taskID, + Data: map[string]any{"auto_approve": req.AutoApprove}, + }) + // Also emit the unified mode event so updated clients keep their selector synced. + s.wsBroker.Broadcast(WSEvent{Type: "mode_changed", TaskID: eng.taskID, Data: map[string]string{"mode": sm.String()}}) + writeJSON(w, http.StatusOK, map[string]any{"auto_approve": req.AutoApprove}) +} diff --git a/internal/web/providers.go b/internal/web/providers.go new file mode 100644 index 00000000..c381dde5 --- /dev/null +++ b/internal/web/providers.go @@ -0,0 +1,720 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "sort" + "strings" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/model" +) + +// handleProviderCatalog returns a provider's browsable model catalog for the +// "browse directory" UI. For registry providers it lists the built-in models +// (the official /models endpoint is not reliably complete); for custom +// (OpenAI-compatible) endpoints it queries the live /models endpoint. Each +// entry is flagged added=true when the model is already in the provider's +// config (either as a CustomModelConfig or a registry model that's enabled). +func (s *Server) handleProviderCatalog(w http.ResponseWriter, r *http.Request) { + providerID := r.PathValue("id") + if providerID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) + return + } + + type catalogEntry struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Added bool `json:"added"` + Context int `json:"context,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Attachment bool `json:"attachment,omitempty"` + EffortTiers []string `json:"effort_tiers,omitempty"` + // Custom marks a user-defined model (editable/removable) vs a built-in + // registry model (toggled via model_state). Surfaced so the catalog row can + // show edit/remove affordances only on user custom models. + Custom bool `json:"custom,omitempty"` + } + + // Collect the set of already-configured model ids for this provider, so each + // catalog entry can be flagged added/可移除. Custom models come from config; + // for registry providers, MergeConfigProviders has already merged custom + // models into the registry, so registry membership is the source of truth. + configured := make(map[string]bool) + customSet := make(map[string]*config.CustomModelConfig) // user-defined models by id + var apiKey, baseURL string + var headers map[string]string + cfg, _ := config.LoadConfig() + if cfg != nil { + if pc := cfg.GetProviders()[providerID]; pc != nil { + apiKey, baseURL, headers = pc.APIKey, pc.BaseURL, pc.Headers + for _, m := range pc.CustomModels { + configured[m.ID] = true + cm := m // copy for map value + customSet[m.ID] = &cm + } + } + } + + // customEntry builds a catalogEntry from a user-defined CustomModelConfig. + customEntry := func(id string) catalogEntry { + m := customSet[id] + e := catalogEntry{ID: id, Added: true, Custom: true} + if m != nil { + e.Name = m.Name + e.Context = m.Context + e.Reasoning = m.Reasoning + e.Attachment = m.Attachment + e.EffortTiers = m.EffortTiers + } + return e + } + + // resolveRegistryBrand finds a registry provider whose brand keyword appears + // in the given id/url — so a custom endpoint pointing at, say, zhipu's API + // still surfaces zhipu's models.dev catalog rather than a fragile live + // /models probe. Returns "" when no brand matches. + resolveRegistryBrand := func(hint string) string { + if s.registry == nil { + return "" + } + hint = strings.ToLower(hint) + for _, rp := range s.registry.ListProviders() { + // Use the registry id as the brand keyword (e.g. "zhipuai", "openai", + // "deepseek"); these already encode the brand and are stable. + brand := strings.ToLower(rp.ID) + if brand != "" && strings.Contains(hint, brand) { + return rp.ID + } + } + return "" + } + + // The catalog defaults to the built-in (models.dev) catalog — this is the + // reliable source, since many endpoints either lack a /models route or + // return an incomplete list. Exact id match first; otherwise a brand match + // on the provider id or base URL (so a custom zhipu endpoint still shows the + // zhipu catalog). + registryID := providerID + if s.registry == nil || !s.registry.HasProvider(registryID) { + if hint := resolveRegistryBrand(providerID + " " + baseURL); hint != "" { + registryID = hint + } + } + if s.registry != nil && s.registry.HasProvider(registryID) { + models := s.registry.ListProviderModels(registryID, true) + result := make([]catalogEntry, 0, len(models)) + for _, m := range models { + // A user-defined custom model is merged into the registry by + // MergeConfigProviders, so it appears here too. For those, build the + // entry from the stored CustomModelConfig (which carries the + // user-set name/context/tiers) rather than the derived registry view — + // otherwise effort tiers and other authored fields are lost. + if cm := customSet[m.ID]; cm != nil { + result = append(result, catalogEntry{ + ID: m.ID, + Name: cm.Name, + Added: true, + Context: cm.Context, + Reasoning: cm.Reasoning, + Attachment: cm.Attachment, + EffortTiers: cm.EffortTiers, + Custom: true, + }) + continue + } + ctx := 0 + if m.Limit != nil { + ctx = m.Limit.Context + } + result = append(result, catalogEntry{ + ID: m.ID, + Name: m.Name, + Added: configured[m.ID] || m.DefaultEnabled, + Context: ctx, + Reasoning: m.Reasoning, + Attachment: m.Attachment, + Custom: false, + }) + } + // Also surface any user-added custom models not in the brand catalog, so + // the catalog isn't missing models the user explicitly configured. + for id := range configured { + found := false + for _, e := range result { + if e.ID == id { + found = true + break + } + } + if !found { + result = append(result, customEntry(id)) + } + } + writeJSON(w, http.StatusOK, result) + return + } + + // Truly custom endpoint with no brand match: probe the live /models endpoint + // as a last resort. Many gateways support the OpenAI-compatible /models list; + // on any failure we fall back to just the configured models so the catalog is + // never empty/erroring. + if baseURL != "" { + if ids := model.ListProviderModelsLive(r.Context(), apiKey, baseURL, headers); len(ids) > 0 { + result := make([]catalogEntry, 0, len(ids)) + seen := make(map[string]bool, len(ids)) + for _, id := range ids { + if seen[id] { + continue + } + seen[id] = true + if c := customSet[id]; c != nil { + result = append(result, customEntry(id)) + } else { + result = append(result, catalogEntry{ID: id, Added: configured[id]}) + } + } + writeJSON(w, http.StatusOK, result) + return + } + } + + // No registry brand and no live /models: show the configured custom models + // (added=true, custom=true) so the catalog reflects what's actually usable. + result := make([]catalogEntry, 0, len(configured)) + for id := range configured { + result = append(result, customEntry(id)) + } + writeJSON(w, http.StatusOK, result) +} + +// maskSecret hides a secret for display: first 4 and last 4 chars for longer +// values, "****" for short ones. Used for API keys and header values so the +// list endpoint never returns plaintext credentials. +func maskSecret(s string) string { + if s == "" { + return "" + } + if len(s) > 8 { + return s[:4] + "..." + s[len(s)-4:] + } + return "****" +} + +// handleListProviders returns all configured providers (key masked). +func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig() + if err != nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + + type customModelView struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Context int `json:"context,omitempty"` + Attachment bool `json:"attachment,omitempty"` + EffortTiers []string `json:"effort_tiers,omitempty"` + // Custom marks a user-defined model (editable) vs a built-in registry + // model surfaced for display (read-only). Omitted/zero ⇒ treated as a + // user custom model for backward compatibility, but we always set it. + Custom bool `json:"custom,omitempty"` + } + type providerDetail struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` // display name for custom providers + Custom bool `json:"custom,omitempty"` + APIKeySet bool `json:"api_key_set"` + APIKey string `json:"api_key,omitempty"` // masked + BaseURL string `json:"base_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` // values masked + CustomModels []customModelView `json:"custom_models,omitempty"` + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + } + + result := make([]providerDetail, 0) + for id, pc := range cfg.GetProviders() { + detail := providerDetail{ + ID: id, + Name: pc.Name, + APIKeySet: pc.APIKey != "", + BaseURL: pc.BaseURL, + Vision: pc.Vision, + Thinking: pc.Thinking, + ReasoningEffort: pc.ReasoningEffort, + } + // A provider is "custom" when it exists only because the user configured + // it (an OpenAI-compatible endpoint), not as a built-in registry brand. + // MergeConfigProviders flags those on the registry entry; a configured id + // with no registry entry at all is custom too. The registry may be nil in + // setup mode — fall back to "has a display name" as the custom signal. + if s.registry != nil { + if prov := s.registry.GetProvider(id); prov != nil { + detail.Custom = prov.Custom + } else { + detail.Custom = true + } + } else if pc.Name != "" { + detail.Custom = true + } + if pc.APIKey != "" { + detail.APIKey = maskSecret(pc.APIKey) + } + if len(pc.Headers) > 0 { + masked := make(map[string]string, len(pc.Headers)) + for k, v := range pc.Headers { + masked[k] = maskSecret(v) + } + detail.Headers = masked + } + // Build the card's unified model list. For registry providers this is the + // built-in (models.dev) models the user has enabled, each marked read-only + // (Custom=false); the user's CustomModels are then appended and marked + // editable (Custom=true). The card renders them identically and only + // surfaces edit/delete affordances on editable rows. + cms := make([]customModelView, 0) + seen := make(map[string]bool) + // Track which ids are user-defined custom models so the registry loop can + // skip them (they're merged into the registry by MergeConfigProviders but + // must surface with their authored fields from CustomModelConfig, handled + // in the custom loop below). + customIDs := make(map[string]bool, len(pc.CustomModels)) + for _, m := range pc.CustomModels { + customIDs[m.ID] = true + } + if !detail.Custom && s.registry != nil && s.registry.HasProvider(id) { + for _, m := range s.registry.ListProviderModels(id, true) { + if !m.DefaultEnabled { + continue + } + if customIDs[m.ID] { + continue // user custom model — emitted below with authored fields + } + if seen[m.ID] { + continue + } + seen[m.ID] = true + ctx := 0 + if m.Limit != nil { + ctx = m.Limit.Context + } + cms = append(cms, customModelView{ + ID: m.ID, + Name: m.Name, + Reasoning: m.Reasoning, + Context: ctx, + Attachment: m.Attachment, + Custom: false, + }) + } + } + for _, m := range pc.CustomModels { + if seen[m.ID] { + continue + } + seen[m.ID] = true + cms = append(cms, customModelView{ + ID: m.ID, + Name: m.Name, + Reasoning: m.Reasoning, + Context: m.Context, + Attachment: m.Attachment, + EffortTiers: m.EffortTiers, + Custom: true, + }) + } + if len(cms) > 0 { + detail.CustomModels = cms + } + result = append(result, detail) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + + writeJSON(w, http.StatusOK, result) +} + +// handleAddProvider adds a new provider to the config. For custom +// (non-registry) providers the caller should also send a name; models are +// optional here and are added afterward from the provider card (the dialog only +// captures the connection). +func (s *Server) handleAddProvider(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + Name string `json:"name,omitempty"` + Model string `json:"model,omitempty"` + ModelReasoning bool `json:"model_reasoning,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.ID == "" || req.APIKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id and api_key are required"}) + return + } + if !validReasoningEffort(req.ReasoningEffort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) + return + } + + // Serialize config RMW + live publish under cfgMu (see Server.cfgMu). + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + + cfg, err := config.LoadConfig() + if err != nil { + cfg = &config.Config{MaxIterations: 1000} + } + if cfg.Providers == nil { + cfg.Providers = make(map[string]*config.ProviderConfig) + } + + // A custom provider (not in the registry) needs a base URL so requests can + // be routed. Models are optional at creation time: the provider is created + // connection-only and models are added afterward from its card, so a brand + // new custom endpoint can be saved before its model list is known. + isCustom := s.registry == nil || !s.registry.HasProvider(req.ID) + if isCustom && req.BaseURL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "base_url is required for custom providers"}) + return + } + + pc := &config.ProviderConfig{ + APIKey: req.APIKey, + BaseURL: req.BaseURL, + Name: req.Name, + Headers: cleanHeaders(req.Headers), + Vision: req.Vision, + Thinking: req.Thinking, + ReasoningEffort: req.ReasoningEffort, + } + if isCustom && req.Model != "" { + pc.CustomModels = []config.CustomModelConfig{{ + ID: req.Model, + Name: req.Model, + ToolCall: true, + Reasoning: req.ModelReasoning, + }} + } + cfg.Providers[req.ID] = pc + + // If there is no active model yet and this is a custom provider with an + // explicit model, adopt it as the active model so the app can boot. + if cfg.Model == "" && isCustom && req.Model != "" { + cfg.Model = req.ID + "/" + req.Model + } + + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + // Publish into the live server so /api/models sees the new provider without a restart. + s.cfg = cfg + s.registry = model.NewModelRegistryWithConfig(cfg) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// validReasoningEffort whitelists the thinking-depth values accepted from +// clients. The set mirrors the effort levels models.dev publishes under +// reasoning_options (see internal/model registry). Empty means "unset / omit +// the parameter". +func validReasoningEffort(v string) bool { + switch v { + case "", "none", "minimal", "low", "medium", "high", "xhigh", "max": + return true + } + return false +} + +// cleanHeaders drops rows with an empty key and trims whitespace from both key +// and value, so blank editor rows never reach the saved config and a pasted +// token with a stray trailing space does not silently break auth. +func cleanHeaders(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + k = strings.TrimSpace(k) + if k == "" { + continue + } + out[k] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil + } + return out +} + +// handleUpdateProvider edits an existing provider, merging secret fields so the +// client may omit unchanged credentials. An empty api_key keeps the stored key; +// a header value left empty keeps the stored value for that key (the list +// endpoint returns masked secrets, so the UI sends blanks for untouched ones). +func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) + return + } + var req struct { + APIKey string `json:"api_key,omitempty"` + BaseURL string `json:"base_url,omitempty"` + Name string `json:"name,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + CustomModels *[]struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Context int `json:"context,omitempty"` + Attachment bool `json:"attachment,omitempty"` + EffortTiers []string `json:"effort_tiers,omitempty"` + } `json:"custom_models,omitempty"` + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if !validReasoningEffort(req.ReasoningEffort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) + return + } + + // Serialize config RMW + live publish under cfgMu (see Server.cfgMu). + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + + cfg, err := config.LoadConfig() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + pc := cfg.GetProviders()[id] + if pc == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + + // Mutate in place so fields not exposed by this endpoint (display name, + // custom models, deprecated lists) are preserved untouched. + prevHeaders := pc.Headers + // base_url uses keep-on-empty semantics (like api_key): the list endpoint + // masks secrets but returns base_url verbatim, yet a client that doesn't + // touch the endpoint may still submit an empty value. Overwriting + // unconditionally would wipe a stored custom endpoint, so only adopt a + // non-empty incoming value. + if req.BaseURL != "" { + pc.BaseURL = req.BaseURL + } + pc.Vision = req.Vision + pc.Thinking = req.Thinking + pc.ReasoningEffort = req.ReasoningEffort + if req.Name != "" { + pc.Name = req.Name + } + if req.APIKey != "" { + pc.APIKey = req.APIKey + } + // Merge headers: empty incoming value ⇒ keep the stored secret for that key. + pc.Headers = nil + if cleaned := cleanHeaders(req.Headers); len(cleaned) > 0 { + merged := make(map[string]string, len(cleaned)) + for k, v := range cleaned { + if v == "" { + if ov, ok := prevHeaders[k]; ok { + merged[k] = ov + continue + } + } + merged[k] = v + } + pc.Headers = merged + } + + // Replace the provider's custom models when the client sends the list (nil ⇒ + // keep existing). Each model's stored Context is preserved by merging on id, + // ToolCall stays true (matching the add path), and the model currently set as + // active cannot be dropped so a save can't strand the running app. + if req.CustomModels != nil { + prev := make(map[string]config.CustomModelConfig, len(pc.CustomModels)) + for _, m := range pc.CustomModels { + prev[m.ID] = m + } + next := make([]config.CustomModelConfig, 0, len(*req.CustomModels)) + seen := make(map[string]bool, len(*req.CustomModels)) + for _, m := range *req.CustomModels { + mid := strings.TrimSpace(m.ID) + if mid == "" || seen[mid] { + continue + } + seen[mid] = true + cm := config.CustomModelConfig{ID: mid, Name: strings.TrimSpace(m.Name), ToolCall: true, Reasoning: m.Reasoning} + // Adopt the incoming per-model capability fields when provided; + // otherwise carry over the previously stored values so an edit that + // only renames a model doesn't silently drop its context window, + // vision flag, or configured effort tiers. + if old, ok := prev[mid]; ok { + if cm.Context == 0 { + cm.Context = old.Context + } + if !cm.Attachment { + cm.Attachment = old.Attachment + } + if len(cm.EffortTiers) == 0 { + cm.EffortTiers = old.EffortTiers + } + } + if m.Context > 0 { + cm.Context = m.Context + } + if m.Attachment { + cm.Attachment = true + } + if len(m.EffortTiers) > 0 { + cm.EffortTiers = m.EffortTiers + } + next = append(next, cm) + } + // Reject custom model ids that collide with the provider's built-in + // (registry) models. A duplicate id would shadow or be shadowed by the + // registry entry, confusing the model picker and catalog. Custom ids + // may still be edited to their own value (handled by seen dedup above). + if s.registry != nil { + if regProv := s.registry.GetProvider(id); regProv != nil { + for _, cm := range next { + if _, ok := regProv.Models[cm.ID]; ok { + // Allow it only if it was already a custom model with this id + // (editing an existing custom entry in place). + if _, wasCustom := prev[cm.ID]; !wasCustom { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "model id '" + cm.ID + "' duplicates a built-in model; choose another id", + }) + return + } + } + } + } + } + if strings.HasPrefix(cfg.Model, id+"/") { + active := strings.TrimPrefix(cfg.Model, id+"/") + if _, wasThere := prev[active]; wasThere && !seen[active] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot remove the active model; switch to another model first"}) + return + } + } + isCustom := s.registry == nil || !s.registry.HasProvider(id) + if isCustom && len(next) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "custom providers need at least one model"}) + return + } + pc.CustomModels = next + } + + if cfg.Providers == nil { + cfg.Providers = make(map[string]*config.ProviderConfig) + } + cfg.Providers[id] = pc + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + // Publish the updated config + registry to the live server so the chat model + // picker (/api/models) and catalog reflect added/edited/removed models + // without a restart — matching handleSetupComplete's publish step. + s.cfg = cfg + s.registry = model.NewModelRegistryWithConfig(cfg) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleDeleteProvider removes a provider from the config. +func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { + providerID := r.PathValue("id") + if providerID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) + return + } + + // Serialize RMW with other config writers (cfgMu documents this in Server). + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + + cfg, err := config.LoadConfig() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + providers := cfg.GetProviders() + if providers == nil || providers[providerID] == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + + activeProvider, _ := cfg.GetProviderModel() + if activeProvider == providerID { + // Pick a surviving provider+model so cfg.Model is never left pointing at + // a deleted provider. Reject when no safe replacement exists. + nextRef := firstAlternateProviderModel(cfg, s.registry, providerID) + if nextRef == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot delete the only provider (or no replacement model available)"}) + return + } + cfg.Model = nextRef + } + + delete(cfg.Providers, providerID) + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + s.cfg = cfg + s.registry = model.NewModelRegistryWithConfig(cfg) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// firstAlternateProviderModel returns "provider/model" for the first configured +// provider other than skipID that has at least one usable model, or "" if none. +func firstAlternateProviderModel(cfg *config.Config, reg *model.ModelRegistry, skipID string) string { + if cfg == nil { + return "" + } + // Prefer a registry rebuilt from cfg so custom models on survivors are visible. + live := model.NewModelRegistryWithConfig(cfg) + if live == nil { + live = reg + } + for id, pc := range cfg.GetProviders() { + if id == skipID || pc == nil { + continue + } + if live != nil { + if models := live.ListProviderModels(id, true); len(models) > 0 { + return id + "/" + models[0].ID + } + } + if len(pc.CustomModels) > 0 { + return id + "/" + pc.CustomModels[0].ID + } + } + return "" +} diff --git a/internal/web/pty.go b/internal/web/pty.go index 834170de..971a30bd 100644 --- a/internal/web/pty.go +++ b/internal/web/pty.go @@ -358,3 +358,50 @@ func shortContainer(id string) string { } return id } + +func (s *Server) handleCreatePTY(w http.ResponseWriter, r *http.Request) { + pwd, owner := "", "" + var dockerExec *tools.DockerExecutor + if eng := s.activeEngine(); eng != nil { + pwd, owner = eng.pwd, eng.taskID + // A container-bound engine gets a terminal INSIDE the container; SSH and + // local engines keep a local shell (SSH-in-terminal remains a known gap). + if eng.env != nil { + if de, ok := eng.env.Exec.(*tools.DockerExecutor); ok { + dockerExec = de + } + } + } + + var ( + id string + err error + ) + if dockerExec != nil { + // createDocker acquires its own container ref (so an env switch can't stop + // the container under a live terminal) and resolves the shared client itself. + id, err = s.ptyMgr.createDocker(dockerExec.ContainerID(), pwd, owner) + } else { + id, err = s.ptyMgr.create(pwd, owner) + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"id": id}) +} + +func (s *Server) handleListPTY(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"sessions": s.ptyMgr.list()}) +} + +func (s *Server) handleKillPTY(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + s.ptyMgr.kill(id) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handlePTYWebSocket(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + s.ptyMgr.serveWS(w, r, id) +} diff --git a/internal/web/remote.go b/internal/web/remote.go index ff001484..0acb83b0 100644 --- a/internal/web/remote.go +++ b/internal/web/remote.go @@ -408,3 +408,37 @@ func (s *Server) handleRemoteSaveDockerAlias(w http.ResponseWriter, r *http.Requ } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } + +// --- SSH list handler --- + +func (s *Server) handleListSSH(w http.ResponseWriter, r *http.Request) { + type sshItem struct { + Name string `json:"name"` + Addr string `json:"addr"` + Path string `json:"path,omitempty"` + } + + var items []sshItem + if s.cfg != nil { + for _, a := range s.cfg.SSHAliases { + items = append(items, sshItem{ + Name: a.Name, + Addr: a.Addr, + Path: a.Path, + }) + } + } + if items == nil { + items = []sshItem{} + } + + current := "local" + if eng := s.activeEngine(); eng != nil && eng.env != nil && eng.env.IsRemote() { + current = "ssh" + } + + writeJSON(w, http.StatusOK, map[string]any{ + "current": current, + "aliases": items, + }) +} diff --git a/internal/web/server.go b/internal/web/server.go index 29ea805f..32df3303 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -2,35 +2,24 @@ package web import ( - "bufio" "context" "encoding/json" - "errors" "fmt" - "io" "net" "net/http" "net/url" - "os" "os/exec" - "path/filepath" - "sort" "strings" "sync" "sync/atomic" - "time" "github.com/cloudwego/eino/adk" - "github.com/cloudwego/eino/schema" - "github.com/gorilla/websocket" - "github.com/cnjack/jcode/internal/automation" "github.com/cnjack/jcode/internal/browser" "github.com/cnjack/jcode/internal/channel" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/flow" "github.com/cnjack/jcode/internal/handler" - "github.com/cnjack/jcode/internal/hooks" "github.com/cnjack/jcode/internal/mode" "github.com/cnjack/jcode/internal/model" "github.com/cnjack/jcode/internal/runner" @@ -596,3456 +585,6 @@ func (s *Server) handleWorkspace(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { - if s.needsSetup { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "setup required: please configure a provider first"}) - return - } - - var req struct { - Message string `json:"message"` - Images []chatImage `json:"images,omitempty"` // optional: base64-encoded images - Mode string `json:"mode,omitempty"` // "build" or "plan" - SessionID string `json:"session_id,omitempty"` // optional: the task (session) to run - } - if err := json.NewDecoder(io.LimitReader(r.Body, 20<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - if strings.TrimSpace(req.Message) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"}) - return - } - - modeStr := req.Mode - if modeStr == "" { - modeStr = s.activeMode() - } - - // Resolve (or lazily create) the engine for this task. Different tasks run - // concurrently; the per-task running flag only blocks double-running the SAME - // task. - eng, err := s.engineForChat(req.SessionID, modeStr) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !eng.running.CompareAndSwap(false, true) { - writeJSON(w, http.StatusConflict, map[string]string{ - "error": "this task is already processing a request", - }) - return - } - - sessionID := s.submitMessage(eng, req.Message, modeStr, "", req.SessionID, req.Images) - writeJSON(w, http.StatusAccepted, map[string]string{"status": "processing", "session_id": sessionID}) -} - -// engineForChat resolves the engine a chat request targets. An empty task id (or -// one matching the active task) uses the active engine; a known live task uses -// its engine; an unknown id lazily spins up a fresh engine for it (a new task or -// the first message of a not-yet-live task), rooted at the active task's pwd. -func (s *Server) engineForChat(taskID, modeStr string) (*Engine, error) { - if eng := s.resolveEngine(taskID); eng != nil { - return eng, nil - } - pwd := "" - if a := s.activeEngine(); a != nil { - pwd = a.pwd - } - return s.buildLocalEngine(taskID, pwd, modeStr) -} - -// chatImage represents a base64-encoded image in a chat request. -type chatImage struct { - Data string `json:"data"` // base64 data (without data: prefix) - MimeType string `json:"media_type"` // e.g. "image/png", "image/jpeg" -} - -// SubmitMessage submits a message for agent processing from an external source -// (e.g. WeChat inbound message). Returns false if the agent is busy. -func (s *Server) SubmitMessage(message, source string) bool { - eng := s.activeEngine() - if eng == nil { - return false - } - if !eng.running.CompareAndSwap(false, true) { - return false - } - s.submitMessage(eng, message, eng.curMode(), source, "", nil) - return true -} - -// submitMessage is the shared implementation for starting an agent run. -// source is an optional label (e.g. "wechat") for the user_message event. -// sessionID is an optional session identifier from the client to ensure -// continuity — if the current recorder has a different UUID, resume the -// correct session instead of creating a new one. -// images is an optional list of base64-encoded images to include in the message. -// The caller must have already set eng.running to true (via CompareAndSwap). -// Returns the session_id of the recorder used. -func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID string, images []chatImage) string { - // Slash command rewrite: if the original message starts with "/", check for - // skill slash commands and rewrite to load_skill instruction (same pattern as - // ACP/TUI). This must happen BEFORE the plan-mode prefix is applied, otherwise - // HasPrefix("/"…) would fail against the prefixed string. - agentMsg := message - if strings.HasPrefix(message, "/") { - cmd := strings.TrimPrefix(message, "/") - parts := strings.SplitN(cmd, " ", 2) - cmdName := parts[0] - userInput := "" - if len(parts) > 1 { - userInput = parts[1] - } - matchedSkill := false - if s.skillLoader != nil { - if sk := s.skillLoader.GetBySlash("/" + cmdName); sk != nil { - var sb strings.Builder - fmt.Fprintf(&sb, "Use the load_skill tool with name=%q and follow its instructions.", sk.Name) - if userInput != "" { - sb.WriteString("\n\nAdditional context: ") - sb.WriteString(userInput) - } - agentMsg = sb.String() - matchedSkill = true - } - } - // Otherwise check workflow slash commands (e.g. /repo-audit) against this - // task's project loader so its .jcode/workflows resolve. - if fl := s.flowLoaderFor(eng); !matchedSkill && fl != nil { - if wf, ok := fl.GetBySlash("/" + cmdName); ok { - agentMsg = flow.SlashRunPrompt(wf.Meta.Name, userInput) - } - } - } - - // Plan mode no longer needs an inline prompt prefix: the agent is rebuilt with - // the read-only plan system prompt + tool set on mode switch (handleSwitchMode), - // matching TUI/ACP. The mode arg is retained for the recorder/event context. - _ = mode - - // Emit user_message event for external sources (e.g. WeChat) so web clients see it. - // Web-originated messages are already added by the frontend's sendMessage(). - if source != "" { - eng.handler.Emit("user_message", map[string]string{ - "content": message, - "source": source, - }) - } - - // Ensure a recorder exists (lazy creation on first message). - // If the client provided a session_id and the current recorder differs, - // resume the client's session to prevent creating a duplicate. - eng.emu.Lock() - if eng.recorder == nil { - rec, _ := session.NewRecorder(eng.pwd, eng.providerName, eng.modelName) - if sessionID != "" { - rec.SetUUID(sessionID) - } - eng.recorder = rec - } else if sessionID != "" && eng.recorder.UUID() != sessionID { - // Client is continuing a session that doesn't match the current recorder. - // Resume the client's session to keep all messages together. - eng.recorder.Close() - rec, _ := session.NewRecorder(eng.pwd, eng.providerName, eng.modelName) - rec.SetUUID(sessionID) - eng.recorder = rec - } - recorder := eng.recorder - eng.emu.Unlock() - - // Record user message. - if recorder != nil { - var entryImages []session.EntryImage - for _, img := range images { - entryImages = append(entryImages, session.EntryImage{ - MimeType: img.MimeType, - Data: img.Data, - }) - } - recorder.RecordUser(agentMsg, entryImages...) - } - - // Build the user message — include images as multimodal content if provided. - var userMsg *schema.Message - if len(images) > 0 { - parts := make([]schema.MessageInputPart, 0, len(images)+1) - parts = append(parts, schema.MessageInputPart{ - Type: schema.ChatMessagePartTypeText, - Text: agentMsg, - }) - for _, img := range images { - data := img.Data - parts = append(parts, schema.MessageInputPart{ - Type: schema.ChatMessagePartTypeImageURL, - Image: &schema.MessageInputImage{ - MessagePartCommon: schema.MessagePartCommon{ - MIMEType: img.MimeType, - Base64Data: &data, - }, - }, - }) - } - userMsg = &schema.Message{ - Role: schema.User, - Content: agentMsg, - UserInputMultiContent: parts, - } - } else { - userMsg = schema.UserMessage(agentMsg) - } - - eng.emu.Lock() - eng.history = append(eng.history, userMsg) - history := make([]adk.Message, len(eng.history)) - copy(history, eng.history) - agent := eng.agent - eng.emu.Unlock() - - // Stream response via WebSocket — run agent in background. Each task derives - // its own cancellable context so /stop cancels only that task. Fall back to - // Background if a run is somehow submitted before Start set the root context. - base := s.rootCtx() - if base == nil { - base = context.Background() - } - runCtx, runCancel := context.WithCancel(base) - eng.emu.Lock() - eng.runGen++ - gen := eng.runGen - eng.runCancel = runCancel - eng.emu.Unlock() - - go func() { - s.setTaskStatus(eng, true) - defer func() { - // Tear down only if this run is still the current one. If a newer turn - // on the same engine has already taken over (runGen advanced) it now - // owns running/runCancel — leave them so /stop still reaches the live - // run and we don't broadcast a spurious idle for it. Releasing running - // inside the same emu section that clears runCancel also closes the - // gate↔cancel interleave window the run-start CAS relies on. - eng.emu.Lock() - superseded := eng.runGen != gen - if !superseded { - eng.runCancel = nil - eng.running.Store(false) - } - eng.emu.Unlock() - if !superseded { - s.setTaskStatus(eng, false) - } - }() - - // Take a git snapshot before the agent run for session diff tracking. - s.takeSessionSnapshot(eng) - - // Inject the hook dispatcher so PreToolUse/PostToolUse/Stop hooks run on the - // Web surface too (parity with the TUI); reloaded per turn for hot-apply. - hookCtx := hooks.WithDispatcher(runCtx, hooks.NewSessionDispatcher(config.ConfigDir(), eng.env.Pwd(), recorder.UUID(), config.Logger().Printf)) - resp := runner.Run(hookCtx, agent, history, eng.eventHandler, recorder, eng.todoStore, eng.env.GoalStore, s.tracer, eng.tokenUsage) - if resp != "" { - eng.emu.Lock() - eng.history = append(eng.history, &schema.Message{Role: schema.Assistant, Content: resp}) - eng.emu.Unlock() - } - }() - - return recorder.UUID() -} - -// handleListAllTasks returns every session across all projects (flat list, -// each tagged with its project path) so the web sidebar can render a -// Workspace > Project > Task tree without switching the active project. -func (s *Server) handleListAllTasks(w http.ResponseWriter, r *http.Request) { - all, err := session.ListAllSessions() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - // Snapshot which task ids are currently running (live engines) so the sidebar - // can show a running indicator even on a fresh page load. - running := make(map[string]bool) - s.tasksMu.RLock() - for id, e := range s.tasks { - if e != nil && e.running.Load() { - running[id] = true - } - } - s.tasksMu.RUnlock() - - type taskItem struct { - UUID string `json:"uuid"` - Project string `json:"project"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at,omitempty"` - Provider string `json:"provider"` - Model string `json:"model"` - Title string `json:"title,omitempty"` - Pinned bool `json:"pinned"` - Archived bool `json:"archived"` - Unread bool `json:"unread"` - Status string `json:"status,omitempty"` - Running bool `json:"running"` - } - items := make([]taskItem, 0) - for project, metas := range all { - for _, m := range metas { - // Automation runs are surfaced on the Automations page ("Recent - // runs"), not the main task list — exclude them here so a nightly - // automation doesn't bury the sidebar. - if m.AutomationID != "" { - continue - } - items = append(items, taskItem{ - UUID: m.UUID, - Project: project, - CreatedAt: m.StartTime, - UpdatedAt: m.UpdatedAt, - Provider: m.Provider, - Model: m.Model, - Title: m.Title, - Pinned: m.Pinned, - Archived: m.Archived, - Unread: m.Unread, - Status: m.Status, - Running: running[m.UUID], - }) - } - } - writeJSON(w, http.StatusOK, items) -} - -// handleUpdateTask applies a partial metadata update (pin/archive/unread/title) -// to a task by uuid across all projects. -func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - var req struct { - Pinned *bool `json:"pinned"` - Archived *bool `json:"archived"` - Unread *bool `json:"unread"` - Title *string `json:"title"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - meta, err := session.UpdateSessionMeta(id, func(m *session.SessionMeta) { - if req.Pinned != nil { - m.Pinned = *req.Pinned - } - if req.Archived != nil { - m.Archived = *req.Archived - } - if req.Unread != nil { - m.Unread = *req.Unread - } - if req.Title != nil { - m.Title = *req.Title - } - m.UpdatedAt = time.Now().Format(time.RFC3339) - }) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if meta == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) - return - } - writeJSON(w, http.StatusOK, meta) -} - -func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { - metas, err := session.ListSessions(s.activePwd()) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - type sessionItem struct { - UUID string `json:"uuid"` - CreatedAt string `json:"created_at"` - Provider string `json:"provider"` - Model string `json:"model"` - Title string `json:"title,omitempty"` - } - - items := make([]sessionItem, 0, len(metas)) - for _, m := range metas { - items = append(items, sessionItem{ - UUID: m.UUID, - CreatedAt: m.StartTime, - Provider: m.Provider, - Model: m.Model, - Title: m.Title, - }) - } - writeJSON(w, http.StatusOK, items) -} - -func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - entries, err := session.LoadSession(id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusOK, entries) -} - -func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session id is required"}) - return - } - // Tear down the live engine for this task (if any) so its run is cancelled and - // resources reclaimed. The active foreground engine is left in place — but its - // recorder is reset to a fresh session so post-delete writes don't land in the - // now-unlinked file (silent data loss). - if eng := s.resolveEngine(id); eng != nil { - eng.emu.Lock() - cancel := eng.runCancel - eng.emu.Unlock() - if cancel != nil { - cancel() - } - if eng != s.activeEngine() { - s.deleteEngine(id) - } else { - // Active task: wait for the cancelled run to drain so its final - // RecordAssistant/usage writes land before we close + reset the recorder - // (a post-close write would re-create and truncate the file). - for i := 0; i < 200 && eng.running.Load(); i++ { - time.Sleep(5 * time.Millisecond) - } - eng.emu.Lock() - if eng.recorder != nil && eng.recorder.UUID() == id { - eng.recorder.Close() - eng.recorder = nil - eng.history = nil - } - eng.emu.Unlock() - } - } - - // Resolve the owning project across all projects: a task deleted from the - // sidebar tree may not belong to the active project. - if _, err := session.DeleteSessionByUUID(id); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -func (s *Server) handleTruncateHistory(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) - return - } - if eng.running.Load() { - writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is currently running"}) - return - } - - var req struct { - // BeforeUserMessage: keep all history entries that come before the - // Nth user message (0-indexed). Everything from that user message - // onward is discarded. Pass 0 to clear everything. - BeforeUserMessage int `json:"before_user_message"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - - // Capture the recorder under eng.emu (same lock submitMessage uses) but do - // file I/O outside the lock. - eng.emu.Lock() - rec := eng.recorder - eng.emu.Unlock() - sessionID := "" - if rec != nil { - sessionID = rec.UUID() - } - - // Persist first — if the file rewrite fails we abort without touching - // the in-memory history so state never diverges. - if rec != nil { - if err := rec.TruncateAtUserMessage(req.BeforeUserMessage); err != nil { - config.Logger().Printf("[truncate] rewrite session file failed: %v", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to truncate session file"}) - return - } - } - - // Now truncate in-memory history under eng.emu. - eng.emu.Lock() - truncAt := 0 - if req.BeforeUserMessage > 0 { - userCount := 0 - truncAt = len(eng.history) // default: keep all - for i, msg := range eng.history { - if msg.Role == schema.User { - if userCount == req.BeforeUserMessage { - truncAt = i - break - } - userCount++ - } - } - } - if truncAt == 0 { - eng.history = nil - } else { - eng.history = eng.history[:truncAt] - } - eng.emu.Unlock() - - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "session_id": sessionID, - }) -} - -func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { - // Parse optional resume session ID + project. Creating a task no longer - // blocks on "is the agent running" — tasks run concurrently. - var req struct { - SessionID string `json:"session_id,omitempty"` - Pwd string `json:"pwd,omitempty"` - } - // The body is optional (empty = brand-new task → EOF), but a non-empty - // malformed body should be rejected rather than creating a zero-value task. - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil && err != io.EOF { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - - // Already-live task: just focus it (do not disturb its run). - if req.SessionID != "" { - if eng := s.resolveEngine(req.SessionID); eng != nil { - s.setActiveEngine(eng) - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) - return - } - } - - if s.newEngine == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "task creation is not supported"}) - return - } - - // Each new/resumed task gets its OWN engine (env, agent, recorder, handler), - // so it runs independently of every other task. - pwd := req.Pwd - if pwd == "" { - if a := s.activeEngine(); a != nil { - pwd = a.pwd - } - } - eng, err := s.buildLocalEngine(req.SessionID, pwd, s.activeMode()) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - // Resume: hydrate the fresh engine with the persisted conversation/todos/goal. - if req.SessionID != "" { - entries, lerr := session.LoadSession(req.SessionID) - if lerr != nil { - // Stale/nonexistent session id: don't silently register a phantom empty - // engine under it — tear the just-built engine down and report not-found. - s.deleteEngine(eng.taskID) - writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) - return - } - st := session.ReconstructState(entries) - eng.emu.Lock() - eng.history = st.History - eng.emu.Unlock() - if eng.todoStore != nil { - items := make([]tools.TodoItem, len(st.Todos)) - for i, t := range st.Todos { - items[i] = tools.TodoItem{ID: t.ID, Title: t.Title, Status: tools.TodoStatus(t.Status)} - } - eng.todoStore.Update(items) - } - if eng.env != nil && eng.env.GoalStore != nil { - eng.env.GoalStore.RestoreFromSnapshot(st.Goal) - if eng.handler != nil { - eng.handler.Emit("goal_update", eng.env.GoalStore.Get()) - } - } - } - - s.setActiveEngine(eng) - - // Brand-new task: tell its view to start clean. - if req.SessionID == "" { - s.wsBroker.Broadcast(WSEvent{TaskID: eng.taskID, Type: "session_reset", Data: map[string]string{}}) - } - - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) -} - -func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { - curProvider, curModel := "", "" - if eng := s.activeEngine(); eng != nil { - curProvider, curModel, _ = eng.modelSnapshot() - } - if s.registry == nil || s.cfg == nil { - writeJSON(w, http.StatusOK, map[string]any{ - "current": map[string]string{"provider": curProvider, "model": curModel}, - "providers": []any{}, - }) - return - } - - type modelInfo struct { - ID string `json:"id"` - Name string `json:"name"` - ToolCall bool `json:"tool_call"` - ContextLimit int `json:"context_limit,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` - Recommended bool `json:"recommended,omitempty"` - DefaultEnabled bool `json:"default_enabled,omitempty"` - Enabled bool `json:"enabled"` - ImageSupport bool `json:"image_support,omitempty"` - ReasoningOptions []model.ReasoningOption `json:"reasoning_options,omitempty"` - } - type providerInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Custom bool `json:"custom,omitempty"` - Models []modelInfo `json:"models"` - } - - modelState, _ := config.LoadModelState() - - // Rebuild the registry from the live config so models added at runtime - // (custom models saved via the providers API) appear immediately in the chat - // model picker. The startup registry (s.registry) is a snapshot and would not - // reflect these additions until a restart. - registry := model.NewModelRegistryWithConfig(s.cfg) - - var result []providerInfo - configuredProviders := s.cfg.GetProviders() - for _, rp := range registry.ListProviders() { - if _, configured := configuredProviders[rp.ID]; !configured { - continue - } - models := registry.ListProviderModels(rp.ID, true) - if len(models) == 0 { - continue - } - pi := providerInfo{ID: rp.ID, Name: rp.Name, Custom: rp.Custom} - for _, m := range models { - ctx := 0 - if m.Limit != nil { - ctx = m.Limit.Context - } - ref := config.ModelRef{Provider: rp.ID, Model: m.ID} - enabled := modelState.IsModelEnabled(ref, m.DefaultEnabled) - imageSupport := false - if m.Modalities != nil { - for _, mod := range m.Modalities.Input { - if mod == "image" { - imageSupport = true - break - } - } - } - pi.Models = append(pi.Models, modelInfo{ - ID: m.ID, Name: m.Name, ToolCall: m.ToolCall, ContextLimit: ctx, - Reasoning: m.Reasoning, Recommended: m.Recommended, - DefaultEnabled: m.DefaultEnabled, Enabled: enabled, - ImageSupport: imageSupport, - ReasoningOptions: m.ReasoningOptions, - }) - } - result = append(result, pi) - } - - writeJSON(w, http.StatusOK, map[string]any{ - "current": map[string]string{"provider": curProvider, "model": curModel}, - "providers": result, - }) -} - -func (s *Server) handleSwitchModel(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) - return - } - // No running gate: applyModelSwitch swaps eng.agent under eng.emu (the lock the - // run reads it under), so a mid-run switch is safe and takes effect next turn — - // consistent with mode/approval switching. - - var req struct { - Provider string `json:"provider"` - Model string `json:"model"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.Provider == "" || req.Model == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) - return - } - - // Rebuild THIS task's agent for the new model and swap it in under eng.emu - // (the same lock submitMessage uses to read the agent). Keep history. - ag, err := eng.createAgent(req.Provider, req.Model) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - eng.applyModelSwitch(ag, req.Provider, req.Model) - - // Track in recent models. - if state, err := config.LoadModelState(); err == nil { - state.AddRecent(config.ModelRef{Provider: req.Provider, Model: req.Model}) - _ = config.SaveModelState(state) - } - - s.wsBroker.Broadcast(WSEvent{Type: "model_changed", TaskID: eng.taskID, Data: map[string]string{ - "provider": req.Provider, - "model": req.Model, - }}) - - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -func (s *Server) handleSwitchMode(w http.ResponseWriter, r *http.Request) { - var req struct { - Mode string `json:"mode"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - // Accept only the three canonical unified mode ids. - switch req.Mode { - case "approval", "plan", "full_access": - default: - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "mode must be 'approval', 'plan', or 'full_access'"}) - return - } - sm := mode.Parse(req.Mode) - - eng := s.activeEngine() - if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) - return - } - // No running gate: applyModeSwitch writes eng.agent under eng.emu, the same - // lock submitMessage reads it under, so a mid-run switch is safe and simply - // takes effect on the next turn (matching TUI/ACP and the "Allow all" path). - - // Rebuild this task's agent FIRST. If the rebuild fails, abort without - // changing the mode/approval axis — otherwise plan mode could be reported while - // a write-capable agent stays live. - var newAg *adk.ChatModelAgent - if eng.rebuildForMode != nil { - ag, err := eng.rebuildForMode(sm.IsPlan()) - if err != nil { - config.Logger().Printf("[web] mode switch agent rebuild error: %v", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to switch mode"}) - return - } - newAg = ag - } - if eng.approvalState != nil { - eng.approvalState.SetSessionMode(sm) // approval axis (Full access → auto) - } - eng.applyModeSwitch(sm.String(), newAg) - - s.wsBroker.Broadcast(WSEvent{Type: "mode_changed", TaskID: eng.taskID, Data: map[string]string{ - "mode": sm.String(), - }}) - - writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "mode": sm.String()}) -} - -func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { - cfg, err := config.LoadConfig() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - // Return safe subset: no API keys. - providerName, modelName := cfg.GetProviderModel() - writeJSON(w, http.StatusOK, map[string]any{ - "provider": providerName, - "model": modelName, - "max_iterations": cfg.MaxIterations, - }) -} - -func (s *Server) handleGetTodos(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil || eng.todoStore == nil { - writeJSON(w, http.StatusOK, []any{}) - return - } - writeJSON(w, http.StatusOK, eng.todoStore.Items()) -} - -// handleGetGoal returns the current session goal (or null when none is set). -func (s *Server) handleGetGoal(w http.ResponseWriter, _ *http.Request) { - eng := s.activeEngine() - if eng == nil || eng.env == nil || eng.env.GoalStore == nil { - writeJSON(w, http.StatusOK, nil) - return - } - writeJSON(w, http.StatusOK, eng.env.GoalStore.Get()) -} - -// handleSetGoal sets (or replaces) the session goal. Unless start=false, it also -// kicks off an agent run so work begins immediately. -func (s *Server) handleSetGoal(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil || eng.env == nil || eng.env.GoalStore == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "goals not available"}) - return - } - var req struct { - Objective string `json:"objective"` - Start *bool `json:"start,omitempty"` // default true - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - objective, err := tools.ValidateGoalObjective(req.Objective) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - g := eng.env.GoalStore.Set(objective) - - if req.Start == nil || *req.Start { - // Start working immediately when idle; if busy, the continuation guard - // will pick the goal up after the current run finishes. Targets the active - // task. - if eng.running.CompareAndSwap(false, true) { - s.submitMessage(eng, tools.GoalKickoffPrompt(objective), eng.curMode(), "", "", nil) - } - } - writeJSON(w, http.StatusOK, g) -} - -// handleClearGoal removes the session goal. -func (s *Server) handleClearGoal(w http.ResponseWriter, _ *http.Request) { - if eng := s.activeEngine(); eng != nil && eng.env != nil && eng.env.GoalStore != nil { - eng.env.GoalStore.Clear() - } - writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"}) -} - -func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { - var req struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - Approved bool `json:"approved"` - ApproveAll bool `json:"approve_all"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - // Route the resolve to the requesting task's handler. resolveEngine maps an - // empty task_id to the active task (legacy clients) but a NON-empty unknown id - // to nil — so a stray id can't resolve against the active task's handler-local - // approval ids. - reng := s.resolveEngine(req.TaskID) - if reng == nil || reng.handler == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"}) - return - } - if err := reng.handler.ResolveApproval(req.ID, req.Approved, req.ApproveAll); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - // "Allow all" promotes that task to auto-approve (the runner flips its - // ApprovalState on resolve). Mirror it onto that task's mode + selector. - s.syncModeAfterApproval(reng, req.Approved, req.ApproveAll) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -// syncModeAfterApproval reflects an approve-all promotion onto the server's -// user-facing mode state and notifies connected clients. A plain single approve -// (or a deny) leaves the mode untouched. The runner's ApprovalState is the -// source of truth for the approval axis; this only projects it onto the unified -// selector the frontend renders. -func (s *Server) syncModeAfterApproval(eng *Engine, approved, approveAll bool) { - if !approved || !approveAll || eng == nil { - return - } - sm := mode.FullAccess - eng.applyModeSwitch(sm.String(), nil) - s.wsBroker.Broadcast(WSEvent{Type: "mode_changed", TaskID: eng.taskID, Data: map[string]string{ - "mode": sm.String(), - }}) -} - -// handlePendingApproval returns approval requests still awaiting a decision. -// The frontend pulls this after rebuilding the timeline (page reload / session -// resume / WS reconnect) so an in-flight approval is re-attached as a card -// instead of leaving the agent blocked forever. -func (s *Server) handlePendingApproval(w http.ResponseWriter, r *http.Request) { - // Empty task_id → active task; non-empty unknown → empty (don't leak another - // task's pending requests under a stray id). - eng := s.resolveEngine(r.URL.Query().Get("task_id")) - if eng == nil || eng.handler == nil { - writeJSON(w, http.StatusOK, []handler.WebApprovalRequestData{}) - return - } - writeJSON(w, http.StatusOK, eng.handler.PendingApprovalRequests()) -} - -// handleAskUser resolves a pending ask_user request with the user's answers, -// routed back to the blocked tool via WebHandler.ResolveAskUser. The "answers" -// array is parallel to the questions the frontend received in ask_user_request: -// each carries the question header plus either a free-text answer or selected -// option labels. -func (s *Server) handleAskUser(w http.ResponseWriter, r *http.Request) { - var req struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - Answers []struct { - QuestionHeader string `json:"question_header"` - Answer string `json:"answer"` - Selected []string `json:"selected"` - } `json:"answers"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - - resp := tools.AskUserBatchResponse{} - for _, a := range req.Answers { - resp.Answers = append(resp.Answers, tools.AskUserAnswer{ - QuestionHeader: a.QuestionHeader, - Answer: a.Answer, - Selected: a.Selected, - }) - } - - // Route the answer to the requesting task's handler. Empty task_id → active; - // non-empty unknown → reject (ids are handler-local). - eng := s.resolveEngine(req.TaskID) - if eng == nil || eng.handler == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"}) - return - } - if err := eng.handler.ResolveAskUser(req.ID, resp); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -// handlePendingAskUser returns ask_user questions still awaiting an answer. -// The frontend pulls this after rebuilding the timeline (page reload / session -// resume) so an in-flight question is re-attached to its tool card instead of -// leaving the agent blocked forever. -func (s *Server) handlePendingAskUser(w http.ResponseWriter, r *http.Request) { - eng := s.resolveEngine(r.URL.Query().Get("task_id")) - if eng == nil || eng.handler == nil { - writeJSON(w, http.StatusOK, []handler.WebAskUserRequestData{}) - return - } - writeJSON(w, http.StatusOK, eng.handler.PendingAskUserRequests()) -} - -// withinWorkspace reports whether abs is the workspace root or strictly inside -// it. Uses filepath.Rel rather than strings.HasPrefix so a sibling like /repo2 -// can't escape /repo, and an empty root rejects everything. -func withinWorkspace(root, abs string) bool { - if root == "" { - return false - } - rel, err := filepath.Rel(root, abs) - if err != nil { - return false - } - return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) -} - -func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) { - pwd := s.activePwd() - dir := r.URL.Query().Get("path") - if dir == "" { - dir = pwd - } else if !filepath.IsAbs(dir) { - dir = filepath.Join(pwd, dir) - } - - // Prevent path traversal / sibling escape. - abs := filepath.Clean(dir) - if !withinWorkspace(pwd, abs) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) - return - } - - entries, err := os.ReadDir(abs) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - type fileItem struct { - Name string `json:"name"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - } - - items := make([]fileItem, 0, len(entries)) - for _, e := range entries { - info, _ := e.Info() - size := int64(0) - if info != nil { - size = info.Size() - } - items = append(items, fileItem{ - Name: e.Name(), - IsDir: e.IsDir(), - Size: size, - }) - } - writeJSON(w, http.StatusOK, items) -} - -func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"}) - return - } - - pwd := s.activePwd() - abs := path - if !filepath.IsAbs(abs) { - abs = filepath.Join(pwd, abs) - } - - // Prevent path traversal / sibling escape. - abs = filepath.Clean(abs) - if !withinWorkspace(pwd, abs) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path outside workspace"}) - return - } - - content, err := os.ReadFile(abs) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - - // Limit file size to 1MB. - if len(content) > 1<<20 { - writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{ - "error": "file too large (>1MB)", - }) - return - } - - writeJSON(w, http.StatusOK, map[string]string{ - "path": abs, - "content": string(content), - }) -} - -func (s *Server) handleExec(w http.ResponseWriter, r *http.Request) { - var req struct { - Command string `json:"command"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if strings.TrimSpace(req.Command) == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "command is required"}) - return - } - - ctx, cancel := context.WithTimeout(s.rootCtx(), 30*1e9) // 30 seconds - defer cancel() - - cmd := exec.CommandContext(ctx, "sh", "-c", req.Command) - cmd.Dir = s.activePwd() - - output, err := cmd.CombinedOutput() - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - } - - // Truncate output to 256KB - out := string(output) - if len(out) > 256*1024 { - out = out[:256*1024] + "\n... (truncated)" - } - - writeJSON(w, http.StatusOK, map[string]any{ - "output": out, - "exit_code": exitCode, - }) -} - -func (s *Server) handleDiff(w http.ResponseWriter, r *http.Request) { - mode := r.URL.Query().Get("mode") - if mode == "" { - mode = "working" - } - - // "session" mode: diff between snapshot taken at agent run start and current state. - if mode == "session" { - s.handleSessionDiff(w, r) - return - } - - var args []string - switch mode { - case "staged": - args = []string{"diff", "--cached", "--no-color"} - case "branch": - args = []string{"diff", "HEAD~1", "--no-color"} - default: // "working" - args = []string{"diff", "--no-color"} - } - - cmd := exec.CommandContext(s.rootCtx(), "git", args...) - cmd.Dir = s.activePwd() - cmd.Env = utils.ScrubbedGitEnv() - output, _ := cmd.CombinedOutput() - - // Parse diff into structured entries - type diffEntry struct { - File string `json:"file"` - Patch string `json:"patch"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - Status string `json:"status"` // "M", "A", "D" - } - - var entries []diffEntry - rawDiff := string(output) - - // Also get changed file list for status - statCmd := exec.CommandContext(s.rootCtx(), "git", "diff", "--stat", "--no-color") - statCmd.Env = utils.ScrubbedGitEnv() - switch mode { - case "staged": - statCmd = exec.CommandContext(s.rootCtx(), "git", "diff", "--cached", "--stat", "--no-color") - statCmd.Env = utils.ScrubbedGitEnv() - case "branch": - statCmd = exec.CommandContext(s.rootCtx(), "git", "diff", "HEAD~1", "--stat", "--no-color") - statCmd.Env = utils.ScrubbedGitEnv() - } - statCmd.Dir = s.activePwd() - _, _ = statCmd.CombinedOutput() - - // Parse unified diff into per-file entries - sections := splitDiffByFile(rawDiff) - for _, sec := range sections { - adds, dels := countDiffLines(sec.patch) - entries = append(entries, diffEntry{ - File: sec.file, - Patch: sec.patch, - Additions: adds, - Deletions: dels, - Status: sec.status, - }) - } - - if entries == nil { - entries = []diffEntry{} - } - - writeJSON(w, http.StatusOK, map[string]any{ - "mode": mode, - "entries": entries, - }) -} - -type diffSection struct { - file string - patch string - status string -} - -func splitDiffByFile(raw string) []diffSection { - var sections []diffSection - lines := strings.Split(raw, "\n") - var current *diffSection - var patchLines []string - - for _, line := range lines { - if strings.HasPrefix(line, "diff --git ") { - // Flush previous - if current != nil { - current.patch = strings.Join(patchLines, "\n") - sections = append(sections, *current) - } - // Parse file name from "diff --git a/foo b/foo" - parts := strings.SplitN(line, " b/", 2) - file := "" - if len(parts) == 2 { - file = parts[1] - } - current = &diffSection{file: file, status: "M"} - patchLines = []string{line} - } else if current != nil { - patchLines = append(patchLines, line) - if strings.HasPrefix(line, "new file") { - current.status = "A" - } else if strings.HasPrefix(line, "deleted file") { - current.status = "D" - } - } - } - if current != nil { - current.patch = strings.Join(patchLines, "\n") - sections = append(sections, *current) - } - return sections -} - -func countDiffLines(patch string) (adds, dels int) { - scanner := bufio.NewScanner(strings.NewReader(patch)) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { - adds++ - } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { - dels++ - } - } - return -} - -// takeSessionSnapshot records the current git working tree state -// so that session-scoped diffs can be computed later. -func (s *Server) takeSessionSnapshot(eng *Engine) { - if eng == nil { - return - } - // Use "git stash create" to get a tree-ish of the current state without - // actually stashing. If there are no changes, use HEAD. - cmd := exec.CommandContext(s.rootCtx(), "git", "stash", "create") - cmd.Dir = eng.pwd - cmd.Env = utils.ScrubbedGitEnv() - out, err := cmd.Output() - snapshot := strings.TrimSpace(string(out)) - if err != nil || snapshot == "" { - // No local changes — use HEAD as baseline - cmd2 := exec.CommandContext(s.rootCtx(), "git", "rev-parse", "HEAD") - cmd2.Dir = eng.pwd - cmd2.Env = utils.ScrubbedGitEnv() - out2, _ := cmd2.Output() - snapshot = strings.TrimSpace(string(out2)) - } - eng.emu.Lock() - eng.sessionSnapshot = snapshot - eng.emu.Unlock() -} - -// handleSessionDiff computes the diff between the session start snapshot and current state. -func (s *Server) handleSessionDiff(w http.ResponseWriter, _ *http.Request) { - // Capture the active engine ONCE so the snapshot and the working dir come - // from the same task's repo even if the active engine is swapped between the - // two reads (otherwise we could diff engine A's snapshot against engine B's - // tree). eng.pwd is immutable after creation, so reading it bare is safe. - eng := s.activeEngine() - snapshot := "" - pwd := "" - if eng != nil { - eng.emu.Lock() - snapshot = eng.sessionSnapshot - eng.emu.Unlock() - pwd = eng.pwd - } - - type diffEntry struct { - File string `json:"file"` - Patch string `json:"patch"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - Status string `json:"status"` - } - - if snapshot == "" { - writeJSON(w, http.StatusOK, map[string]any{ - "mode": "session", - "entries": []diffEntry{}, - }) - return - } - - // Diff from snapshot to current working tree - cmd := exec.CommandContext(s.rootCtx(), "git", "diff", snapshot, "--no-color") - cmd.Dir = pwd - cmd.Env = utils.ScrubbedGitEnv() - output, _ := cmd.CombinedOutput() - - var entries []diffEntry - sections := splitDiffByFile(string(output)) - for _, sec := range sections { - adds, dels := countDiffLines(sec.patch) - entries = append(entries, diffEntry{ - File: sec.file, - Patch: sec.patch, - Additions: adds, - Deletions: dels, - Status: sec.status, - }) - } - - if entries == nil { - entries = []diffEntry{} - } - - writeJSON(w, http.StatusOK, map[string]any{ - "mode": "session", - "entries": entries, - }) -} - -// mcpLoginState tracks an in-progress or finished OAuth login for a server. -type mcpLoginState struct { - Status string `json:"status"` // pending | authorized | error | needs_client_id - AuthURL string `json:"auth_url,omitempty"` - Message string `json:"message,omitempty"` -} - -// mcpServerView is the JSON shape returned for one MCP server in the list and -// CRUD responses — enough for the management UI's status badges and edit form. -type mcpServerView struct { - Name string `json:"name"` - Type string `json:"type"` - URL string `json:"url,omitempty"` - Command string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` - Env []string `json:"env,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - Timeout int `json:"timeout,omitempty"` - Enabled bool `json:"enabled"` - OAuth bool `json:"oauth"` // OAuth enabled for this server - HasAuth bool `json:"has_auth"` // a token is stored - Status string `json:"status"` // connected | needs_auth | error | disabled | configured - Error string `json:"error,omitempty"` -} - -// mcpServerReq is the request body for creating/updating an MCP server. -type mcpServerReq struct { - Name string `json:"name"` - Type string `json:"type"` // local|stdio|http|sse - URL string `json:"url"` - Command string `json:"command"` - Args []string `json:"args"` - Env []string `json:"env"` - Headers map[string]string `json:"headers"` - Timeout int `json:"timeout"` - OAuth *struct { - Enabled bool `json:"enabled"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - Scopes []string `json:"scopes"` - } `json:"oauth"` -} - -// serverFromReq builds a config.MCPServer from a request body, normalizing the -// transport ("local" → "stdio") and preserving any existing OAuth token state. -func serverFromReq(req *mcpServerReq) (*config.MCPServer, error) { - srv := &config.MCPServer{ - Headers: req.Headers, - TimeoutSeconds: req.Timeout, - } - t := req.Type - if t == "local" { - t = "stdio" - } - switch t { - case "http", "sse": - if req.URL == "" { - return nil, fmt.Errorf("url is required for %s servers", t) - } - srv.Type = t - srv.URL = req.URL - case "stdio", "": - if req.Command == "" { - return nil, fmt.Errorf("command is required for local servers") - } - srv.Type = "stdio" - srv.Command = req.Command - srv.Args = req.Args - srv.Env = req.Env - default: - return nil, fmt.Errorf("unknown server type %q (use local, http, or sse)", req.Type) - } - if req.OAuth != nil && (req.OAuth.Enabled || req.OAuth.ClientID != "" || len(req.OAuth.Scopes) > 0) { - srv.OAuth = &config.MCPOAuthConfig{ - Enabled: req.OAuth.Enabled || req.OAuth.ClientID != "", - ClientID: req.OAuth.ClientID, - ClientSecret: req.OAuth.ClientSecret, - Scopes: req.OAuth.Scopes, - } - } - return srv, nil -} - -func cloneMCPServers(in map[string]*config.MCPServer) map[string]*config.MCPServer { - if len(in) == 0 { - return nil - } - out := make(map[string]*config.MCPServer, len(in)) - for name, srv := range in { - if srv == nil { - out[name] = nil - continue - } - cp := *srv - cp.Args = append([]string(nil), srv.Args...) - cp.Env = append([]string(nil), srv.Env...) - if srv.Headers != nil { - cp.Headers = make(map[string]string, len(srv.Headers)) - for k, v := range srv.Headers { - cp.Headers[k] = v - } - } - if srv.OAuth != nil { - oa := *srv.OAuth - oa.Scopes = append([]string(nil), srv.OAuth.Scopes...) - cp.OAuth = &oa - } - out[name] = &cp - } - return out -} - -// ReloadMCPInBackground connects configured MCP servers without blocking web -// startup. Slow or unreachable MCP servers should update settings/tool state -// when they finish, never delay /api/health or the desktop window. -func (s *Server) ReloadMCPInBackground() { - if s.reloadMCP == nil { - return - } - go func() { - config.Logger().Printf("[web] loading MCP tools in background") - if err := s.reloadMCPAndRebuild(); err != nil { - config.Logger().Printf("[web] background MCP reload failed: %v", err) - } else { - config.Logger().Printf("[web] background MCP reload finished") - } - s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"source": "startup"}}) - }() -} - -// reloadMCPAndRebuild reconnects MCP servers from the current config and -// rebuilds the live agent so new tools take effect without a restart. -func (s *Server) reloadMCPAndRebuild() error { - if s.reloadMCP != nil { - s.mu.RLock() - servers := cloneMCPServers(s.cfg.MCPServers) - s.mu.RUnlock() - statuses, err := s.reloadMCP(servers) - if err != nil { - return err - } - s.mu.Lock() - s.mcpStatuses = make(map[string]tools.MCPStatus, len(statuses)) - for _, st := range statuses { - s.mcpStatuses[st.Name] = st - } - s.mu.Unlock() - } - if !s.needsSetup { - // Rebuild the foreground task's agent so the new MCP tools take effect. - if eng := s.activeEngine(); eng != nil && eng.createAgent != nil { - prov, mod, _ := eng.modelSnapshot() - ag, err := eng.createAgent(prov, mod) - if err != nil { - return err - } - eng.setAgent(ag) - } - } - return nil -} - -// mcpServerStatus derives the UI status string for a server from its config and -// last-known connection status. -func (s *Server) mcpServerStatus(name string, srv *config.MCPServer) (status, errMsg string) { - if srv.Disabled { - return "disabled", "" - } - st, ok := s.mcpStatuses[name] - switch { - case !ok: - return "configured", "" - case st.NeedsAuth: - return "needs_auth", "" - case st.Running: - return "connected", "" - case st.Error != nil: - return "error", st.Error.Error() - default: - return "configured", "" - } -} - -func (s *Server) handleListMCP(w http.ResponseWriter, r *http.Request) { - s.mu.RLock() - defer s.mu.RUnlock() - servers := make(map[string]mcpServerView) - if s.cfg != nil { - for name, srv := range s.cfg.MCPServers { - status, errMsg := s.mcpServerStatus(name, srv) - servers[name] = mcpServerView{ - Name: name, - Type: srv.Type, - URL: srv.URL, - Command: srv.Command, - Args: srv.Args, - Env: srv.Env, - Headers: srv.Headers, - Timeout: srv.TimeoutSeconds, - Enabled: !srv.Disabled, - OAuth: srv.OAuth != nil && srv.OAuth.Enabled, - HasAuth: tools.HasMCPOAuthToken(name), - Status: status, - Error: errMsg, - } - } - } - writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) -} - -func (s *Server) handleCreateMCP(w http.ResponseWriter, r *http.Request) { - var req mcpServerReq - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<18)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.Name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) - return - } - srv, err := serverFromReq(&req) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - s.mu.Lock() - if s.cfg.MCPServers == nil { - s.cfg.MCPServers = make(map[string]*config.MCPServer) - } - if _, exists := s.cfg.MCPServers[req.Name]; exists { - s.mu.Unlock() - writeJSON(w, http.StatusConflict, map[string]string{"error": "a server with that name already exists"}) - return - } - s.cfg.MCPServers[req.Name] = srv - if err := config.SaveConfig(s.cfg); err != nil { - s.mu.Unlock() - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - s.mu.Unlock() - - if err := s.reloadMCPAndRebuild(); err != nil { - config.Logger().Printf("[web] mcp create reload failed: %v", err) - } - s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": req.Name}}) - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": req.Name}) -} - -func (s *Server) handleUpdateMCP(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - var req mcpServerReq - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<18)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - srv, err := serverFromReq(&req) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - s.mu.Lock() - existing, ok := s.cfg.MCPServers[name] - if !ok { - s.mu.Unlock() - writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) - return - } - // Preserve disabled flag and any already-obtained OAuth client id/secret so - // editing other fields doesn't drop a working registration. - srv.Disabled = existing.Disabled - if srv.OAuth != nil && existing.OAuth != nil { - if srv.OAuth.ClientID == "" { - srv.OAuth.ClientID = existing.OAuth.ClientID - } - if srv.OAuth.ClientSecret == "" { - srv.OAuth.ClientSecret = existing.OAuth.ClientSecret - } - } - s.cfg.MCPServers[name] = srv - if err := config.SaveConfig(s.cfg); err != nil { - s.mu.Unlock() - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - s.mu.Unlock() - - if err := s.reloadMCPAndRebuild(); err != nil { - config.Logger().Printf("[web] mcp update reload failed: %v", err) - } - s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": name}}) - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": name}) -} - -func (s *Server) handleDeleteMCP(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - s.mu.Lock() - if _, ok := s.cfg.MCPServers[name]; !ok { - s.mu.Unlock() - writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) - return - } - delete(s.cfg.MCPServers, name) - delete(s.mcpStatuses, name) - delete(s.mcpLogins, name) - if err := config.SaveConfig(s.cfg); err != nil { - s.mu.Unlock() - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - s.mu.Unlock() - - _ = tools.DeleteMCPOAuthToken(name) - if err := s.reloadMCPAndRebuild(); err != nil { - config.Logger().Printf("[web] mcp delete reload failed: %v", err) - } - s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": name}}) - writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) -} - -func (s *Server) handleToggleMCP(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) - return - } - var req struct { - Enabled bool `json:"enabled"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - s.mu.Lock() - srv, ok := s.cfg.MCPServers[name] - if !ok { - s.mu.Unlock() - writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) - return - } - srv.Disabled = !req.Enabled - if err := config.SaveConfig(s.cfg); err != nil { - s.mu.Unlock() - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - s.mu.Unlock() - - if err := s.reloadMCPAndRebuild(); err != nil { - config.Logger().Printf("[web] mcp toggle reload failed: %v", err) - } - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": name, "enabled": req.Enabled}) -} - -// handleMCPLogin starts the OAuth authorization flow for an HTTP/SSE server in -// the background and opens the user's browser. Progress is polled via -// handleMCPLoginStatus. -func (s *Server) handleMCPLogin(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - s.mu.Lock() - srv, ok := s.cfg.MCPServers[name] - if !ok { - s.mu.Unlock() - writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) - return - } - if srv.URL == "" || (srv.Type != "http" && srv.Type != "sse") { - s.mu.Unlock() - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "OAuth login only applies to http/sse servers"}) - return - } - if existing := s.mcpLogins[name]; existing != nil && existing.Status == "pending" { - s.mu.Unlock() - writeJSON(w, http.StatusConflict, map[string]string{"error": "a login is already in progress"}) - return - } - if srv.OAuth == nil { - srv.OAuth = &config.MCPOAuthConfig{Enabled: true} - } - s.mcpLogins[name] = &mcpLoginState{Status: "pending"} - s.mu.Unlock() - - go s.runMCPLogin(name) - writeJSON(w, http.StatusOK, map[string]any{"status": "pending"}) -} - -func (s *Server) setMCPLogin(name, status, msg string) { - s.mu.Lock() - st := s.mcpLogins[name] - if st == nil { - st = &mcpLoginState{} - s.mcpLogins[name] = st - } - st.Status = status - st.Message = msg - s.mu.Unlock() -} - -func (s *Server) runMCPLogin(name string) { - ctx, cancel := context.WithTimeout(s.rootCtx(), 5*time.Minute) - defer cancel() - - s.mu.RLock() - srv := s.cfg.MCPServers[name] - s.mu.RUnlock() - if srv == nil { - s.setMCPLogin(name, "error", "server not found") - return - } - - err := tools.PerformMCPOAuthLogin(ctx, name, srv, func(authURL string) { - s.mu.Lock() - if st := s.mcpLogins[name]; st != nil { - st.AuthURL = authURL - } - s.mu.Unlock() - s.wsBroker.Broadcast(WSEvent{Type: "mcp_login", Data: map[string]string{"name": name, "auth_url": authURL}}) - openBrowser(authURL) - }) - if err != nil { - status := "error" - if errors.Is(err, tools.ErrOAuthNeedsClientID) { - status = "needs_client_id" - } - s.setMCPLogin(name, status, err.Error()) - config.Logger().Printf("[web] mcp login %q failed: %v", name, err) - return - } - - // Persist the (possibly dynamically registered) client id and enabled flag. - s.mu.Lock() - if saveErr := config.SaveConfig(s.cfg); saveErr != nil { - config.Logger().Printf("[web] mcp login %q: save config failed: %v", name, saveErr) - } - s.mu.Unlock() - - if reErr := s.reloadMCPAndRebuild(); reErr != nil { - config.Logger().Printf("[web] mcp login %q: reload failed: %v", name, reErr) - } - s.setMCPLogin(name, "authorized", "") - s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"name": name}}) -} - -func (s *Server) handleMCPLoginStatus(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - s.mu.RLock() - st := s.mcpLogins[name] - s.mu.RUnlock() - if st == nil { - writeJSON(w, http.StatusOK, map[string]string{"status": "idle"}) - return - } - writeJSON(w, http.StatusOK, st) -} - -func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { - dir := r.URL.Query().Get("path") - if dir == "" { - home, err := os.UserHomeDir() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - dir = home - } - - abs, err := filepath.Abs(dir) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) - return - } - - entries, err := os.ReadDir(abs) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - type folderItem struct { - Name string `json:"name"` - Path string `json:"path"` - } - - var folders []folderItem - for _, e := range entries { - if !e.IsDir() { - continue - } - // Skip hidden folders - if strings.HasPrefix(e.Name(), ".") { - continue - } - folders = append(folders, folderItem{ - Name: e.Name(), - Path: filepath.Join(abs, e.Name()), - }) - } - if folders == nil { - folders = []folderItem{} - } - - writeJSON(w, http.StatusOK, map[string]any{ - "current": abs, - "folders": folders, - }) -} - -func (s *Server) handleCreatePTY(w http.ResponseWriter, r *http.Request) { - pwd, owner := "", "" - var dockerExec *tools.DockerExecutor - if eng := s.activeEngine(); eng != nil { - pwd, owner = eng.pwd, eng.taskID - // A container-bound engine gets a terminal INSIDE the container; SSH and - // local engines keep a local shell (SSH-in-terminal remains a known gap). - if eng.env != nil { - if de, ok := eng.env.Exec.(*tools.DockerExecutor); ok { - dockerExec = de - } - } - } - - var ( - id string - err error - ) - if dockerExec != nil { - // createDocker acquires its own container ref (so an env switch can't stop - // the container under a live terminal) and resolves the shared client itself. - id, err = s.ptyMgr.createDocker(dockerExec.ContainerID(), pwd, owner) - } else { - id, err = s.ptyMgr.create(pwd, owner) - } - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"id": id}) -} - -func (s *Server) handleListPTY(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{"sessions": s.ptyMgr.list()}) -} - -func (s *Server) handleKillPTY(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - s.ptyMgr.kill(id) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -func (s *Server) handlePTYWebSocket(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - s.ptyMgr.serveWS(w, r, id) -} - -// handleValidatePaths reports which of the given local paths no longer exist (or -// are not directories). The web UI keeps its workspace list in localStorage and -// can't stat the disk itself, so it calls this to prune dead workspaces from the -// picker instead of letting the user click one and hit "path does not exist". -// Callers send local paths only; ssh:// labels can't be stat'd here and would be -// wrongly reported missing, so they must be filtered out client-side. -func (s *Server) handleValidatePaths(w http.ResponseWriter, r *http.Request) { - var req struct { - Paths []string `json:"paths"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - - missing := []string{} - for _, p := range req.Paths { - if p == "" { - continue - } - info, err := os.Stat(p) - if err != nil { - // Only a confirmed not-exist means the workspace is gone. Transient - // errors (permission, NFS hiccup) are inconclusive — keep the path - // rather than silently dropping a still-valid workspace from the picker. - if os.IsNotExist(err) { - missing = append(missing, p) - } - continue - } - if !info.IsDir() { - missing = append(missing, p) - } - } - - writeJSON(w, http.StatusOK, map[string]any{"missing": missing}) -} - -func (s *Server) handleSwitchProject(w http.ResponseWriter, r *http.Request) { - // No running gate: "switch project" builds a NEW independent engine and leaves - // the previous task running in the background — switching to another task while - // one is chatting is the whole point of concurrent tasks. - if s.newEngine == nil { - writeJSON(w, http.StatusNotImplemented, map[string]string{ - "error": "project switching is not supported", - }) - return - } - - var req struct { - Path string `json:"path"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - if req.Path == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"}) - return - } - - // Validate path exists and is a directory. - info, err := os.Stat(req.Path) - if err != nil || !info.IsDir() { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path does not exist or is not a directory"}) - return - } - - // Snapshot the outgoing task once, build the new engine BEFORE tearing down its - // PTYs — a failed build must not kill the current task's terminals. - prevTaskID, curMode := "", "" - if cur := s.activeEngine(); cur != nil { - prevTaskID, curMode = cur.taskID, cur.curMode() - } - - // "Switch project" = build a fresh engine rooted at the new path and make it - // active. This replaces in-place env mutation, so no other live task's - // execution context is disturbed. - eng, err := s.buildLocalEngine("", req.Path, curMode) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": fmt.Sprintf("failed to switch project: %v", err), - }) - return - } - s.ptyMgr.closeForTask(prevTaskID) // outgoing task's PTYs only - s.setActiveEngine(eng) - - // Reset todos for the (now empty) active task view. - if eng.todoStore != nil { - eng.todoStore.Update(nil) - } - - // Broadcast project change to clients. - s.wsBroker.Broadcast(WSEvent{ - Type: "project_switched", - Data: map[string]string{ - "pwd": req.Path, - }, - }) - - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "pwd": req.Path, - }) -} - -func (s *Server) handleGetApprovalMode(w http.ResponseWriter, r *http.Request) { - autoApprove := false - if eng := s.activeEngine(); eng != nil && eng.approvalState != nil { - autoApprove = eng.approvalState.GetMode() == handler.ModeAuto - } - writeJSON(w, http.StatusOK, map[string]any{"auto_approve": autoApprove}) -} - -func (s *Server) handleSetApprovalMode(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) - return - } - // No running gate: the rebuild is emu-safe and applies next turn, consistent - // with the "Allow all" approval path which also flips full_access mid-run. - var req struct { - AutoApprove bool `json:"auto_approve"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - // Legacy endpoint: auto-approve now maps onto the unified mode (Full access vs - // Approval). Both are non-plan, so rebuild to the full tool set for consistency. - sm := mode.Approval - if req.AutoApprove { - sm = mode.FullAccess - } - // Rebuild first; abort the toggle if the rebuild fails (don't desync the - // reported mode from the live agent). - var newAg *adk.ChatModelAgent - if eng.rebuildForMode != nil { - ag, err := eng.rebuildForMode(false) - if err != nil { - config.Logger().Printf("[web] approval mode agent rebuild error: %v", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to set approval mode"}) - return - } - newAg = ag - } - if eng.approvalState != nil { - eng.approvalState.SetSessionMode(sm) - } - eng.applyModeSwitch(sm.String(), newAg) - // Persist as the default startup mode so the preference survives restarts — - // resolveStartupMode reads cfg.DefaultMode. cfgMu serializes the config RMW. - s.cfgMu.Lock() - if s.cfg != nil { - s.cfg.DefaultMode = sm.String() - if err := config.SaveConfig(s.cfg); err != nil { - config.Logger().Printf("[web] approval mode save config failed: %v", err) - } - } - s.cfgMu.Unlock() - - s.wsBroker.Broadcast(WSEvent{ - Type: "approval_mode_changed", - TaskID: eng.taskID, - Data: map[string]any{"auto_approve": req.AutoApprove}, - }) - // Also emit the unified mode event so updated clients keep their selector synced. - s.wsBroker.Broadcast(WSEvent{Type: "mode_changed", TaskID: eng.taskID, Data: map[string]string{"mode": sm.String()}}) - writeJSON(w, http.StatusOK, map[string]any{"auto_approve": req.AutoApprove}) -} - -// --- WebSocket handler --- - -// CheckOrigin rejects cross-origin WebSocket handshakes from untrusted web -// pages (see isAllowedWebOrigin); without this any website could open a socket -// to the loopback server and read the agent's live event stream. -var wsUpgrader = websocket.Upgrader{ - CheckOrigin: isAllowedWebOrigin, - // Advertise the auth subprotocol so gorilla echoes it back on the handshake - // response; browsers send ["jcode-auth", ""] and expect the server to - // confirm a subprotocol, otherwise some reject the connection. The token (the - // second value) is never echoed. - Subprotocols: []string{wsAuthSubprotocol}, -} - -func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { - conn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - config.Logger().Printf("[ws] upgrade error: %v", err) - return - } - - id, client, unsub := s.wsBroker.Register(conn) - config.Logger().Printf("[ws] client %d connected", id) - - // Write pump: send events to client. - go client.writePump() - - // Read pump: handle incoming messages. - defer func() { - unsub() - _ = conn.Close() - config.Logger().Printf("[ws] client %d disconnected", id) - }() - - for { - _, msg, err := conn.ReadMessage() - if err != nil { - return - } - var incoming WSIncoming - if err := json.Unmarshal(msg, &incoming); err != nil { - continue - } - s.handleWSMessage(client, incoming) - } -} - -func (s *Server) handleWSMessage(client *WSClient, msg WSIncoming) { - switch msg.Type { - case "ping": - // Unicast the pong to the pinging client (broadcasting it woke every - // client unnecessarily). - if data, err := json.Marshal(WSEvent{Type: "pong"}); err == nil { - client.send(data) - } - case "subscribe": - var data struct { - TaskIDs []string `json:"task_ids"` - } - if json.Unmarshal(msg.Data, &data) == nil { - client.subscribe(data.TaskIDs) - } - case "unsubscribe": - var data struct { - TaskIDs []string `json:"task_ids"` - } - if json.Unmarshal(msg.Data, &data) == nil { - client.unsubscribe(data.TaskIDs) - } - case "approval": - var data struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - Approved bool `json:"approved"` - ApproveAll bool `json:"approve_all"` - } - if err := json.Unmarshal(msg.Data, &data); err != nil { - return - } - // Empty task_id → active task (legacy); non-empty unknown → drop (ids are - // handler-local and could collide with another task's). - reng := s.resolveEngine(data.TaskID) - if reng == nil || reng.handler == nil { - return - } - if err := reng.handler.ResolveApproval(data.ID, data.Approved, data.ApproveAll); err != nil { - config.Logger().Printf("[ws] resolve approval failed for id=%q: %v", data.ID, err) - return - } - // Same mode-sync as the POST path: an "allow all" over WS must also - // update the selector pill the user is looking at. - s.syncModeAfterApproval(reng, data.Approved, data.ApproveAll) - } -} - -// --- Stop handler --- - -func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { - // Cancel only the targeted task. task_id comes via query or JSON body; absent, - // fall back to the active task (legacy clients). - taskID := r.URL.Query().Get("task_id") - if taskID == "" { - var req struct { - TaskID string `json:"task_id"` - } - _ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req) - taskID = req.TaskID - } - - eng := s.resolveEngine(taskID) - if eng == nil || !eng.running.Load() { - writeJSON(w, http.StatusOK, map[string]string{"status": "not_running"}) - return - } - - eng.emu.Lock() - cancel := eng.runCancel - eng.emu.Unlock() - if cancel != nil { - cancel() - } - - // Notify clients on that task's channel. - eng.handler.OnAgentDone(fmt.Errorf("stopped by user")) - - writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"}) -} - -// --- SSH list handler --- - -func (s *Server) handleListSSH(w http.ResponseWriter, r *http.Request) { - type sshItem struct { - Name string `json:"name"` - Addr string `json:"addr"` - Path string `json:"path,omitempty"` - } - - var items []sshItem - if s.cfg != nil { - for _, a := range s.cfg.SSHAliases { - items = append(items, sshItem{ - Name: a.Name, - Addr: a.Addr, - Path: a.Path, - }) - } - } - if items == nil { - items = []sshItem{} - } - - current := "local" - if eng := s.activeEngine(); eng != nil && eng.env != nil && eng.env.IsRemote() { - current = "ssh" - } - - writeJSON(w, http.StatusOK, map[string]any{ - "current": current, - "aliases": items, - }) -} - -// --- Skills list handler (for slash commands) --- - -func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) { - type skillItem struct { - Name string `json:"name"` - Description string `json:"description"` - Slash string `json:"slash"` - Builtin bool `json:"builtin"` - Source string `json:"source"` // builtin | local - Enabled bool `json:"enabled"` - } - - var items []skillItem - if s.skillLoader != nil { - for _, sk := range s.skillLoader.All() { - source := "local" - if sk.Builtin { - source = "builtin" - } - items = append(items, skillItem{ - Name: sk.Name, - Description: sk.Description, - Slash: sk.Slash, - Builtin: sk.Builtin, - Source: source, - Enabled: s.skillLoader.IsEnabled(sk.Name), - }) - } - } - if items == nil { - items = []skillItem{} - } - writeJSON(w, http.StatusOK, items) -} - -// handleToggleSkill enables/disables a skill, persisting to config and updating -// the loader + agent so the change takes effect immediately. -func (s *Server) handleToggleSkill(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) - return - } - var req struct { - Enabled bool `json:"enabled"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if s.skillLoader == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "skills unavailable"}) - return - } - - // cfgMu (not s.mu) serializes the cfg read-modify-write+save, so concurrent - // approval-mode / MCP / skill saves can't clobber each other in memory or on - // disk. - s.cfgMu.Lock() - // Rebuild the disabled set from config. - disabled := make(map[string]bool, len(s.cfg.DisabledSkills)) - for _, n := range s.cfg.DisabledSkills { - disabled[n] = true - } - if req.Enabled { - delete(disabled, name) - } else { - disabled[name] = true - } - list := make([]string, 0, len(disabled)) - for n := range disabled { - list = append(list, n) - } - sort.Strings(list) - s.cfg.DisabledSkills = list - if err := config.SaveConfig(s.cfg); err != nil { - s.cfgMu.Unlock() - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - s.cfgMu.Unlock() - - s.skillLoader.SetDisabled(list) - // Rebuild the foreground task's agent so the system prompt (skill descriptions) - // and load_skill tool reflect the change on the next run. - if !s.needsSetup { - if eng := s.activeEngine(); eng != nil && eng.createAgent != nil { - prov, mod, _ := eng.modelSnapshot() - if ag, err := eng.createAgent(prov, mod); err == nil { - eng.setAgent(ag) - } - } - } - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": name, "enabled": req.Enabled}) -} - -// handleSlashCommands returns skill slash commands for the web frontend -// autocomplete menu. Built-in commands (/setting, /model, /ssh, etc.) are -// excluded because the web UI provides dedicated controls for those features -// and submitMessage only dispatches skill-based slash commands. -func (s *Server) handleSlashCommands(w http.ResponseWriter, r *http.Request) { - type slashItem struct { - Slash string `json:"slash"` - Description string `json:"description"` - Type string `json:"type"` // "skill" | "flow" - } - - var items []slashItem - if s.skillLoader != nil { - for _, sk := range s.skillLoader.SlashCommands() { - items = append(items, slashItem{ - Slash: sk.Slash, - Description: sk.Description, - Type: "skill", - }) - } - } - // Workflows resolve against the foreground task's project so its - // .jcode/workflows show up in autocomplete, falling back to the boot loader. - if fl := s.flowLoaderFor(s.activeEngine()); fl != nil { - for _, fc := range fl.SlashCommands() { - items = append(items, slashItem{ - Slash: fc.Slash, - Description: fc.Description, - Type: "flow", - }) - } - } - - if items == nil { - items = []slashItem{} - } - - sort.Slice(items, func(i, j int) bool { - return items[i].Slash < items[j].Slash - }) - - writeJSON(w, http.StatusOK, items) -} - -// --- Setup & Provider Management Handlers --- - -// handleSetupStatus returns whether the server is in setup mode. -func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "needs_setup": s.needsSetup, - }) -} - -// handleSetupValidate tests connectivity to a provider with the given API key. -func (s *Server) handleSetupValidate(w http.ResponseWriter, r *http.Request) { - var req struct { - Provider string `json:"provider"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.APIKey == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "api_key is required"}) - return - } - - baseURL := req.BaseURL - if baseURL == "" && s.registry != nil { - baseURL = s.registry.GetProviderAPI(req.Provider) - } - if baseURL == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no base URL available for this provider"}) - return - } - - res := model.ValidateProviderDetailed(r.Context(), req.APIKey, baseURL, req.Headers) - if !res.OK { - writeJSON(w, http.StatusOK, map[string]any{ - "valid": false, - "error": res.Error, - "error_type": res.ErrorType, - }) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "valid": true, - "latency_ms": res.LatencyMS, - "model_count": res.ModelCount, - }) -} - -// handleSetupProviders returns all available providers from the registry. -func (s *Server) handleSetupProviders(w http.ResponseWriter, r *http.Request) { - if s.registry == nil { - writeJSON(w, http.StatusOK, []any{}) - return - } - - type providerItem struct { - ID string `json:"id"` - Name string `json:"name"` - Doc string `json:"doc,omitempty"` - API string `json:"api,omitempty"` - Env []string `json:"env,omitempty"` - Configured bool `json:"configured"` - Tag string `json:"tag,omitempty"` // "recommended", "free", "local" - } - - providers := s.registry.ListProviders() - cfg, _ := config.LoadConfig() - configured := map[string]bool{} - if cfg != nil { - for k := range cfg.GetProviders() { - configured[k] = true - } - } - - // Provider tags for recommendation. - tags := map[string]string{ - "openai": "recommended", - "anthropic": "recommended", - "ollama": "local", - } - - result := make([]providerItem, 0, len(providers)) - for _, p := range providers { - result = append(result, providerItem{ - ID: p.ID, - Name: p.Name, - Doc: p.Doc, - API: p.API, - Env: p.Env, - Configured: configured[p.ID], - Tag: tags[p.ID], - }) - } - - // Sort: configured first, then by tag (recommended > local > ""), then by name. - sort.SliceStable(result, func(i, j int) bool { - ri, rj := result[i], result[j] - if ri.Configured != rj.Configured { - return ri.Configured - } - tagOrder := map[string]int{"recommended": 0, "local": 1, "": 2} - oi := tagOrder[ri.Tag] - oj := tagOrder[rj.Tag] - if oi != oj { - return oi < oj - } - return ri.Name < rj.Name - }) - - writeJSON(w, http.StatusOK, result) -} - -// handleSetupProviderModels returns models for a specific provider from the registry. -func (s *Server) handleSetupProviderModels(w http.ResponseWriter, r *http.Request) { - providerID := r.PathValue("id") - if providerID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) - return - } - - if s.registry == nil { - writeJSON(w, http.StatusOK, []any{}) - return - } - - models := s.registry.ListProviderModels(providerID, true) - type modelItem struct { - ID string `json:"id"` - Name string `json:"name"` - ToolCall bool `json:"tool_call"` - ContextLimit int `json:"context_limit,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` - Attachment bool `json:"attachment,omitempty"` - ReasoningOptions []model.ReasoningOption `json:"reasoning_options,omitempty"` - } - - result := make([]modelItem, 0, len(models)) - for _, m := range models { - ctx := 0 - if m.Limit != nil { - ctx = m.Limit.Context - } - result = append(result, modelItem{ - ID: m.ID, - Name: m.Name, - ToolCall: m.ToolCall, - ContextLimit: ctx, - Reasoning: m.Reasoning, - Attachment: m.Attachment, - ReasoningOptions: m.ReasoningOptions, - }) - } - - writeJSON(w, http.StatusOK, result) -} - -// handleProviderCatalog returns a provider's browsable model catalog for the -// "browse directory" UI. For registry providers it lists the built-in models -// (the official /models endpoint is not reliably complete); for custom -// (OpenAI-compatible) endpoints it queries the live /models endpoint. Each -// entry is flagged added=true when the model is already in the provider's -// config (either as a CustomModelConfig or a registry model that's enabled). -func (s *Server) handleProviderCatalog(w http.ResponseWriter, r *http.Request) { - providerID := r.PathValue("id") - if providerID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) - return - } - - type catalogEntry struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - Added bool `json:"added"` - Context int `json:"context,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` - Attachment bool `json:"attachment,omitempty"` - EffortTiers []string `json:"effort_tiers,omitempty"` - // Custom marks a user-defined model (editable/removable) vs a built-in - // registry model (toggled via model_state). Surfaced so the catalog row can - // show edit/remove affordances only on user custom models. - Custom bool `json:"custom,omitempty"` - } - - // Collect the set of already-configured model ids for this provider, so each - // catalog entry can be flagged added/可移除. Custom models come from config; - // for registry providers, MergeConfigProviders has already merged custom - // models into the registry, so registry membership is the source of truth. - configured := make(map[string]bool) - customSet := make(map[string]*config.CustomModelConfig) // user-defined models by id - var apiKey, baseURL string - var headers map[string]string - cfg, _ := config.LoadConfig() - if cfg != nil { - if pc := cfg.GetProviders()[providerID]; pc != nil { - apiKey, baseURL, headers = pc.APIKey, pc.BaseURL, pc.Headers - for _, m := range pc.CustomModels { - configured[m.ID] = true - cm := m // copy for map value - customSet[m.ID] = &cm - } - } - } - - // customEntry builds a catalogEntry from a user-defined CustomModelConfig. - customEntry := func(id string) catalogEntry { - m := customSet[id] - e := catalogEntry{ID: id, Added: true, Custom: true} - if m != nil { - e.Name = m.Name - e.Context = m.Context - e.Reasoning = m.Reasoning - e.Attachment = m.Attachment - e.EffortTiers = m.EffortTiers - } - return e - } - - // resolveRegistryBrand finds a registry provider whose brand keyword appears - // in the given id/url — so a custom endpoint pointing at, say, zhipu's API - // still surfaces zhipu's models.dev catalog rather than a fragile live - // /models probe. Returns "" when no brand matches. - resolveRegistryBrand := func(hint string) string { - if s.registry == nil { - return "" - } - hint = strings.ToLower(hint) - for _, rp := range s.registry.ListProviders() { - // Use the registry id as the brand keyword (e.g. "zhipuai", "openai", - // "deepseek"); these already encode the brand and are stable. - brand := strings.ToLower(rp.ID) - if brand != "" && strings.Contains(hint, brand) { - return rp.ID - } - } - return "" - } - - // The catalog defaults to the built-in (models.dev) catalog — this is the - // reliable source, since many endpoints either lack a /models route or - // return an incomplete list. Exact id match first; otherwise a brand match - // on the provider id or base URL (so a custom zhipu endpoint still shows the - // zhipu catalog). - registryID := providerID - if s.registry == nil || !s.registry.HasProvider(registryID) { - if hint := resolveRegistryBrand(providerID + " " + baseURL); hint != "" { - registryID = hint - } - } - if s.registry != nil && s.registry.HasProvider(registryID) { - models := s.registry.ListProviderModels(registryID, true) - result := make([]catalogEntry, 0, len(models)) - for _, m := range models { - // A user-defined custom model is merged into the registry by - // MergeConfigProviders, so it appears here too. For those, build the - // entry from the stored CustomModelConfig (which carries the - // user-set name/context/tiers) rather than the derived registry view — - // otherwise effort tiers and other authored fields are lost. - if cm := customSet[m.ID]; cm != nil { - result = append(result, catalogEntry{ - ID: m.ID, - Name: cm.Name, - Added: true, - Context: cm.Context, - Reasoning: cm.Reasoning, - Attachment: cm.Attachment, - EffortTiers: cm.EffortTiers, - Custom: true, - }) - continue - } - ctx := 0 - if m.Limit != nil { - ctx = m.Limit.Context - } - result = append(result, catalogEntry{ - ID: m.ID, - Name: m.Name, - Added: configured[m.ID] || m.DefaultEnabled, - Context: ctx, - Reasoning: m.Reasoning, - Attachment: m.Attachment, - Custom: false, - }) - } - // Also surface any user-added custom models not in the brand catalog, so - // the catalog isn't missing models the user explicitly configured. - for id := range configured { - found := false - for _, e := range result { - if e.ID == id { - found = true - break - } - } - if !found { - result = append(result, customEntry(id)) - } - } - writeJSON(w, http.StatusOK, result) - return - } - - // Truly custom endpoint with no brand match: probe the live /models endpoint - // as a last resort. Many gateways support the OpenAI-compatible /models list; - // on any failure we fall back to just the configured models so the catalog is - // never empty/erroring. - if baseURL != "" { - if ids := model.ListProviderModelsLive(r.Context(), apiKey, baseURL, headers); len(ids) > 0 { - result := make([]catalogEntry, 0, len(ids)) - seen := make(map[string]bool, len(ids)) - for _, id := range ids { - if seen[id] { - continue - } - seen[id] = true - if c := customSet[id]; c != nil { - result = append(result, customEntry(id)) - } else { - result = append(result, catalogEntry{ID: id, Added: configured[id]}) - } - } - writeJSON(w, http.StatusOK, result) - return - } - } - - // No registry brand and no live /models: show the configured custom models - // (added=true, custom=true) so the catalog reflects what's actually usable. - result := make([]catalogEntry, 0, len(configured)) - for id := range configured { - result = append(result, customEntry(id)) - } - writeJSON(w, http.StatusOK, result) -} - -// It saves the provider config and creates the agent. -// -// The wizard no longer forces a model selection: for registry providers, a -// default model is auto-picked (DefaultEnabled → Recommended → first). A -// caller-supplied model always wins. Custom (non-registry) providers must send -// a model explicitly since none can be inferred. -func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { - var req struct { - Provider string `json:"provider"` - Model string `json:"model,omitempty"` - ModelReasoning bool `json:"model_reasoning,omitempty"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url,omitempty"` - Name string `json:"name,omitempty"` // custom provider display name - Headers map[string]string `json:"headers,omitempty"` - Vision *bool `json:"vision,omitempty"` - Thinking *bool `json:"thinking,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.Provider == "" || req.APIKey == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and api_key are required"}) - return - } - if !validReasoningEffort(req.ReasoningEffort) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) - return - } - - // Resolve the active model. An explicit model always wins; otherwise try to - // auto-pick a default for registry providers. Custom providers (not in the - // registry) cannot infer a model and require one from the caller. - resolvedModel := req.Model - isCustom := s.registry == nil || !s.registry.HasProvider(req.Provider) - if resolvedModel == "" { - if isCustom { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model is required for custom providers"}) - return - } - if s.registry != nil { - resolvedModel = s.registry.PickDefaultModel(req.Provider) - } - } - if resolvedModel == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no default model available for this provider; please choose one"}) - return - } - - // Build or update config. - var cfg *config.Config - cfg, err := config.LoadConfig() - if err != nil { - // First time — create fresh config. - cfg = &config.Config{ - MaxIterations: 1000, - } - } - - if cfg.Providers == nil { - cfg.Providers = make(map[string]*config.ProviderConfig) - } - setupPC := &config.ProviderConfig{ - APIKey: req.APIKey, - BaseURL: req.BaseURL, - Name: req.Name, - Headers: cleanHeaders(req.Headers), - Vision: req.Vision, - Thinking: req.Thinking, - ReasoningEffort: req.ReasoningEffort, - } - // For a custom provider, persist the model as a custom model so it survives - // a model switch (otherwise it exists only as the active-model string and - // vanishes from the picker once changed). - if isCustom && resolvedModel != "" { - setupPC.CustomModels = []config.CustomModelConfig{{ - ID: resolvedModel, - Name: resolvedModel, - ToolCall: true, - Reasoning: req.ModelReasoning, - }} - } - cfg.Providers[req.Provider] = setupPC - cfg.Model = req.Provider + "/" + resolvedModel - - if err := config.SaveConfig(cfg); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) - return - } - - // Create the foreground task's agent with the new config. - eng := s.activeEngine() - if eng == nil || eng.createAgent == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no active task to configure"}) - return - } - ag, err := eng.createAgent(req.Provider, resolvedModel) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create agent: " + err.Error()}) - return - } - eng.applyModelSwitch(ag, req.Provider, resolvedModel) - // Publish the new config + registry to the live server so endpoints - // (/api/models, context-limit, etc.) reflect the just-configured provider - // without a restart. - s.cfgMu.Lock() - s.cfg = cfg - s.registry = model.NewModelRegistryWithConfig(cfg) - s.cfgMu.Unlock() - s.mu.Lock() - s.needsSetup = false - s.mu.Unlock() - - // Notify clients that setup is complete. - s.wsBroker.Broadcast(WSEvent{Type: "model_changed", TaskID: eng.taskID, Data: map[string]string{ - "provider": req.Provider, - "model": resolvedModel, - }}) - - writeJSON(w, http.StatusOK, map[string]string{ - "status": "ok", - "provider": req.Provider, - "model": resolvedModel, - }) -} - -// maskSecret hides a secret for display: first 4 and last 4 chars for longer -// values, "****" for short ones. Used for API keys and header values so the -// list endpoint never returns plaintext credentials. -func maskSecret(s string) string { - if s == "" { - return "" - } - if len(s) > 8 { - return s[:4] + "..." + s[len(s)-4:] - } - return "****" -} - -// handleListProviders returns all configured providers (key masked). -func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { - cfg, err := config.LoadConfig() - if err != nil { - writeJSON(w, http.StatusOK, []any{}) - return - } - - type customModelView struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` - Context int `json:"context,omitempty"` - Attachment bool `json:"attachment,omitempty"` - EffortTiers []string `json:"effort_tiers,omitempty"` - // Custom marks a user-defined model (editable) vs a built-in registry - // model surfaced for display (read-only). Omitted/zero ⇒ treated as a - // user custom model for backward compatibility, but we always set it. - Custom bool `json:"custom,omitempty"` - } - type providerDetail struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` // display name for custom providers - Custom bool `json:"custom,omitempty"` - APIKeySet bool `json:"api_key_set"` - APIKey string `json:"api_key,omitempty"` // masked - BaseURL string `json:"base_url,omitempty"` - Headers map[string]string `json:"headers,omitempty"` // values masked - CustomModels []customModelView `json:"custom_models,omitempty"` - Vision *bool `json:"vision,omitempty"` - Thinking *bool `json:"thinking,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - } - - result := make([]providerDetail, 0) - for id, pc := range cfg.GetProviders() { - detail := providerDetail{ - ID: id, - Name: pc.Name, - APIKeySet: pc.APIKey != "", - BaseURL: pc.BaseURL, - Vision: pc.Vision, - Thinking: pc.Thinking, - ReasoningEffort: pc.ReasoningEffort, - } - // A provider is "custom" when it exists only because the user configured - // it (an OpenAI-compatible endpoint), not as a built-in registry brand. - // MergeConfigProviders flags those on the registry entry; a configured id - // with no registry entry at all is custom too. The registry may be nil in - // setup mode — fall back to "has a display name" as the custom signal. - if s.registry != nil { - if prov := s.registry.GetProvider(id); prov != nil { - detail.Custom = prov.Custom - } else { - detail.Custom = true - } - } else if pc.Name != "" { - detail.Custom = true - } - if pc.APIKey != "" { - detail.APIKey = maskSecret(pc.APIKey) - } - if len(pc.Headers) > 0 { - masked := make(map[string]string, len(pc.Headers)) - for k, v := range pc.Headers { - masked[k] = maskSecret(v) - } - detail.Headers = masked - } - // Build the card's unified model list. For registry providers this is the - // built-in (models.dev) models the user has enabled, each marked read-only - // (Custom=false); the user's CustomModels are then appended and marked - // editable (Custom=true). The card renders them identically and only - // surfaces edit/delete affordances on editable rows. - cms := make([]customModelView, 0) - seen := make(map[string]bool) - // Track which ids are user-defined custom models so the registry loop can - // skip them (they're merged into the registry by MergeConfigProviders but - // must surface with their authored fields from CustomModelConfig, handled - // in the custom loop below). - customIDs := make(map[string]bool, len(pc.CustomModels)) - for _, m := range pc.CustomModels { - customIDs[m.ID] = true - } - if !detail.Custom && s.registry != nil && s.registry.HasProvider(id) { - for _, m := range s.registry.ListProviderModels(id, true) { - if !m.DefaultEnabled { - continue - } - if customIDs[m.ID] { - continue // user custom model — emitted below with authored fields - } - if seen[m.ID] { - continue - } - seen[m.ID] = true - ctx := 0 - if m.Limit != nil { - ctx = m.Limit.Context - } - cms = append(cms, customModelView{ - ID: m.ID, - Name: m.Name, - Reasoning: m.Reasoning, - Context: ctx, - Attachment: m.Attachment, - Custom: false, - }) - } - } - for _, m := range pc.CustomModels { - if seen[m.ID] { - continue - } - seen[m.ID] = true - cms = append(cms, customModelView{ - ID: m.ID, - Name: m.Name, - Reasoning: m.Reasoning, - Context: m.Context, - Attachment: m.Attachment, - EffortTiers: m.EffortTiers, - Custom: true, - }) - } - if len(cms) > 0 { - detail.CustomModels = cms - } - result = append(result, detail) - } - sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) - - writeJSON(w, http.StatusOK, result) -} - -// handleAddProvider adds a new provider to the config. For custom -// (non-registry) providers the caller should also send a name; models are -// optional here and are added afterward from the provider card (the dialog only -// captures the connection). -func (s *Server) handleAddProvider(w http.ResponseWriter, r *http.Request) { - var req struct { - ID string `json:"id"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url,omitempty"` - Name string `json:"name,omitempty"` - Model string `json:"model,omitempty"` - ModelReasoning bool `json:"model_reasoning,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - Vision *bool `json:"vision,omitempty"` - Thinking *bool `json:"thinking,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.ID == "" || req.APIKey == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id and api_key are required"}) - return - } - if !validReasoningEffort(req.ReasoningEffort) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) - return - } - - cfg, err := config.LoadConfig() - if err != nil { - cfg = &config.Config{MaxIterations: 1000} - } - if cfg.Providers == nil { - cfg.Providers = make(map[string]*config.ProviderConfig) - } - - // A custom provider (not in the registry) needs a base URL so requests can - // be routed. Models are optional at creation time: the provider is created - // connection-only and models are added afterward from its card, so a brand - // new custom endpoint can be saved before its model list is known. - isCustom := s.registry == nil || !s.registry.HasProvider(req.ID) - if isCustom && req.BaseURL == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "base_url is required for custom providers"}) - return - } - - pc := &config.ProviderConfig{ - APIKey: req.APIKey, - BaseURL: req.BaseURL, - Name: req.Name, - Headers: cleanHeaders(req.Headers), - Vision: req.Vision, - Thinking: req.Thinking, - ReasoningEffort: req.ReasoningEffort, - } - if isCustom && req.Model != "" { - pc.CustomModels = []config.CustomModelConfig{{ - ID: req.Model, - Name: req.Model, - ToolCall: true, - Reasoning: req.ModelReasoning, - }} - } - cfg.Providers[req.ID] = pc - - // If there is no active model yet and this is a custom provider with an - // explicit model, adopt it as the active model so the app can boot. - if cfg.Model == "" && isCustom && req.Model != "" { - cfg.Model = req.ID + "/" + req.Model - } - - if err := config.SaveConfig(cfg); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) - return - } - - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -// validReasoningEffort whitelists the thinking-depth values accepted from -// clients. The set mirrors the effort levels models.dev publishes under -// reasoning_options (see internal/model registry). Empty means "unset / omit -// the parameter". -func validReasoningEffort(v string) bool { - switch v { - case "", "none", "minimal", "low", "medium", "high", "xhigh", "max": - return true - } - return false -} - -// cleanHeaders drops rows with an empty key and trims whitespace from both key -// and value, so blank editor rows never reach the saved config and a pasted -// token with a stray trailing space does not silently break auth. -func cleanHeaders(in map[string]string) map[string]string { - if len(in) == 0 { - return nil - } - out := make(map[string]string, len(in)) - for k, v := range in { - k = strings.TrimSpace(k) - if k == "" { - continue - } - out[k] = strings.TrimSpace(v) - } - if len(out) == 0 { - return nil - } - return out -} - -// handleUpdateProvider edits an existing provider, merging secret fields so the -// client may omit unchanged credentials. An empty api_key keeps the stored key; -// a header value left empty keeps the stored value for that key (the list -// endpoint returns masked secrets, so the UI sends blanks for untouched ones). -func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) - return - } - var req struct { - APIKey string `json:"api_key,omitempty"` - BaseURL string `json:"base_url,omitempty"` - Name string `json:"name,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - CustomModels *[]struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` - Context int `json:"context,omitempty"` - Attachment bool `json:"attachment,omitempty"` - EffortTiers []string `json:"effort_tiers,omitempty"` - } `json:"custom_models,omitempty"` - Vision *bool `json:"vision,omitempty"` - Thinking *bool `json:"thinking,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if !validReasoningEffort(req.ReasoningEffort) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) - return - } - - cfg, err := config.LoadConfig() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - pc := cfg.GetProviders()[id] - if pc == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) - return - } - - // Mutate in place so fields not exposed by this endpoint (display name, - // custom models, deprecated lists) are preserved untouched. - prevHeaders := pc.Headers - // base_url uses keep-on-empty semantics (like api_key): the list endpoint - // masks secrets but returns base_url verbatim, yet a client that doesn't - // touch the endpoint may still submit an empty value. Overwriting - // unconditionally would wipe a stored custom endpoint, so only adopt a - // non-empty incoming value. - if req.BaseURL != "" { - pc.BaseURL = req.BaseURL - } - pc.Vision = req.Vision - pc.Thinking = req.Thinking - pc.ReasoningEffort = req.ReasoningEffort - if req.Name != "" { - pc.Name = req.Name - } - if req.APIKey != "" { - pc.APIKey = req.APIKey - } - // Merge headers: empty incoming value ⇒ keep the stored secret for that key. - pc.Headers = nil - if cleaned := cleanHeaders(req.Headers); len(cleaned) > 0 { - merged := make(map[string]string, len(cleaned)) - for k, v := range cleaned { - if v == "" { - if ov, ok := prevHeaders[k]; ok { - merged[k] = ov - continue - } - } - merged[k] = v - } - pc.Headers = merged - } - - // Replace the provider's custom models when the client sends the list (nil ⇒ - // keep existing). Each model's stored Context is preserved by merging on id, - // ToolCall stays true (matching the add path), and the model currently set as - // active cannot be dropped so a save can't strand the running app. - if req.CustomModels != nil { - prev := make(map[string]config.CustomModelConfig, len(pc.CustomModels)) - for _, m := range pc.CustomModels { - prev[m.ID] = m - } - next := make([]config.CustomModelConfig, 0, len(*req.CustomModels)) - seen := make(map[string]bool, len(*req.CustomModels)) - for _, m := range *req.CustomModels { - mid := strings.TrimSpace(m.ID) - if mid == "" || seen[mid] { - continue - } - seen[mid] = true - cm := config.CustomModelConfig{ID: mid, Name: strings.TrimSpace(m.Name), ToolCall: true, Reasoning: m.Reasoning} - // Adopt the incoming per-model capability fields when provided; - // otherwise carry over the previously stored values so an edit that - // only renames a model doesn't silently drop its context window, - // vision flag, or configured effort tiers. - if old, ok := prev[mid]; ok { - if cm.Context == 0 { - cm.Context = old.Context - } - if !cm.Attachment { - cm.Attachment = old.Attachment - } - if len(cm.EffortTiers) == 0 { - cm.EffortTiers = old.EffortTiers - } - } - if m.Context > 0 { - cm.Context = m.Context - } - if m.Attachment { - cm.Attachment = true - } - if len(m.EffortTiers) > 0 { - cm.EffortTiers = m.EffortTiers - } - next = append(next, cm) - } - // Reject custom model ids that collide with the provider's built-in - // (registry) models. A duplicate id would shadow or be shadowed by the - // registry entry, confusing the model picker and catalog. Custom ids - // may still be edited to their own value (handled by seen dedup above). - if s.registry != nil { - if regProv := s.registry.GetProvider(id); regProv != nil { - for _, cm := range next { - if _, ok := regProv.Models[cm.ID]; ok { - // Allow it only if it was already a custom model with this id - // (editing an existing custom entry in place). - if _, wasCustom := prev[cm.ID]; !wasCustom { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "model id '" + cm.ID + "' duplicates a built-in model; choose another id", - }) - return - } - } - } - } - } - if strings.HasPrefix(cfg.Model, id+"/") { - active := strings.TrimPrefix(cfg.Model, id+"/") - if _, wasThere := prev[active]; wasThere && !seen[active] { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot remove the active model; switch to another model first"}) - return - } - } - isCustom := s.registry == nil || !s.registry.HasProvider(id) - if isCustom && len(next) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "custom providers need at least one model"}) - return - } - pc.CustomModels = next - } - - if cfg.Providers == nil { - cfg.Providers = make(map[string]*config.ProviderConfig) - } - cfg.Providers[id] = pc - if err := config.SaveConfig(cfg); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) - return - } - - // Publish the updated config + registry to the live server so the chat model - // picker (/api/models) and catalog reflect added/edited/removed models - // without a restart — matching handleSetupComplete's publish step. - s.cfgMu.Lock() - s.cfg = cfg - s.registry = model.NewModelRegistryWithConfig(cfg) - s.cfgMu.Unlock() - - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -// handleDeleteProvider removes a provider from the config. -func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { - providerID := r.PathValue("id") - if providerID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) - return - } - - cfg, err := config.LoadConfig() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - providers := cfg.GetProviders() - if providers == nil || providers[providerID] == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) - return - } - - // Don't allow deleting the active provider. - activeProvider, _ := cfg.GetProviderModel() - if activeProvider == providerID { - remaining := 0 - for k := range providers { - if k != providerID { - remaining++ - } - } - if remaining == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot delete the only provider"}) - return - } - } - - delete(cfg.Providers, providerID) - if err := config.SaveConfig(cfg); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) - return - } - - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -// handleGetModelState returns the recent, favorite, and visibility settings. -func (s *Server) handleGetModelState(w http.ResponseWriter, r *http.Request) { - state, err := config.LoadModelState() - if err != nil { - state = &config.ModelState{} - } - type modelRefJSON struct { - Provider string `json:"provider"` - Model string `json:"model"` - } - - recent := make([]modelRefJSON, 0, len(state.Recent)) - for _, r := range state.Recent { - recent = append(recent, modelRefJSON{Provider: r.Provider, Model: r.Model}) - } - favorites := make([]modelRefJSON, 0, len(state.Favorite)) - for _, r := range state.Favorite { - favorites = append(favorites, modelRefJSON{Provider: r.Provider, Model: r.Model}) - } - enabledModels := make([]modelRefJSON, 0, len(state.EnabledModels)) - for _, r := range state.EnabledModels { - enabledModels = append(enabledModels, modelRefJSON{Provider: r.Provider, Model: r.Model}) - } - disabledModels := make([]modelRefJSON, 0, len(state.DisabledModels)) - for _, r := range state.DisabledModels { - disabledModels = append(disabledModels, modelRefJSON{Provider: r.Provider, Model: r.Model}) - } - - writeJSON(w, http.StatusOK, map[string]any{ - "recent": recent, - "favorite": favorites, - "enabled_models": enabledModels, - "disabled_models": disabledModels, - "effort_overrides": state.EffortOverrides, - }) -} - -// handleToggleFavorite toggles a model in the favorites list. -func (s *Server) handleToggleFavorite(w http.ResponseWriter, r *http.Request) { - var req struct { - Provider string `json:"provider"` - Model string `json:"model"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.Provider == "" || req.Model == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) - return - } - - state, err := config.LoadModelState() - if err != nil { - state = &config.ModelState{} - } - nowFavorite := state.ToggleFavorite(config.ModelRef{Provider: req.Provider, Model: req.Model}) - if err := config.SaveModelState(state); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "favorite": nowFavorite, - }) -} - -// handleToggleModelEnabled toggles whether a model is shown in the model selector. -func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request) { - var req struct { - Provider string `json:"provider"` - Model string `json:"model"` - Enabled bool `json:"enabled"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.Provider == "" || req.Model == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) - return - } - - state, err := config.LoadModelState() - if err != nil { - state = &config.ModelState{} - } - state.SetModelEnabled(config.ModelRef{Provider: req.Provider, Model: req.Model}, req.Enabled) - if err := config.SaveModelState(state); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "enabled": req.Enabled, - }) -} - -// handleSetModelEffort records the user's reasoning-effort choice for a single -// model (set from the chat model picker). An empty effort clears the override, -// restoring the provider-level default. The agent is rebuilt so the change -// takes effect on the next turn. -func (s *Server) handleSetModelEffort(w http.ResponseWriter, r *http.Request) { - var req struct { - Provider string `json:"provider"` - Model string `json:"model"` - Effort string `json:"effort"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) - return - } - if req.Provider == "" || req.Model == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) - return - } - if req.Effort != "" && !validReasoningEffort(req.Effort) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid effort"}) - return - } - - state, err := config.LoadModelState() - if err != nil { - state = &config.ModelState{} - } - state.SetEffortOverride(config.ModelRef{Provider: req.Provider, Model: req.Model}, req.Effort) - if err := config.SaveModelState(state); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "effort": req.Effort, - }) -} - // --- Helpers --- func writeJSON(w http.ResponseWriter, status int, data any) { diff --git a/internal/web/sessions.go b/internal/web/sessions.go new file mode 100644 index 00000000..37e3b722 --- /dev/null +++ b/internal/web/sessions.go @@ -0,0 +1,351 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "github.com/cloudwego/eino/schema" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/session" + "github.com/cnjack/jcode/internal/tools" +) + +// handleListAllTasks returns every session across all projects (flat list, +// each tagged with its project path) so the web sidebar can render a +// Workspace > Project > Task tree without switching the active project. +func (s *Server) handleListAllTasks(w http.ResponseWriter, r *http.Request) { + all, err := session.ListAllSessions() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + // Snapshot which task ids are currently running (live engines) so the sidebar + // can show a running indicator even on a fresh page load. + running := make(map[string]bool) + s.tasksMu.RLock() + for id, e := range s.tasks { + if e != nil && e.running.Load() { + running[id] = true + } + } + s.tasksMu.RUnlock() + + type taskItem struct { + UUID string `json:"uuid"` + Project string `json:"project"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + Title string `json:"title,omitempty"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + Unread bool `json:"unread"` + Status string `json:"status,omitempty"` + Running bool `json:"running"` + } + items := make([]taskItem, 0) + for project, metas := range all { + for _, m := range metas { + // Automation runs are surfaced on the Automations page ("Recent + // runs"), not the main task list — exclude them here so a nightly + // automation doesn't bury the sidebar. + if m.AutomationID != "" { + continue + } + items = append(items, taskItem{ + UUID: m.UUID, + Project: project, + CreatedAt: m.StartTime, + UpdatedAt: m.UpdatedAt, + Provider: m.Provider, + Model: m.Model, + Title: m.Title, + Pinned: m.Pinned, + Archived: m.Archived, + Unread: m.Unread, + Status: m.Status, + Running: running[m.UUID], + }) + } + } + writeJSON(w, http.StatusOK, items) +} + +// handleUpdateTask applies a partial metadata update (pin/archive/unread/title) +// to a task by uuid across all projects. +func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var req struct { + Pinned *bool `json:"pinned"` + Archived *bool `json:"archived"` + Unread *bool `json:"unread"` + Title *string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + meta, err := session.UpdateSessionMeta(id, func(m *session.SessionMeta) { + if req.Pinned != nil { + m.Pinned = *req.Pinned + } + if req.Archived != nil { + m.Archived = *req.Archived + } + if req.Unread != nil { + m.Unread = *req.Unread + } + if req.Title != nil { + m.Title = *req.Title + } + m.UpdatedAt = time.Now().Format(time.RFC3339) + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if meta == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { + metas, err := session.ListSessions(s.activePwd()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + type sessionItem struct { + UUID string `json:"uuid"` + CreatedAt string `json:"created_at"` + Provider string `json:"provider"` + Model string `json:"model"` + Title string `json:"title,omitempty"` + } + + items := make([]sessionItem, 0, len(metas)) + for _, m := range metas { + items = append(items, sessionItem{ + UUID: m.UUID, + CreatedAt: m.StartTime, + Provider: m.Provider, + Model: m.Model, + Title: m.Title, + }) + } + writeJSON(w, http.StatusOK, items) +} + +func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + entries, err := session.LoadSession(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, entries) +} + +func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session id is required"}) + return + } + // Tear down the live engine for this task (if any) so its run is cancelled and + // resources reclaimed. The active foreground engine is left in place — but its + // recorder is reset to a fresh session so post-delete writes don't land in the + // now-unlinked file (silent data loss). + if eng := s.resolveEngine(id); eng != nil { + eng.emu.Lock() + cancel := eng.runCancel + eng.emu.Unlock() + if cancel != nil { + cancel() + } + if eng != s.activeEngine() { + s.deleteEngine(id) + } else { + // Active task: wait for the cancelled run to drain so its final + // RecordAssistant/usage writes land before we close + reset the recorder + // (a post-close write would re-create and truncate the file). + for i := 0; i < 200 && eng.running.Load(); i++ { + time.Sleep(5 * time.Millisecond) + } + eng.emu.Lock() + if eng.recorder != nil && eng.recorder.UUID() == id { + eng.recorder.Close() + eng.recorder = nil + eng.history = nil + } + eng.emu.Unlock() + } + } + + // Resolve the owning project across all projects: a task deleted from the + // sidebar tree may not belong to the active project. + if _, err := session.DeleteSessionByUUID(id); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleTruncateHistory(w http.ResponseWriter, r *http.Request) { + eng := s.activeEngine() + if eng == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + return + } + if eng.running.Load() { + writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is currently running"}) + return + } + + var req struct { + // BeforeUserMessage: keep all history entries that come before the + // Nth user message (0-indexed). Everything from that user message + // onward is discarded. Pass 0 to clear everything. + BeforeUserMessage int `json:"before_user_message"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + // Capture the recorder under eng.emu (same lock submitMessage uses) but do + // file I/O outside the lock. + eng.emu.Lock() + rec := eng.recorder + eng.emu.Unlock() + sessionID := "" + if rec != nil { + sessionID = rec.UUID() + } + + // Persist first — if the file rewrite fails we abort without touching + // the in-memory history so state never diverges. + if rec != nil { + if err := rec.TruncateAtUserMessage(req.BeforeUserMessage); err != nil { + config.Logger().Printf("[truncate] rewrite session file failed: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to truncate session file"}) + return + } + } + + // Now truncate in-memory history under eng.emu. + eng.emu.Lock() + truncAt := 0 + if req.BeforeUserMessage > 0 { + userCount := 0 + truncAt = len(eng.history) // default: keep all + for i, msg := range eng.history { + if msg.Role == schema.User { + if userCount == req.BeforeUserMessage { + truncAt = i + break + } + userCount++ + } + } + } + if truncAt == 0 { + eng.history = nil + } else { + eng.history = eng.history[:truncAt] + } + eng.emu.Unlock() + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "session_id": sessionID, + }) +} + +func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { + // Parse optional resume session ID + project. Creating a task no longer + // blocks on "is the agent running" — tasks run concurrently. + var req struct { + SessionID string `json:"session_id,omitempty"` + Pwd string `json:"pwd,omitempty"` + } + // The body is optional (empty = brand-new task → EOF), but a non-empty + // malformed body should be rejected rather than creating a zero-value task. + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil && err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + // Already-live task: just focus it (do not disturb its run). + if req.SessionID != "" { + if eng := s.resolveEngine(req.SessionID); eng != nil { + s.setActiveEngine(eng) + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) + return + } + } + + if s.newEngine == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "task creation is not supported"}) + return + } + + // Each new/resumed task gets its OWN engine (env, agent, recorder, handler), + // so it runs independently of every other task. + pwd := req.Pwd + if pwd == "" { + if a := s.activeEngine(); a != nil { + pwd = a.pwd + } + } + eng, err := s.buildLocalEngine(req.SessionID, pwd, s.activeMode()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // Resume: hydrate the fresh engine with the persisted conversation/todos/goal. + if req.SessionID != "" { + entries, lerr := session.LoadSession(req.SessionID) + if lerr != nil { + // Stale/nonexistent session id: don't silently register a phantom empty + // engine under it — tear the just-built engine down and report not-found. + s.deleteEngine(eng.taskID) + writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) + return + } + st := session.ReconstructState(entries) + eng.emu.Lock() + eng.history = st.History + eng.emu.Unlock() + if eng.todoStore != nil { + items := make([]tools.TodoItem, len(st.Todos)) + for i, t := range st.Todos { + items[i] = tools.TodoItem{ID: t.ID, Title: t.Title, Status: tools.TodoStatus(t.Status)} + } + eng.todoStore.Update(items) + } + if eng.env != nil && eng.env.GoalStore != nil { + eng.env.GoalStore.RestoreFromSnapshot(st.Goal) + if eng.handler != nil { + eng.handler.Emit("goal_update", eng.env.GoalStore.Get()) + } + } + } + + s.setActiveEngine(eng) + + // Brand-new task: tell its view to start clean. + if req.SessionID == "" { + s.wsBroker.Broadcast(WSEvent{TaskID: eng.taskID, Type: "session_reset", Data: map[string]string{}}) + } + + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) +} diff --git a/internal/web/setup.go b/internal/web/setup.go new file mode 100644 index 00000000..4204bbd6 --- /dev/null +++ b/internal/web/setup.go @@ -0,0 +1,299 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "sort" + + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/model" +) + +// --- Setup & Provider Management Handlers --- + +// handleSetupStatus returns whether the server is in setup mode. +func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "needs_setup": s.needsSetup, + }) +} + +// handleSetupValidate tests connectivity to a provider with the given API key. +func (s *Server) handleSetupValidate(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.APIKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "api_key is required"}) + return + } + + baseURL := req.BaseURL + if baseURL == "" && s.registry != nil { + baseURL = s.registry.GetProviderAPI(req.Provider) + } + if baseURL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no base URL available for this provider"}) + return + } + + res := model.ValidateProviderDetailed(r.Context(), req.APIKey, baseURL, req.Headers) + if !res.OK { + writeJSON(w, http.StatusOK, map[string]any{ + "valid": false, + "error": res.Error, + "error_type": res.ErrorType, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "valid": true, + "latency_ms": res.LatencyMS, + "model_count": res.ModelCount, + }) +} + +// handleSetupProviders returns all available providers from the registry. +func (s *Server) handleSetupProviders(w http.ResponseWriter, r *http.Request) { + if s.registry == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + + type providerItem struct { + ID string `json:"id"` + Name string `json:"name"` + Doc string `json:"doc,omitempty"` + API string `json:"api,omitempty"` + Env []string `json:"env,omitempty"` + Configured bool `json:"configured"` + Tag string `json:"tag,omitempty"` // "recommended", "free", "local" + } + + providers := s.registry.ListProviders() + cfg, _ := config.LoadConfig() + configured := map[string]bool{} + if cfg != nil { + for k := range cfg.GetProviders() { + configured[k] = true + } + } + + // Provider tags for recommendation. + tags := map[string]string{ + "openai": "recommended", + "anthropic": "recommended", + "ollama": "local", + } + + result := make([]providerItem, 0, len(providers)) + for _, p := range providers { + result = append(result, providerItem{ + ID: p.ID, + Name: p.Name, + Doc: p.Doc, + API: p.API, + Env: p.Env, + Configured: configured[p.ID], + Tag: tags[p.ID], + }) + } + + // Sort: configured first, then by tag (recommended > local > ""), then by name. + sort.SliceStable(result, func(i, j int) bool { + ri, rj := result[i], result[j] + if ri.Configured != rj.Configured { + return ri.Configured + } + tagOrder := map[string]int{"recommended": 0, "local": 1, "": 2} + oi := tagOrder[ri.Tag] + oj := tagOrder[rj.Tag] + if oi != oj { + return oi < oj + } + return ri.Name < rj.Name + }) + + writeJSON(w, http.StatusOK, result) +} + +// handleSetupProviderModels returns models for a specific provider from the registry. +func (s *Server) handleSetupProviderModels(w http.ResponseWriter, r *http.Request) { + providerID := r.PathValue("id") + if providerID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) + return + } + + if s.registry == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + + models := s.registry.ListProviderModels(providerID, true) + type modelItem struct { + ID string `json:"id"` + Name string `json:"name"` + ToolCall bool `json:"tool_call"` + ContextLimit int `json:"context_limit,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Attachment bool `json:"attachment,omitempty"` + ReasoningOptions []model.ReasoningOption `json:"reasoning_options,omitempty"` + } + + result := make([]modelItem, 0, len(models)) + for _, m := range models { + ctx := 0 + if m.Limit != nil { + ctx = m.Limit.Context + } + result = append(result, modelItem{ + ID: m.ID, + Name: m.Name, + ToolCall: m.ToolCall, + ContextLimit: ctx, + Reasoning: m.Reasoning, + Attachment: m.Attachment, + ReasoningOptions: m.ReasoningOptions, + }) + } + + writeJSON(w, http.StatusOK, result) +} + +// It saves the provider config and creates the agent. +// +// The wizard no longer forces a model selection: for registry providers, a +// default model is auto-picked (DefaultEnabled → Recommended → first). A +// caller-supplied model always wins. Custom (non-registry) providers must send +// a model explicitly since none can be inferred. +func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model,omitempty"` + ModelReasoning bool `json:"model_reasoning,omitempty"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + Name string `json:"name,omitempty"` // custom provider display name + Headers map[string]string `json:"headers,omitempty"` + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.APIKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and api_key are required"}) + return + } + if !validReasoningEffort(req.ReasoningEffort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) + return + } + + // Resolve the active model. An explicit model always wins; otherwise try to + // auto-pick a default for registry providers. Custom providers (not in the + // registry) cannot infer a model and require one from the caller. + resolvedModel := req.Model + isCustom := s.registry == nil || !s.registry.HasProvider(req.Provider) + if resolvedModel == "" { + if isCustom { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model is required for custom providers"}) + return + } + if s.registry != nil { + resolvedModel = s.registry.PickDefaultModel(req.Provider) + } + } + if resolvedModel == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no default model available for this provider; please choose one"}) + return + } + + // Build or update config. + var cfg *config.Config + cfg, err := config.LoadConfig() + if err != nil { + // First time — create fresh config. + cfg = &config.Config{ + MaxIterations: 1000, + } + } + + if cfg.Providers == nil { + cfg.Providers = make(map[string]*config.ProviderConfig) + } + setupPC := &config.ProviderConfig{ + APIKey: req.APIKey, + BaseURL: req.BaseURL, + Name: req.Name, + Headers: cleanHeaders(req.Headers), + Vision: req.Vision, + Thinking: req.Thinking, + ReasoningEffort: req.ReasoningEffort, + } + // For a custom provider, persist the model as a custom model so it survives + // a model switch (otherwise it exists only as the active-model string and + // vanishes from the picker once changed). + if isCustom && resolvedModel != "" { + setupPC.CustomModels = []config.CustomModelConfig{{ + ID: resolvedModel, + Name: resolvedModel, + ToolCall: true, + Reasoning: req.ModelReasoning, + }} + } + cfg.Providers[req.Provider] = setupPC + cfg.Model = req.Provider + "/" + resolvedModel + + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + // Create the foreground task's agent with the new config. + eng := s.activeEngine() + if eng == nil || eng.createAgent == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no active task to configure"}) + return + } + ag, err := eng.createAgent(req.Provider, resolvedModel) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create agent: " + err.Error()}) + return + } + eng.applyModelSwitch(ag, req.Provider, resolvedModel) + // Publish the new config + registry to the live server so endpoints + // (/api/models, context-limit, etc.) reflect the just-configured provider + // without a restart. + s.cfgMu.Lock() + s.cfg = cfg + s.registry = model.NewModelRegistryWithConfig(cfg) + s.cfgMu.Unlock() + s.mu.Lock() + s.needsSetup = false + s.mu.Unlock() + + // Notify clients that setup is complete. + s.wsBroker.Broadcast(WSEvent{Type: "model_changed", TaskID: eng.taskID, Data: map[string]string{ + "provider": req.Provider, + "model": resolvedModel, + }}) + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "provider": req.Provider, + "model": resolvedModel, + }) +} diff --git a/internal/web/skills.go b/internal/web/skills.go new file mode 100644 index 00000000..3231df34 --- /dev/null +++ b/internal/web/skills.go @@ -0,0 +1,150 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "sort" + + "github.com/cnjack/jcode/internal/config" +) + +// --- Skills list handler (for slash commands) --- + +func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) { + type skillItem struct { + Name string `json:"name"` + Description string `json:"description"` + Slash string `json:"slash"` + Builtin bool `json:"builtin"` + Source string `json:"source"` // builtin | local + Enabled bool `json:"enabled"` + } + + var items []skillItem + if s.skillLoader != nil { + for _, sk := range s.skillLoader.All() { + source := "local" + if sk.Builtin { + source = "builtin" + } + items = append(items, skillItem{ + Name: sk.Name, + Description: sk.Description, + Slash: sk.Slash, + Builtin: sk.Builtin, + Source: source, + Enabled: s.skillLoader.IsEnabled(sk.Name), + }) + } + } + if items == nil { + items = []skillItem{} + } + writeJSON(w, http.StatusOK, items) +} + +// handleToggleSkill enables/disables a skill, persisting to config and updating +// the loader + agent so the change takes effect immediately. +func (s *Server) handleToggleSkill(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if s.skillLoader == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "skills unavailable"}) + return + } + + // cfgMu (not s.mu) serializes the cfg read-modify-write+save, so concurrent + // approval-mode / MCP / skill saves can't clobber each other in memory or on + // disk. + s.cfgMu.Lock() + // Rebuild the disabled set from config. + disabled := make(map[string]bool, len(s.cfg.DisabledSkills)) + for _, n := range s.cfg.DisabledSkills { + disabled[n] = true + } + if req.Enabled { + delete(disabled, name) + } else { + disabled[name] = true + } + list := make([]string, 0, len(disabled)) + for n := range disabled { + list = append(list, n) + } + sort.Strings(list) + s.cfg.DisabledSkills = list + if err := config.SaveConfig(s.cfg); err != nil { + s.cfgMu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + s.cfgMu.Unlock() + + s.skillLoader.SetDisabled(list) + // Rebuild the foreground task's agent so the system prompt (skill descriptions) + // and load_skill tool reflect the change on the next run. + if !s.needsSetup { + if eng := s.activeEngine(); eng != nil && eng.createAgent != nil { + prov, mod, _ := eng.modelSnapshot() + if ag, err := eng.createAgent(prov, mod); err == nil { + eng.setAgent(ag) + } + } + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "name": name, "enabled": req.Enabled}) +} + +// handleSlashCommands returns skill slash commands for the web frontend +// autocomplete menu. Built-in commands (/setting, /model, /ssh, etc.) are +// excluded because the web UI provides dedicated controls for those features +// and submitMessage only dispatches skill-based slash commands. +func (s *Server) handleSlashCommands(w http.ResponseWriter, r *http.Request) { + type slashItem struct { + Slash string `json:"slash"` + Description string `json:"description"` + Type string `json:"type"` // "skill" | "flow" + } + + var items []slashItem + if s.skillLoader != nil { + for _, sk := range s.skillLoader.SlashCommands() { + items = append(items, slashItem{ + Slash: sk.Slash, + Description: sk.Description, + Type: "skill", + }) + } + } + // Workflows resolve against the foreground task's project so its + // .jcode/workflows show up in autocomplete, falling back to the boot loader. + if fl := s.flowLoaderFor(s.activeEngine()); fl != nil { + for _, fc := range fl.SlashCommands() { + items = append(items, slashItem{ + Slash: fc.Slash, + Description: fc.Description, + Type: "flow", + }) + } + } + + if items == nil { + items = []slashItem{} + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Slash < items[j].Slash + }) + + writeJSON(w, http.StatusOK, items) +} diff --git a/internal/web/ws.go b/internal/web/ws.go index 4b1edec8..5b384aa3 100644 --- a/internal/web/ws.go +++ b/internal/web/ws.go @@ -2,6 +2,7 @@ package web import ( "encoding/json" + "net/http" "sync" "sync/atomic" @@ -179,3 +180,98 @@ type WSIncoming struct { Type string `json:"type"` Data json.RawMessage `json:"data,omitempty"` } + +// --- WebSocket handler --- + +// CheckOrigin rejects cross-origin WebSocket handshakes from untrusted web +// pages (see isAllowedWebOrigin); without this any website could open a socket +// to the loopback server and read the agent's live event stream. +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: isAllowedWebOrigin, + // Advertise the auth subprotocol so gorilla echoes it back on the handshake + // response; browsers send ["jcode-auth", ""] and expect the server to + // confirm a subprotocol, otherwise some reject the connection. The token (the + // second value) is never echoed. + Subprotocols: []string{wsAuthSubprotocol}, +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + config.Logger().Printf("[ws] upgrade error: %v", err) + return + } + + id, client, unsub := s.wsBroker.Register(conn) + config.Logger().Printf("[ws] client %d connected", id) + + // Write pump: send events to client. + go client.writePump() + + // Read pump: handle incoming messages. + defer func() { + unsub() + _ = conn.Close() + config.Logger().Printf("[ws] client %d disconnected", id) + }() + + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + var incoming WSIncoming + if err := json.Unmarshal(msg, &incoming); err != nil { + continue + } + s.handleWSMessage(client, incoming) + } +} + +func (s *Server) handleWSMessage(client *WSClient, msg WSIncoming) { + switch msg.Type { + case "ping": + // Unicast the pong to the pinging client (broadcasting it woke every + // client unnecessarily). + if data, err := json.Marshal(WSEvent{Type: "pong"}); err == nil { + client.send(data) + } + case "subscribe": + var data struct { + TaskIDs []string `json:"task_ids"` + } + if json.Unmarshal(msg.Data, &data) == nil { + client.subscribe(data.TaskIDs) + } + case "unsubscribe": + var data struct { + TaskIDs []string `json:"task_ids"` + } + if json.Unmarshal(msg.Data, &data) == nil { + client.unsubscribe(data.TaskIDs) + } + case "approval": + var data struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Approved bool `json:"approved"` + ApproveAll bool `json:"approve_all"` + } + if err := json.Unmarshal(msg.Data, &data); err != nil { + return + } + // Empty task_id → active task (legacy); non-empty unknown → drop (ids are + // handler-local and could collide with another task's). + reng := s.resolveEngine(data.TaskID) + if reng == nil || reng.handler == nil { + return + } + if err := reng.handler.ResolveApproval(data.ID, data.Approved, data.ApproveAll); err != nil { + config.Logger().Printf("[ws] resolve approval failed for id=%q: %v", data.ID, err) + return + } + // Same mode-sync as the POST path: an "allow all" over WS must also + // update the selector pill the user is looking at. + s.syncModeAfterApproval(reng, data.Approved, data.ApproveAll) + } +} diff --git a/packages/jcode-ui-core/package.json b/packages/jcode-ui-core/package.json index ce486b47..e34a49b7 100644 --- a/packages/jcode-ui-core/package.json +++ b/packages/jcode-ui-core/package.json @@ -8,7 +8,7 @@ "homepage": "https://www.j-code.net/chat-ui/docs", "repository": { "type": "git", - "url": "https://github.com/cnjack/jcode", + "url": "git+https://github.com/cnjack/jcode.git", "directory": "packages/jcode-ui-core" }, "keywords": [ diff --git a/packages/jcode-ui/package.json b/packages/jcode-ui/package.json index 9bf1fff8..7c59a52b 100644 --- a/packages/jcode-ui/package.json +++ b/packages/jcode-ui/package.json @@ -1,6 +1,6 @@ { "name": "jcode-ui", - "version": "0.1.0", + "version": "0.1.1", "description": "React AI chat UI components — styled, token-driven, backend-agnostic. Streams messages, tool calls, approvals, and ask-user interactions.", "type": "module", "license": "MIT", @@ -8,7 +8,7 @@ "homepage": "https://www.j-code.net/chat-ui/docs", "repository": { "type": "git", - "url": "https://github.com/cnjack/jcode", + "url": "git+https://github.com/cnjack/jcode.git", "directory": "packages/jcode-ui" }, "keywords": [ @@ -67,7 +67,7 @@ "@tailwindcss/typography": "^0.5.16", "dompurify": "^3.2.4", "highlight.js": "^11.11.1", - "jcode-ui-core": "file:../jcode-ui-core", + "jcode-ui-core": "^0.1.0", "marked": "^18.0.0", "marked-highlight": "^2.2.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 340fd49e..e68cd42e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^11.11.1 version: 11.11.1 jcode-ui-core: - specifier: file:../jcode-ui-core - version: link:../jcode-ui-core + specifier: ^0.1.0 + version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) marked: specifier: ^18.0.0 version: 18.0.5 @@ -116,11 +116,11 @@ importers: specifier: ^25.3.2 version: 25.10.10(typescript@5.9.3) jcode-ui: - specifier: workspace:* - version: link:../packages/jcode-ui + specifier: ^0.1.1 + version: 0.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@4.3.2) jcode-ui-core: - specifier: workspace:* - version: link:../packages/jcode-ui-core + specifier: ^0.1.0 + version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) marked: specifier: ^18.0.0 version: 18.0.5 @@ -1028,6 +1028,23 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + jcode-ui-core@0.1.0: + resolution: {integrity: sha512-ziWRzhQoP8yU9Un4qE6OWayyhRzxrVC8hKJQXm+Aff135X54xNgJim1Vf1QUZVrw3oBERpeK8TJlJjbq6D6i/w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + jcode-ui@0.1.1: + resolution: {integrity: sha512-XHg8Xe0PFPGnTJGcTE3LOMX9symuJurNUjwxygr95qAvsvSa1VN8CRGcNYRJ/34CiK7TQXfI0VoqqiM8H22jLg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2088,6 +2105,27 @@ snapshots: is-number@7.0.0: {} + jcode-ui-core@0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@tanstack/react-virtual': 3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + jcode-ui@0.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@4.3.2): + dependencies: + '@heroicons/react': 2.2.0(react@18.3.1) + '@tailwindcss/typography': 0.5.20(tailwindcss@4.3.2) + dompurify: 3.4.11 + highlight.js: 11.11.1 + jcode-ui-core: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + marked: 18.0.5 + marked-highlight: 2.2.4(marked@18.0.5) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - tailwindcss + jiti@2.7.0: {} js-tokens@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b88afd36..eecdc391 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,9 @@ packages: allowBuilds: '@parcel/watcher': true esbuild: true +minimumReleaseAgeExclude: + - jcode-ui-core@0.1.0 + - jcode-ui@0.1.1 # Native build scripts to allow. esbuild (Vite's bundler) and @parcel/watcher # (dev-server file watching) are trusted toolchain deps — whitelisting them # avoids ERR_PNPM_IGNORED_BUILDS failing `pnpm install` (and the Makefile build). diff --git a/site/docs/desktop.md b/site/docs/desktop.md index d2636c6d..3ca6856e 100644 --- a/site/docs/desktop.md +++ b/site/docs/desktop.md @@ -135,7 +135,7 @@ desktop/ ``` The desktop bridge on the frontend side lives in -`web/src/composables/useDesktop.ts` — every export is feature-detected, so the +`web/src/lib/useDesktop.ts` — every export is feature-detected, so the same web bundle runs unchanged in a browser and inside the desktop shell. ## Troubleshooting diff --git a/site/package.json b/site/package.json index 8fb7e2e3..b6347e03 100644 --- a/site/package.json +++ b/site/package.json @@ -13,8 +13,8 @@ "@remotion/player": "4.0.410", "github-slugger": "^2.0.0", "highlight.js": "^11.11.1", - "jcode-ui": "file:../packages/jcode-ui", - "jcode-ui-core": "file:../packages/jcode-ui-core", + "jcode-ui": "^0.1.1", + "jcode-ui-core": "^0.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.3", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 77efc4e9..c4676030 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -18,11 +18,11 @@ importers: specifier: ^11.11.1 version: 11.11.1 jcode-ui: - specifier: file:../packages/jcode-ui - version: file:../packages/jcode-ui(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^0.1.1 + version: 0.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) jcode-ui-core: - specifier: file:../packages/jcode-ui-core - version: file:../packages/jcode-ui-core(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^0.1.0 + version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -735,8 +735,8 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - jcode-ui-core@file:../packages/jcode-ui-core: - resolution: {directory: ../packages/jcode-ui-core, type: directory} + jcode-ui-core@0.1.0: + resolution: {integrity: sha512-ziWRzhQoP8yU9Un4qE6OWayyhRzxrVC8hKJQXm+Aff135X54xNgJim1Vf1QUZVrw3oBERpeK8TJlJjbq6D6i/w==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -746,8 +746,8 @@ packages: react-dom: optional: true - jcode-ui@file:../packages/jcode-ui: - resolution: {directory: ../packages/jcode-ui, type: directory} + jcode-ui@0.1.1: + resolution: {integrity: sha512-XHg8Xe0PFPGnTJGcTE3LOMX9symuJurNUjwxygr95qAvsvSa1VN8CRGcNYRJ/34CiK7TQXfI0VoqqiM8H22jLg==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -1753,20 +1753,20 @@ snapshots: is-plain-obj@4.1.0: {} - jcode-ui-core@file:../packages/jcode-ui-core(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + jcode-ui-core@0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@tanstack/react-virtual': 3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - jcode-ui@file:../packages/jcode-ui(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + jcode-ui@0.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@heroicons/react': 2.2.0(react@18.3.1) '@tailwindcss/typography': 0.5.20 dompurify: 3.4.11 highlight.js: 11.11.1 - jcode-ui-core: file:../packages/jcode-ui-core(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + jcode-ui-core: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) marked: 18.0.5 marked-highlight: 2.2.4(marked@18.0.5) react: 18.3.1 diff --git a/site/pnpm-workspace.yaml b/site/pnpm-workspace.yaml index 5ed0b5af..43bf28d8 100644 --- a/site/pnpm-workspace.yaml +++ b/site/pnpm-workspace.yaml @@ -1,2 +1,5 @@ allowBuilds: esbuild: true +minimumReleaseAgeExclude: + - jcode-ui-core@0.1.0 + - jcode-ui@0.1.1 diff --git a/web/package.json b/web/package.json index 11f9ade2..05b104c6 100644 --- a/web/package.json +++ b/web/package.json @@ -22,8 +22,8 @@ "@xterm/xterm": "^5.5.0", "dompurify": "^3.2.4", "highlight.js": "^11.11.1", - "jcode-ui": "workspace:*", - "jcode-ui-core": "workspace:*", + "jcode-ui": "^0.1.1", + "jcode-ui-core": "^0.1.0", "marked": "^18.0.0", "marked-highlight": "^2.2.2", "qrcode": "^1.5.4", diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 63284adb..373121a6 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -202,7 +202,7 @@ export default { }, tool: { - // Tool display titles (composables/toolInfo.ts). + // Tool display titles (lib/toolInfo.ts). read: 'Read', write: 'Write', edit: 'Edit',