diff --git a/.env.example b/.env.example index 2be9b5c..9c58838 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,8 @@ LOG_LEVEL=debug # 数据库引擎:sqlite | postgres(当前只实现 sqlite,默认 sqlite) DB_ENGINE=sqlite -# 数据库连接串(默认 file:codedock.db) -DB_DSN=file:codedock.db +# 数据库连接串。未设且引擎为 sqlite 时默认 <仓根>/data/codedock.db +# DB_DSN=file:/abs/path/data/codedock.db # 模型提供方:fake | openai(默认 fake,测试与离线闭环用) LLM_PROVIDER=fake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28d7ab8..204de6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: HTTP_ADDR: :18080 LLM_PROVIDER: fake LLM_MODEL: fake - DB_DSN: file:ci.db + DB_DSN: file:../data/ci.db LOG_LEVEL: info run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 796402f..b5e6d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .env .env.local +/data/ *.db +*.db-shm +*.db-wal tmp/ .DS_Store node_modules diff --git a/AGENTS.md b/AGENTS.md index fa99a50..52f7692 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ 修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。 -Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具 `memory_read` / `memory_write` / `memory_search`,不实现文件 / Shell / Git **工具**。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。 +Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出目录浏览框(`apps/web` 列本机目录),不走 Agent Tool。 - Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 - 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。 @@ -16,6 +16,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 +- 运行时产生的文件(SQLite 等)放在仓根 `data/`,不要写进 `server/`。该目录 gitignore。 - 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。 - 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。 - 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 diff --git a/README.md b/README.md index 06108d7..5d271a7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ cp apps/web/.env.example apps/web/.env.local pnpm install ``` -一次起 API + Web。若已有 `tmp/git-sandbox`,API 默认指到沙箱,避免在本仓上试撤回: +一条命令同时起 Go API 和 Web。日志带 `api` / `web` 前缀;Ctrl+C 会一起停。若已有 `tmp/git-sandbox`,API 默认指到沙箱,避免在本仓上试撤回: ```bash pnpm dev @@ -26,7 +26,7 @@ pnpm dev pnpm dev:api ``` -从 `server/` 直接 `go run` 时,未设 `GIT_REPO` 会用进程 cwd(`server/` 不是仓根)。服务会从当前目录向上查找 `.env`。 +从 `server/` 直接 `go run` 时,未设 `GIT_REPO` 会用进程 cwd(`server/` 不是仓根)。未设 `DB_DSN` 时 SQLite 写仓根 `data/`。服务会从当前目录向上查找 `.env`。 Web(默认 `http://localhost:3000`): diff --git a/apps/web/app/api/directories/route.ts b/apps/web/app/api/directories/route.ts new file mode 100644 index 0000000..c86f23f --- /dev/null +++ b/apps/web/app/api/directories/route.ts @@ -0,0 +1,24 @@ +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { NextResponse } from "next/server"; + +export async function GET(req: Request) { + const raw = new URL(req.url).searchParams.get("path")?.trim() || homedir(); + const current = resolve(raw); + try { + const dirents = await readdir(current, { withFileTypes: true }); + const entries = dirents + .filter((entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink())) + .map((entry) => ({ name: entry.name, path: join(current, entry.name) })) + .sort((a, b) => a.name.localeCompare(b.name, "zh")); + const parent = dirname(current); + return NextResponse.json({ + path: current, + parent: parent === current ? undefined : parent, + entries, + }); + } catch { + return NextResponse.json({ error: "无法打开该目录" }, { status: 400 }); + } +} diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index dc2050f..2e31146 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -3,11 +3,16 @@ import { ChatPage } from "@codedock/views/chat"; import { usePathname, useRouter } from "next/navigation"; +import { rememberSession } from "@/lib/session"; + export function ChatHost() { const pathname = usePathname(); const router = useRouter(); const match = pathname.match(/^\/s\/([^/]+)/); const sessionId = match?.[1] ? decodeURIComponent(match[1]) : undefined; + if (sessionId) { + rememberSession(sessionId); + } return ( void) { + if (typeof window === "undefined") { + return () => {}; + } + window.addEventListener("storage", onStoreChange); + return () => window.removeEventListener("storage", onStoreChange); +} export function GitHost() { - const client = useMemo(() => new GitClient({ baseUrl: apiBase }), []); + const sessionId = useSyncExternalStore(subscribeSession, readCurrentSession, () => undefined); + const client = useMemo( + () => new GitClient({ baseUrl: apiBase, sessionId }), + [sessionId], + ); return ( - +
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index b7094b7..caebd09 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -4,12 +4,13 @@ import { AgentClient } from "@codedock/core/chat"; import { AgentProvider } from "@codedock/views"; import { useMemo, type ReactNode } from "react"; +import { listDirectories } from "@/lib/directories"; import { apiBase, defaultUserId } from "@/lib/env"; export function Providers({ children }: { children: ReactNode }) { const client = useMemo(() => new AgentClient({ baseUrl: apiBase }), []); return ( - + {children} ); diff --git a/apps/web/lib/directories.ts b/apps/web/lib/directories.ts new file mode 100644 index 0000000..5865f0f --- /dev/null +++ b/apps/web/lib/directories.ts @@ -0,0 +1,15 @@ +import type { DirectoryListing } from "@codedock/views"; + +export async function listDirectories(path?: string): Promise { + const query = path?.trim() ? `?path=${encodeURIComponent(path.trim())}` : ""; + const res = await fetch(`/api/directories${query}`); + const body = (await res.json().catch(() => ({}))) as DirectoryListing & { error?: string }; + if (!res.ok) { + throw new Error(body.error || "无法列出目录"); + } + return { + path: body.path, + parent: body.parent, + entries: body.entries ?? [], + }; +} diff --git a/apps/web/lib/session.ts b/apps/web/lib/session.ts new file mode 100644 index 0000000..2383208 --- /dev/null +++ b/apps/web/lib/session.ts @@ -0,0 +1,23 @@ +const CURRENT_SESSION_KEY = "codedock.currentSession"; + +export function rememberSession(sessionId: string): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(CURRENT_SESSION_KEY, sessionId); + } catch { + // ignore + } +} + +export function readCurrentSession(): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + try { + return window.localStorage.getItem(CURRENT_SESSION_KEY) || undefined; + } catch { + return undefined; + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 1f10207..038b757 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,7 @@ CodeDock/ │ ├── ui/ # 无业务语义;components / lib / styles,不要 src/ │ └── views/ # 组合层;按业务域拆(现有 chat/),不要 src/ ├── docs/ +├── data/ # 运行时文件(sqlite 等),gitignore ├── server/ │ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配 │ ├── internal/ @@ -136,7 +137,7 @@ packages/views -> packages/ui 不 import next/* 按业务域拆目录,与 core 对齐(现有 chat) - AgentProvider 在包根注入 client + userId;导航用回调 + AgentProvider 在包根注入 client + userId,以及可选的本机目录列举;导航用回调 apps/web -> packages/views @@ -151,15 +152,15 @@ apps/web 承担大部分接口逻辑: -- Session / Message / Usage / Approval 的增删改查。`sessions.summary` 在首次用户消息写入,列表与详情返回 +- Session / Message / Usage / Approval 的增删改查。创建 Session 时在本包冻结 `workspace_id`(工作目录):用户指定的路径必须是已存在目录,否则 400;未指定(空或 `default`)则 `GIT_REPO`,再否则 cwd。不 import `internal/agent/tools`。新对话选目录由 web 弹出目录浏览框。`sessions.summary` 在首次用户消息写入,列表与详情返回 - 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id,Get/Delete 用 name 默认目录) - SSE:先按 `afterSeq` / `Last-Event-ID` 回放已落库事件,再 `SubscribeAll` 并按 Session 过滤;客户端断开不取消 Run - 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播 - Run 的 Start / Continue / Retry / Cancel 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker -- 同一 Session 只有一个 active Run:`interrupt` 先取消再开新 Run;`queue` 只落库,当前结束后自动领取 -- Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。`GIT_REPO` 为空则用进程 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating) +- 同一 Session 只有一个 active Run:已有 active 时 409。要打断当前轮,先 Cancel 再 Start +- Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。带 `session_id` 时仓库根是该会话冻结的 `workspace_id`;未带则 `GIT_REPO`,再否则 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating) -Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库。 +Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `session_id` 时只查该 Session 的 `workspace_id`,不经过 Store。 ### `internal/agent` @@ -235,11 +236,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库 ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 +组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt、新对话目录选择。包根 `provider.tsx` 注入 `AgentClient` + `userId`,以及可选的本机目录列举。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,请求带当前会话 `session_id`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 ### `apps/web` -路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。新对话选目录走 web 的本机目录接口。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`,并带当前会话 `session_id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 ## 组装关系 @@ -261,7 +262,7 @@ Worker ## 配置 -`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。 +`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。 HTTP 出站领域对象使用 snake_case JSON。Router 只对本地回环 Origin 放行 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。 diff --git a/package.json b/package.json index a2b32fd..a7271a5 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,16 @@ "name": "codedock", "private": true, "scripts": { - "dev": "sh scripts/dev.sh", + "dev": "concurrently -k --names api,web --prefix-colors cyan,magenta \"pnpm dev:api\" \"pnpm dev:web\"", "dev:api": "sh scripts/dev-api.sh", "dev:web": "pnpm --filter web dev", "build:web": "pnpm --filter web build", "test:client": "pnpm --filter @codedock/core test", "lint:web": "pnpm --filter web lint" }, + "devDependencies": { + "concurrently": "^9.2.4" + }, "packageManager": "pnpm@11.17.0", "engines": { "node": ">=22.13.0" diff --git a/packages/core/chat/client.ts b/packages/core/chat/client.ts index 2a9a1cd..ef74f1e 100644 --- a/packages/core/chat/client.ts +++ b/packages/core/chat/client.ts @@ -6,6 +6,7 @@ import type { DecideApprovalRequest, Message, PageInfo, + Run, Session, StartRunRequest, StartRunResponse, @@ -42,7 +43,7 @@ export class AgentClient { tenant_id: req.tenant_id ?? "default", user_id: req.user_id, agent_id: req.agent_id ?? "default", - workspace_id: req.workspace_id ?? "default", + workspace_id: req.workspace_id?.trim() || undefined, }, }); return body.session; @@ -68,8 +69,15 @@ export class AgentClient { }; } - async getSession(sessionId: string): Promise { - const body = await this.request<{ session: Session }>(`/sessions/${sessionId}`); + async getSession(sessionId: string, signal?: AbortSignal): Promise { + const body = await this.request<{ session: Session }>(`/sessions/${sessionId}`, { signal }); + return body.session; + } + + async archiveSession(sessionId: string): Promise { + const body = await this.request<{ session: Session }>(`/sessions/${sessionId}/archive`, { + method: "POST", + }); return body.session; } @@ -112,16 +120,41 @@ export class AgentClient { method: "POST", json: { content: req.content, - input_mode: req.input_mode ?? "queue", mode: req.mode ?? ("ask_for_approval" satisfies AgentMode), }, }); } + async getRun(runId: string, signal?: AbortSignal): Promise { + const body = await this.request<{ run: Run }>(`/runs/${runId}`, { signal }); + return body.run; + } + + async continueRun(runId: string): Promise { + await this.request<{ ok: boolean }>(`/runs/${runId}/continue`, { method: "POST" }); + } + async cancelRun(runId: string): Promise { await this.request<{ ok: boolean }>(`/runs/${runId}/cancel`, { method: "POST" }); } + async listApprovals(sessionId: string, signal?: AbortSignal): Promise { + const query = new URLSearchParams({ + page: "1", + page_size: "100", + }); + const body = await this.request<{ approvals: Approval[] }>( + `/sessions/${sessionId}/approvals?${query}`, + { signal }, + ); + return body.approvals ?? []; + } + + async getApproval(approvalId: string, signal?: AbortSignal): Promise { + const body = await this.request<{ approval: Approval }>(`/approvals/${approvalId}`, { signal }); + return body.approval; + } + async decideApproval(approvalId: string, req: DecideApprovalRequest): Promise { const body = await this.request<{ approval: Approval }>(`/approvals/${approvalId}/decision`, { method: "POST", diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index 30692d2..2501c8c 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -1,8 +1,13 @@ export { AgentClient, AgentClientError, type AgentClientOptions } from "./client.ts"; export { decodeText, firstLine, parseDelta } from "./content.ts"; +export { joinQueuedTexts } from "./queue.ts"; export { + applyApprovalRecord, + applyApprovals, applyEvent, + decisionsForApproval, applyOptimisticUser, + applyUserText, dropOptimisticUser, emptyState, hydrate, @@ -21,6 +26,7 @@ export type { EventType, Message, PageInfo, + Run, RunStatus, Session, SessionState, @@ -31,4 +37,11 @@ export type { ToolCall, ToolItemState, } from "./types.ts"; -export { isTerminalRun, isThinkingPhase, TERMINAL_RUN_STATUSES, THINKING_PHASES } from "./types.ts"; +export { + isRecoverableRun, + isTerminalRun, + isThinkingPhase, + RECOVERABLE_RUN_STATUSES, + TERMINAL_RUN_STATUSES, + THINKING_PHASES, +} from "./types.ts"; diff --git a/packages/core/chat/queue.test.ts b/packages/core/chat/queue.test.ts new file mode 100644 index 0000000..0673e7f --- /dev/null +++ b/packages/core/chat/queue.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { joinQueuedTexts } from "./queue.ts"; + +test("joinQueuedTexts trims and joins with single newlines", () => { + assert.equal(joinQueuedTexts([" a ", "", "b\n", " "]), "a\nb"); +}); + +test("joinQueuedTexts returns empty when nothing remains", () => { + assert.equal(joinQueuedTexts([]), ""); + assert.equal(joinQueuedTexts([" ", "\n"]), ""); +}); diff --git a/packages/core/chat/queue.ts b/packages/core/chat/queue.ts new file mode 100644 index 0000000..7d86a71 --- /dev/null +++ b/packages/core/chat/queue.ts @@ -0,0 +1,7 @@ +/** joinQueuedTexts 把待发正文 trim 后按换行拼成一条,中间不留空行。 */ +export function joinQueuedTexts(texts: string[]): string { + return texts + .map((text) => text.trim()) + .filter((text) => text.length > 0) + .join("\n"); +} diff --git a/packages/core/chat/reducer.test.ts b/packages/core/chat/reducer.test.ts index 23567b2..c976721 100644 --- a/packages/core/chat/reducer.test.ts +++ b/packages/core/chat/reducer.test.ts @@ -5,8 +5,11 @@ import { parseSSEChunk } from "./sse.ts"; import type { AgentEvent, Message, TimelineItem } from "./types.ts"; import { decodeText, parseDelta } from "./content.ts"; import { + applyApprovalRecord, + applyApprovals, applyEvent, applyOptimisticUser, + decisionsForApproval, emptyState, hydrate, } from "./reducer.ts"; @@ -326,6 +329,101 @@ test("tool and approval lifecycle", () => { assert.deepEqual(tool.output, { pong: true }); }); +test("approval event keeps every tool call when a later event only lists one", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + type: "tool.approval_required", + payload: { + approval_id: "ap1", + tool_calls: [ + { id: "c1", name: "memory_read" }, + { id: "c2", name: "ping" }, + ], + }, + }), + ); + state = applyEvent( + state, + ev({ + seq: 2, + type: "tool.approval_required", + payload: { approval_id: "ap1", tool_calls: [{ id: "c2", name: "ping" }] }, + }), + ); + const approval = state.items.find((item) => item.kind === "approval"); + assert.ok(approval && approval.kind === "approval"); + assert.equal(approval.toolCalls.length, 2); +}); + +test("decisionsForApproval fills the rest of a batch with the same status", () => { + const decisions = decisionsForApproval( + { + id: "ap1", + session_id: "s1", + run_id: "r1", + tool_call_id: "c1", + tool_calls: [ + { id: "c1", name: "memory_read" }, + { id: "c2", name: "ping" }, + ], + scope: "once", + status: "pending", + expires_at: "2026-01-01T01:00:00Z", + }, + [{ tool_call_id: "c2", status: "approved" }], + ); + assert.deepEqual(decisions, [ + { tool_call_id: "c1", status: "approved" }, + { tool_call_id: "c2", status: "approved" }, + ]); +}); + +test("approval record overlay clears a pending dock without a decided event", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + type: "tool.approval_required", + payload: { approval_id: "ap1", tool_calls: [{ id: "c1", name: "ping" }] }, + }), + ); + state = applyApprovalRecord(state, { + id: "ap1", + session_id: "s1", + run_id: "r1", + tool_call_id: "c1", + tool_calls: [{ id: "c1", name: "ping", status: "approved" }], + scope: "once", + status: "approved", + expires_at: "2026-01-01T01:00:00Z", + }); + const approval = state.items.find((item) => item.kind === "approval"); + assert.ok(approval && approval.kind === "approval"); + assert.equal(approval.status, "approved"); + assert.equal(state.lastSeq, 1); +}); + +test("applyApprovals hydrates a pending approval when events omitted it", () => { + const state = applyApprovals(emptyState(), [ + { + id: "ap2", + session_id: "s1", + run_id: "r1", + tool_call_id: "c2", + tool_calls: [{ id: "c2", name: "memory_write" }], + scope: "once", + status: "pending", + expires_at: "2026-01-01T01:00:00Z", + }, + ]); + const approval = state.items.find((item) => item.kind === "approval"); + assert.ok(approval && approval.kind === "approval"); + assert.equal(approval.status, "pending"); + assert.equal(approval.approvalId, "ap2"); +}); + test("denied approval marks the tool denied", () => { let state = applyEvent( emptyState(), @@ -371,6 +469,43 @@ test("optimistic user is replaced when run.created arrives", () => { assert.equal(users[0]?.kind === "user" && users[0].text, "hi"); }); +test("a later run.created does not steal the executing run", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + run_id: "r1", + type: "run.created", + payload: { trigger_message_id: "m1", mode: "auto_approve", status: "queued", text: "first" }, + }), + ); + state = applyEvent( + state, + ev({ + seq: 2, + run_id: "r1", + type: "run.state_changed", + payload: { from: "queued", to: "running_llm", reason: "" }, + }), + ); + state = applyEvent( + state, + ev({ + seq: 3, + run_id: "r2", + type: "run.created", + payload: { trigger_message_id: "m2", mode: "auto_approve", status: "queued", text: "second" }, + }), + ); + assert.equal(state.activeRunId, "r1"); + assert.equal(state.runStatus, "running_llm"); + const users = state.items.filter((item) => item.kind === "user"); + assert.deepEqual( + users.map((item) => (item.kind === "user" ? item.text : "")), + ["first", "second"], + ); +}); + test("context compacted becomes a timeline item", () => { const state = applyEvent( emptyState(), diff --git a/packages/core/chat/reducer.ts b/packages/core/chat/reducer.ts index a997cc7..8d166a2 100644 --- a/packages/core/chat/reducer.ts +++ b/packages/core/chat/reducer.ts @@ -3,8 +3,11 @@ import { isTerminalRun, isThinkingPhase, type AgentEvent, + type Approval, type ApprovalDecidedPayload, + type ApprovalDecision, type ApprovalRequiredPayload, + type ApprovalStatus, type ApprovalToolCall, type AssistantCompletedPayload, type AssistantDeltaPayload, @@ -52,10 +55,12 @@ export function applyOptimisticUser( state: SessionState, input: { runId: string; text: string }, ): SessionState { + const queued = hasExecutingRun(state, input.runId); return upsertUser(state, { messageId: `pending:${input.runId}`, runId: input.runId, text: input.text, + queued, seq: state.lastSeq, }); } @@ -73,7 +78,6 @@ export function applyEvent(state: SessionState, event: AgentEvent): SessionState lastSeq: event.seq, items: state.items.slice(), messages: state.messages, - activeRunId: event.run_id || state.activeRunId, }; switch (event.type) { @@ -125,31 +129,59 @@ export function applyEvent(state: SessionState, event: AgentEvent): SessionState function applyRunCreated(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as RunCreatedPayload; const message = state.messages[payload.trigger_message_id]; - const pendingId = `pending:${event.run_id}`; + const pending = findPendingUser(state, event.run_id, payload.text); const text = (message ? decodeText(message.content) : "") || - userTextByRun(state, event.run_id) || - pendingUserText(state); - let next = replaceUser(state, pendingId, payload.trigger_message_id, event.run_id, text, event.seq); - next = dropPendingUsers(next); + payload.text || + (pending?.kind === "user" ? pending.text : "") || + userTextByRun(state, event.run_id); + const takeActive = canTakeActive(state, event.run_id); + const queued = payload.status === "queued" && !takeActive; + let next = state; + if (pending?.kind === "user") { + next = replaceUser(next, pending.messageId, payload.trigger_message_id, event.run_id, text, event.seq, queued); + } next = upsertUser(next, { messageId: payload.trigger_message_id, runId: event.run_id, text, + queued, seq: event.seq, }); - next.runStatus = payload.status; - next.activeRunId = event.run_id; - if (isThinkingPhase(payload.status)) { - next = upsertThinking(next, event.run_id, payload.status, event.seq); + if (payload.trigger_message_id && text) { + next = { + ...next, + messages: { + ...next.messages, + [payload.trigger_message_id]: { + id: payload.trigger_message_id, + session_id: event.session_id, + run_id: event.run_id, + role: "user", + content: { text }, + event_seq: event.seq, + created_at: event.occurred_at, + }, + }, + }; + } + if (takeActive) { + next = { ...next, runStatus: payload.status, activeRunId: event.run_id }; + if (isThinkingPhase(payload.status) && payload.status !== "queued") { + next = upsertThinking(next, event.run_id, payload.status, event.seq); + } } return next; } function applyRunStateChanged(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as RunStateChangedPayload; - let next: SessionState = { ...state, runStatus: payload.to, activeRunId: event.run_id }; - if (isThinkingPhase(payload.to)) { + const takeActive = canTakeActive(state, event.run_id); + let next: SessionState = takeActive + ? { ...state, runStatus: payload.to, activeRunId: event.run_id } + : state; + next = setUserQueued(next, event.run_id, false); + if (isThinkingPhase(payload.to) && payload.to !== "queued") { next = upsertThinking(next, event.run_id, payload.to, event.seq); } else { next = removeItem(next, thinkingId(event.run_id)); @@ -257,7 +289,7 @@ function applyApprovalRequired(state: SessionState, event: AgentEvent): SessionS id: approvalId(payload.approval_id), runId: event.run_id, approvalId: payload.approval_id, - toolCalls: payload.tool_calls ?? [], + toolCalls: mergeApprovalCalls(existingApprovalCalls(state, payload.approval_id), payload.tool_calls ?? []), status: "pending", seq: event.seq, }); @@ -265,20 +297,68 @@ function applyApprovalRequired(state: SessionState, event: AgentEvent): SessionS function applyApprovalDecided(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as ApprovalDecidedPayload; - let next = upsertItem(state, { - kind: "approval", - id: approvalId(payload.approval_id), - runId: event.run_id, + return applyApprovalDecision(state, { approvalId: payload.approval_id, - toolCalls: payload.tool_calls ?? existingApprovalCalls(state, payload.approval_id), + runId: event.run_id, status: payload.status, + toolCalls: payload.tool_calls ?? existingApprovalCalls(state, payload.approval_id), + decisions: payload.decisions ?? [], seq: event.seq, }); - for (const decision of payload.decisions ?? []) { +} + +export function applyApprovals(state: SessionState, approvals: Approval[]): SessionState { + let next = state; + for (const approval of approvals) { + next = applyApprovalRecord(next, approval); + } + return next; +} + +export function applyApprovalRecord(state: SessionState, approval: Approval): SessionState { + const toolCalls = mergeApprovalCalls( + existingApprovalCalls(state, approval.id), + approval.tool_calls ?? [], + ); + return applyApprovalDecision(state, { + approvalId: approval.id, + runId: approval.run_id, + status: approval.status, + toolCalls, + decisions: toolCalls.map((call) => ({ + tool_call_id: call.id, + status: call.status ?? approval.status, + reason: call.reason, + })), + seq: state.lastSeq, + }); +} + +function applyApprovalDecision( + state: SessionState, + input: { + approvalId: string; + runId: string; + status: ApprovalStatus; + toolCalls: ApprovalToolCall[]; + decisions: ApprovalDecision[]; + seq: number; + }, +): SessionState { + let next = upsertItem(state, { + kind: "approval", + id: approvalId(input.approvalId), + runId: input.runId, + approvalId: input.approvalId, + toolCalls: input.toolCalls, + status: input.status, + seq: input.seq, + }); + for (const decision of input.decisions) { if (decision.status === "denied" || decision.status === "expired") { const current = findTool(next, decision.tool_call_id); next = upsertTool(next, { - runId: event.run_id, + runId: input.runId, call: { id: decision.tool_call_id, name: current?.name ?? decision.tool_call_id, @@ -286,7 +366,7 @@ function applyApprovalDecided(state: SessionState, event: AgentEvent): SessionSt }, state: "denied", error: decision.reason || "denied", - seq: event.seq, + seq: input.seq, }); } } @@ -318,26 +398,64 @@ function applyRunTerminal(state: SessionState, event: AgentEvent): SessionState stopReason: payload.stop_reason, seq: event.seq, }); - next.runStatus = status; + if (canTakeActive(state, event.run_id)) { + next.runStatus = status; + } + next = setUserQueued(next, event.run_id, false); if (isTerminalRun(status) && next.activeRunId === event.run_id) { next.activeRunId = null; } return next; } +export function applyUserText( + state: SessionState, + messageId: string, + text: string, + queued = true, +): SessionState { + const existing = state.items.find( + (item): item is Extract => + item.kind === "user" && item.messageId === messageId, + ); + if (!existing) { + return state; + } + let next = upsertUser(state, { + messageId, + runId: existing.runId, + text, + queued, + seq: existing.seq, + }); + const current = next.messages[messageId]; + if (current) { + next = { + ...next, + messages: { ...next.messages, [messageId]: { ...current, content: { text } } }, + }; + } + return next; +} + function upsertUser( state: SessionState, - input: { messageId: string; runId: string; text: string; seq: number }, + input: { messageId: string; runId: string; text: string; seq: number; queued?: boolean }, ): SessionState { if (!input.text) { return state; } + const existing = state.items.find( + (item): item is Extract => + item.kind === "user" && item.messageId === input.messageId, + ); return upsertItem(state, { kind: "user", id: userId(input.messageId), runId: input.runId, messageId: input.messageId, text: input.text, + queued: input.queued ?? existing?.queued, seq: input.seq, }); } @@ -349,6 +467,7 @@ function replaceUser( runId: string, text: string, seq: number, + queued?: boolean, ): SessionState { const from = userId(fromMessageId); const index = state.items.findIndex((item) => item.id === from); @@ -356,12 +475,14 @@ function replaceUser( return state; } const items = state.items.slice(); + const current = items[index]; items[index] = { kind: "user", id: userId(toMessageId), runId, messageId: toMessageId, - text: text || (items[index].kind === "user" ? items[index].text : ""), + text: text || (current.kind === "user" ? current.text : ""), + queued: queued ?? (current.kind === "user" ? current.queued : undefined), seq, }; return { ...state, items }; @@ -476,6 +597,33 @@ function findTool( ); } +export function decisionsForApproval( + approval: Approval, + requested: ApprovalDecision[], +): ApprovalDecision[] { + const calls = approval.tool_calls ?? []; + if (calls.length === 0) { + return requested; + } + const byId = new Map(requested.map((item) => [item.tool_call_id, item])); + const fallback = requested[0]?.status ?? approval.status; + const status = fallback === "denied" || fallback === "approved" ? fallback : "approved"; + return calls + .filter((call) => Boolean(call.id)) + .map((call) => byId.get(call.id) ?? { tool_call_id: call.id, status }); +} + +function mergeApprovalCalls(current: ApprovalToolCall[], incoming: ApprovalToolCall[]): ApprovalToolCall[] { + const byId = new Map(); + for (const call of [...current, ...incoming]) { + if (!call.id) { + continue; + } + byId.set(call.id, { ...byId.get(call.id), ...call }); + } + return [...byId.values()]; +} + function existingApprovalCalls(state: SessionState, approvalId: string): ApprovalToolCall[] { const item = state.items.find( (current): current is Extract => @@ -492,19 +640,54 @@ function userTextByRun(state: SessionState, runId: string): string { return pending?.text ?? ""; } -function pendingUserText(state: SessionState): string { - const pending = state.items.find( +function hasExecutingRun(state: SessionState, exceptRunId?: string): boolean { + if (!state.activeRunId || state.activeRunId === exceptRunId || !state.runStatus) { + return false; + } + return !isTerminalRun(state.runStatus); +} + +function canTakeActive(state: SessionState, runId: string): boolean { + return !hasExecutingRun(state, runId); +} + +function findPendingUser( + state: SessionState, + runId: string, + text?: string, +): Extract | undefined { + const exact = state.items.find( (item): item is Extract => - item.kind === "user" && item.messageId.startsWith("pending:") && Boolean(item.text), + item.kind === "user" && (item.runId === runId || item.messageId === `pending:${runId}`), + ); + if (exact) { + return exact; + } + if (text) { + const byText = state.items.find( + (item): item is Extract => + item.kind === "user" && item.messageId.startsWith("pending:") && item.text === text, + ); + if (byText) { + return byText; + } + } + return state.items.find( + (item): item is Extract => + item.kind === "user" && item.messageId.startsWith("pending:"), ); - return pending?.text ?? ""; } -function dropPendingUsers(state: SessionState): SessionState { - const items = state.items.filter( - (item) => !(item.kind === "user" && item.messageId.startsWith("pending:")), - ); - return items.length === state.items.length ? state : { ...state, items }; +function setUserQueued(state: SessionState, runId: string, queued: boolean): SessionState { + let changed = false; + const items = state.items.map((item) => { + if (item.kind === "user" && item.runId === runId && item.queued !== queued) { + changed = true; + return { ...item, queued }; + } + return item; + }); + return changed ? { ...state, items } : state; } function userId(messageId: string): string { diff --git a/packages/core/chat/sse.ts b/packages/core/chat/sse.ts index 9b4917e..a27d429 100644 --- a/packages/core/chat/sse.ts +++ b/packages/core/chat/sse.ts @@ -32,6 +32,7 @@ export interface WatchEventsOptions { sessionId: string; getAfterSeq: () => number; onEvent: (event: AgentEvent) => void; + onStreamEnd?: () => void | Promise; signal: AbortSignal; fetch?: typeof fetch; retryDelayMs?: number; @@ -66,6 +67,16 @@ export async function watchEvents(options: WatchEventsOptions): Promise { if (options.signal.aborted) { return; } + if (options.onStreamEnd) { + try { + await options.onStreamEnd(); + } catch { + // 重连探测失败不打断 SSE 重试 + } + } + if (options.signal.aborted) { + return; + } await sleep(retryDelayMs, options.signal); } } @@ -88,6 +99,9 @@ async function readSSEStream( const parsed = parseSSEChunk(buffer); buffer = parsed.rest; for (const event of parsed.events) { + if (signal.aborted) { + return; + } onEvent(event); } } diff --git a/packages/core/chat/types.ts b/packages/core/chat/types.ts index 8122b75..8f8e34d 100644 --- a/packages/core/chat/types.ts +++ b/packages/core/chat/types.ts @@ -61,6 +61,8 @@ export interface Session { workspace_id: string; status: SessionStatus; active_run_id?: string; + /** 当前 active Run 已中断且 Worker 不在跑,界面才应显示「恢复」。 */ + needs_recover?: boolean; last_event_seq: number; compaction_seq: number; summary?: string; @@ -132,6 +134,7 @@ export interface RunCreatedPayload { trigger_message_id: string; mode: AgentMode; status: RunStatus; + text?: string; } export interface RunStateChangedPayload { @@ -201,12 +204,32 @@ export interface CreateSessionRequest { tenant_id?: string; user_id: string; agent_id?: string; + /** 工作目录。显式路径必须已存在,服务端冻结为绝对路径;省略则 GIT_REPO / cwd。 */ workspace_id?: string; } +export interface Run { + id: string; + session_id: string; + status: RunStatus; + cancel_requested?: boolean; + needs_recover?: boolean; +} + +/** RecoverRun 能接着跑的状态;界面是否显示「恢复」还要看 needs_recover(Worker 已不在跑)。 */ +export const RECOVERABLE_RUN_STATUSES: readonly RunStatus[] = [ + "queued", + "loading_context", + "running_llm", + "executing_tools", +]; + +export function isRecoverableRun(status: string): boolean { + return (RECOVERABLE_RUN_STATUSES as readonly string[]).includes(status); +} + export interface StartRunRequest { content: string; - input_mode?: "interrupt" | "queue"; mode?: AgentMode; } @@ -229,6 +252,7 @@ export type TimelineItem = runId: string; messageId: string; text: string; + queued?: boolean; seq: number; } | { diff --git a/packages/core/git/client.test.ts b/packages/core/git/client.test.ts index 815b2ed..93a6f16 100644 --- a/packages/core/git/client.test.ts +++ b/packages/core/git/client.test.ts @@ -124,6 +124,37 @@ test("GitClient status and mutations hit /git routes", async () => { assert.deepEqual(calls[13]?.body, { checkout: "" }); }); +test("GitClient appends session_id to git routes", async () => { + const urls: string[] = []; + const client = new GitClient({ + baseUrl: "http://api.test", + sessionId: "sess-1", + fetch: async (input) => { + urls.push(String(input)); + return json({ + path: "/repo", + is_repo: true, + empty: false, + branch: "main", + head: "abc", + detached: false, + upstream: "", + ahead: 0, + behind: 0, + upstream_gone: false, + integrating: "", + default_branch: "", + files: [], + remotes: [], + }); + }, + }); + await client.status(); + await client.listBranches("/wt"); + assert.equal(urls[0], "http://api.test/git/status?session_id=sess-1"); + assert.equal(urls[1], "http://api.test/git/branches?checkout=%2Fwt&session_id=sess-1"); +}); + test("GitClient maps error JSON", async () => { const client = new GitClient({ baseUrl: "http://api.test", diff --git a/packages/core/git/client.ts b/packages/core/git/client.ts index a02fa3e..9210ea6 100644 --- a/packages/core/git/client.ts +++ b/packages/core/git/client.ts @@ -22,15 +22,19 @@ export class GitClientError extends Error { export type GitClientOptions = { baseUrl: string; + /** 当前会话。Git 操作打在该会话冻结的工作目录上。 */ + sessionId?: string; fetch?: typeof fetch; }; export class GitClient { readonly baseUrl: string; + readonly sessionId?: string; private readonly fetchImpl: typeof fetch; constructor(options: GitClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.sessionId = options.sessionId?.trim() || undefined; this.fetchImpl = options.fetch ?? fetch.bind(globalThis); } @@ -131,7 +135,7 @@ export class GitClient { if (init.json !== undefined) { headers.set("Content-Type", "application/json"); } - const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + const res = await this.fetchImpl(`${this.baseUrl}${this.withSession(path)}`, { ...init, headers, body: init.json !== undefined ? JSON.stringify(init.json) : init.body, @@ -154,6 +158,16 @@ export class GitClient { } return parsed as T; } + + private withSession(path: string): string { + if (!this.sessionId) { + return path; + } + const [base, qs] = path.split("?"); + const search = new URLSearchParams(qs); + search.set("session_id", this.sessionId); + return `${base}?${search.toString()}`; + } } function query(params: Record): string { diff --git a/packages/core/index.ts b/packages/core/index.ts index 5c4be59..d3d32a7 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -1,18 +1,25 @@ export { AgentClient, AgentClientError, + applyApprovalRecord, + applyApprovals, applyEvent, + decisionsForApproval, applyOptimisticUser, + applyUserText, decodeText, emptyState, firstLine, hydrate, indexMessages, + joinQueuedTexts, + isRecoverableRun, isTerminalRun, isThinkingPhase, parseDelta, parseSSEBlock, parseSSEChunk, + RECOVERABLE_RUN_STATUSES, TERMINAL_RUN_STATUSES, THINKING_PHASES, watchEvents, @@ -51,6 +58,7 @@ export type { EventType, Message, PageInfo, + Run, RunStatus, Session, SessionState, diff --git a/packages/core/package.json b/packages/core/package.json index 83a9523..0bf1103 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "./git": "./git/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/sse.test.ts git/client.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts git/client.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/ui/components/conversation.tsx b/packages/ui/components/conversation.tsx index 2535986..3a0434c 100644 --- a/packages/ui/components/conversation.tsx +++ b/packages/ui/components/conversation.tsx @@ -3,6 +3,7 @@ import { ArrowDownIcon, MessageSquare } from "lucide-react"; import { useCallback, + useEffect, useLayoutEffect, useRef, useState, @@ -13,6 +14,13 @@ import { import { cn } from "../lib/cn.ts"; import { Button } from "./ui/button.tsx"; +/** 最新一条消息落在视口从上往下的位置。 */ +const LATEST_ANCHOR = 0.7; +/** 新消息入列后再滚到锚点的时长。 */ +const FOLLOW_MS = 200; +/** 流式生成时按这个间隔把最新内容拉回锚点。 */ +const STREAM_FOLLOW_MS = 500; + export function Conversation({ className, children, ...props }: HTMLAttributes) { return (
@@ -25,60 +33,184 @@ export function ConversationContent({ className, children, scrollKey, + followKey, + streaming = false, ...props -}: HTMLAttributes & { scrollKey?: string }) { +}: HTMLAttributes & { + scrollKey?: string; + followKey?: string; + streaming?: boolean; +}) { const ref = useRef(null); - const nearBottom = useRef(true); - const seenKey = useRef(scrollKey); + const innerRef = useRef(null); + const following = useRef(true); + const seenKey = useRef(undefined); + const skipFollow = useRef(false); + const animating = useRef(false); + const animFrame = useRef(0); + const wasStreaming = useRef(false); const [showJump, setShowJump] = useState(false); if (seenKey.current !== scrollKey) { seenKey.current = scrollKey; - nearBottom.current = true; + following.current = true; + skipFollow.current = true; } - const onScroll = useCallback(() => { + const stopAnim = useCallback(() => { + if (animFrame.current) { + cancelAnimationFrame(animFrame.current); + animFrame.current = 0; + } + animating.current = false; + }, []); + + const applyPad = useCallback(() => { const el = ref.current; - if (!el) { + const inner = innerRef.current; + if (!el || !inner) { return; } - const distance = el.scrollHeight - el.scrollTop - el.clientHeight; - nearBottom.current = distance < 96; - setShowJump(!nearBottom.current); + inner.style.paddingBottom = `${Math.round(el.clientHeight * (1 - LATEST_ANCHOR))}px`; }, []); - useLayoutEffect(() => { + const targetTop = useCallback((edge: "top" | "bottom" = "top") => { + const el = ref.current; + if (!el) { + return 0; + } + return latestScrollTop(el, edge); + }, []); + + const goToLatest = useCallback( + (ms: number, edge: "top" | "bottom" = "top") => { + const el = ref.current; + if (!el) { + return; + } + applyPad(); + const to = targetTop(edge); + following.current = true; + setShowJump(false); + if (ms <= 0) { + stopAnim(); + el.scrollTop = to; + return; + } + animateScroll(el, to, ms, stopAnim, (handle) => { + animating.current = true; + animFrame.current = handle; + }); + }, + [applyPad, stopAnim, targetTop], + ); + + const onScroll = useCallback(() => { const el = ref.current; - if (!el || !nearBottom.current) { + if (!el || animating.current) { return; } - el.scrollTop = el.scrollHeight; - setShowJump(false); + const distance = Math.abs(targetTop(streaming ? "bottom" : "top") - el.scrollTop); + following.current = distance < 96; + setShowJump(!following.current); + }, [streaming, targetTop]); + + useLayoutEffect(() => { + applyPad(); + if (!skipFollow.current) { + return; + } + goToLatest(0); }); + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + const ro = new ResizeObserver(() => { + applyPad(); + if (following.current && !animating.current) { + el.scrollTop = targetTop(streaming ? "bottom" : "top"); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, [applyPad, streaming, targetTop]); + + useEffect(() => { + if (skipFollow.current) { + skipFollow.current = false; + return; + } + if (!following.current || !followKey) { + return; + } + let inner = 0; + const outer = requestAnimationFrame(() => { + inner = requestAnimationFrame(() => goToLatest(FOLLOW_MS)); + }); + return () => { + cancelAnimationFrame(outer); + cancelAnimationFrame(inner); + stopAnim(); + }; + }, [followKey, goToLatest, stopAnim]); + + useEffect(() => { + if (!streaming) { + return; + } + const timer = window.setInterval(() => { + if (following.current) { + goToLatest(FOLLOW_MS, "bottom"); + } + }, STREAM_FOLLOW_MS); + return () => window.clearInterval(timer); + }, [goToLatest, streaming]); + + useEffect(() => { + const ended = wasStreaming.current && !streaming; + wasStreaming.current = streaming; + if (!ended || !following.current) { + return; + } + let inner = 0; + const outer = requestAnimationFrame(() => { + inner = requestAnimationFrame(() => goToLatest(FOLLOW_MS, "bottom")); + }); + return () => { + cancelAnimationFrame(outer); + cancelAnimationFrame(inner); + }; + }, [goToLatest, streaming]); + + useEffect(() => () => stopAnim(), [stopAnim]); + return ( <>
{ + stopAnim(); + }} + onPointerDown={() => { + stopAnim(); + }} + className="mx-auto min-h-0 w-full max-w-3xl flex-1 overflow-y-auto" + data-conversation-scroll="" > - {children} +
+ {children} +
{showJump ? ( @@ -111,7 +243,7 @@ export function ConversationEmptyState({ <> {icon ?? }
{title}
-

{description}

+

{description}

)}
@@ -121,3 +253,41 @@ export function ConversationEmptyState({ export function ConversationScrollButton() { return null; } + +function latestScrollTop(scroller: HTMLElement, edge: "top" | "bottom" = "top"): number { + const inner = scroller.firstElementChild as HTMLElement | null; + const latest = + inner?.querySelector("[data-conversation-latest]") ?? + (inner?.lastElementChild as HTMLElement | null); + if (!latest) { + return Math.max(0, scroller.scrollHeight - scroller.clientHeight); + } + const scrollerBox = scroller.getBoundingClientRect(); + const latestBox = latest.getBoundingClientRect(); + const y = + (edge === "bottom" ? latestBox.bottom : latestBox.top) - scrollerBox.top + scroller.scrollTop; + const max = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + return Math.max(0, Math.min(max, y - scroller.clientHeight * LATEST_ANCHOR)); +} + +function animateScroll( + el: HTMLElement, + to: number, + ms: number, + onStop: () => void, + onFrame: (handle: number) => void, +) { + const from = el.scrollTop; + const started = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - started) / ms); + const eased = t * (2 - t); + el.scrollTop = from + (to - from) * eased; + if (t < 1) { + onFrame(requestAnimationFrame(tick)); + return; + } + onStop(); + }; + onFrame(requestAnimationFrame(tick)); +} diff --git a/packages/ui/components/message.tsx b/packages/ui/components/message.tsx index 148c159..035afb0 100644 --- a/packages/ui/components/message.tsx +++ b/packages/ui/components/message.tsx @@ -28,7 +28,7 @@ export function Message({
{children} @@ -38,7 +38,7 @@ export function Message({ } export function MessageContent({ className, ...props }: HTMLAttributes) { - return
; + return
; } const plugins = { cjk, code, math, mermaid }; @@ -50,11 +50,13 @@ export const MessageResponse = memo( return ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + "size-full space-y-1 leading-5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", + "[&_[data-streamdown^='heading']]:mt-2.5 [&_[data-streamdown^='heading']]:mb-1", + "[&_li]:py-0", "[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground", - "[&_h1]:mt-3 [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:mt-3 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:font-semibold", - "[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5", - "[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3", + "[&_h1]:text-lg [&_h1]:font-semibold [&_h2]:text-base [&_h2]:font-semibold [&_h3]:font-semibold", + "[&_ol]:my-1 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5", + "[&_pre]:my-1.5 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3", "[&_code]:font-mono [&_code]:text-[13px]", className, )} diff --git a/packages/ui/components/prompt-input.tsx b/packages/ui/components/prompt-input.tsx index 6fbea15..97bddee 100644 --- a/packages/ui/components/prompt-input.tsx +++ b/packages/ui/components/prompt-input.tsx @@ -3,6 +3,7 @@ import type { FormEvent, HTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react"; import { cn } from "../lib/cn.ts"; +import { useImeGuard } from "../lib/ime.ts"; import { Button } from "./ui/button.tsx"; export type PromptInputMessage = { text: string }; @@ -40,17 +41,36 @@ export function PromptInput({ export function PromptInputTextarea({ className, + onKeyDown, + onCompositionStart, + onCompositionEnd, ...props }: TextareaHTMLAttributes) { + const ime = useImeGuard(); return (