diff --git a/.env.example b/.env.example index 9c58838..7cd85bf 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,7 @@ LLM_BASE_URL= # 撤回会改磁盘。本地试用先跑 scripts/git-sandbox.sh,再把这里指到 tmp/git-sandbox。 # 要操作本仓时显式写成仓根。pnpm dev:api 在沙箱存在时默认用沙箱。 GIT_REPO= + +# 本机 Codex CLI 可执行文件(默认 codex)。未安装时主服务仍可启动,/codex/status 会说明原因。 +CODEX_BIN=codex + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 204de6f..1f12e76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,16 @@ jobs: - name: Test run: go test ./... + - name: Codex coverage + run: | + go test -covermode=atomic -coverprofile=/tmp/codex.out \ + -coverpkg=codedock/pkg/codex,codedock/internal/codex,codedock/internal/handler/codex \ + ./pkg/codex ./internal/codex ./internal/handler/codex + total=$(go tool cover -func=/tmp/codex.out | awk '/^total:/ {print $3}' | tr -d '%') + echo "codex coverage: ${total}%" + awk -v t="$total" 'BEGIN { if (t+0 < 85) { print "codex coverage below 85%"; exit 1 } }' + + - name: Build run: go build -o /tmp/codedock ./cmd/server diff --git a/AGENTS.md b/AGENTS.md index 1163a21..70039c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,25 +2,27 @@ 修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。 -Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。 +Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。Codex 对话复用 Agent 页,新建会话时选择模式;HTTP 走独立 `/codex`,前端用 `CodexClient`,不扩 `AgentClient`。 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 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。 +- 大部分 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 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。 - Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 +- 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。 - 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`。 - Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。 - Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。 +- Codex 协议与领域类型放在 `server/pkg/codex`:看板的子模块,JSONL 客户端给 `internal/codex` 调用;不查库、不 spawn CLI。不进 `pkg/agent`。 - 进程内事件总线放在 `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`。 +- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,不扩 `AgentClient`。 - 无业务 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/`,不预建空业务域。 -- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。开发态切页顶栏只放 web。不解析 SSE。 +- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,由 `ChatPage` 在 Codex 模式下组合,不单独做 Codex 页。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 +- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 不单独路由,走 `/` 与 `/s/c/:id`。开发态切页顶栏只放 web。不解析 SSE。 - 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。 - 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。 @@ -38,6 +40,6 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 不要预先创建 Issue、Task、Review、Workspace 或其他具体业务域目录。 - 不要把产品工作流放入 `server/pkg`。 - 前端三层不得反依赖:`core` 不依赖 React / Next / DOM / `process.env`;`ui` 不依赖 `core`;`views` 不 import `next/*`;`apps/web` 只做路由与平台装配。 -- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 +- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 当需求变更没有明显的代码归属时,先依据 `docs/architecture.md` 对其分类,再开始编写代码。 diff --git a/apps/web/app/(chat)/s/c/[sessionId]/page.tsx b/apps/web/app/(chat)/s/c/[sessionId]/page.tsx new file mode 100644 index 0000000..c9a4dc1 --- /dev/null +++ b/apps/web/app/(chat)/s/c/[sessionId]/page.tsx @@ -0,0 +1,3 @@ +export default function CodexSessionPage() { + return null; +} diff --git a/apps/web/app/api/directories/pick/route.ts b/apps/web/app/api/directories/pick/route.ts new file mode 100644 index 0000000..7e864fc --- /dev/null +++ b/apps/web/app/api/directories/pick/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { pickDirectoryNative } from "@/lib/native-file-pick"; + +export const maxDuration = 300; + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as { start?: string }; + try { + const path = await pickDirectoryNative({ start: body.start }); + return NextResponse.json({ path }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "无法打开系统目录选择框" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/directories/route.ts b/apps/web/app/api/directories/route.ts deleted file mode 100644 index c86f23f..0000000 --- a/apps/web/app/api/directories/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/api/files/pick/route.ts b/apps/web/app/api/files/pick/route.ts new file mode 100644 index 0000000..dfbbbab --- /dev/null +++ b/apps/web/app/api/files/pick/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +import { pickFilesNative } from "@/lib/native-file-pick"; + +export const maxDuration = 300; + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as { + images?: boolean; + multiple?: boolean; + start?: string; + }; + try { + const paths = await pickFilesNative({ + images: Boolean(body.images), + multiple: body.multiple !== false, + start: body.start, + }); + return NextResponse.json({ paths }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "无法打开系统文件选择框" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index 2e31146..66112b8 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChatPage } from "@codedock/views/chat"; +import { ChatPage, type SessionEngine } from "@codedock/views/chat"; import { usePathname, useRouter } from "next/navigation"; import { rememberSession } from "@/lib/session"; @@ -8,19 +8,21 @@ 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); + const parsed = parseChatPath(pathname); + if (parsed.sessionId && parsed.engine === "agent") { + rememberSession(parsed.sessionId); } return ( { - if (id !== sessionId) { - router.push(`/s/${id}`); + codexIconSrc="/brand/codex-app-icon.png" + onOpenSession={(id, engine = parsed.engine ?? "agent") => { + const path = engine === "codex" ? `/s/c/${id}` : `/s/${id}`; + if (pathname !== path) { + router.push(path); } }} onNewConversation={() => { @@ -31,3 +33,15 @@ export function ChatHost() { /> ); } + +function parseChatPath(pathname: string): { sessionId?: string; engine?: SessionEngine } { + const codex = pathname.match(/^\/s\/c\/([^/]+)/); + if (codex?.[1]) { + return { sessionId: decodeURIComponent(codex[1]), engine: "codex" }; + } + const agent = pathname.match(/^\/s\/([^/]+)/); + if (agent?.[1] && agent[1] !== "c") { + return { sessionId: decodeURIComponent(agent[1]), engine: "agent" }; + } + return {}; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index b9b72a9..c532a28 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -17,7 +17,7 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "CodeDock", - description: "Agent 对话工作台", + description: "Local 对话工作台", }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index caebd09..3f0d9e2 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,17 +1,26 @@ "use client"; import { AgentClient } from "@codedock/core/chat"; +import { CodexClient } from "@codedock/core/codex"; +import { CodexProvider } from "@codedock/views/codex"; import { AgentProvider } from "@codedock/views"; import { useMemo, type ReactNode } from "react"; -import { listDirectories } from "@/lib/directories"; import { apiBase, defaultUserId } from "@/lib/env"; +import { pickDirectory } from "@/lib/pick-directory"; +import { pickFiles } from "@/lib/pick-files"; export function Providers({ children }: { children: ReactNode }) { const client = useMemo(() => new AgentClient({ baseUrl: apiBase }), []); + const codex = useMemo(() => new CodexClient({ baseUrl: apiBase }), []); return ( - - {children} + + {children} ); } diff --git a/apps/web/lib/directories.ts b/apps/web/lib/directories.ts deleted file mode 100644 index 5865f0f..0000000 --- a/apps/web/lib/directories.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/native-file-pick.ts b/apps/web/lib/native-file-pick.ts new file mode 100644 index 0000000..65a35cf --- /dev/null +++ b/apps/web/lib/native-file-pick.ts @@ -0,0 +1,225 @@ +import { execFile } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import { dirname } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export async function pickFilesNative(options: { + images?: boolean; + multiple?: boolean; + start?: string; +}): Promise { + const start = await existingDir(options.start); + switch (platform()) { + case "darwin": + return pickMacFiles({ ...options, start }); + case "linux": + return pickLinuxFiles({ ...options, start }); + case "win32": + return pickWindowsFiles({ ...options, start }); + default: + throw new Error("当前系统不支持弹出文件选择框"); + } +} + +export async function pickDirectoryNative(options: { start?: string } = {}): Promise { + const start = await existingDir(options.start); + switch (platform()) { + case "darwin": + return pickMacDirectory(start); + case "linux": + return pickLinuxDirectory(start); + case "win32": + return pickWindowsDirectory(start); + default: + throw new Error("当前系统不支持弹出目录选择框"); + } +} + +async function existingDir(path?: string): Promise { + const raw = path?.trim(); + if (!raw) { + return homedir(); + } + try { + const info = await stat(raw); + if (info.isDirectory()) { + return raw; + } + if (info.isFile()) { + return dirname(raw); + } + } catch { + // fall through + } + return homedir(); +} + +function splitPaths(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +async function pickMacFiles(options: { images?: boolean; multiple?: boolean; start?: string }): Promise { + const prompt = options.images ? "选择图片" : "选择要挂上的文件"; + const typeClause = options.images ? ' of type {"public.image"}' : ""; + const multiClause = options.multiple ? " with multiple selections allowed" : ""; + const defaultClause = options.start + ? ` default location POSIX file ${appleString(options.start)}` + : ""; + const script = ` +try + set theChoice to choose file with prompt ${appleString(prompt)}${typeClause}${defaultClause}${multiClause} + set output to "" + if class of theChoice is list then + repeat with f in theChoice + set output to output & POSIX path of f & linefeed + end repeat + else + set output to POSIX path of theChoice + end if + return output +on error number -128 + return "" +end try +`; + try { + const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 300_000 }); + return splitPaths(stdout); + } catch (err) { + throw nativePickError(err, "无法打开系统文件选择框"); + } +} + +async function pickMacDirectory(start?: string): Promise { + const defaultClause = start ? ` default location POSIX file ${appleString(start)}` : ""; + const script = ` +try + set theChoice to choose folder with prompt ${appleString("选择工作目录")}${defaultClause} + return POSIX path of theChoice +on error number -128 + return "" +end try +`; + try { + const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 300_000 }); + return firstPath(stdout); + } catch { + throw new Error("无法打开系统目录选择框"); + } +} + +async function pickLinuxFiles(options: { images?: boolean; multiple?: boolean; start?: string }): Promise { + const args = ["--file-selection", "--separator=\n"]; + if (options.multiple) { + args.push("--multiple"); + } + if (options.images) { + args.push("--file-filter=图片 | *.png *.jpg *.jpeg *.gif *.webp *.heic *.bmp *.svg"); + } + if (options.start) { + args.push(`--filename=${options.start.replace(/\/?$/, "/")}`); + } + try { + const { stdout } = await execFileAsync("zenity", args, { timeout: 300_000 }); + return splitPaths(stdout); + } catch (err) { + if (isCancel(err)) { + return []; + } + throw nativePickError(err, "无法打开系统文件选择框(需要 zenity)"); + } +} + +async function pickLinuxDirectory(start?: string): Promise { + const args = ["--file-selection", "--directory"]; + if (start) { + args.push(`--filename=${start.replace(/\/?$/, "/")}`); + } + try { + const { stdout } = await execFileAsync("zenity", args, { timeout: 300_000 }); + return firstPath(stdout); + } catch (err) { + if (isCancel(err)) { + return undefined; + } + throw new Error("无法打开系统目录选择框(需要 zenity)"); + } +} + +async function pickWindowsFiles(options: { images?: boolean; multiple?: boolean; start?: string }): Promise { + const filter = options.images + ? "Images (*.png;*.jpg;*.jpeg;*.gif;*.webp;*.bmp)|*.png;*.jpg;*.jpeg;*.gif;*.webp;*.bmp|All files (*.*)|*.*" + : "All files (*.*)|*.*"; + const script = ` +Add-Type -AssemblyName System.Windows.Forms +$dialog = New-Object System.Windows.Forms.OpenFileDialog +$dialog.Multiselect = $${options.multiple ? "true" : "false"} +$dialog.Filter = ${psString(filter)} +${options.start ? `$dialog.InitialDirectory = ${psString(options.start)}` : ""} +if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { exit 0 } +$dialog.FileNames -join [Environment]::NewLine +`; + try { + const { stdout } = await execFileAsync( + "powershell", + ["-NoProfile", "-NonInteractive", "-Command", script], + { timeout: 300_000 }, + ); + return splitPaths(stdout); + } catch (err) { + throw nativePickError(err, "无法打开系统文件选择框"); + } +} + +async function pickWindowsDirectory(start?: string): Promise { + const script = ` +Add-Type -AssemblyName System.Windows.Forms +$dialog = New-Object System.Windows.Forms.FolderBrowserDialog +$dialog.Description = '选择工作目录' +${start ? `$dialog.SelectedPath = ${psString(start)}` : ""} +if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { exit 0 } +$dialog.SelectedPath +`; + try { + const { stdout } = await execFileAsync( + "powershell", + ["-NoProfile", "-NonInteractive", "-Command", script], + { timeout: 300_000 }, + ); + return firstPath(stdout); + } catch { + throw new Error("无法打开系统目录选择框"); + } +} + +function firstPath(stdout: string): string | undefined { + const path = splitPaths(stdout)[0]; + if (!path) { + return undefined; + } + if (path === "/" || /^[A-Za-z]:[\\/]?$/.test(path)) { + return path; + } + return path.replace(/[/\\]+$/, ""); +} + +function appleString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function psString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function isCancel(err: unknown): boolean { + return Boolean(err && typeof err === "object" && "code" in err && (err as { code?: number }).code === 1); +} + +function nativePickError(_err: unknown, fallback: string): Error { + return new Error(fallback); +} diff --git a/apps/web/lib/pick-directory.ts b/apps/web/lib/pick-directory.ts new file mode 100644 index 0000000..faef41f --- /dev/null +++ b/apps/web/lib/pick-directory.ts @@ -0,0 +1,12 @@ +export async function pickDirectory(options?: { start?: string }): Promise { + const res = await fetch("/api/directories/pick", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ start: options?.start }), + }); + const body = (await res.json().catch(() => ({}))) as { path?: string; error?: string }; + if (!res.ok) { + throw new Error(body.error || "无法打开系统目录选择框"); + } + return body.path || undefined; +} diff --git a/apps/web/lib/pick-files.ts b/apps/web/lib/pick-files.ts new file mode 100644 index 0000000..da3389b --- /dev/null +++ b/apps/web/lib/pick-files.ts @@ -0,0 +1,25 @@ +import type { PickedLocalFile } from "@codedock/views"; + +export async function pickFiles(options?: { + images?: boolean; + multiple?: boolean; + start?: string; +}): Promise { + const res = await fetch("/api/files/pick", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + images: options?.images, + multiple: options?.multiple ?? true, + start: options?.start, + }), + }); + const body = (await res.json().catch(() => ({}))) as { paths?: string[]; error?: string }; + if (!res.ok) { + throw new Error(body.error || "无法打开系统文件选择框"); + } + return (body.paths ?? []).map((path) => ({ + path, + name: path.split(/[/\\]/).filter(Boolean).at(-1) || path, + })); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 6ebb7d3..8fc0f9e 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + async redirects() { + return [ + { source: "/codex", destination: "/", permanent: false }, + { source: "/codex/:path*", destination: "/", permanent: false }, + ]; + }, transpilePackages: [ "@codedock/core", "@codedock/ui", diff --git a/apps/web/public/brand/codex-app-icon.png b/apps/web/public/brand/codex-app-icon.png new file mode 100644 index 0000000..72e9baf Binary files /dev/null and b/apps/web/public/brand/codex-app-icon.png differ diff --git "a/docs/Codex\345\257\271\346\216\245.md" "b/docs/Codex\345\257\271\346\216\245.md" new file mode 100644 index 0000000..e2c0e85 --- /dev/null +++ "b/docs/Codex\345\257\271\346\216\245.md" @@ -0,0 +1,37 @@ +# Codex 对接 模块 + +把本机已安装的 Codex 接到看板可调用的后端。协议走 `codex app-server` JSONL,不把 Codex 当成本地模型的又一个供应商。 + +## 功能职责 + +- 发现本机是否装了 Codex、是否已取得授权、有哪些模型与模式 +- 一个对话只走 Codex,`session_id` 即官方 `thread_id` +- 回合带上当前生效的模型与推理强度(本机配置或用户改过的);Plan、权限仍只传用户改过的 +- `/` 命令与官方扩展按钮共用同一套动作 +- 给本条消息挂文件提及或图片 +- 发送、排队、手动打断;正忙时不自动插话 +- 分叉、归档、改标题、新开对话 +- 回放正文、推理、命令、改文件、方案 +- 回答跑命令、改文件、补一句、MCP 表单、额外权限 +- 认不出的官方反问立刻按 `-32601` 回包,避免转圈 +- 压缩按钮显示官方 `thread/tokenUsage/updated` 的剩余上下文(进程内存 + SSE,不入库) + +## 边界 + +- 不走本地对话的工具、记忆和压缩 +- 前端复用 Agent 对话页:新建会话时选 Agent 或 Codex,Codex 会话走 `/s/c/:id`,不单独开页面 +- 绑了 Codex 的对话不能中途改成本地模型 +- 一条对话同时只有一个进行中的回合;多条对话可以并行 +- 共用本机 `~/.codex` 配置与授权,不另存密钥 +- 凡 Codex CLI / app-server 能读到的都不进 CodeDock 数据库 +- 排队、附件草稿、问票、SSE 环只在当前进程里;重启后不恢复、不重发 +- `/mcp`、`/skills` 只提示去终端改 + +## 分层 + +- `pkg/codex`:领域类型与协议客户端,不 spawn CLI +- `internal/codex`:本机进程与内存编排 +- `internal/handler/codex`:独立 `/codex/*` HTTP +- `packages/core/codex`:无头 `CodexClient` 与 SSE/reducer +- `packages/views/codex`:由 `ChatPage` 在 Codex 模式下组合(输入栏设置、时间线、问票、prompt);归档在侧栏每条会话右侧 +- `apps/web`:对话页 `/` 与 `/s/c/:id` 装配 `CodexClient`,不单独开 Codex 页 diff --git a/docs/architecture.md b/docs/architecture.md index 0f2e95b..e2a9fc2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runt ## 总体架构 ```text -apps/web (路由 + NEXT_PUBLIC_* + AgentClient / AgentProvider) +apps/web (路由 + NEXT_PUBLIC_* + AgentClient / GitClient / CodexClient) | v packages/views (Chat 壳 / hooks,无 next/*) @@ -39,6 +39,7 @@ server/internal/agent v server/pkg/agent server/pkg/git +server/pkg/codex ``` `pkg/ai` 已删除。大模型调用放在 `pkg/agent`,由 `ModelConfig` 在方法内创建,不由 Runtime 注入。 @@ -50,18 +51,20 @@ CodeDock/ ├── apps/ │ └── web/ # Next.js 路由与平台装配;不解析 SSE ├── packages/ -│ ├── core/ # 无头业务;按业务域拆(现有 chat/),不要 src/ +│ ├── core/ # 无头业务;按业务域拆(现有 chat/ git/ codex/),不要 src/ │ ├── ui/ # 无业务语义;components / lib / styles,不要 src/ -│ └── views/ # 组合层;按业务域拆(现有 chat/),不要 src/ +│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/),不要 src/ ├── docs/ ├── data/ # 运行时文件(sqlite 等),gitignore ├── server/ │ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配 │ ├── internal/ │ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git +│ │ │ └── codex/ # 独立 /codex HTTP 薄桥接 │ │ ├── agent/ # 运行时编排 + sqlc 持久化 │ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 │ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_* +│ │ ├── codex/ # 本机 app-server 生命周期与内存排队/问票/SSE │ │ ├── events/ # 进程内事件总线 │ │ ├── config/ │ │ ├── logger/ @@ -70,6 +73,7 @@ CodeDock/ │ ├── pkg/ │ │ ├── agent/ # 全部通用无状态逻辑,含模型调用与 Tool 抽象 │ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用 +│ │ ├── codex/ # 看板的 Codex 子模块:协议客户端与领域类型 │ │ └── db/ # Client 与 sqlc 生成代码 │ ├── migrations/ │ ├── go.mod @@ -82,6 +86,8 @@ CodeDock/ ```text cmd/server -> internal/handler + -> internal/handler/codex + -> internal/codex -> internal/agent -> internal/events -> pkg/db @@ -93,6 +99,15 @@ internal/handler -> internal/agent # Worker 领取后的 Loop -> internal/agent/memory # 用户侧记忆响应类型 +internal/handler/codex + -> internal/codex + -> pkg/codex + +internal/codex + -> pkg/codex # JSONL 协议客户端;不查库 + 启动本机 `codex app-server --stdio` + 排队、草稿、问票、SSE 只放内存 + internal/agent -> pkg/db/sqlite.Queries -> pkg/agent @@ -121,9 +136,15 @@ pkg/git 不依赖 handler、internal、sqlc 无状态,只 exec 本机 git;不写产品流程 +pkg/codex + 看板的 Codex 子模块:领域类型与 app-server JSONL 协议客户端 + 不依赖 handler、internal、sqlc + 不查库、不 spawn `codex`;Transport 由 internal/codex 注入 + 不进 pkg/agent + packages/core 不依赖 React、Next、DOM、process.env、AI SDK - 按业务域拆目录(chat),不要 src/ + 按业务域拆目录(chat / git / codex),不要 src/ 文件直接落在 packages/core// baseUrl / userId 由调用方注入 @@ -136,12 +157,13 @@ packages/views -> packages/core -> packages/ui 不 import next/* - 按业务域拆目录,与 core 对齐(现有 chat) - AgentProvider 在包根注入 client + userId;导航用回调 + 按业务域拆目录,与 core 对齐(现有 chat / git / codex) + AgentProvider 在包根注入 client + userId;CodexProvider 注入 CodexClient + ChatPage 在新建会话时选择 Agent / Codex 模式;导航用回调 apps/web -> packages/views - -> packages/core # 创建 AgentClient + -> packages/core # 创建 AgentClient / GitClient / CodexClient -> packages/ui # 引入 tokens.css 不直接解析 SSE 或 event type ``` @@ -152,7 +174,7 @@ apps/web 承担大部分接口逻辑: -- Session / Message / Usage / Approval 的增删改查。创建 Session 时在本包冻结 `workspace_id`(工作目录):用户指定的路径必须是已存在目录,否则 400;未指定(空或 `default`)则 `GIT_REPO`,再否则 cwd。不 import `internal/agent/tools`。新对话选目录由 web 弹出目录浏览框。`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 直播 @@ -212,6 +234,18 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi - `fake`:读 `Model.Options` 脚本(多段 text / tool_calls、失败次数、可取消挂起),测试不打外网 - `openai`:OpenAI 兼容 HTTP(`BaseURL` + API Key) +### `pkg/codex` + +看板的 Codex 子模块。领域类型与 `codex app-server` JSONL 协议客户端给看板 / `internal/codex` 调用。不查库、不 spawn CLI。`session_id` 即官方 `thread_id`。不进 `pkg/agent`。 + +### `internal/codex` + +本机 `codex app-server --stdio` 生命周期与内存编排:懒启动、握手、崩溃后不重发当前回合。官方 `thread/list/read` 是历史数据源;排队、附件草稿、问票和 SSE 环只驻进程内。未安装或未授权不能拖垮主服务。 + +### `internal/handler/codex` + +独立 `/codex/*` HTTP 薄桥接。不写 Codex 业务表。 + ### `pkg/db` 统一数据库入口。SQLite 已接入 sqlc;Handler 和运行时直接使用 `*sqlite.Queries`。启动时按文件名顺序应用 `migrations/*.sql`。 @@ -222,7 +256,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。有鉴权再加 `auth/`,有记忆再加 `memory/`,Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。 +跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。 ### `packages/ui` @@ -236,11 +270,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `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;新建会话可选 Agent 或 Codex 模式。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。Codex 在 `codex/`:`CodexProvider` 只注入 `CodexClient`,由 `ChatPage` 组合,不单独做 Codex 页。不 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` / `CodexClient`、包 `AgentProvider` / `CodexProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。Codex 不单独路由,走对话页的 `/` 与 `/s/c/:id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 ## 组装关系 @@ -262,7 +296,7 @@ Worker ## 配置 -`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 只读快照。 +`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。`CODEX_BIN` 为本机 Codex CLI(默认 `codex`)。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/packages/core/codex/client.test.ts b/packages/core/codex/client.test.ts new file mode 100644 index 0000000..9eb2e57 --- /dev/null +++ b/packages/core/codex/client.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { CodexClient, CodexClientError } from "./client.ts"; + +test("CodexClient hits every /codex route", async () => { + const calls: { url: string; method: string; body?: unknown; headers?: string }[] = []; + const client = new CodexClient({ + baseUrl: "http://api.test/", + fetch: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const raw = typeof init?.body === "string" ? init.body : undefined; + calls.push({ + url, + method, + body: raw ? JSON.parse(raw) : undefined, + headers: new Headers(init?.headers).get("Last-Event-ID") ?? undefined, + }); + if (url.endsWith("/codex/status")) { + return json({ available: true, authorized: true, version: "0.149.0" }); + } + if (url.endsWith("/codex/models")) { + return json({ models: [{ id: "gpt-5.6", efforts: ["low"], default_effort: "low", hidden: false, is_default: true }] }); + } + if (url.endsWith("/codex/modes")) { + return json({ modes: [{ id: "plan", kind: "collaboration", allowed: true }] }); + } + if (url.endsWith("/codex/commands")) { + return json({ commands: [{ name: "mcp", action: "hint", hint: "终端" }] }); + } + if (url.includes("/codex/sessions?") || url.endsWith("/codex/sessions")) { + if (method === "POST") { + return json({ session: { id: "th1", thread_id: "th1", archived: false } }, 201); + } + return json({ sessions: [{ id: "th1", thread_id: "th1", archived: false }], next_cursor: "c2" }); + } + if (url.endsWith("/codex/sessions/th1")) { + if (method === "PATCH") { + return json({ ok: "true" }); + } + return json({ + session: { id: "th1", thread_id: "th1", archived: false }, + progress: [{ kind: "user", text: "hi" }], + asks: [], + }); + } + if (url.endsWith("/fork")) { + return json({ session: { id: "th2", thread_id: "th2", archived: false } }, 201); + } + if (url.endsWith("/settings")) { + return json({ settings: { model: "gpt-5.6", effort: "high" } }); + } + if (url.endsWith("/commands")) { + return json({ handled: true, hint: "终端", action: "hint" }); + } + if (url.endsWith("/turns")) { + return json({ turn: { id: "t1", session_id: "th1", status: "running" } }, 202); + } + if (url.includes("/asks/") && url.endsWith("/decision")) { + return json({ ok: "true" }); + } + if (url.includes("/asks/") && url.endsWith("/expire")) { + return json({ ok: "true" }); + } + if (url.endsWith("/asks")) { + return json({ asks: [{ id: "8", kind: "command", command: "ls", external_request_id: "8" }] }); + } + if (url.includes("/turns/") && url.endsWith("/interrupt")) { + return json({ ok: "true" }); + } + return json({ ok: "true" }); + }, + }); + + const status = await client.status(); + assert.equal(status.version, "0.149.0"); + assert.equal((await client.listModels())[0]?.id, "gpt-5.6"); + assert.equal((await client.listModes())[0]?.id, "plan"); + assert.equal((await client.listCommands())[0]?.name, "mcp"); + const page = await client.listSessions({ archived: true, cursor: "abc" }); + assert.equal(page.next_cursor, "c2"); + const created = await client.createSession({ cwd: "/tmp" }); + assert.equal(created.id, "th1"); + const detail = await client.getSession("th1"); + assert.equal(detail.progress[0]?.text, "hi"); + await client.renameSession("th1", "Hi"); + const forked = await client.forkSession("th1"); + assert.equal(forked.id, "th2"); + await client.archiveSession("th1"); + await client.compactSession("th1"); + await client.reviewSession("th1"); + assert.equal((await client.getSettings("th1")).model, "gpt-5.6"); + assert.equal((await client.applySettings("th1", { effort: "high" })).effort, "high"); + assert.equal((await client.invokeCommand("th1", "mcp")).handled, true); + const turn = await client.startTurn("th1", { content: "hello", mode: "queue" }); + assert.equal(turn.id, "t1"); + await client.mention("th1", "a.go"); + await client.attachImage("th1", "a.png"); + assert.equal((await client.listAsks("th1"))[0]?.id, "8"); + await client.interruptTurn("t1", "th1"); + await client.decideAsk("8", { approved: true, scope: "once" }); + await client.expireAsk("8"); + assert.equal(client.eventsUrl("th1", 3), "http://api.test/codex/sessions/th1/events?after=3"); + + const urls = calls.map((call) => `${call.method} ${call.url}`); + assert.deepEqual(urls, [ + "GET http://api.test/codex/status", + "GET http://api.test/codex/models", + "GET http://api.test/codex/modes", + "GET http://api.test/codex/commands", + "GET http://api.test/codex/sessions?archived=true&cursor=abc", + "POST http://api.test/codex/sessions", + "GET http://api.test/codex/sessions/th1", + "PATCH http://api.test/codex/sessions/th1", + "POST http://api.test/codex/sessions/th1/fork", + "POST http://api.test/codex/sessions/th1/archive", + "POST http://api.test/codex/sessions/th1/compact", + "POST http://api.test/codex/sessions/th1/review", + "GET http://api.test/codex/sessions/th1/settings", + "POST http://api.test/codex/sessions/th1/settings", + "POST http://api.test/codex/sessions/th1/commands", + "POST http://api.test/codex/sessions/th1/turns", + "POST http://api.test/codex/sessions/th1/attachments/mention", + "POST http://api.test/codex/sessions/th1/attachments/image", + "GET http://api.test/codex/sessions/th1/asks", + "POST http://api.test/codex/turns/t1/interrupt", + "POST http://api.test/codex/asks/8/decision", + "POST http://api.test/codex/asks/8/expire", + ]); + assert.deepEqual(calls[5]?.body, { settings: { cwd: "/tmp" } }); + assert.deepEqual(calls[7]?.body, { title: "Hi" }); + assert.deepEqual(calls[15]?.body, { content: "hello", input: {}, mode: "queue" }); + assert.deepEqual(calls[19]?.body, { session_id: "th1" }); + assert.deepEqual(calls[20]?.body, { approved: true, scope: "once" }); +}); + +test("CodexClient maps error JSON", async () => { + const client = new CodexClient({ + baseUrl: "http://api.test", + fetch: async () => + new Response(JSON.stringify({ error: "codex is not authorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }); + await assert.rejects(() => client.createSession(), (err: unknown) => { + assert.ok(err instanceof CodexClientError); + assert.equal(err.status, 401); + assert.equal(err.message, "codex is not authorized"); + return true; + }); +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/core/codex/client.ts b/packages/core/codex/client.ts new file mode 100644 index 0000000..0ad180c --- /dev/null +++ b/packages/core/codex/client.ts @@ -0,0 +1,224 @@ +import type { + ApprovalAsk, + AskAnswer, + CommandResult, + CommandSpec, + EngineStatus, + ModeInfo, + ModelInfo, + Session, + SessionDetail, + SessionPage, + Settings, + StartTurnRequest, + Turn, +} from "./types.ts"; + +export class CodexClientError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "CodexClientError"; + this.status = status; + } +} + +export type CodexClientOptions = { + baseUrl: string; + fetch?: typeof fetch; +}; + +export class CodexClient { + readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: CodexClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.fetchImpl = options.fetch ?? fetch.bind(globalThis); + } + + async status(): Promise { + return this.request("/codex/status"); + } + + async listModels(): Promise { + const body = await this.request<{ models?: ModelInfo[] }>("/codex/models"); + return body.models ?? []; + } + + async listModes(): Promise { + const body = await this.request<{ modes?: ModeInfo[] }>("/codex/modes"); + return body.modes ?? []; + } + + async listCommands(): Promise { + const body = await this.request<{ commands?: CommandSpec[] }>("/codex/commands"); + return body.commands ?? []; + } + + async listSessions(opts?: { archived?: boolean; cursor?: string }): Promise { + const query = new URLSearchParams(); + if (opts?.archived) { + query.set("archived", "true"); + } + if (opts?.cursor) { + query.set("cursor", opts.cursor); + } + const suffix = query.toString() ? `?${query}` : ""; + const body = await this.request(`/codex/sessions${suffix}`); + return { sessions: body.sessions ?? [], next_cursor: body.next_cursor }; + } + + async createSession(settings: Settings = {}): Promise { + const body = await this.request<{ session: Session }>("/codex/sessions", { + method: "POST", + json: { settings }, + }); + return body.session; + } + + async getSession(sessionId: string, signal?: AbortSignal): Promise { + const body = await this.request<{ + session: Session; + progress?: SessionDetail["progress"]; + asks?: ApprovalAsk[]; + usage?: SessionDetail["usage"]; + }>(`/codex/sessions/${sessionId}`, { signal }); + return { + session: body.session, + progress: body.progress ?? [], + asks: body.asks ?? [], + usage: body.usage, + }; + } + + async renameSession(sessionId: string, title: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}`, { + method: "PATCH", + json: { title }, + }); + } + + async forkSession(sessionId: string): Promise { + const body = await this.request<{ session: Session }>(`/codex/sessions/${sessionId}/fork`, { + method: "POST", + }); + return body.session; + } + + async archiveSession(sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/archive`, { method: "POST" }); + } + + async compactSession(sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/compact`, { method: "POST" }); + } + + async reviewSession(sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/review`, { method: "POST" }); + } + + async getSettings(sessionId: string): Promise { + const body = await this.request<{ settings: Settings }>(`/codex/sessions/${sessionId}/settings`); + return body.settings ?? {}; + } + + async applySettings(sessionId: string, patch: Settings): Promise { + const body = await this.request<{ settings: Settings }>(`/codex/sessions/${sessionId}/settings`, { + method: "POST", + json: patch, + }); + return body.settings ?? {}; + } + + async invokeCommand(sessionId: string, name: string, args = ""): Promise { + return this.request(`/codex/sessions/${sessionId}/commands`, { + method: "POST", + json: { name, args }, + }); + } + + async startTurn(sessionId: string, req: StartTurnRequest = {}): Promise { + const body = await this.request<{ turn: Turn }>(`/codex/sessions/${sessionId}/turns`, { + method: "POST", + json: { + content: req.content ?? "", + input: req.input ?? {}, + mode: req.mode ?? "start", + }, + }); + return body.turn; + } + + async mention(sessionId: string, path: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/attachments/mention`, { + method: "POST", + json: { path }, + }); + } + + async attachImage(sessionId: string, path: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/attachments/image`, { + method: "POST", + json: { path }, + }); + } + + async listAsks(sessionId: string): Promise { + const body = await this.request<{ asks?: ApprovalAsk[] }>(`/codex/sessions/${sessionId}/asks`); + return body.asks ?? []; + } + + async interruptTurn(turnId: string, sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/turns/${turnId}/interrupt`, { + method: "POST", + json: { session_id: sessionId }, + }); + } + + async decideAsk(requestId: string, answer: AskAnswer): Promise { + await this.request<{ ok: string }>(`/codex/asks/${requestId}/decision`, { + method: "POST", + json: answer, + }); + } + + async expireAsk(requestId: string): Promise { + await this.request<{ ok: string }>(`/codex/asks/${requestId}/expire`, { method: "POST" }); + } + + eventsUrl(sessionId: string, after = 0): string { + return `${this.baseUrl}/codex/sessions/${sessionId}/events?after=${after}`; + } + + private async request(path: string, init: RequestInit & { json?: unknown } = {}): Promise { + const headers = new Headers(init.headers); + if (init.json !== undefined) { + headers.set("Content-Type", "application/json"); + } + const { json, ...rest } = init; + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...rest, + headers, + body: json !== undefined ? JSON.stringify(json) : rest.body, + }); + const text = await res.text(); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: text }; + } + } + if (!res.ok) { + const message = + parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : res.statusText; + throw new CodexClientError(res.status, message); + } + return parsed as T; + } +} diff --git a/packages/core/codex/index.ts b/packages/core/codex/index.ts new file mode 100644 index 0000000..0dec36c --- /dev/null +++ b/packages/core/codex/index.ts @@ -0,0 +1,40 @@ +export { CodexClient, CodexClientError, type CodexClientOptions } from "./client.ts"; +export { + applyCodexEvent, + applyOptimisticUser, + dropOptimisticUser, + emptyCodexState, + hydrateCodex, +} from "./reducer.ts"; +export { parseSSEBlock, parseSSEChunk, watchCodexEvents, type WatchCodexEventsOptions } from "./sse.ts"; +export type { + ApprovalAsk, + AskAnswer, + AskKind, + AskOption, + AskQuestion, + CodexEvent, + CodexEventType, + CodexViewState, + CommandAction, + CommandResult, + CommandSpec, + DecisionScope, + EngineStatus, + Input, + InputMode, + ModeInfo, + ModelInfo, + Progress, + ProgressKind, + Session, + SessionDetail, + SessionPage, + Settings, + StartTurnRequest, + TimelineItem, + TokenUsage, + Turn, + TurnStatus, +} from "./types.ts"; +export { isLiveTurn, LIVE_TURN_STATUSES } from "./types.ts"; diff --git a/packages/core/codex/reducer.test.ts b/packages/core/codex/reducer.test.ts new file mode 100644 index 0000000..4a36606 --- /dev/null +++ b/packages/core/codex/reducer.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { applyCodexEvent, applyOptimisticUser, emptyCodexState, hydrateCodex } from "./reducer.ts"; +import { parseSSEBlock } from "./sse.ts"; + +test("hydrateCodex and applyCodexEvent cover turn, progress, ask, reset", () => { + let state = hydrateCodex({ + session: { id: "th", thread_id: "th", archived: false, active_turn_id: "t1" }, + progress: [{ kind: "user", item_id: "u1", text: "hi" }], + asks: [], + settings: { model: "gpt-5.6" }, + }); + assert.equal(state.items[0]?.text, "hi"); + assert.equal(state.activeTurn?.id, "t1"); + assert.equal(state.settings.model, "gpt-5.6"); + assert.equal(state.usage, undefined); + + state = applyCodexEvent(state, { + seq: 8, + type: "token.usage", + session_id: "th", + usage: { used: 250, window: 1000 }, + }); + assert.equal(state.usage?.used, 250); + assert.equal(state.usage?.window, 1000); + + state = applyCodexEvent(state, { + seq: 1, + type: "progress", + session_id: "th", + progress: { kind: "text", item_id: "m1", text: "hel" }, + }); + state = applyCodexEvent(state, { + seq: 2, + type: "progress", + session_id: "th", + progress: { kind: "text", item_id: "m1", text: "lo" }, + }); + const message = state.items.find((item) => item.id === "m1"); + assert.equal(message?.text, "hello"); + + state = applyCodexEvent(state, { + seq: 3, + type: "ask.required", + session_id: "th", + ask: { id: "8", kind: "command", command: "ls", external_request_id: "8" }, + }); + assert.equal(state.asks.length, 1); + state = applyCodexEvent(state, { + seq: 4, + type: "ask.resolved", + session_id: "th", + ask: { id: "8", kind: "command", command: "ls", external_request_id: "8" }, + }); + assert.equal(state.asks.length, 0); + + state = applyCodexEvent(state, { + seq: 5, + type: "turn.completed", + session_id: "th", + turn: { id: "t1", session_id: "th", status: "completed" }, + }); + assert.equal(state.activeTurn, null); + + state = applyCodexEvent(state, { + seq: 6, + type: "notice", + session_id: "th", + notice: "不支持", + }); + state = applyCodexEvent(state, { + seq: 7, + type: "reset", + session_id: "th", + notice: "event gap", + }); + assert.equal(state.reset, true); + assert.equal(state.lastSeq, 8); +}); + +test("parseSSEBlock reads Codex event data", () => { + const ev = parseSSEBlock( + [ + "id: 3", + "event: progress", + 'data: {"seq":3,"type":"progress","session_id":"th","progress":{"kind":"text","text":"hi"}}', + ].join("\n"), + ); + assert.ok(ev); + assert.equal(ev.seq, 3); + assert.equal(ev.type, "progress"); + assert.equal(ev.progress?.text, "hi"); +}); + +test("user progress replaces the optimistic bubble", () => { + let state = applyOptimisticUser(emptyCodexState(), "hi"); + state = applyCodexEvent(state, { + seq: 1, + type: "turn.started", + session_id: "th", + turn: { id: "t1", session_id: "th", status: "running" }, + progress: { kind: "user", item_id: "u1", text: "hi" }, + }); + const users = state.items.filter((item) => item.kind === "user"); + assert.equal(users.length, 1); + assert.equal(users[0]?.id, "u1"); + assert.equal(state.activeTurn?.id, "t1"); +}); + +test("duplicate user progress is ignored", () => { + let state = applyOptimisticUser(emptyCodexState(), "hi"); + state = applyCodexEvent(state, { + seq: 1, + type: "progress", + session_id: "th", + progress: { kind: "user", item_id: "u1", text: "hi" }, + }); + state = applyCodexEvent(state, { + seq: 2, + type: "progress", + session_id: "th", + progress: { kind: "user", item_id: "u2", text: "hi" }, + }); + const users = state.items.filter((item) => item.kind === "user"); + assert.equal(users.length, 1); + assert.equal(users[0]?.text, "hi"); +}); + +test("emptyCodexState starts idle", () => { + const state = emptyCodexState(); + assert.equal(state.items.length, 0); + assert.equal(state.lastSeq, 0); +}); diff --git a/packages/core/codex/reducer.ts b/packages/core/codex/reducer.ts new file mode 100644 index 0000000..5fda778 --- /dev/null +++ b/packages/core/codex/reducer.ts @@ -0,0 +1,214 @@ +import type { + ApprovalAsk, + CodexEvent, + CodexViewState, + Progress, + Session, + Settings, + TimelineItem, + Turn, +} from "./types.ts"; + +export function emptyCodexState(): CodexViewState { + return { + session: null, + settings: {}, + items: [], + asks: [], + lastSeq: 0, + activeTurn: null, + reset: false, + usage: undefined, + }; +} + +export function hydrateCodex(detail: { + session: Session; + progress: Progress[]; + asks: ApprovalAsk[]; + settings?: Settings; + usage?: CodexViewState["usage"]; +}): CodexViewState { + return { + session: detail.session, + settings: detail.settings ?? {}, + items: detail.progress.map((item, index) => progressItem(item, index)), + asks: detail.asks.slice(), + lastSeq: 0, + activeTurn: detail.session.active_turn_id + ? { + id: detail.session.active_turn_id, + session_id: detail.session.id, + status: "running", + } + : null, + reset: false, + usage: detail.usage, + }; +} + +export function applyCodexEvent(state: CodexViewState, event: CodexEvent): CodexViewState { + const next: CodexViewState = { + ...state, + items: state.items.slice(), + asks: state.asks.slice(), + lastSeq: Math.max(state.lastSeq, event.seq), + reset: false, + }; + switch (event.type) { + case "reset": + if (event.notice) { + next.items.push(noticeItem(event.notice, event.seq)); + } + return { ...next, reset: true }; + case "turn.queued": + case "turn.started": + case "turn.completed": + case "turn.failed": + case "turn.cancelled": + if (event.turn) { + next.activeTurn = isOpen(event.turn) ? event.turn : null; + next.items.push(turnItem(event.turn)); + } + if (event.progress) { + if (event.progress.kind === "user") { + next.items = next.items.filter((item) => item.id !== "local-user"); + } + next.items = upsertProgress(next.items, event.progress); + } + if (event.notice) { + next.items.push(noticeItem(event.notice, event.seq)); + } + return next; + case "progress": + if (event.progress) { + if (event.progress.kind === "user") { + next.items = next.items.filter((item) => item.id !== "local-user"); + } + next.items = upsertProgress(next.items, event.progress); + } + return next; + case "ask.required": + if (event.ask) { + next.asks = next.asks.filter((ask) => ask.id !== event.ask?.id); + next.asks.push(event.ask); + if (next.activeTurn) { + next.activeTurn = { ...next.activeTurn, status: "waiting_approval" }; + } + } + return next; + case "ask.resolved": + if (event.ask) { + next.asks = next.asks.filter((ask) => ask.id !== event.ask?.id); + } + return next; + case "notice": + if (event.notice) { + next.items.push(noticeItem(event.notice, event.seq)); + } + return next; + case "token.usage": + if (event.usage) { + next.usage = event.usage; + } + return next; + default: + return next; + } +} + +export function applyOptimisticUser(state: CodexViewState, text: string): CodexViewState { + return { + ...state, + items: [...state.items, { id: "local-user", kind: "user", text, streaming: false }], + }; +} + +export function dropOptimisticUser(state: CodexViewState): CodexViewState { + return { + ...state, + items: state.items.filter((item) => item.id !== "local-user"), + }; +} + +function isOpen(turn: Turn): boolean { + return turn.status === "queued" || turn.status === "running" || turn.status === "waiting_approval"; +} + +function progressItem(progress: Progress, index: number): TimelineItem { + return { + id: progress.item_id || `progress-${index}`, + kind: progress.kind, + text: progress.text, + command: progress.command, + paths: progress.paths, + diff: progress.diff, + status: progress.status, + }; +} + +function turnItem(turn: Turn): TimelineItem { + return { + id: `turn-${turn.id}-${turn.status}`, + kind: "turn", + status: turn.status, + error: turn.error, + text: + turn.status === "queued" + ? "已排队,等当前回合结束" + : turn.status === "completed" + ? "本轮完成" + : turn.status === "cancelled" + ? "已打断" + : turn.status === "failed" + ? turn.error || "本轮失败" + : turn.status === "waiting_approval" + ? "等待你的作答" + : "Codex 正在工作", + }; +} + +function noticeItem(text: string, seq: number): TimelineItem { + return { id: `notice-${seq}`, kind: "notice", text }; +} + +function upsertProgress(items: TimelineItem[], progress: Progress): TimelineItem[] { + if (progress.kind === "user") { + const text = progress.text ?? ""; + if (items.some((item) => item.kind === "user" && item.text === text)) { + return items.filter((item) => item.id !== "local-user"); + } + return [...items.filter((item) => item.id !== "local-user"), progressItem(progress, items.length)]; + } + const id = progress.item_id; + if (!id) { + return [...items, progressItem(progress, items.length)]; + } + const index = items.findIndex((item) => item.id === id && item.kind !== "turn"); + if (index < 0) { + return [...items, progressItem(progress, items.length)]; + } + const current = items[index]; + const next = items.slice(); + next[index] = { + ...current, + kind: progress.kind || current.kind, + text: append(current.text, progress.text), + command: progress.command || current.command, + paths: progress.paths?.length ? progress.paths : current.paths, + diff: append(current.diff, progress.diff), + status: progress.status || current.status, + streaming: progress.kind === "text" || progress.kind === "reasoning", + }; + return next; +} + +function append(left: string | undefined, right: string | undefined): string | undefined { + if (!right) { + return left; + } + if (!left) { + return right; + } + return left + right; +} diff --git a/packages/core/codex/sse.ts b/packages/core/codex/sse.ts new file mode 100644 index 0000000..9feba78 --- /dev/null +++ b/packages/core/codex/sse.ts @@ -0,0 +1,115 @@ +import type { CodexEvent } from "./types.ts"; + +export function parseSSEChunk(buffer: string): { events: CodexEvent[]; rest: string } { + const parts = buffer.split("\n\n"); + const rest = parts.pop() ?? ""; + const events: CodexEvent[] = []; + for (const block of parts) { + const ev = parseSSEBlock(block); + if (ev) { + events.push(ev); + } + } + return { events, rest }; +} + +export function parseSSEBlock(block: string): CodexEvent | null { + let data = ""; + for (const raw of block.split("\n")) { + const line = raw.replace(/\r$/, ""); + if (line.startsWith("data:")) { + data += line.slice(5).trim(); + } + } + if (!data) { + return null; + } + return JSON.parse(data) as CodexEvent; +} + +export interface WatchCodexEventsOptions { + baseUrl: string; + sessionId: string; + getAfterSeq: () => number; + onEvent: (event: CodexEvent) => void; + signal: AbortSignal; + fetch?: typeof fetch; + retryDelayMs?: number; +} + +export async function watchCodexEvents(options: WatchCodexEventsOptions): Promise { + const fetchImpl = options.fetch ?? fetch.bind(globalThis); + const retryDelayMs = options.retryDelayMs ?? 500; + const baseUrl = options.baseUrl.replace(/\/$/, ""); + + while (!options.signal.aborted) { + const after = options.getAfterSeq(); + const url = `${baseUrl}/codex/sessions/${options.sessionId}/events?after=${after}`; + try { + const res = await fetchImpl(url, { + headers: { Accept: "text/event-stream", "Last-Event-ID": String(after) }, + signal: options.signal, + }); + if (!res.ok || !res.body) { + throw new Error(`sse status ${res.status}`); + } + await readSSEStream(res.body, options.onEvent, options.signal); + } catch (err) { + if (options.signal.aborted) { + return; + } + const name = err instanceof Error ? err.name : ""; + if (name === "AbortError") { + return; + } + } + if (options.signal.aborted) { + return; + } + await sleep(retryDelayMs, options.signal); + } +} + +async function readSSEStream( + body: ReadableStream, + onEvent: (event: CodexEvent) => void, + signal: AbortSignal, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const parsed = parseSSEChunk(buffer); + buffer = parsed.rest; + for (const event of parsed.events) { + onEvent(event); + } + } + } finally { + reader.releaseLock(); + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/core/codex/types.ts b/packages/core/codex/types.ts new file mode 100644 index 0000000..1a6c7f0 --- /dev/null +++ b/packages/core/codex/types.ts @@ -0,0 +1,217 @@ +export type EngineStatus = { + available: boolean; + authorized: boolean; + version?: string; + hint?: string; +}; + +export type ModelInfo = { + id: string; + display_name?: string; + efforts: string[]; + default_effort: string; + hidden: boolean; + is_default: boolean; +}; + +export type ModeInfo = { + id: string; + label?: string; + kind: "collaboration" | "permission" | string; + approval?: string; + sandbox?: string; + allowed: boolean; +}; + +export type CommandAction = "apply_settings" | "turn" | "session" | "attach" | "hint" | string; + +export type CommandSpec = { + name: string; + action: CommandAction; + hint?: string; + field?: string; +}; + +export type CommandResult = { + hint?: string; + action?: CommandAction; + handled: boolean; +}; + +export type Session = { + id: string; + thread_id: string; + title?: string; + preview?: string; + cwd?: string; + active_turn_id?: string; + archived: boolean; + ephemeral?: boolean; + created_at?: number; + updated_at?: number; +}; + +export type SessionPage = { + sessions: Session[]; + next_cursor?: string; +}; + +export type Settings = { + model?: string; + effort?: string; + collaboration_mode?: string; + approval_policy?: string; + sandbox?: string; + cwd?: string; + overridden?: string[]; +}; + +export type InputMode = "start" | "queue"; + +export type TurnStatus = + | "queued" + | "running" + | "waiting_approval" + | "completed" + | "failed" + | "cancelled"; + +export type Turn = { + id: string; + session_id: string; + codex_id?: string; + status: TurnStatus; + error?: string; +}; + +export type Input = { + text?: string; + mentions?: string[]; + images?: string[]; +}; + +export type StartTurnRequest = { + content?: string; + input?: Input; + mode?: InputMode; +}; + +export type ProgressKind = "user" | "text" | "reasoning" | "command" | "file_change" | "plan" | "notice"; + +export type Progress = { + kind: ProgressKind; + item_id?: string; + text?: string; + command?: string; + paths?: string[]; + diff?: string; + status?: string; +}; + +export type AskKind = "command" | "file_change" | "question" | "form" | "permissions"; + +export type DecisionScope = "once" | "session"; + +export type AskOption = { + id?: string; + label: string; + recommended?: boolean; + other?: boolean; +}; + +export type AskQuestion = { + id?: string; + header?: string; + prompt?: string; + options?: AskOption[]; +}; + +export type ApprovalAsk = { + id: string; + kind: AskKind; + thread_id?: string; + turn_id?: string; + method?: string; + command?: string; + paths?: string[]; + diff?: string; + prompt?: string; + options?: string[]; + questions?: AskQuestion[]; + fields?: string[]; + external_request_id: string; +}; + +export type AskAnswer = { + approved: boolean; + scope?: DecisionScope; + choice?: string; + values?: string[]; + answers?: Record; +}; + +export type CodexEventType = + | "turn.queued" + | "turn.started" + | "turn.completed" + | "turn.failed" + | "turn.cancelled" + | "progress" + | "ask.required" + | "ask.resolved" + | "notice" + | "reset" + | "token.usage"; + +export type TokenUsage = { + used: number; + window: number; +}; + +export type CodexEvent = { + seq: number; + type: CodexEventType; + session_id: string; + turn_id?: string; + progress?: Progress; + turn?: Turn; + ask?: ApprovalAsk; + notice?: string; + usage?: TokenUsage; +}; + +export type SessionDetail = { + session: Session; + progress: Progress[]; + asks: ApprovalAsk[]; + usage?: TokenUsage; +}; + +export type TimelineItem = { + id: string; + kind: ProgressKind | "turn"; + text?: string; + command?: string; + paths?: string[]; + diff?: string; + status?: string; + streaming?: boolean; + error?: string; +}; + +export type CodexViewState = { + session: Session | null; + settings: Settings; + items: TimelineItem[]; + asks: ApprovalAsk[]; + lastSeq: number; + activeTurn: Turn | null; + reset: boolean; + usage?: TokenUsage; +}; + +export const LIVE_TURN_STATUSES: readonly TurnStatus[] = ["queued", "running", "waiting_approval"]; + +export function isLiveTurn(status: TurnStatus | undefined): boolean { + return Boolean(status && (LIVE_TURN_STATUSES as readonly string[]).includes(status)); +} diff --git a/packages/core/index.ts b/packages/core/index.ts index b19268e..b2cf33b 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -34,6 +34,20 @@ export { type WatchEventsOptions, } from "./chat/index.ts"; export { GitClient, GitClientError, type GitClientOptions } from "./git/index.ts"; +export { CodexClient, CodexClientError, type CodexClientOptions } from "./codex/index.ts"; +export type { + ApprovalAsk, + AskAnswer, + CodexEvent, + CodexViewState, + CommandSpec, + EngineStatus, + ModeInfo, + ModelInfo, + Session as CodexSession, + Settings as CodexSettings, + Turn as CodexTurn, +} from "./codex/index.ts"; export type { Branch, BranchView, diff --git a/packages/core/package.json b/packages/core/package.json index 8ba12e4..900af33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,10 +6,11 @@ "exports": { ".": "./index.ts", "./chat": "./chat/index.ts", - "./git": "./git/index.ts" + "./git": "./git/index.ts", + "./codex": "./codex/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/ui/components/conversation.tsx b/packages/ui/components/conversation.tsx index 3a0434c..4f47ad4 100644 --- a/packages/ui/components/conversation.tsx +++ b/packages/ui/components/conversation.tsx @@ -18,12 +18,10 @@ 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 ( -
+
{children}
); @@ -117,24 +115,33 @@ export function ConversationContent({ useLayoutEffect(() => { applyPad(); + }); + + useLayoutEffect(() => { if (!skipFollow.current) { return; } goToLatest(0); - }); + skipFollow.current = false; + }, [goToLatest, scrollKey]); useEffect(() => { const el = ref.current; + const inner = innerRef.current; if (!el) { return; } - const ro = new ResizeObserver(() => { + const sync = () => { applyPad(); if (following.current && !animating.current) { el.scrollTop = targetTop(streaming ? "bottom" : "top"); } - }); + }; + const ro = new ResizeObserver(sync); ro.observe(el); + if (inner) { + ro.observe(inner); + } return () => ro.disconnect(); }, [applyPad, streaming, targetTop]); @@ -155,19 +162,7 @@ export function ConversationContent({ 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]); + }, [followKey, goToLatest, scrollKey, stopAnim]); useEffect(() => { const ended = wasStreaming.current && !streaming; @@ -198,10 +193,14 @@ export function ConversationContent({ onPointerDown={() => { stopAnim(); }} - className="mx-auto min-h-0 w-full max-w-3xl flex-1 overflow-y-auto" + className="min-h-0 w-full flex-1 overflow-y-auto" data-conversation-scroll="" > -
+
{children}
@@ -222,7 +221,7 @@ export function ConversationContent({ export function ConversationEmptyState({ className, title = "开始一段对话", - description = "在下方输入消息,Agent 的思考与工具会按瀑布展开。", + description = "在下方输入消息,Local 的思考与工具会按瀑布展开。", icon, children, }: { diff --git a/packages/ui/components/prompt-input.tsx b/packages/ui/components/prompt-input.tsx index 97bddee..4d1bd1d 100644 --- a/packages/ui/components/prompt-input.tsx +++ b/packages/ui/components/prompt-input.tsx @@ -1,6 +1,6 @@ "use client"; -import type { FormEvent, HTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react"; +import type { FormEvent, HTMLAttributes, ReactNode, Ref, TextareaHTMLAttributes } from "react"; import { cn } from "../lib/cn.ts"; import { useImeGuard } from "../lib/ime.ts"; @@ -31,6 +31,8 @@ export function PromptInput({ return; } onSend?.({ text }); + const box = form.querySelector('textarea[name="message"]'); + box?.focus(); }} {...props} > @@ -40,15 +42,17 @@ export function PromptInput({ } export function PromptInputTextarea({ + ref, className, onKeyDown, onCompositionStart, onCompositionEnd, ...props -}: TextareaHTMLAttributes) { +}: TextareaHTMLAttributes & { ref?: Ref }) { const ime = useImeGuard(); return (