diff --git a/.env.example b/.env.example index bfdfee17..1b793580 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,18 @@ DIRECT_URL=postgres://postgres.xxxx:password@aws-0-region.pooler.supabase.com:54 # 可选:每实例连接数上限(Serverless + 池化下宜小);直连想启用预处理语句设 DB_PREPARE=true。 # DB_POOL_MAX=10 # DB_PREPARE=false +# Vercel Preview 默认禁止在共享数据库上执行 DDL。只有给 Preview 配置了独立数据库时才设为 true。 +# VERCEL_PREVIEW_DATABASE_MIGRATIONS=false + +# === Issue #34 Conversation 单一权威与切换 === +# ThreadTree 权威已退役;必须显式使用 canonical,禁止再拆成 client/API/generation 多个开关。 +CONVERSATION_AUTHORITY=canonical +# 每次正式发布使用新的不可变 epoch,客户端会在加载实体前与服务端核对。 +CONVERSATION_CUTOVER_EPOCH=replace-with-release-epoch +# 受控维护窗口可设为 read-only:拒绝新写入,但允许读取和 Generation Stop/排空。 +# CONVERSATION_MAINTENANCE_MODE=off +# 仅本机/测试可用;生产环境设置 true 会启动失败。 +# CONVERSATION_ISOLATED_TEST=false # === 用户系统(better-auth,邮箱注册登录) === # BETTER_AUTH_SECRET:任意高熵随机串(如 openssl rand -base64 32)。 diff --git a/.gitignore b/.gitignore index 42b6aca8..5777816a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ pnpm-debug.log* next-env.d.ts .gstack/ +# local cutover evidence and database dumps (never commit) +/.local-backups/ +/.local-cutover/ + # thread-chat e2e 验收脚本的截图输出 e2e/thread-chat/shots/ .vercel diff --git a/AGENTS.md b/AGENTS.md index e4306bc5..80250c83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,3 +15,7 @@ project-wide instructions. `CLAUDE.md` is the single source of truth for shared development commands, workflow rules, architecture, and implementation notes. If an instruction in this file conflicts with `CLAUDE.md`, follow this file. + +## Formatting + +- **禁止 Agent 执行 `pnpm format`,也禁止主动执行其他 Prettier 格式化命令。** 仓库的 pre-commit hook 已负责格式化;如果该环节没有执行 format,不补跑、不排查,也不做任何手动格式化。 diff --git a/CLAUDE.md b/CLAUDE.md index 1a7af2f5..617cf937 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Package manager is **pnpm** (pnpm-lock.yaml / pnpm-workspace.yaml). - `pnpm build` — production build - `pnpm lint` — ESLint (flat config, eslint.config.mjs) - `pnpm typecheck` — `tsc --noEmit` -- `pnpm format` — Prettier (with prettier-plugin-tailwindcss for class sorting) +- `pnpm format` — 仅供 pre-commit hook 使用,Agent 禁止主动执行 - `pnpm db:generate` — generate a Drizzle migration from `lib/db/schema.ts` - `pnpm db:migrate` — apply pending migrations to `DATABASE_URL` - `pnpm db:push` — push schema directly without a migration file (quick local iteration) @@ -27,7 +27,7 @@ To add a shadcn/ui component: `npx shadcn@latest add ` (lands in `componen ## Development workflow -- **Don't run `pnpm format` while writing code.** Only check logic correctness during development; formatting happens once, right before committing. (No husky/lint-staged is configured yet, so this is a manual discipline, not an enforced hook — set one up if asked.) +- **禁止 Agent 执行 `pnpm format`,也禁止主动执行其他 Prettier 格式化命令。** 仓库的 pre-commit hook 已负责格式化;如果该环节没有执行 format,不补跑、不排查,也不做任何手动格式化。 - **Run `pnpm typecheck` after each batch of code changes** (a file, or a set of related edits) and fix any errors immediately rather than letting them accumulate. - **凡是 `import`(含 `import type`)某个包的子路径,该包必须是 `package.json` 里的直接依赖 —— 不要依赖幻影依赖(phantom dependency)。** pnpm 有时会把传递依赖 hoist 到 `node_modules/` 根,使得本地 `pnpm typecheck`/`pnpm build` 侥幸通过,但 Vercel 干净安装 + pnpm 严格解析下不可从项目根解析,构建报 `Cannot find module 'X'`。**本地构建过 ≠ Vercel 构建过。** 判据:import 的包名若不在 `package.json` 的 `dependencies`/`devDependencies` 里,就显式声明它(版本对齐同族包)。运行时才需要的进 `dependencies`,纯类型(`import type`,构建时擦除)进 `devDependencies`。已被 shiki 家族咬过两次:`@shikijs/langs`·`@shikijs/themes`·`@shikijs/transformers`(运行时,`dependencies`)、`@shikijs/types`(类型,`devDependencies`)—— 这个包族把 langs/themes/types 拆成一堆子包,直接 import 任一子路径都得声明。 - **After finishing a module-sized chunk of work, sweep for magic strings and duplicated variables/strings/functions.** @@ -42,7 +42,7 @@ Next.js **16** App Router project (React 19, TypeScript, Tailwind CSS **v4**), s - **shadcn/ui on Base UI, not Radix.** `components.json` uses the `base-rhea` style; primitives in `components/ui/` import from `@base-ui/react` (e.g. `@base-ui/react/button`). Don't reach for `@radix-ui/*` when editing or adding components. - **The full component kit is already vendored** in `components/ui/` (~60 components), including chat-oriented primitives: `message.tsx`, `message-scroller.tsx`, `bubble.tsx`, `attachment.tsx`, `marker.tsx`. Check for an existing component before adding or writing a new one. - **Tailwind v4, CSS-first config.** There is no tailwind.config file; theme tokens live as CSS variables in `app/globals.css`. Class merging goes through `cn()` in `lib/utils.ts`. -- **thread-chat 的手写样式** 是独立于 Tailwind 的一层:全部收敛在 `.tc` 作用域(手工命名空间的手稿风设计系统,语义类名、非原子类)。实体规则按功能区块拆在 `app/thread-chat/styles/*.css`,`app/thread-chat/thread-chat.css` 只是按**源码顺序** `@import` 它们的桶文件——改这里务必保持 `@import` 顺序(级联依赖它),且非相邻功能刻意拆成 `*-collapse/-stream/-extras` 等后缀文件以保序(如流式的 `.send.stop` 覆盖必须在 `composer.css` 之后)。设计 token(`--paper/--ink/--d1..d5/字体/尺寸`)的**单一来源**是 `styles/tokens.css`,`theme.ts` 的深度→变量映射依赖此处变量名。 +- **thread-chat 的手写样式** 是独立于 Tailwind 的一层:全部收敛在 `.tc` 作用域(手工命名空间的手稿风设计系统,语义类名、非原子类)。共享规则按功能区块拆在 `app/thread-chat/styles/*.css`,`app/thread-chat/thread-chat.css` 只是按**源码顺序** `@import` 它们的桶文件;canonical 页面自己的布局扩展位于 `app/thread-chat/canonical/canonical-thread-chat.css`。设计 token(`--paper/--ink/--d1..d5/字体/尺寸`)的单一来源是 `styles/tokens.css`。 - Path aliases: `@/components`, `@/components/ui`, `@/lib`, `@/hooks` (see `components.json` and tsconfig.json). - Theming via `next-themes` through `components/theme-provider.tsx`, wired up in `app/layout.tsx` (dark mode toggles with the `d` key on the starter page). @@ -61,7 +61,7 @@ Key patterns: ## AI backend -`app/api/chat/route.ts` streams from **MiniMax** via `@ai-sdk/openai-compatible` — not real OpenAI. AI SDK is at **v7** (`ai@^7`); note `ai` and `@ai-sdk/react` (`@^4`) track independent version numbers in this ecosystem, they are not in lockstep. Env vars live in `.env.local`: `MINIMAX_API_KEY`, `MINIMAX_BASE_URL`, `LLM_MODEL_ID`. +`app/api/chat/route.ts` 只服务首页 assistant-ui 的线性聊天;它通过统一模型注册表选择 MiniMax、Ark、OpenRouter 等 provider。AI SDK is at **v7** (`ai@^7`); note `ai` and `@ai-sdk/react` (`@^4`) track independent version numbers in this ecosystem, they are not in lockstep. Env vars live in `.env.local`,实际键名由 provider 注册表决定。 MiniMax emits chain-of-thought as literal `...` text rather than a dedicated reasoning stream part. The model is wrapped with `wrapLanguageModel` + `extractReasoningMiddleware({ tagName: "think" })` so it renders as a collapsible reasoning block instead of raw text in the message. @@ -72,7 +72,7 @@ Three tools are wired end-to-end as a reference for adding more: Each tool's custom UI is registered with the `useAssistantTool({ toolName, type, render, ... })` hook from `@assistant-ui/react`, in `components/assistant-ui/{weather,notepad,compare-table}-tool.tsx`. These are null-returning components mounted via `` (`components/assistant-ui/tools.tsx`) inside `AssistantRuntimeProvider` in `app/page.tsx`. `useAssistantTool` is marked `@deprecated` in favor of `defineToolkit`/`Tools({ toolkit })` + `useAui({ tools })`, but that path assumes assistant-ui's "use generative" compiler, which isn't set up in this project — keep using `useAssistantTool` for new tools unless that changes. -`/api/chat` also has a **threadChat mode** for the branch-chat page (`app/thread-chat/`): the client sends the full persisted turn identity (`anchorText`, `treeId`, `threadId`, user/assistant message ids, and an app `generationId`) in the body. The route validates that identity against the owner-scoped saved tree before any paid model call. AI SDK v7's `streamText` rejects system-role messages from the client, so system prompts remain server-owned and are built via `buildThreadChatSystem()` (`lib/chat/thread-chat-prompt.ts`, templates in `constants/thread-chat.ts`). In this mode `getWeather`/`compareTable` are excluded and the only backend artifact tool is `createMarkdownArtifact`. Its bilingual semantic description plus a conservative bilingual first-step intent check route requests to create/deliver a Markdown document; concept questions such as “Markdown 是什么?” remain ordinary text answers. `tool-input-start` creates a non-clickable inline progress card immediately; matching `tool-input-delta` chunks are accumulated by call id and parsed with AI SDK `parsePartialJson` to show partial title, real character/line counts, and recent headings. The validated `tool-input-available` event atomically replaces that transient progress with the persisted Markdown Artifact, rendered in the shared drawer through `MarkdownBody`. Never persist `Message.markdownGeneration` or partial tool input: `saveTree` strips the transient field, while the next prompt reconstructs model context only from completed message-owned Artifact title/content. `hasToolCall(createMarkdownArtifact)` terminates the model loop after the tool step so no redundant recap is generated. Additionally, `toUIMessageStreamResponse({ onError })` logs in-stream errors server-side (`[chat] 流内错误:`) for all modes while still masking them to the client. +`/thread-chat` 不使用 `/api/chat`。它通过 `app/api/conversation-commands/*`、`app/api/conversation-generations/*` 和 `app/api/conversations/*` 操作 canonical `Project → Conversation → Thread → Turn/Message/Generation` 实体;服务端从规范 snapshot 编译模型上下文,客户端只保存可丢弃的列/画布 UI 状态。向 `/api/chat` 发送旧 `threadChat` envelope 会返回 `410 legacy_protocol_retired`。 ## Database & thread persistence @@ -84,4 +84,4 @@ Drizzle ORM + Postgres backs chat history so threads survive page reloads (previ - Client: `lib/db/index.ts` — a global-singleton `postgres`/drizzle client so dev HMR doesn't exhaust Postgres connections. - Migrations: `drizzle.config.ts` + `drizzle/` (see `pnpm db:*` scripts above). - Persistence wiring: `lib/chat/thread-list-adapter.ts` implements assistant-ui's `RemoteThreadListAdapter` (list/rename/archive/delete/initialize/fetch/generateTitle) against `app/api/threads/*` route handlers. `lib/chat/use-thread-history-adapter.ts` implements `ThreadHistoryAdapter.withFormat()` for per-thread message load/append. Both are composed in `app/page.tsx` via `useRemoteThreadListRuntime({ runtimeHook: () => useChatRuntime({ adapters: { history } }), adapter })` — `useChatRuntime`'s own internal remote-thread-list wrapper detects it's nested (`allowNesting: true`) and no-ops, so this composition doesn't conflict with it. -- Branch-tree persistence (`app/thread-chat/`): `branch_trees` stores each branch-conversation tree as **one owner-scoped row of whole-tree JSON** (`state` = full `ThreadTreeState`) — completely separate from the assistant-ui `threads`/`messages` tables. Tree identity lives in the URL (`/thread-chat/{treeId}`, client-generated UUID); localStorage only remembers the last tree id and per-tree workbench UI. Historical null-owner rows are never listed and may be atomically claimed only by opening their exact URL. `branch_generations` is the authoritative per-attempt sidecar: it stores the verified turn snapshot, lifecycle/heartbeat, versioned structured result, and billing state. The browser must strictly save the user message + assistant placeholder before posting `/api/chat`; refreshing only detaches the browser fetch, while the server-owned stream consumer continues the model, finalizes/charges once, and lets tree GET or generation polling merge the current terminal patch by `generationId` CAS. Only the explicit Stop API requests model abort. P0 restores the completed structured answer after refresh; it deliberately does not replay missed token deltas as a live stream (that is a separate resumable-stream/P1 protocol). Titles remain **dual-track**: debounced PUT writes derived `title`, rename PATCH writes `custom_title`, and display uses `coalesce(custom_title, title)`. Before deploying this contract, run `pnpm db:migrate`; do not deploy the new client before the owner/generation APIs and migration. +- **Canonical Conversation persistence** (`app/thread-chat/`): URL 中的 ID 是 `Conversation.id`,不是 Tree ID。`lib/db/schema.ts` 中的 `conversations`、`conversation_threads`、`thread_forks`、`conversation_turns`、`conversation_messages`、`conversation_generations`、Artifact、command/outbox 与反馈表是唯一事实源;关系由 FK、唯一约束和 revision/idempotency CAS 保护。客户端 normalized store 只投影服务端 snapshot,localStorage 仅保存按 Conversation 分隔且可丢弃的 UI workspace。裸 `/thread-chat` 通过认证后的 canonical bootstrap 找到或建立个人 Project/Conversation。旧 `branch_trees`、`branch_generations` 与 `branch_message_feedback` 已由迁移删除;历史 migration 和已验证备份不改写。变更 schema 使用 `pnpm db:generate` 后运行 `pnpm db:migrate`,不得把交互式 `db:push` 当作发布迁移。 diff --git a/README.md b/README.md index 5324a1ff..1b29e25d 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,9 @@ The project is a Next.js 16 App Router application using React, TypeScript, Tail | Boundary | Location | Responsibility | | ------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| Core | [`app/thread-chat/core/`](./app/thread-chat/core/) | Tree state, selectors, and the branch-conversation store | +| Legacy core | [`app/thread-chat/core/`](./app/thread-chat/core/) | Issue #34 迁移期间的临时整树权威、选择器与 branch-conversation store;不是目标领域模型 | +| Domain | [`lib/thread-chat/domain/`](./lib/thread-chat/domain/) | 规范 Conversation 实体、不变量、标题与变体选择契约 | +| Legacy import | [`lib/thread-chat/legacy/`](./lib/thread-chat/legacy/) | 遗留 Thread Tree 到规范 Conversation 快照的单向只读投影 | | Branching | [`app/thread-chat/branching/`](./app/thread-chat/branching/) | `selection/` owns text anchors and selection UI; `assistant/` owns branch-aware assistant rendering | | Chat | [`app/thread-chat/chat/`](./app/thread-chat/chat/) | `message/`, `composer/`, and `actions/` group the three conversation feature sets | | Orchestration | [`app/thread-chat/orchestration/`](./app/thread-chat/orchestration/) | `canvas/`, `columns/`, `navigation/`, `artifacts/`, `overlays/`, and `workspace/` compose the workbench | @@ -131,6 +133,8 @@ Detailed design material is available in the repository: - [ChatPDF research](./docs/chatpdf/01-调研报告.md) and [design](./docs/chatpdf/02-设计方案.md) - [Deep research design](./docs/deep-research/设计说明.md) +- [Conversation 领域术语与迁移边界](./docs/architecture/conversation-domain-glossary.md) +- [Issue #34 后续迁移输入清单](./docs/architecture/conversation-migration-inputs.md) - [OpenSpec change records](./openspec/changes/) - [Project development guidance](./CLAUDE.md) diff --git a/app/api/branch-generations/[generationId]/route.ts b/app/api/branch-generations/[generationId]/route.ts deleted file mode 100644 index a6a455f2..00000000 --- a/app/api/branch-generations/[generationId]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { failStaleGenerationForOwner } from "@/lib/thread-chat-generation/stale-generation-repository" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" - -type RouteContext = { params: Promise<{ generationId: string }> } - -export async function GET(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const { generationId } = await params - if (!isValidTreeId(generationId)) - return Response.json( - { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, - { status: 400 } - ) - - const generation = await failStaleGenerationForOwner(userId, generationId) - if (!generation) - return Response.json( - { error: { code: "not_found", message: "generation 不存在" } }, - { status: 404 } - ) - return Response.json({ generation: toGenerationSummary(generation) }) -} diff --git a/app/api/branch-generations/[generationId]/stop/route.ts b/app/api/branch-generations/[generationId]/stop/route.ts deleted file mode 100644 index 385b63d6..00000000 --- a/app/api/branch-generations/[generationId]/stop/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { requestGenerationStop } from "@/lib/thread-chat-generation/execution-state-repository" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" -import { abortGenerationLocally } from "@/lib/thread-chat-generation/execution" - -type RouteContext = { params: Promise<{ generationId: string }> } - -export async function POST(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const { generationId } = await params - if (!isValidTreeId(generationId)) - return Response.json( - { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, - { status: 400 } - ) - - const generation = await requestGenerationStop(userId, generationId) - if (!generation) - return Response.json( - { error: { code: "not_found", message: "generation 不存在" } }, - { status: 404 } - ) - if (generation.status === "stop_requested") { - abortGenerationLocally(generationId) - } - return Response.json({ generation: toGenerationSummary(generation) }) -} diff --git a/app/api/branch-trees/[treeId]/active-leaf/route.ts b/app/api/branch-trees/[treeId]/active-leaf/route.ts deleted file mode 100644 index c7af8f3e..00000000 --- a/app/api/branch-trees/[treeId]/active-leaf/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - SWITCH_ACTIVE_LEAF_ERROR_STATUS, - SWITCH_ACTIVE_LEAF_ROUTE_ERRORS, - switchActiveLeafErrorResponseSchema, - switchActiveLeafRequestSchema, - switchActiveLeafSuccessResponseSchema, - type SwitchActiveLeafErrorCode, -} from "@/lib/thread-chat/contracts/switch-active-leaf" -import { - switchActiveLeafForOwner, - TreeCommandError, -} from "@/lib/thread-chat-generation/tree-repository" - -type RouteContext = { params: Promise<{ treeId: string }> } - -function activeLeafErrorResponse( - code: SwitchActiveLeafErrorCode, - message: string, - currentRevision?: number -) { - return Response.json( - switchActiveLeafErrorResponseSchema.parse({ - error: { - code, - message, - ...(currentRevision !== undefined ? { currentRevision } : {}), - }, - }), - { status: SWITCH_ACTIVE_LEAF_ERROR_STATUS[code] } - ) -} - -export async function PATCH(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.unauthorized - return activeLeafErrorResponse(error.code, error.message) - } - - const { treeId } = await params - if (!isValidTreeId(treeId)) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.invalid_id - return activeLeafErrorResponse(error.code, error.message) - } - const body = switchActiveLeafRequestSchema.safeParse( - await req.json().catch(() => null) - ) - if (!body.success) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.invalid_request - return activeLeafErrorResponse(error.code, error.message) - } - - try { - return Response.json( - switchActiveLeafSuccessResponseSchema.parse( - await switchActiveLeafForOwner({ userId, treeId, ...body.data }) - ) - ) - } catch (error) { - if (!(error instanceof TreeCommandError)) throw error - return activeLeafErrorResponse( - error.code, - error.message, - error.currentRevision - ) - } -} diff --git a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts b/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts deleted file mode 100644 index e1fee337..00000000 --- a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - MESSAGE_FEEDBACK_HTTP_ERRORS, - setMessageFeedbackErrorResponseSchema, - setMessageFeedbackRequestSchema, - setMessageFeedbackSuccessResponseSchema, -} from "@/lib/thread-chat/contracts/message-feedback" -import { setMessageFeedbackForOwner } from "@/lib/thread-chat-generation/message-feedback-repository" - -type RouteContext = { - params: Promise<{ treeId: string; messageId: string }> -} - -function feedbackErrorResponse(key: keyof typeof MESSAGE_FEEDBACK_HTTP_ERRORS) { - const definition = MESSAGE_FEEDBACK_HTTP_ERRORS[key] - return Response.json( - setMessageFeedbackErrorResponseSchema.parse({ error: definition.error }), - { status: definition.status } - ) -} - -export async function PUT(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return feedbackErrorResponse("unauthorized") - - const { treeId, messageId } = await params - if (!isValidTreeId(treeId) || messageId.trim() === "") - return feedbackErrorResponse("invalid_id") - - const body = setMessageFeedbackRequestSchema.safeParse( - await req.json().catch(() => null) - ) - if (!body.success) return feedbackErrorResponse("invalid_feedback") - - const result = await setMessageFeedbackForOwner({ - userId, - treeId, - threadId: body.data.threadId, - messageId, - feedback: body.data.feedback, - }) - if (!result.ok) return feedbackErrorResponse(result.reason) - - return Response.json( - setMessageFeedbackSuccessResponseSchema.parse({ - feedback: result.feedback, - }) - ) -} diff --git a/app/api/branch-trees/[treeId]/route.ts b/app/api/branch-trees/[treeId]/route.ts deleted file mode 100644 index ec3fd4d5..00000000 --- a/app/api/branch-trees/[treeId]/route.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * /api/branch-trees/[treeId] —— 分支对话树(app/thread-chat)的整树读写。 - * - * 一棵树一行(branch_trees.state = 完整 ThreadTreeState JSON): - * · GET 命中返回 { state, customTitle }(customTitle = 用户重命名过的标题,未改过为 null, - * 供主线列头副标题优先展示);未命中返回 200 + { state: null, customTitle: null }—— - * 首次访问是正常路径不是错误,客户端一个分支判断即可,无需在 fetch 层区分 - * 「404 = 正常」与「404 = 路由不存在」。 - * · PUT { state, title?, baseRevision } 严格校验 schema-v2 消息图,并按 owner/revision - * 做 CAS upsert。只写 state / 派生 title / updatedAt,不触碰 custom_title(双轨标题)。 - * · PATCH { title } 重命名:trim 后非空且 ≤ CUSTOM_TITLE_MAX_LEN,只写 custom_title 列; - * 树不存在 404——与 PUT 的派生轨互不踩踏。 - * · DELETE 删除该行,幂等(不存在也返回 { ok: true })。 - * treeId 做 UUID 形状校验(安全阀),不合法一律 400。 - */ - -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - CUSTOM_TITLE_MAX_LEN, - THREAD_TREE_SCHEMA_VERSION, -} from "@/constants/thread-chat" -import { getCurrentUserId } from "@/lib/auth/server" -import type { ThreadTreeState } from "@/lib/thread-chat/domain/types" -import { parseThreadTreeState } from "@/lib/thread-chat/domain/message-graph" -import { - assertCompletedMessageGenerationLinks, - reconcileThreadChatTurns, -} from "@/lib/thread-chat/application/reconcile-turns" -import { failStaleGenerationsForTree } from "@/lib/thread-chat-generation/stale-generation-repository" -import { - listCurrentGenerationsForTree, - toGenerationSummary, -} from "@/lib/thread-chat-generation/query-repository" -import { listMessageFeedbackForTree } from "@/lib/thread-chat-generation/message-feedback-repository" -import { - deleteOwnedTreeIfIdle, - loadOwnedOrClaimLegacyTree, - renameOwnedTree, - saveOwnedTree, -} from "@/lib/thread-chat-generation/tree-repository" -import { - SAVE_TREE_ERROR_STATUS, - SAVE_TREE_REVISION_ERRORS, - saveTreeErrorResponseSchema, - saveTreeRequestSchema, - saveTreeSuccessResponseSchema, - type SaveTreeErrorCode, -} from "@/lib/thread-chat/contracts/save-tree" - -type RouteContext = { params: Promise<{ treeId: string }> } - -function unauthorized() { - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) -} - -function notFound() { - return Response.json( - { error: { code: "not_found", message: "分支树不存在" } }, - { status: 404 } - ) -} - -function saveTreeErrorResponse( - code: SaveTreeErrorCode, - message: string, - currentRevision?: number -) { - return Response.json( - saveTreeErrorResponseSchema.parse({ - error: { - code, - message, - ...(currentRevision !== undefined ? { currentRevision } : {}), - }, - }), - { status: SAVE_TREE_ERROR_STATUS[code] } - ) -} - -export async function GET(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - const row = await loadOwnedOrClaimLegacyTree({ userId, treeId }) - if (!row) return notFound() - - await failStaleGenerationsForTree(userId, treeId) - const [generations, messageFeedbacks] = await Promise.all([ - listCurrentGenerationsForTree(userId, treeId), - listMessageFeedbackForTree(userId, treeId), - ]) - const generationSummaries = generations.map(toGenerationSummary) - let reconciled - try { - reconciled = reconcileThreadChatTurns({ - state: row.state as ThreadTreeState, - generations: generations.map((generation) => ({ - ...toGenerationSummary(generation), - turnSnapshot: generation.turnSnapshot, - })), - }) - assertCompletedMessageGenerationLinks( - reconciled.state, - generationSummaries - ) - } catch (error) { - console.error("[thread-chat] 消息图读取协调失败", { treeId, error }) - return Response.json( - { - error: { - code: "invalid_tree_state", - message: "分支树消息结构或生成关联无效", - }, - }, - { status: 500 } - ) - } - return Response.json({ - state: reconciled.state, - revision: row.revision, - customTitle: row.customTitle, - generations: generationSummaries, - messageFeedbacks, - recoverableTurns: reconciled.recoverableTurns, - }) -} - -export async function PUT(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - let body: { state?: unknown; title?: unknown; baseRevision?: unknown } - try { - body = await req.json() - } catch { - return new Response("body 必须是 JSON", { status: 400 }) - } - const { state } = body - if (typeof state !== "object" || state === null || Array.isArray(state)) - return new Response("state 缺失或不是对象", { status: 400 }) - // threads 必须是普通对象(codex review:数组/标量会让列表接口的 jsonb_object_keys - // 对这一行永久抛错,一行毒数据打挂整个 GET /api/branch-trees) - const threads = (state as Record).threads - if (typeof threads !== "object" || threads === null || Array.isArray(threads)) - return new Response("state.threads 必须是对象", { status: 400 }) - - const title = typeof body.title === "string" ? body.title : null - const incomingSchemaVersion = (state as Record).schemaVersion - if (incomingSchemaVersion !== THREAD_TREE_SCHEMA_VERSION) - return saveTreeErrorResponse( - "invalid_tree_state", - `只接受 schemaVersion=${THREAD_TREE_SCHEMA_VERSION} 的消息图` - ) - const command = saveTreeRequestSchema.safeParse(body) - if (!command.success) { - const error = SAVE_TREE_REVISION_ERRORS.revision_required - return saveTreeErrorResponse(error.code, error.message) - } - - let validatedState: ThreadTreeState - try { - validatedState = parseThreadTreeState(state) - } catch { - return saveTreeErrorResponse( - "invalid_tree_state", - "消息图包含无效的 parent、active leaf 或 Artifact source" - ) - } - const saved = await saveOwnedTree({ - userId, - treeId, - state: validatedState, - title, - baseRevision: command.data.baseRevision, - }) - if (saved.kind === "not_found") return notFound() - if (saved.kind === "conflict") { - const error = SAVE_TREE_REVISION_ERRORS.tree_revision_conflict - return saveTreeErrorResponse(error.code, error.message, saved.revision) - } - return Response.json( - saveTreeSuccessResponseSchema.parse({ - ok: true, - revision: saved.revision, - }) - ) -} - -export async function PATCH(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - let body: { title?: unknown } - try { - body = await req.json() - } catch { - return new Response("body 必须是 JSON", { status: 400 }) - } - const title = typeof body.title === "string" ? body.title.trim() : "" - if (title === "" || title.length > CUSTOM_TITLE_MAX_LEN) - return new Response( - `title 必须为 trim 后非空且不超过 ${CUSTOM_TITLE_MAX_LEN} 字的字符串`, - { status: 400 } - ) - - // 只写 custom_title(用户意志轨)——防抖 PUT 的派生 title 与之互不踩踏(design D1) - const renamed = await renameOwnedTree({ - userId, - treeId, - customTitle: title, - }) - if (!renamed) return new Response("树不存在", { status: 404 }) - return Response.json({ ok: true }) -} - -export async function DELETE(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - const outcome = await deleteOwnedTreeIfIdle({ userId, treeId }) - if (outcome === "generation_running") { - return Response.json( - { - error: { - code: "generation_running", - message: "请先停止正在运行的生成,再删除这棵对话树", - }, - }, - { status: 409 } - ) - } - return Response.json({ ok: true }) -} diff --git a/app/api/branch-trees/route.ts b/app/api/branch-trees/route.ts deleted file mode 100644 index e794a955..00000000 --- a/app/api/branch-trees/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * /api/branch-trees —— 分支树的轻量列表(会话列表 UI 的数据源)。 - * - * GET 返回 { trees: [{ id, title, updatedAt, threadCount }] }: - * · title = coalesce(custom_title, title)(双轨标题,design D1),双空回退「未命名对话」; - * · threadCount 在 SQL 内由 state->'threads' 的顶层键数派生(design D2)—— - * 不回传整树 state(可能百 KB 级),列表只要元信息; - * · updated_at 降序,limit 100 兜底(v1 不做分页/搜索)。 - */ - -import { getCurrentUserId } from "@/lib/auth/server" -import { listOwnedTreeSummaries } from "@/lib/thread-chat-generation/tree-repository" - -export async function GET() { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const rows = await listOwnedTreeSummaries(userId) - return Response.json({ trees: rows }) -} diff --git a/app/api/chat/generation-settlement.ts b/app/api/chat/generation-settlement.ts deleted file mode 100644 index 82fa3421..00000000 --- a/app/api/chat/generation-settlement.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { UIMessage } from "ai" -import type { ResearchPlan, ResearchRoute } from "@/lib/chat/research-router" -import type { ThreadChatGenerationIdentity } from "@/lib/thread-chat/contracts/generation-identity" -import { GENERATION_ERRORS } from "@/constants/generation" -import { projectGenerationResult } from "@/lib/thread-chat/application/project-generation-result" -import { finalizeGenerationWithRetry } from "@/lib/thread-chat-generation/finalize-with-retry" -import type { StreamLifecycle } from "@/app/api/chat/stream-lifecycle" - -type SettlementDependencies = { - project: typeof projectGenerationResult - finalize: typeof finalizeGenerationWithRetry -} - -const defaultDependencies: SettlementDependencies = { - project: projectGenerationResult, - finalize: finalizeGenerationWithRetry, -} - -type GenerationSettlementInput = { - persistence: ThreadChatGenerationIdentity - researchRoute: ResearchRoute - researchPlan: ResearchPlan | null - unbilledPreview: boolean - streamLifecycle: Pick -} - -/** 将 UI stream 的结束信号投影并一次性收口到 generation 终态。 */ -export function createGenerationSettlementHandler( - { - persistence, - researchRoute, - researchPlan, - unbilledPreview, - streamLifecycle, - }: GenerationSettlementInput, - dependencies: SettlementDependencies = defaultDependencies -) { - return async ({ - responseMessage, - isAborted, - finishReason, - }: { - responseMessage: Pick - isAborted: boolean - finishReason?: string | null - }) => { - const { capturedUsage, modelStreamError, abortedUsageUnavailable } = - streamLifecycle.snapshot() - const failedWithoutFinish = - finishReason == null && modelStreamError !== undefined - const requestedTerminal = isAborted - ? "stopped" - : failedWithoutFinish - ? "failed" - : "completed" - const projected = dependencies.project({ - generationId: persistence.generationId, - threadId: persistence.threadId, - assistantMessageId: persistence.assistantMessageId, - responseMessage, - terminalStatus: requestedTerminal, - error: modelStreamError, - researchRoute, - researchPlan: researchPlan ?? undefined, - usage: capturedUsage - ? { - inputTokens: capturedUsage.inputTokens, - outputTokens: capturedUsage.outputTokens, - totalTokens: capturedUsage.inputTokens + capturedUsage.outputTokens, - } - : undefined, - }) - const outcome = - requestedTerminal === "completed" && !projected.hasDisplayableOutput - ? "failed" - : requestedTerminal - await dependencies.finalize({ - generationId: persistence.generationId, - outcome, - result: projected.result, - error: projected.result.error ?? modelStreamError, - usage: unbilledPreview ? undefined : capturedUsage, - usageUnavailable: - !unbilledPreview && (abortedUsageUnavailable || !capturedUsage), - }) - } -} - -/** stream 初始化阶段抛错时,尽力保存失败终态;结算失败不覆盖原 HTTP 错误。 */ -export async function settleGenerationInitializationFailure( - { - persistence, - usageUnavailable, - }: { - persistence: ThreadChatGenerationIdentity - error: unknown - usageUnavailable: boolean - }, - dependencies: SettlementDependencies = defaultDependencies -) { - const projected = dependencies.project({ - generationId: persistence.generationId, - threadId: persistence.threadId, - assistantMessageId: persistence.assistantMessageId, - responseMessage: { parts: [] }, - terminalStatus: "failed", - error: GENERATION_ERRORS.streamFailed, - }) - try { - await dependencies.finalize({ - generationId: persistence.generationId, - outcome: "failed", - result: projected.result, - error: projected.result.error, - usageUnavailable, - }) - } catch (finalizeError) { - console.error("[thread-chat-generation] 请求初始化失败后的终态保存失败", { - generationId: persistence.generationId, - finalizeError, - }) - } -} diff --git a/app/api/chat/generation-start-error.ts b/app/api/chat/generation-start-error.ts deleted file mode 100644 index 8de4ec82..00000000 --- a/app/api/chat/generation-start-error.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { GenerationRepositoryError } from "@/lib/thread-chat-generation/start-generation-repository" -import type { MessageActionFailureResponse } from "@/lib/thread-chat/contracts/message-action-failure" - -/** 将 generation start 事务错误映射为稳定的 HTTP 响应。 */ -export function generationStartErrorResponse(error: unknown): Response { - if (error instanceof GenerationRepositoryError) { - return Response.json( - { - error: { - code: error.code, - message: error.message, - }, - } satisfies MessageActionFailureResponse, - { - status: - error.code === "not_found" - ? 404 - : error.code === "persistence_failed" - ? 503 - : 409, - } - ) - } - console.error("[thread-chat-generation] start transaction 失败", error) - return Response.json( - { - error: { - code: "persistence_failed", - message: "无法建立生成任务,尚未调用模型", - }, - } satisfies MessageActionFailureResponse, - { status: 503 } - ) -} diff --git a/app/api/chat/request-context.ts b/app/api/chat/request-context.ts index 1c866dfc..1e9ab99e 100644 --- a/app/api/chat/request-context.ts +++ b/app/api/chat/request-context.ts @@ -6,22 +6,16 @@ import { DEFAULT_MODEL_ID, getChatModel, isLinearChatModelId, - isThreadChatModelId, isUnbilledPreviewModel, } from "@/constants/model" import { isModelConfigured } from "@/lib/ai/provider" import { hasPositiveBalance } from "@/lib/billing/credits" -import { - threadChatGenerationIdentitySchema, - type ThreadChatGenerationIdentity, -} from "@/lib/thread-chat/contracts/generation-identity" -import type { MessageActionFailureResponse } from "@/lib/thread-chat/contracts/message-action-failure" type ChatRequestBody = { messages: UIMessage[] tools?: Record deepResearch?: boolean - /** thread-chat 分支对话页的持久化 generation identity。 */ + /** 仅用于明确拒绝已退役客户端,不再解析其内部结构。 */ threadChat?: unknown modelId?: unknown id?: string @@ -47,7 +41,6 @@ type ChatRequestContextDependencies = { currentUserId: typeof getCurrentUserId getModel: typeof getChatModel linearModelAllowed: typeof isLinearChatModelId - threadModelAllowed: typeof isThreadChatModelId modelConfigured: typeof isModelConfigured unbilledPreview: typeof isUnbilledPreviewModel positiveBalance: typeof hasPositiveBalance @@ -57,7 +50,6 @@ const defaultDependencies: ChatRequestContextDependencies = { currentUserId: getCurrentUserId, getModel: getChatModel, linearModelAllowed: isLinearChatModelId, - threadModelAllowed: isThreadChatModelId, modelConfigured: isModelConfigured, unbilledPreview: isUnbilledPreviewModel, positiveBalance: hasPositiveBalance, @@ -104,6 +96,19 @@ export async function prepareChatRequestContext( messages: validatedMessages.data, tools: envelope.data.tools as Record | undefined, } + if (body.threadChat != null) + return { + kind: "response" as const, + response: Response.json( + { + error: { + code: "legacy_protocol_retired", + message: "旧分叉聊天协议已退役", + }, + }, + { status: 410 } + ), + } const rawModelId = body.modelId if ( rawModelId !== undefined && @@ -117,39 +122,7 @@ export async function prepareChatRequestContext( const modelId = typeof rawModelId === "string" ? rawModelId : DEFAULT_MODEL_ID const model = dependencies.getModel(modelId)! - let threadChat: ThreadChatGenerationIdentity | undefined - if (body.threadChat != null) { - const parsedIdentity = threadChatGenerationIdentitySchema.safeParse( - body.threadChat - ) - if (!parsedIdentity.success) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "invalid_generation_identity", - message: "thread-chat 请求缺少有效的持久化身份,请刷新页面后重试", - }, - } satisfies MessageActionFailureResponse, - { status: 400 } - ), - } - if (!dependencies.threadModelAllowed(modelId)) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "invalid_thread_model", - message: "Thread Chat 不允许使用该模型,请刷新页面后重试", - }, - } satisfies MessageActionFailureResponse, - { status: 400 } - ), - } - threadChat = parsedIdentity.data - } else if (!dependencies.linearModelAllowed(modelId)) { + if (!dependencies.linearModelAllowed(modelId)) { return { kind: "response" as const, response: Response.json( @@ -187,7 +160,6 @@ export async function prepareChatRequestContext( messages: body.messages, tools: body.tools, deepResearch: body.deepResearch, - threadChat, linearThreadId: body.id, modelId, model, diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index bf256a67..2d62e350 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,40 +1,32 @@ import { convertToModelMessages, - consumeStream, createUIMessageStream, createUIMessageStreamResponse, isStepCount, streamText, } from "ai" -import { after } from "next/server" import { frontendTools } from "@assistant-ui/react-ai-sdk" -import { resolveAttachmentParts } from "@/lib/chat/resolve-attachments" -import { isSearchConfigured } from "@/lib/ai/search" -import { RESEARCH_MAX_STEPS } from "@/constants/research" + import { MAX_OUTPUT_TOKENS } from "@/constants/model" import { MODEL_CALL_PURPOSE } from "@/constants/model-call" +import { RESEARCH_MAX_STEPS } from "@/constants/research" +import { isSearchConfigured } from "@/lib/ai/search" import { resolveChatModel } from "@/lib/ai/provider" import { withModelCallLogging, type ModelCallTrace, } from "@/lib/ai/model-call-logger" import { buildUsageMetadata } from "@/lib/billing/usage-meta" -import { isExplicitMarkdownArtifactRequest } from "@/lib/chat/markdown-artifact" +import { resolveAttachmentParts } from "@/lib/chat/resolve-attachments" import { reasoningForResearchRoute } from "@/lib/chat/research-router" -import { unregisterGenerationController } from "@/lib/thread-chat-generation/execution" -import { createToolStepPolicy } from "@/app/api/chat/tool-step-policy" -import { buildChatSystemPrompt } from "@/app/api/chat/system-prompt" -import { resolveResearchContext } from "@/app/api/chat/research-context" -import { buildChatToolSet } from "@/app/api/chat/tool-set" -import { createStreamLifecycle } from "@/app/api/chat/stream-lifecycle" -import { - createGenerationSettlementHandler, - settleGenerationInitializationFailure, -} from "@/app/api/chat/generation-settlement" -import { prepareThreadGenerationContext } from "@/app/api/chat/thread-generation-context" -import { prepareChatRequestContext } from "@/app/api/chat/request-context" -// AnySearch 搜索与网页深读可能形成多步循环,放宽单次请求时长上限。 +import { prepareChatRequestContext } from "./request-context" +import { resolveResearchContext } from "./research-context" +import { createLinearStreamLifecycle } from "./stream-lifecycle" +import { buildChatSystemPrompt } from "./system-prompt" +import { buildChatToolSet } from "./tool-set" +import { createToolStepPolicy } from "./tool-step-policy" + export const maxDuration = 300 export async function POST(req: Request) { @@ -45,139 +37,85 @@ export async function POST(req: Request) { messages, tools, deepResearch, - threadChat, linearThreadId, modelId, model, isUnbilledPreview, } = requestContext - const prepared = await prepareThreadGenerationContext({ - userId, - modelId, - messages, - threadChat, - unbilledPreview: isUnbilledPreview, - }) - if (prepared.kind === "response") return prepared.response - const { - persistence, - authoritativeMessages, - authoritativeAnchorText, - preparedRevision, - generationController, - generationObserver, - } = prepared - try { - // AnySearch 是当前统一联网层:所有模型都获得相同的搜索与网页深读工具。 - // deepResearch 只控制研究提示强度,不再决定工具是否存在。 const research = deepResearch === true const searchReady = isSearchConfigured() - const isThreadChat = persistence != null const chatModel = resolveChatModel(modelId) const modelCallTrace: ModelCallTrace = { requestId: crypto.randomUUID(), - ...(persistence - ? { - treeId: persistence.treeId, - threadId: persistence.threadId, - generationId: persistence.generationId, - assistantMessageId: persistence.assistantMessageId, - } - : linearThreadId - ? { threadId: linearThreadId } - : {}), + ...(linearThreadId ? { threadId: linearThreadId } : {}), } - const { latestText, researchRoute, researchPlan } = - await resolveResearchContext({ - model: chatModel, - messages: authoritativeMessages, - deepResearchRequested: research, - searchReady, - modelCallTrace, - }) - const markdownArtifactRequested = - isThreadChat && isExplicitMarkdownArtifactRequest(latestText) - const { tools: allTools, webToolsEnabled } = buildChatToolSet({ - researchMode: researchRoute.mode, + const { researchRoute, researchPlan } = await resolveResearchContext({ + model: chatModel, + messages, + deepResearchRequested: research, searchReady, - threadChat: isThreadChat, - markdownArtifactRequested, - frontendToolSet: frontendTools(tools ?? {}), + modelCallTrace, }) - - // MiniMax 不接受 file part:先把附件(PDF→提取文本,其余→占位说明)转换为 text part - const resolvedMessages = await resolveAttachmentParts(authoritativeMessages) - - const system = buildChatSystemPrompt({ - threadChat: isThreadChat, - anchorText: authoritativeAnchorText, - markdownArtifactRequested, + const { tools: allTools, webToolsEnabled } = buildChatToolSet({ researchMode: researchRoute.mode, - researchPlan, - deepResearchRequested: research, searchReady, + threadChat: false, + markdownArtifactRequested: false, + frontendToolSet: frontendTools(tools ?? {}), }) - - const streamLifecycle = createStreamLifecycle({ + const resolvedMessages = await resolveAttachmentParts(messages) + const lifecycle = createLinearStreamLifecycle({ userId, modelId, model, - persistentGeneration: isThreadChat, unbilledPreview: isUnbilledPreview, linearThreadId, }) - const result = streamText({ model: withModelCallLogging( chatModel, MODEL_CALL_PURPOSE.chatAnswer, modelCallTrace ), - ...(generationController - ? { abortSignal: generationController.signal } - : {}), reasoning: reasoningForResearchRoute(researchRoute.mode, model), - system, + system: buildChatSystemPrompt({ + threadChat: false, + anchorText: null, + markdownArtifactRequested: false, + researchMode: researchRoute.mode, + researchPlan, + deepResearchRequested: research, + searchReady, + }), messages: await convertToModelMessages(resolvedMessages, { tools: allTools, }), tools: allTools, - // 明确 Markdown 交付请求只强制第 0 步启动工具调用;后续步骤仍保留工具, - // 让模型在用户要求多份独立文档时,为每份文档分别创建一个 Artifact。 prepareStep: createToolStepPolicy({ - isThreadChat, - markdownArtifactRequested, + isThreadChat: false, + markdownArtifactRequested: false, researchMode: researchRoute.mode, }), maxOutputTokens: MAX_OUTPUT_TOKENS, stopWhen: isStepCount(webToolsEnabled ? RESEARCH_MAX_STEPS : 5), - onError: streamLifecycle.onError, - onAbort: streamLifecycle.onAbort, - onEnd: streamLifecycle.onEnd, + onError: lifecycle.onError, + onEnd: lifecycle.onEnd, }) - const uiStream = createUIMessageStream({ - ...(persistence - ? { - originalMessages: resolvedMessages, - generateId: () => persistence.assistantMessageId, - } - : {}), execute: ({ writer }) => { writer.write({ type: "data-research-route", id: "research-route", data: researchRoute, }) - if (researchPlan) { + if (researchPlan) writer.write({ type: "data-research-plan", id: "research-plan", data: researchPlan, }) - } writer.merge( result.toUIMessageStream({ onError: (error) => { @@ -191,55 +129,9 @@ export async function POST(req: Request) { }) ) }, - onEnd: persistence - ? createGenerationSettlementHandler({ - persistence, - researchRoute, - researchPlan, - unbilledPreview: isUnbilledPreview, - streamLifecycle, - }) - : undefined, - }) - - const response = createUIMessageStreamResponse({ - stream: uiStream, - consumeSseStream: ({ stream }) => { - after(async () => { - await consumeStream({ - stream, - onError: (error) => { - console.error("[chat] 服务端 UI stream 消费失败", error) - }, - }) - generationObserver?.stop() - if (generationObserver) await generationObserver.done - if (persistence && generationController) { - unregisterGenerationController( - persistence.generationId, - generationController - ) - } - }) - }, }) - if (preparedRevision !== null) - response.headers.set("x-thread-tree-revision", String(preparedRevision)) - return response + return createUIMessageStreamResponse({ stream: uiStream }) } catch (error) { - generationController?.abort(error) - generationObserver?.stop() - if (persistence && generationController) { - unregisterGenerationController( - persistence.generationId, - generationController - ) - await settleGenerationInitializationFailure({ - persistence, - error, - usageUnavailable: !isUnbilledPreview, - }) - } console.error("[chat] 请求初始化失败", error) return Response.json({ error: "生成初始化失败,请重试。" }, { status: 500 }) } diff --git a/app/api/chat/stream-lifecycle.ts b/app/api/chat/stream-lifecycle.ts index 28d9e87c..fd51896e 100644 --- a/app/api/chat/stream-lifecycle.ts +++ b/app/api/chat/stream-lifecycle.ts @@ -1,82 +1,34 @@ import type { ProviderMetadata } from "ai" + import type { ChatModel } from "@/constants/model" -import { GENERATION_ERRORS } from "@/constants/generation" import { chargeUsage } from "@/lib/billing/credits" import { usageCostEvidence } from "@/lib/billing/usage-cost-evidence" import type { OpenRouterStepLike } from "@/lib/ai/openrouter" -import type { FinalizeGenerationUsage } from "@/lib/thread-chat-generation/finalize" type UsageStep = OpenRouterStepLike & { - usage: { - inputTokens?: number - outputTokens?: number - } -} - -type StreamLifecycleInput = { - userId: string - modelId: string - model: Pick - persistentGeneration: boolean - unbilledPreview: boolean - linearThreadId?: string + usage: { inputTokens?: number; outputTokens?: number } } -type StreamLifecycleDependencies = { - charge: typeof chargeUsage -} +type StreamLifecycleDependencies = { charge: typeof chargeUsage } -const defaultDependencies: StreamLifecycleDependencies = { - charge: chargeUsage, -} +const defaultDependencies: StreamLifecycleDependencies = { charge: chargeUsage } -/** 请求级 stream usage/error 状态;handler 写入,持久化终态只读取 snapshot。 */ -export function createStreamLifecycle( - { - userId, - modelId, - model, - persistentGeneration, - unbilledPreview, - linearThreadId, - }: StreamLifecycleInput, +/** 线性聊天的模型流错误记录与 exactly-once 请求结算。 */ +export function createLinearStreamLifecycle( + input: { + userId: string + modelId: string + model: Pick + unbilledPreview: boolean + linearThreadId?: string + }, dependencies: StreamLifecycleDependencies = defaultDependencies ) { - let capturedUsage: FinalizeGenerationUsage | undefined - let modelStreamError: string | undefined - let abortedUsageUnavailable = false - return { onError({ error }: { error: unknown }) { - modelStreamError = GENERATION_ERRORS.streamFailed console.error("[chat] 模型流错误:", error) }, - onAbort({ steps }: { steps: readonly UsageStep[] }) { - if (!persistentGeneration) return - const inputTokens = steps.reduce( - (total, step) => total + (step.usage.inputTokens ?? 0), - 0 - ) - const outputTokens = steps.reduce( - (total, step) => total + (step.usage.outputTokens ?? 0), - 0 - ) - const providerMetadata = steps.at(-1)?.providerMetadata - if (steps.length > 0) { - capturedUsage = { - inputTokens, - outputTokens, - costEvidence: usageCostEvidence({ - provider: model.provider, - steps, - providerMetadata, - }), - } - } - abortedUsageUnavailable = true - }, - async onEnd({ usage, providerMetadata, @@ -86,46 +38,27 @@ export function createStreamLifecycle( providerMetadata?: ProviderMetadata steps: readonly UsageStep[] }) { - if (unbilledPreview) return + if (input.unbilledPreview) return const costEvidence = usageCostEvidence({ - provider: model.provider, + provider: input.model.provider, steps, providerMetadata, }) if ( - model.provider === "openrouter" && + input.model.provider === "openrouter" && costEvidence.source !== "openrouter" - ) { + ) console.warn( - `[chat] OpenRouter 成本元数据不完整,使用静态估值:${model.id}` + `[chat] OpenRouter 成本元数据不完整,使用静态估值:${input.model.id}` ) - } - if (persistentGeneration) { - capturedUsage = { - inputTokens: usage.inputTokens ?? 0, - outputTokens: usage.outputTokens ?? 0, - costEvidence, - } - return - } await dependencies.charge({ - userId, - model: modelId, + userId: input.userId, + model: input.modelId, inputTokens: usage.inputTokens ?? 0, outputTokens: usage.outputTokens ?? 0, - threadId: linearThreadId ?? null, + threadId: input.linearThreadId ?? null, costEvidence, }) }, - - snapshot() { - return { - capturedUsage, - modelStreamError, - abortedUsageUnavailable, - } - }, } } - -export type StreamLifecycle = ReturnType diff --git a/app/api/chat/thread-generation-context.ts b/app/api/chat/thread-generation-context.ts deleted file mode 100644 index 88369e4a..00000000 --- a/app/api/chat/thread-generation-context.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { UIMessage } from "ai" -import { compileThreadChatMessages } from "@/lib/thread-chat/application/compile-thread-chat-messages" -import type { ThreadChatGenerationIdentity } from "@/lib/thread-chat/contracts/generation-identity" -import { - observeGenerationCancellation, - registerGenerationController, - unregisterGenerationController, -} from "@/lib/thread-chat-generation/execution" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" -import { prepareGeneration } from "@/lib/thread-chat-generation/start-generation-repository" -import { generationStartErrorResponse } from "@/app/api/chat/generation-start-error" -import { settleGenerationInitializationFailure } from "@/app/api/chat/generation-settlement" -import type { MessageActionFailureResponse } from "@/lib/thread-chat/contracts/message-action-failure" - -type ThreadGenerationContextInput = { - userId: string - modelId: string - messages: UIMessage[] - threadChat?: ThreadChatGenerationIdentity - unbilledPreview: boolean -} - -type ThreadGenerationContextDependencies = { - prepare: typeof prepareGeneration - summarize: typeof toGenerationSummary - compile: typeof compileThreadChatMessages - createController(): AbortController - register: typeof registerGenerationController - unregister: typeof unregisterGenerationController - observe: typeof observeGenerationCancellation - startErrorResponse: typeof generationStartErrorResponse - settleInitializationFailure: typeof settleGenerationInitializationFailure -} - -const defaultDependencies: ThreadGenerationContextDependencies = { - prepare: prepareGeneration, - summarize: toGenerationSummary, - compile: compileThreadChatMessages, - createController: () => new AbortController(), - register: registerGenerationController, - unregister: unregisterGenerationController, - observe: observeGenerationCancellation, - startErrorResponse: generationStartErrorResponse, - settleInitializationFailure: settleGenerationInitializationFailure, -} - -/** 校验并准备一次线性或持久化 Thread generation 的权威请求上下文。 */ -export async function prepareThreadGenerationContext( - { - userId, - modelId, - messages, - threadChat, - unbilledPreview, - }: ThreadGenerationContextInput, - dependencies: ThreadGenerationContextDependencies = defaultDependencies -) { - if (threadChat == null) { - return { - kind: "ready" as const, - persistence: null, - authoritativeMessages: messages, - authoritativeAnchorText: null, - preparedRevision: null, - generationController: null, - generationObserver: null, - } - } - - const persistence = threadChat - - let started: Awaited> - try { - started = await dependencies.prepare({ - userId, - modelId, - ...persistence, - }) - } catch (error) { - return { - kind: "response" as const, - response: dependencies.startErrorResponse(error), - } - } - if (!started.created) { - return { - kind: "response" as const, - response: Response.json( - { generation: dependencies.summarize(started.generation) }, - { status: 202 } - ), - } - } - - let generationController: AbortController | null = null - let registered = false - try { - const committedThread = started.state.threads[persistence.threadId] - const authoritativeAnchorText = committedThread?.anchorText?.trim() - ? committedThread.anchorText - : null - const authoritativeMessages = dependencies.compile({ - state: started.state, - threadId: persistence.threadId, - excludeAssistantMessageId: persistence.assistantMessageId, - }) as UIMessage[] - generationController = dependencies.createController() - dependencies.register(persistence.generationId, generationController) - registered = true - const generationObserver = dependencies.observe( - persistence.generationId, - generationController - ) - - return { - kind: "ready" as const, - persistence, - authoritativeMessages, - authoritativeAnchorText, - preparedRevision: started.revision, - generationController, - generationObserver, - } - } catch (error) { - generationController?.abort(error) - if (registered && generationController) - dependencies.unregister(persistence.generationId, generationController) - await dependencies.settleInitializationFailure({ - persistence, - error, - usageUnavailable: !unbilledPreview, - }) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "network_error", - message: "生成初始化失败,请重试。", - }, - } satisfies MessageActionFailureResponse, - { status: 500 } - ), - } - } -} diff --git a/app/api/conversation-authority/route.ts b/app/api/conversation-authority/route.ts new file mode 100644 index 00000000..243e6f27 --- /dev/null +++ b/app/api/conversation-authority/route.ts @@ -0,0 +1,11 @@ +import { resolveConversationAuthority } from "@/lib/thread-chat/cutover/conversation-authority" + +export async function GET() { + const state = resolveConversationAuthority() + return Response.json({ + authority: state.authority, + schemaVersion: state.schemaVersion, + epoch: state.epoch, + maintenanceMode: state.maintenanceMode, + }) +} diff --git a/app/api/conversations/[conversationId]/message-feedback/route.ts b/app/api/conversations/[conversationId]/message-feedback/route.ts new file mode 100644 index 00000000..2cc7daac --- /dev/null +++ b/app/api/conversations/[conversationId]/message-feedback/route.ts @@ -0,0 +1,20 @@ +import { canonicalMessageFeedbackListSchema } from "@/lib/thread-chat/contracts/conversation-message-feedback" +import { conversationId } from "@/lib/thread-chat/domain/conversation-model" +import { + authenticatedActor, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { listCanonicalMessageFeedback } from "@/lib/thread-chat/persistence/canonical-message-feedback-repository" + +type Context = { params: Promise<{ conversationId: string }> } +export async function GET(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const targetConversationId = conversationId((await params).conversationId) + const feedback = await listCanonicalMessageFeedback({ + userId: actor.userId, + conversationId: targetConversationId, + }) + return Response.json(canonicalMessageFeedbackListSchema.parse({ feedback })) + }) +} diff --git a/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts b/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts new file mode 100644 index 00000000..50478467 --- /dev/null +++ b/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts @@ -0,0 +1,53 @@ +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" +import { + setCanonicalMessageFeedbackRequestSchema, + setCanonicalMessageFeedbackResponseSchema, +} from "@/lib/thread-chat/contracts/conversation-message-feedback" +import { + conversationId, + messageId, + threadId, +} from "@/lib/thread-chat/domain/conversation-model" +import { + authenticatedActor, + assertCanonicalMutationAllowed, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { setCanonicalMessageFeedback } from "@/lib/thread-chat/persistence/canonical-message-feedback-repository" + +type Context = { + params: Promise<{ conversationId: string; messageId: string }> +} +export async function PUT(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + assertCanonicalMutationAllowed() + const ids = await params + const body = await parseJson( + request, + setCanonicalMessageFeedbackRequestSchema + ) + const result = await setCanonicalMessageFeedback({ + userId: actor.userId, + conversationId: conversationId(ids.conversationId), + threadId: threadId(body.threadId), + messageId: messageId(ids.messageId), + feedback: body.feedback, + }) + if (!result.ok) { + const message = { + not_found: "消息不存在", + not_completed: "只有已完成的 AI 回复可以评价", + missing_generation: "已完成回复缺少 completed Generation", + }[result.reason] + throw new ConversationCommandError( + result.reason === "not_found" ? "not_found" : "state_conflict", + message + ) + } + return Response.json( + setCanonicalMessageFeedbackResponseSchema.parse(result) + ) + }) +} diff --git a/app/api/conversations/[conversationId]/restore/route.ts b/app/api/conversations/[conversationId]/restore/route.ts new file mode 100644 index 00000000..7bd15273 --- /dev/null +++ b/app/api/conversations/[conversationId]/restore/route.ts @@ -0,0 +1,28 @@ +import { conversationId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ conversationId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const result = + await getConversationCommandComposition().service.setConversationLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: { lifecycle: "active" }, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/conversations/[conversationId]/route.ts b/app/api/conversations/[conversationId]/route.ts new file mode 100644 index 00000000..33007ca3 --- /dev/null +++ b/app/api/conversations/[conversationId]/route.ts @@ -0,0 +1,79 @@ +import { conversationPatchRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" +import { conversationId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ conversationId: string }> } + +export async function GET(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const result = + await getConversationCommandComposition().service.getConversationSnapshot( + { + actorUserId: actor.userId, + conversationId: id, + } + ) + if (!result) + throw new ConversationCommandError("not_found", "Conversation 不存在") + return queryResponse(result, result.snapshot.conversation.revision) + }) +} + +export async function PATCH(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const body = await parseJson(request, conversationPatchRequestSchema) + const service = getConversationCommandComposition().service + const result = + "title" in body + ? await service.renameConversation( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + : await service.setConversationLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} + +export async function DELETE(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const result = + await getConversationCommandComposition().service.deleteConversation( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: {}, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/conversations/bootstrap/route.ts b/app/api/conversations/bootstrap/route.ts new file mode 100644 index 00000000..289179a3 --- /dev/null +++ b/app/api/conversations/bootstrap/route.ts @@ -0,0 +1,60 @@ +import { + assertCanonicalMutationAllowed, + authenticatedActor, + commandEnvelope, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + canonicalBootstrapConversationIds, + ensureCanonicalPersonalProject, +} from "@/lib/thread-chat/persistence/canonical-conversation-bootstrap" + +/** 返回最近的 active Conversation;首次进入时经规范 command API 创建。 */ +export async function POST(request: Request) { + return withConversationRoute(async () => { + assertCanonicalMutationAllowed() + const actor = await authenticatedActor() + const project = await ensureCanonicalPersonalProject(actor.userId) + const service = getConversationCommandComposition().service + const existing = await service.listConversations({ + actorUserId: actor.userId, + projectId: project, + }) + const recent = existing.at(-1) + if (recent) + return queryResponse({ + conversationId: recent.id, + created: false, + }) + + const ids = canonicalBootstrapConversationIds(actor.userId) + try { + await service.createConversation( + commandEnvelope({ + request, + actor, + scope: { type: "project", id: project }, + payload: { + ...ids, + title: null, + modelId: "glm-5.3", + }, + }) + ) + } catch (cause) { + // 两个首次请求可同时观察到空列表;唯一键让其中一个获胜,另一个只接受 + // 已经可读的同一 Conversation,不能吞掉其他创建错误。 + const raced = await service.listConversations({ + actorUserId: actor.userId, + projectId: project, + }) + if (!raced.some((item) => item.id === ids.conversationId)) throw cause + } + return queryResponse({ + conversationId: ids.conversationId, + created: true, + }) + }) +} diff --git a/app/api/generations/[generationId]/route.ts b/app/api/generations/[generationId]/route.ts new file mode 100644 index 00000000..5e3169bf --- /dev/null +++ b/app/api/generations/[generationId]/route.ts @@ -0,0 +1,31 @@ +import { generationId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { CONVERSATION_POLL_AFTER_MS } from "@/constants/conversation-command" +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" + +type Context = { params: Promise<{ generationId: string }> } + +export async function GET(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = generationId((await params).generationId) + const generation = + await getConversationCommandComposition().service.getGeneration({ + actorUserId: actor.userId, + generationId: id, + }) + if (!generation) + throw new ConversationCommandError("not_found", "Generation 不存在") + return queryResponse({ + generation, + pollAfterMs: ["running", "stop_requested"].includes(generation.status) + ? CONVERSATION_POLL_AFTER_MS + : null, + }) + }) +} diff --git a/app/api/generations/[generationId]/stop/route.ts b/app/api/generations/[generationId]/stop/route.ts new file mode 100644 index 00000000..5059fea4 --- /dev/null +++ b/app/api/generations/[generationId]/stop/route.ts @@ -0,0 +1,29 @@ +import { generationId } from "@/lib/thread-chat/domain/conversation-model" +import { + abortCanonicalGenerationLocally, + getConversationCommandComposition, +} from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" + +type Context = { params: Promise<{ generationId: string }> } + +export async function POST(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = generationId((await params).generationId) + const generation = + await getConversationCommandComposition().service.stopGeneration({ + actorUserId: actor.userId, + generationId: id, + notifyLocalAbort: abortCanonicalGenerationLocally, + }) + if (!generation) + throw new ConversationCommandError("not_found", "Generation 不存在") + return queryResponse({ generation }) + }) +} diff --git a/app/api/projects/[projectId]/conversations/route.ts b/app/api/projects/[projectId]/conversations/route.ts new file mode 100644 index 00000000..8f32cab8 --- /dev/null +++ b/app/api/projects/[projectId]/conversations/route.ts @@ -0,0 +1,56 @@ +import { createConversationRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + conversationId, + projectId, + threadId, +} from "@/lib/thread-chat/domain/conversation-model" + +type Context = { params: Promise<{ projectId: string }> } + +export async function GET(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const targetProjectId = projectId((await params).projectId) + const includeArchived = + new URL(request.url).searchParams.get("includeArchived") === "true" + const items = + await getConversationCommandComposition().service.listConversations({ + actorUserId: actor.userId, + projectId: targetProjectId, + includeArchived, + }) + return queryResponse({ conversations: items }) + }) +} + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const targetProjectId = projectId((await params).projectId) + const body = await parseJson(request, createConversationRequestSchema) + const result = + await getConversationCommandComposition().service.createConversation( + commandEnvelope({ + request, + actor, + scope: { type: "project", id: targetProjectId }, + payload: { + conversationId: conversationId(body.conversationId), + rootThreadId: threadId(body.rootThreadId), + title: body.title, + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, body.conversationId) + }) +} diff --git a/app/api/threads/[threadId]/forks/route.ts b/app/api/threads/[threadId]/forks/route.ts new file mode 100644 index 00000000..b6f87ed2 --- /dev/null +++ b/app/api/threads/[threadId]/forks/route.ts @@ -0,0 +1,43 @@ +import { forkThreadRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + messageId, + threadForkId, + threadId, +} from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const body = await parseJson(request, forkThreadRequestSchema) + const result = await getConversationCommandComposition().service.forkThread( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + forkId: threadForkId(body.forkId), + childThreadId: threadId(body.childThreadId), + sourceMessageId: messageId(body.sourceMessageId), + modelId: body.modelId, + localTitle: body.localTitle, + anchor: body.anchor, + }, + }) + ) + return commandResponse(result, body.conversationId) + }) +} diff --git a/app/api/threads/[threadId]/restore/route.ts b/app/api/threads/[threadId]/restore/route.ts new file mode 100644 index 00000000..9042dae1 --- /dev/null +++ b/app/api/threads/[threadId]/restore/route.ts @@ -0,0 +1,28 @@ +import { threadId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const result = + await getConversationCommandComposition().service.setThreadLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + payload: { lifecycle: "active" }, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/threads/[threadId]/route.ts b/app/api/threads/[threadId]/route.ts new file mode 100644 index 00000000..93117965 --- /dev/null +++ b/app/api/threads/[threadId]/route.ts @@ -0,0 +1,42 @@ +import { threadPatchRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { threadId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function PATCH(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const body = await parseJson(request, threadPatchRequestSchema) + const service = getConversationCommandComposition().service + const result = + "title" in body + ? await service.renameThread( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + : await service.setThreadLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/threads/[threadId]/turns/route.ts b/app/api/threads/[threadId]/turns/route.ts new file mode 100644 index 00000000..ee69647e --- /dev/null +++ b/app/api/threads/[threadId]/turns/route.ts @@ -0,0 +1,45 @@ +import { sendTurnRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + generationId, + messageId, + threadId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import type { MessageContent } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const body = await parseJson(request, sendTurnRequestSchema) + const result = await getConversationCommandComposition().service.sendTurn( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + turnId: turnId(body.turnId), + userMessageId: messageId(body.userMessageId), + assistantMessageId: messageId(body.assistantMessageId), + generationId: generationId(body.generationId), + content: body.content as MessageContent, + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/turns/[turnId]/active-variant/route.ts b/app/api/turns/[turnId]/active-variant/route.ts new file mode 100644 index 00000000..8029aae0 --- /dev/null +++ b/app/api/turns/[turnId]/active-variant/route.ts @@ -0,0 +1,39 @@ +import { selectTurnVariantRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + messageId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ turnId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = turnId((await params).turnId) + const body = await parseJson(request, selectTurnVariantRequestSchema) + const result = + await getConversationCommandComposition().service.selectTurnVariant( + commandEnvelope({ + request, + actor, + scope: { type: "turn", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + messageId: messageId(body.messageId), + role: body.role, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/turns/[turnId]/input-edits/route.ts b/app/api/turns/[turnId]/input-edits/route.ts new file mode 100644 index 00000000..afcd2755 --- /dev/null +++ b/app/api/turns/[turnId]/input-edits/route.ts @@ -0,0 +1,45 @@ +import { editTurnInputRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + generationId, + messageId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import type { MessageContent } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ turnId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = turnId((await params).turnId) + const body = await parseJson(request, editTurnInputRequestSchema) + const result = + await getConversationCommandComposition().service.editTurnInput( + commandEnvelope({ + request, + actor, + scope: { type: "turn", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + userMessageId: messageId(body.userMessageId), + assistantMessageId: messageId(body.assistantMessageId), + generationId: generationId(body.generationId), + sourceUserMessageId: messageId(body.sourceUserMessageId), + content: body.content as MessageContent, + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/turns/[turnId]/regenerations/route.ts b/app/api/turns/[turnId]/regenerations/route.ts new file mode 100644 index 00000000..ad5bf999 --- /dev/null +++ b/app/api/turns/[turnId]/regenerations/route.ts @@ -0,0 +1,42 @@ +import { regenerateTurnRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + generationId, + messageId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ turnId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = turnId((await params).turnId) + const body = await parseJson(request, regenerateTurnRequestSchema) + const result = + await getConversationCommandComposition().service.regenerateTurn( + commandEnvelope({ + request, + actor, + scope: { type: "turn", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + assistantMessageId: messageId(body.assistantMessageId), + generationId: generationId(body.generationId), + sourceAssistantMessageId: messageId(body.sourceAssistantMessageId), + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/start-chat/page.tsx b/app/start-chat/page.tsx index 01a77b0b..e0eaf34b 100644 --- a/app/start-chat/page.tsx +++ b/app/start-chat/page.tsx @@ -1,11 +1,9 @@ -import { randomUUID } from "node:crypto" import type { Metadata } from "next" import { redirect } from "next/navigation" -import { ROUTES, signInWithRedirect, threadTreeRoute } from "@/constants/routes" +import { ROUTES, signInWithRedirect } from "@/constants/routes" import { getSession } from "@/lib/auth/server" -// A fresh tree ID must be generated for every request, never at build time. export const dynamic = "force-dynamic" export const metadata: Metadata = { @@ -16,11 +14,11 @@ export const metadata: Metadata = { }, } -/** Authenticated entry that always opens a new branch-conversation tree. */ +/** 认证入口交给 canonical bootstrap 打开最近 Conversation 或建立首个 Conversation。 */ export default async function StartChatPage(): Promise { const session = await getSession() if (!session) { redirect(signInWithRedirect(ROUTES.startChat)) } - redirect(threadTreeRoute(randomUUID())) + redirect(ROUTES.flagship) } diff --git a/app/thread-chat/[conversationId]/page.tsx b/app/thread-chat/[conversationId]/page.tsx new file mode 100644 index 00000000..df4303d3 --- /dev/null +++ b/app/thread-chat/[conversationId]/page.tsx @@ -0,0 +1,31 @@ +import { notFound } from "next/navigation" +import { isValidConversationRouteId } from "@/lib/thread-chat/domain/conversation-route-id" +import { CanonicalThreadChat } from "../canonical/canonical-thread-chat" +import { threadChatMetadata } from "../page-metadata" +import { resolveConversationAuthority } from "@/lib/thread-chat/cutover/conversation-authority" + +export const metadata = threadChatMetadata + +/** + * URL 参数是规范 Conversation ID。裸入口负责显式 bootstrap,不再把任意新 UUID + * 隐式解释为一棵可写 ThreadTree。 + */ +export default async function ConversationPage({ + params, +}: { + params: Promise<{ conversationId: string }> +}) { + const { conversationId } = await params + if (!isValidConversationRouteId(conversationId)) notFound() + const authority = resolveConversationAuthority() + if (authority.authority !== "canonical") + throw new Error("Thread Chat 只允许 canonical Conversation authority") + return ( + + ) +} diff --git a/app/thread-chat/[treeId]/page.tsx b/app/thread-chat/[treeId]/page.tsx deleted file mode 100644 index bad9041d..00000000 --- a/app/thread-chat/[treeId]/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { notFound } from "next/navigation" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { ThreadChatDemo } from "../thread-chat-demo" -import { threadChatMetadata } from "../page-metadata" - -export const metadata = threadChatMetadata - -/** - * URL 即树身份:/thread-chat/{treeId} 打开指定的分支树(直访新 UUID = 开新树)。 - * treeId 做 UUID 形状校验(安全阀),不合法 404。key={treeId} 保证切树(如「新对话」 - * 跳转)时 loader/store 整体重挂,不残留上一棵树的内存状态。 - */ -export default async function ThreadChatTreePage({ - params, -}: { - params: Promise<{ treeId: string }> -}) { - const { treeId } = await params - if (!isValidTreeId(treeId)) notFound() - return -} diff --git a/app/thread-chat/branching/assistant/anchored-assistant-body.tsx b/app/thread-chat/branching/assistant/anchored-assistant-body.tsx deleted file mode 100644 index dc41558f..00000000 --- a/app/thread-chat/branching/assistant/anchored-assistant-body.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client" - -import type { Message, ThreadTreeState } from "../../core/types" -import { WebResearchPanel } from "../../orchestration/overlays/web-research-panel" -import { AnchoredMarkdown } from "./anchored-markdown" -import { webResearchPlacement } from "./web-research-placement" - -export function AnchoredAssistantBody({ - state, - message, - onOpenThread, -}: { - state: ThreadTreeState - message: Message - onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void -}) { - const research = webResearchPlacement(message) - const hasResearch = research.activities.length > 0 - - return ( - - ) : undefined - } - /> - ) -} diff --git a/app/thread-chat/branching/assistant/anchored-markdown.tsx b/app/thread-chat/branching/assistant/anchored-markdown.tsx deleted file mode 100644 index ca36cdce..00000000 --- a/app/thread-chat/branching/assistant/anchored-markdown.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client" - -import React, { useCallback, useEffect, useReducer, useRef } from "react" -import type { Message, ThreadTreeState } from "../../core/types" -import { threadTitle } from "../../core/selectors" -import { dc } from "../../theme" -import { MarkdownBody } from "../../chat/message/markdown-body" -import { useSmoothText } from "../../chat/message/smooth-text" -import { - clearHighlights, - locateAnchor, - paintRange, -} from "../selection/text-anchor" - -/** - * Markdown 正文 + 渲染后锚点高亮/脚注。 - * - * 锚点定位发生在 Markdown 渲染后的真实 DOM 上;流式阶段仅呈现平滑文本, - * 内容稳定后才绘制非 React 管理的高亮节点,避免与代码高亮 commit 竞争。 - */ -export function AnchoredMarkdown({ - state, - msg, - onOpenThread, - insertAt, - insert, -}: { - state: ThreadTreeState - msg: Message - onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void - /** 在流事件记录的正文字符偏移处插入工具活动;缺省时渲染普通单段 Markdown。 */ - insertAt?: number - insert?: React.ReactNode -}) { - const hostRef = useRef(null) - // forksKey 只随 fork 的增删与编号变化——source 未变、仅新增 fork 时也能触发重绘 - const forksKey = msg.forks - .map((fork) => `${fork.threadId}:${fork.num}`) - .join("|") - const active = msg.status === "streaming" || msg.status === "pending" - const display = useSmoothText(msg.text, active) - const renderedSource = active ? display : msg.text - const [settledRevision, bumpSettledRevision] = useReducer( - (revision: number) => revision + 1, - 0 - ) - const onContentSettled = useCallback(() => { - bumpSettledRevision() - }, []) - - useEffect(() => { - const host = hostRef.current - if (!host) return - const markdownBodies = [...host.querySelectorAll(".md-body")] - if (markdownBodies.length === 0) return - - const wipe = () => { - markdownBodies.forEach((markdownBody) => { - clearHighlights(markdownBody) - markdownBody - .querySelectorAll("sup.fn-mark") - .forEach((node) => node.remove()) - }) - } - wipe() - - // 高亮仍在飞或当前是流式 plaintext 时,绝不手改 React 即将 reconcile 的代码 DOM。 - if ( - active || - markdownBodies.some( - (markdownBody) => markdownBody.dataset.contentSettled !== "true" - ) - ) - return wipe - - for (const fork of msg.forks) { - if (!fork.anchor) continue - let markdownBody: HTMLElement | null = null - let located: ReturnType = null - for (const candidate of markdownBodies) { - located = locateAnchor(candidate, fork.anchor) - if (located) { - markdownBody = candidate - break - } - } - if (!markdownBody || !located) continue - const color = `color-mix(in srgb, var(--d${dc(fork.depth)}) 20%, transparent)` - paintRange(located.range, fork.threadId, color) - - const marks = markdownBody.querySelectorAll( - `[data-text-anchor-mark="${cssEscape(fork.threadId)}"]` - ) - marks.forEach((mark) => { - mark.setAttribute("data-fork-id", fork.threadId) - mark.classList.add("anchored-mark", `fc-${dc(fork.depth)}`) - mark.title = `分支「${threadTitle(state, fork.threadId)}」· 点击打开 · ⌘点击保留本列在右侧打开` - }) - - const last = marks[marks.length - 1] - if (last) { - const footnote = document.createElement("sup") - footnote.className = `fn-mark fc-${dc(fork.depth)}` - footnote.setAttribute("data-fork-id", fork.threadId) - footnote.textContent = String(fork.num) - last.after(footnote) - } - } - - return wipe - // state 仅用于 title 文案,不参与重绘时机;有意省略以免每次 version 变动都重绘 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [active, msg.text, forksKey, settledRevision]) - - const onClick = (event: React.MouseEvent) => { - const element = (event.target as HTMLElement).closest?.("[data-fork-id]") - if (!element) return - const threadId = element.getAttribute("data-fork-id") - if (!threadId) return - onOpenThread(threadId, { - keepSource: event.metaKey || event.ctrlKey, - }) - } - - const normalizedInsertAt = - insertAt == null ? null : Math.max(0, Math.min(insertAt, msg.text.length)) - const insertIsVisible = - insert != null && - normalizedInsertAt != null && - renderedSource.length >= normalizedInsertAt - const beforeInsert = insertIsVisible - ? renderedSource.slice(0, normalizedInsertAt) - : renderedSource - const afterInsert = insertIsVisible - ? renderedSource.slice(normalizedInsertAt) - : "" - - return ( -
- {beforeInsert ? ( - - ) : null} - {insertIsVisible ? insert : null} - {afterInsert ? ( - - ) : null} -
- ) -} - -function cssEscape(value: string): string { - if (typeof CSS !== "undefined" && typeof CSS.escape === "function") - return CSS.escape(value) - return value.replace(/"/g, '\\"') -} diff --git a/app/thread-chat/branching/assistant/web-research-placement.ts b/app/thread-chat/branching/assistant/web-research-placement.ts deleted file mode 100644 index 461cd551..00000000 --- a/app/thread-chat/branching/assistant/web-research-placement.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Message } from "../../core/types" - -export function webResearchPlacement(message: Message) { - const activities = message.webResearch ?? [] - return { - activities, - insertAt: - activities.length > 0 ? (message.webResearchTextOffset ?? 0) : undefined, - } -} diff --git a/app/thread-chat/branching/branchable-chat.tsx b/app/thread-chat/branching/branchable-chat.tsx deleted file mode 100644 index 59716f7d..00000000 --- a/app/thread-chat/branching/branchable-chat.tsx +++ /dev/null @@ -1,274 +0,0 @@ -"use client" -/** - * branching/branchable-chat —— 装饰层:把「分支能力」注入单会话 ChatView。 - * - * 组装内容: - * · 列头:面包屑(就地回退)/ L 深度徽章 / 子分支弹层按钮 / ⇄ 切换 / 收起; - * · focus banner(讨论焦点 · 划选自 X)+「继承的上文」折叠区; - * · assistant 正文的锚点高亮 + 脚注上标(点击 = 打开对应分支); - * · 消息下方的 artifact 卡片。 - * 本层只发出意图回调(打开会话 / 回退 / 收起…),列槽的增删换由 orchestration 决定。 - */ - -import React from "react" -import { ListTree } from "lucide-react" -import type { Message, ThreadTreeState } from "../core/types" -import { - activeMessagePath, - collectInherited, - lineage, - messagesByIdOrder, - threadTitle, -} from "../core/selectors" -import { ChatView } from "../chat/chat-view" -import { MessageArtifacts } from "../orchestration/artifacts/message-artifacts" -import { AnchoredAssistantBody } from "./assistant/anchored-assistant-body" -import type { MessageActionViewState } from "../chat/actions/message-action-types" -import type { ThreadMessageActionCommands } from "../chat/actions/message-action-commands" - -export interface BranchableChatProps { - state: ThreadTreeState - threadId: string - /** 主线列的副标题(demo 文案由壳层传入) */ - subtitle?: string - /** 消息列表顶部的插卡(主线 hint) */ - intro?: React.ReactNode - /** 统一意图:打开某会话(本列作为「来源列」参与放置策略)。 - opts.keepSource:⌘/Ctrl 点击 = 保留本列,把目标开在紧邻右侧 */ - onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void - onOpenArtifact: (artifactId: string) => void - /** 面包屑就地回退(collapse 语义由 orchestration 实现) */ - onCrumbNav: (targetId: string) => void - /** ⇄ 把本列切换为任意会话(弹出 local 切换器,锚定在按钮上) */ - onOpenSwitcher: (anchor: HTMLElement) => void - /** 查看以本会话为根的子树(弹出 subtree 面板,锚定在按钮上) */ - onOpenSubtree: (anchor: HTMLElement) => void - onCollapse: () => void - /** 流式生成中:透传给 ChatView 禁用发送键 */ - busy?: boolean - /** 错误消息下的「重试」按钮回调,透传给 ChatView */ - onRetry?: (msg: Message) => void - /** busy 时发送键变「停止」的回调,透传给 ChatView */ - onStop?: () => void - /** composer 预填文案(新开分支的代拟首问,待用户回车确认),透传给 ChatView */ - composerPrefill?: string - /** 根 Thread 模型切换意图;分支 selector 仍由本层锁定。 */ - onModelChange: (modelId: string) => void - onSend: (text: string) => void - messageActionState?: MessageActionViewState - messageCommands?: ThreadMessageActionCommands -} - -export function BranchableChat({ - state, - threadId, - subtitle, - intro, - onOpenThread, - onOpenArtifact, - onCrumbNav, - onOpenSwitcher, - onOpenSubtree, - onCollapse, - busy, - onRetry, - onStop, - composerPrefill, - onModelChange, - onSend, - messageActionState, - messageCommands, -}: BranchableChatProps) { - const thread = state.threads[threadId] - if (!thread) return null - const isMain = threadId === "main" - const chain = isMain ? [] : lineage(state, threadId) - const inherited = isMain ? [] : collectInherited(state, thread) - const childCount = thread.children.length - const presentation = messageActionState?.presentationByThreadId.get(threadId) - const sourceProvenance = presentation?.sourceProvenance ?? null - const visibleMessages = messageActionState - ? messagesByIdOrder( - thread.messages, - messageActionState.activePathByThreadId.get(threadId) ?? [] - ) - : activeMessagePath(thread) - - /* ---------- 注入:assistant 正文(Markdown 渲染 + 渲染后手绘锚点高亮/脚注) ---------- */ - const renderAssistantBody = (msg: Message) => { - return ( - - ) - } - - /* ---------- 注入:消息下方的 artifact 卡片 ---------- */ - const renderAfterMessage = (msg: Message) => { - return ( - - ) - } - - /* ---------- 列头(主线 / 分支两种形态,子分支按钮两者都有) ---------- */ - const subtreeBtn = ( - - ) - - const header = ( -
- {/* 列头背景 / 底部分隔线随列通栏,内容收敛在 .lane 阅读通道内(与消息流对齐) */} -
- {isMain ? ( - <> -
- 锚定 - 主线 -
{subtreeBtn}
-
- {subtitle &&
{subtitle}
} - - ) : ( - <> -
- {chain.map((c, i) => { - const here = i === chain.length - 1 - return ( - - onCrumbNav(c.id)} - title={here ? undefined : `回到「${c.title}」`} - > - {c.title} - - {!here && } - - ) - })} -
-
- L{thread.depth} - {thread.title} -
- {subtreeBtn} - - -
-
- - )} -
-
- ) - - /* ---------- focus banner + 继承的上文(仅分支列) ---------- - 父级(列)没有水平 padding,用 .lane.pad 承担 18px 侧距并居中通道 */ - const banner = isMain ? null : ( -
-
- {thread.footnote} -
- - 讨论焦点 · 划选自 - {thread.parentId === "main" - ? "主线" - : `「${threadTitle(state, thread.parentId!)}」`} - - {thread.anchorText} -
- {sourceProvenance && !sourceProvenance.isOnActivePath && ( -
- - 基于回复 - {sourceProvenance.alternativeIndex === null - ? "" - : " " + - (sourceProvenance.alternativeIndex + 1) + - "/" + - sourceProvenance.alternativeCount}{" "} - · 当前未展示 - - -
- )} -
-
- - 继承的上文 · {inherited.length} 条 - -
- {inherited.map((m) => ( -
- {m.role === "user" ? "你" : "AI"} - {m.text.length > 130 ? m.text.slice(0, 130) + "…" : m.text} -
- ))} -
-
-
- ) - - return ( - - ) -} diff --git a/app/thread-chat/branching/selection/bubble-position.ts b/app/thread-chat/branching/selection/bubble-position.ts deleted file mode 100644 index f8d1419d..00000000 --- a/app/thread-chat/branching/selection/bubble-position.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * branching/bubble-position —— 划选气泡的落点计算(纯几何、无 DOM 依赖)。 - * - * 移植自 playground.zilin.im 的 floating-popup 定位模型(`app/floating-popup/position.ts`): - * 在 container(这里是浏览器 viewport)内,让 popup 围绕 anchor(选区包围盒)择位。 - * 按 sides 顺序逐个尝试,第一个能「干净放下」的方向即采用;主轴贴 anchor 留 gap, - * 交叉轴对 anchor 居中、越界时沿交叉轴滑动(clamp 进安全区,不算失败); - * popup 永不越出 container 内缩 safePadding 的安全区;四边都放不下时进入 fallback, - * 取遮挡 anchor 面积最小的候选 —— 尽量不糊在用户刚选中的字上。 - * - * 划选气泡只用上下两向(sides: ["bottom","top"],见 selection-bubble.tsx), - * 因为气泡的「平滑曲线小尾巴」是竖直指向的(bubble-shape.ts)。 - */ - -export interface Rect { - left: number - top: number - width: number - height: number -} - -export interface Size { - width: number - height: number -} - -export type Side = "right" | "bottom" | "left" | "top" - -export interface PositionOptions { - /** popup 与 container 四边的最小安全边距,默认 12 */ - safePadding?: number - /** popup 与 anchor 之间的呼吸空间,默认 4 */ - gap?: number - /** 各方向的尝试顺序,默认 ["right", "bottom", "left", "top"] */ - sides?: Side[] -} - -export interface PositionResult { - /** popup 左上角坐标(与 container 同一坐标系) */ - left: number - top: number - /** 实际采用的方向 */ - side: Side - /** true = 四边都放不下,当前是「遮挡最小」的兜底位置 */ - fallback: boolean - /** popup 与 anchor 的重叠面积(px²),0 = 完全没有遮挡 */ - overlapArea: number -} - -export interface SideCandidate { - side: Side - /** clamp 进安全区之后的位置 */ - left: number - top: number - /** 该方向能否「干净」放下(不出安全区、不遮挡 anchor) */ - fits: boolean - overlapArea: number -} - -const DEFAULT_SIDES: Side[] = ["right", "bottom", "left", "top"] - -function clamp(value: number, min: number, max: number): number { - if (max < min) return min // 安全区都装不下 popup 时,贴 min(左/上) - return Math.min(Math.max(value, min), max) -} - -function intersectArea( - left: number, - top: number, - size: Size, - rect: Rect -): number { - const w = - Math.min(left + size.width, rect.left + rect.width) - - Math.max(left, rect.left) - const h = - Math.min(top + size.height, rect.top + rect.height) - - Math.max(top, rect.top) - return Math.max(0, w) * Math.max(0, h) -} - -/** - * 带过程信息的版本:除最终结果外,返回各方向的候选位置与可行性,方便调试 / 可视化。 - * 日常使用直接调 computePopupPosition 即可。 - */ -export function explainPopupPosition( - anchor: Rect, - popup: Size, - container: Rect, - options: PositionOptions = {} -): { result: PositionResult; candidates: SideCandidate[] } { - const safePadding = options.safePadding ?? 12 - const gap = options.gap ?? 4 - const sides = options.sides ?? DEFAULT_SIDES - - // popup 左上角允许出现的范围(container 内缩 safePadding 的安全区) - const minLeft = container.left + safePadding - const minTop = container.top + safePadding - const maxLeft = container.left + container.width - safePadding - popup.width - const maxTop = container.top + container.height - safePadding - popup.height - // popup 整体能否装进安全区(任何方向都以此为前提) - const containable = maxLeft >= minLeft && maxTop >= minTop - - const anchorRight = anchor.left + anchor.width - const anchorBottom = anchor.top + anchor.height - // 交叉轴居中的理想位置 - const centeredLeft = anchor.left + anchor.width / 2 - popup.width / 2 - const centeredTop = anchor.top + anchor.height / 2 - popup.height / 2 - - // 各方向候选:主轴贴 anchor + gap;交叉轴居中后 clamp 进安全区(允许滑动) - const ideal: Record = { - right: { left: anchorRight + gap, top: clamp(centeredTop, minTop, maxTop) }, - bottom: { - left: clamp(centeredLeft, minLeft, maxLeft), - top: anchorBottom + gap, - }, - left: { - left: anchor.left - gap - popup.width, - top: clamp(centeredTop, minTop, maxTop), - }, - top: { - left: clamp(centeredLeft, minLeft, maxLeft), - top: anchor.top - gap - popup.height, - }, - } - - const candidates: SideCandidate[] = sides.map((side) => { - const c = ideal[side] - const fits = - containable && - c.left >= minLeft && - c.left <= maxLeft && - c.top >= minTop && - c.top <= maxTop - // 不可行的方向按 clamp 进安全区后的位置参与兜底比较 - const left = clamp(c.left, minLeft, maxLeft) - const top = clamp(c.top, minTop, maxTop) - return { - side, - left, - top, - fits, - overlapArea: intersectArea(left, top, popup, anchor), - } - }) - - // 1) 有干净放得下的方向 → 按顺序取第一个 - const clean = candidates.find((c) => c.fits) - if (clean) { - return { - result: { - left: clean.left, - top: clean.top, - side: clean.side, - fallback: false, - overlapArea: 0, - }, - candidates, - } - } - - // 2) fallback:取遮挡 anchor 面积最小的候选(并列时尊重 sides 顺序) - let best = candidates[0] - for (const c of candidates) { - if (c.overlapArea < best.overlapArea) best = c - } - return { - result: { - left: best.left, - top: best.top, - side: best.side, - fallback: true, - overlapArea: best.overlapArea, - }, - candidates, - } -} - -/** - * 计算 popup 围绕 anchor 在 container 内的落点。 - * - * @param anchor 选区矩形(选区包围盒),与 container 同坐标系 - * @param popup popup 的尺寸(气泡面板宽高) - * @param container 限定范围,viewport 场景传 {left:0, top:0, width:innerWidth, height:innerHeight} - */ -export function computePopupPosition( - anchor: Rect, - popup: Size, - container: Rect, - options: PositionOptions = {} -): PositionResult { - return explainPopupPosition(anchor, popup, container, options).result -} diff --git a/app/thread-chat/branching/selection/bubble-shape.tsx b/app/thread-chat/branching/selection/bubble-shape.tsx deleted file mode 100644 index 61391f66..00000000 --- a/app/thread-chat/branching/selection/bubble-shape.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client" -/** - * branching/bubble-shape —— 划选气泡的「平滑曲线」外轮廓(面板 + 指向选区的小尾巴,一条 path)。 - * - * 移植自 playground.zilin.im 的 smooth-tooltip(`app/smooth-tooltip/page.tsx`): - * 整条轮廓用一条闭合 path 画成 —— 面板四角圆角(Q) · 尾巴根部凹角外扩(C) · 直斜边(L) · - * 顶点圆角(A),单 fill 无拼接缝,取代过去那个 CSS 旋转小方块。 - * - * 与 tooltip 的差异:tooltip 只朝下;气泡要么在选区下方(尾巴朝上 dir="up"), - * 要么在上方(尾巴朝下 dir="down")。canonical path 按「尾巴朝下」构造,dir="up" 时 - * 整条 path 竖直翻转即可(面板落到下半、尾巴翻到上半)。尾巴横向落点 cx 由上层按 - * 选区中心传入(不再永远居中),这样气泡贴边滑动后尾巴仍指着选区。 - */ - -import React, { useId } from "react" -import type { - TailDir, - TailGeo, -} from "@/lib/thread-chat/domain/selection-bubble" - -export type { - TailDir, - TailGeo, -} from "@/lib/thread-chat/domain/selection-bubble" - -interface PathGeo extends TailGeo { - W: number - H: number - cx: number -} - -/** - * canonical 轮廓:面板左上角 (0,0),宽 W、高 H,尾巴朝下、顶点在 (cx, H+ah)。 - * 三段曲线:① 凹·外扩 C(底边水平切线 → 斜边方向切线)② 直斜边 L ③ 凸·圆角 A。 - */ -export function buildBubblePath({ - W, - H, - R, - cx, - aw, - ah, - flare, - tip, -}: PathGeo): string { - const len = Math.hypot(aw, ah) || 1 - // 右侧斜边「下行」单位向量(由根部指向顶点,即向下偏左) - const dlx = -aw / len - const dly = ah / len - - // 顶点圆角:沿斜边从顶点回退的切线长 d = tip / tan(θ) = tip·ah/aw - const d = Math.min((tip * ah) / Math.max(aw, 1e-4), len * 0.6) - // 外扩段沿斜边占用的长度(夹住,别和顶点圆角打架) - const fs = Math.max(0, Math.min(flare, len - d - 2)) - - const apexY = H + ah - // 顶点圆弧的两个切点(分别在左右斜边上) - const Trx = cx + (aw / len) * d, - Try = apexY - (ah / len) * d - const Tlx = cx - (aw / len) * d, - Tly = Try - // 外扩曲线与斜边的衔接点(根部往斜边下移 fs) - const Srx = cx + aw + dlx * fs, - Sry = H + dly * fs - const Slx = cx - aw - dlx * fs, - Sly = Sry - // 外扩曲线在底边上的起点(凹角向外铺开 flare 的宽度) - const Erx = cx + aw + flare, - Elx = cx - aw - flare - - // 控制柄长度 - const c1 = flare * 0.55 // 底边端,水平 - const c2 = fs * 0.55 // 斜边端,沿斜边 - - const f = (n: number) => Number(n.toFixed(2)) - const p = (a: number, b: number) => `${f(a)} ${f(b)}` - - return [ - `M ${p(R, 0)}`, - `L ${p(W - R, 0)}`, - `Q ${p(W, 0)} ${p(W, R)}`, // 右上角 - `L ${p(W, H - R)}`, - `Q ${p(W, H)} ${p(W - R, H)}`, // 右下角 - `L ${p(Erx, H)}`, // 底边·右段 - // ① 右侧外扩:水平切线 → 斜边切线 - `C ${p(Erx - c1, H)} ${p(Srx - dlx * c2, Sry - dly * c2)} ${p(Srx, Sry)}`, - `L ${p(Trx, Try)}`, // ② 右直斜边 - `A ${f(tip)} ${f(tip)} 0 0 1 ${p(Tlx, Tly)}`, // ③ 顶点圆弧(凸) - `L ${p(Slx, Sly)}`, // ② 左直斜边 - // ① 左侧外扩:斜边切线 → 水平切线 - `C ${p(Slx + dlx * c2, Sly - dly * c2)} ${p(Elx + c1, H)} ${p(Elx, H)}`, - `L ${p(R, H)}`, // 底边·左段 - `Q ${p(0, H)} ${p(0, H - R)}`, // 左下角 - `L ${p(0, R)}`, - `Q ${p(0, 0)} ${p(R, 0)}`, // 左上角 - "Z", - ].join(" ") -} - -/** - * 纯形状(svg 背景层)。面板宽 W、高 H,尾巴另占 ah 高。 - * dir="down":面板在上、尾巴朝下(顶点 y=H+ah); - * dir="up" :整条 path 竖直翻转,面板落到下半、尾巴翻到上半(顶点 y=0)。 - * fill 默认 var(--ink),与气泡深底一致;shadow 用 feDropShadow 复刻原气泡阴影。 - */ -export function BubbleShape({ - W, - H, - cx, - geo, - dir, - fill = "var(--ink)", - shadow = true, -}: { - W: number - H: number - cx: number - geo: TailGeo - dir: TailDir - fill?: string - shadow?: boolean -}) { - const id = useId().replace(/:/g, "") - const totalH = H + geo.ah - const dStr = buildBubblePath({ W, H, cx, ...geo }) - // dir="up":竖直翻转(面板到下半、尾巴到上半) - const flip = dir === "up" ? `matrix(1 0 0 -1 0 ${totalH})` : undefined - - return ( - - {shadow && ( - - - - - - )} - - - ) -} diff --git a/app/thread-chat/branching/selection/selection-bubble.tsx b/app/thread-chat/branching/selection/selection-bubble.tsx deleted file mode 100644 index 7bea8836..00000000 --- a/app/thread-chat/branching/selection/selection-bubble.tsx +++ /dev/null @@ -1,316 +0,0 @@ -"use client" -/** - * branching/selection-bubble —— 划选 assistant 消息文字 → 迷你气泡 → 开分支。 - * - * document 级划选监听与命令式 DOM Selection 读取由 - * useAssistantTextSelection 封装;本组件组合气泡状态、定位、放置预览与提交 UI。 - * 气泡的开合状态由上层持有(sel / onSelChange),以便 Esc 逐层关闭链能先关它。 - * - * 放置控制(存在 ≥1 个分支列时显示,见任务「打开到哪一列」): - * · 底部迷你列条按当前列序预览「新分支会放到哪」——将替换(斜纹)/ 将折叠 / 插入 - * 位置的虚线幽灵格,全部来自 placement.previewPlacement(与提交共用同一套规则, - * 预览不撒谎);主线小格锚定不可选; - * · 点非主线小格 = 显式指定让位列(override,再点同格取消); - * · 按住 ⌘/Ctrl = 保留来源列、新列开在其紧邻右侧(气泡实时跟踪修饰键,按钮文案 - * 与列条目标同步切换)。生效目标 = override > 修饰键 > 默认规则。 - * - * 可选输入框(Phase A,openspec: add-bubble-composer): - * · 输入后提交 = 带问开分支:问题经 onFork 第三参传给壳层,fork 后直接 chat.send - * 成为新分支第 1 条 user 消息(不进 composer 预填流); - * · 留空提交 = 现有预填流原样保留(空分支 + composer 预填代拟问题 + 回车确认); - * · 键位:Enter 提交 / Shift+Enter 换行 / ⌘Ctrl+Enter 提交且保留来源列 / - * Esc 交由壳层关闭链关气泡;Enter 有 IME 守卫(isComposing / keyCode 229)。 - */ - -import React, { useEffect, useLayoutEffect, useRef, useState } from "react" -import { GitMerge } from "lucide-react" -import type { ThreadTreeState } from "../../core/types" -import { threadTitle } from "../../core/selectors" -import { - previewPlacement, - type PlacementHint, - type PlacementMode, - type Slot, -} from "../../orchestration/columns/placement" -import { computePopupPosition } from "./bubble-position" -import { BubbleShape, type TailDir } from "./bubble-shape" -import { SELECTION_QUESTION_MAX_HEIGHT } from "./selection-composer-dimensions" -import { - useAssistantTextSelection, - type SelectionInfo, -} from "./use-assistant-text-selection" -import { SelectionPlacementMap } from "./selection-placement-map" -import { - BUBBLE_GAP, - BUBBLE_SAFE_PADDING, - BUBBLE_TAIL, - BUBBLE_TAIL_MARGIN, - BUBBLE_W, -} from "@/constants/selection-bubble" - -export type { SelectionInfo } from "./use-assistant-text-selection" - -export interface SelectionBubbleProps { - state: ThreadTreeState - sel: SelectionInfo | null - onSelChange: (s: SelectionInfo | null) => void - /** 提交开分支:上层负责真正 fork + 放置;hint 见 placement.ts; - question = 气泡输入框里的可选首问(trim 后非空才传,成为新分支第 1 条 user 消息) */ - onFork: (s: SelectionInfo, hint?: PlacementHint, question?: string) => void - /* —— 迷你列条的放置上下文(与提交走同一套 placement 规则)—— */ - slots: Slot[] - mode: PlacementMode - maxExpanded: number - lastActiveOf: (id: string) => number -} - -export function SelectionBubble({ - state, - sel, - onSelChange, - onFork, - slots, - mode, - maxExpanded, - lastActiveOf, -}: SelectionBubbleProps) { - useAssistantTextSelection({ - state, - selection: sel, - onSelectionChange: onSelChange, - }) - /** 迷你列条点选的让位列(override);气泡隐藏 / 换一段划选时清空 */ - const [override, setOverride] = useState(null) - /** ⌘/Ctrl 是否按住(实时跟踪,目标与按钮文案随之切换) */ - const [metaHeld, setMetaHeld] = useState(false) - /** 可选首问(受控 textarea):留空提交 = 现有预填流;非空提交 = 带问开分支 */ - const [question, setQuestion] = useState("") - const taRef = useRef(null) - /** 气泡内容层(面板本体):测其高度 H 喂定位模型与轮廓 path */ - const contentRef = useRef(null) - /** 实测面板高度(内容驱动:输入框自增高、迷你列条出现都会变),0 = 尚未测量 */ - const [measuredH, setMeasuredH] = useState(0) - /** 渲染期间的派生状态调整(React 官方写法):sel 变化 = 新一次划选,重置各态 */ - const [forSel, setForSel] = useState(sel) - if (forSel !== sel) { - setForSel(sel) - setOverride(null) - setMetaHeld(sel?.meta ?? false) - setQuestion("") - setMeasuredH(0) // 换一段划选:高度作废,等重新测量再定位(先隐藏,避免旧位闪现) - } - - /* 测量面板高度:内容变化(打字自增高 / 列条出现)经 ResizeObserver 实时回填, - 驱动定位模型重新择位(贴底时自动上下翻转),气泡不会被 viewport 裁切 */ - useLayoutEffect(() => { - const el = contentRef.current - if (!sel || !el) return - // 清掉上一次划选留在 textarea 上的自增高(跨划选不重挂载),否则首帧量到偏大的 H - if (taRef.current) taRef.current.style.height = "" - const measure = () => setMeasuredH(el.offsetHeight) - measure() - const ro = new ResizeObserver(measure) - ro.observe(el) - return () => ro.disconnect() - }, [sel]) - - /* 气泡弹出即聚焦输入框(preventScroll:气泡定位刚结算完,不能再引发滚动); - 顺手清掉上一次自增高留下的行内高度(textarea 跨划选不重挂载) */ - useEffect(() => { - const ta = taRef.current - if (!sel || !ta) return - ta.style.height = "" - ta.focus({ preventScroll: true }) - }, [sel]) - - /* 气泡打开期间跟踪 ⌘/Ctrl 起落(keydown/keyup 都带 metaKey/ctrlKey 快照) */ - useEffect(() => { - if (!sel) return - const sync = (e: KeyboardEvent) => setMetaHeld(e.metaKey || e.ctrlKey) - const onBlur = () => setMetaHeld(false) - document.addEventListener("keydown", sync) - document.addEventListener("keyup", sync) - window.addEventListener("blur", onBlur) - return () => { - document.removeEventListener("keydown", sync) - document.removeEventListener("keyup", sync) - window.removeEventListener("blur", onBlur) - } - }, [sel]) - - if (!sel) return null - - /* —— 落点:floating-popup 定位模型,只用上/下两向(尾巴竖直指向选区)—— - 测得高度前(measuredH=0)先把气泡藏到屏外并隐藏,测完这一帧即就位,避免旧位闪现。 */ - const ready = measuredH > 0 && typeof window !== "undefined" - const pos = ready - ? computePopupPosition( - sel.rect, - { width: BUBBLE_W, height: measuredH }, - { - left: 0, - top: 0, - width: window.innerWidth, - height: window.innerHeight, - }, - { - sides: ["bottom", "top"], - gap: BUBBLE_GAP, - safePadding: BUBBLE_SAFE_PADDING, - } - ) - : null - // side="bottom"(面板在选区下方)→ 尾巴朝上;side="top"(面板在上方)→ 尾巴朝下 - const dir: TailDir = pos?.side === "top" ? "down" : "up" - // 尾巴横向落点:对准选区中心,夹进面板安全范围(不让根部爬上圆角) - const anchorCx = sel.rect.left + sel.rect.width / 2 - const cx = pos - ? Math.min( - BUBBLE_W - BUBBLE_TAIL_MARGIN, - Math.max(BUBBLE_TAIL_MARGIN, anchorCx - pos.left) - ) - : BUBBLE_W / 2 - - /* —— 生效目标 = override > 修饰键推导 > 默认规则(列条与提交共用 hint) —— */ - const ov = - override && slots.some((s) => s.id === override && !s.folded) - ? override - : null - const hint: PlacementHint | undefined = ov - ? { targetId: ov } - : metaHeld - ? { keepSource: true } - : undefined - const hasMap = slots.length > 0 // 仅主线时无需放置控制,不显示列条 - const preview = hasMap - ? previewPlacement(mode, slots, { - sourceId: sel.threadId, - maxExpanded, - lastActiveOf, - hint, - }) - : null - - /* —— 按钮文案四态(优先级):列条 override > ⌘ 按住 > 有输入 > 默认 —— */ - const hasQuestion = question.trim().length > 0 - // 按钮只表达「动作」(两态、长度稳定);「放置后果」下沉到列条下的提示行—— - // 变长的列标题在提示行里可单行省略,按钮宽度不再被撑爆(用户定的通用方案) - const btnLabel = hasQuestion ? "带着问题开分支" : "开启分支讨论" - - /** 放置后果提示行:override 优先,其次 ⌘ 跟踪态,否则读 placement 预览 */ - const placeHint = ov - ? `将${mode === "replace" ? "替换" : "折叠"}『${threadTitle(state, ov)}』` - : metaHeld - ? "⌘ 保留本列 · 新列开在紧邻右侧" - : preview?.replaceId - ? `默认替换『${threadTitle(state, preview.replaceId)}』(点小格可换)` - : preview?.foldId - ? `默认折叠『${threadTitle(state, preview.foldId)}』(点小格可换)` - : preview - ? "将在右侧新开一列" - : null - - /** 统一提交:按钮点击与输入框 Enter 共用(事件瞬时修饰键与跟踪态任一为真即 keepSource)。 - question trim 后非空 = 带问开分支;留空 = 现有预填流(上层据第三参分流) */ - const submit = (metaFromEvent: boolean) => { - const h: PlacementHint | undefined = ov - ? { targetId: ov } - : metaFromEvent || metaHeld - ? { keepSource: true } - : undefined - const q = question.trim() - window.getSelection()?.removeAllRanges() - onSelChange(null) - onFork(sel, h, q || undefined) - } - - return ( -
- {/* 平滑曲线轮廓(背景层):面板 + 指向选区的尾巴,一条 path。尾巴朝上时 - 整层上移 ah 让顶点探出面板上沿;朝下时留在原位向下探出。 */} - -
-
在新分支中讨论这段
-
{sel.text}
-
-