diff --git a/.env.example b/.env.example index bfdfee17..255ce700 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ ARK_CODING_BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3 # - DIRECT_URL:迁移(pnpm db:migrate)用「直连」(端口 5432)——池化连接不适合跑 DDL。 DATABASE_URL=postgres://postgres.xxxx:password@aws-0-region.pooler.supabase.com:6543/postgres DIRECT_URL=postgres://postgres.xxxx:password@aws-0-region.pooler.supabase.com:5432/postgres +# 测试只允许连接物理隔离的 thread-chat-test 数据库;测试 Drizzle 配置不会回退到 DATABASE_URL。 +TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/thread-chat-test # 可选:每实例连接数上限(Serverless + 池化下宜小);直连想启用预处理语句设 DB_PREPARE=true。 # DB_POOL_MAX=10 # DB_PREPARE=false diff --git a/.gitignore b/.gitignore index 42b6aca8..31173775 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ next-env.d.ts # thread-chat e2e 验收脚本的截图输出 e2e/thread-chat/shots/ .vercel + +.local-backups diff --git a/AGENTS.md b/AGENTS.md index e4306bc5..702c97cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,3 +15,30 @@ 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. + +## 交付物卫生规则 + +### 注释 + +- 注释只写「从代码本身看不出来的为什么」(non-obvious why) +- 禁止出现:修改历史、曾经的做法、"原本/之前/改为"、本次对话中讨论过的内容 +- 不为「没有做的东西」写注释或测试, + 唯一例外:该空缺会被后来者误认为 bug 并试图"修复"时, + 才允许一句话说明这是有意的(intentionally absent) + +### PR / Commit + +- 标题与描述只描述最终行为:这个 diff 让系统变成了什么 +- 禁止出现:被否决的方案、中间尝试、「应要求移除了 X」之类的痕迹 +- 用户的反馈已经体现在 diff 里,不需要文字复述 + +### 文档 / UI 文案 + +- 只服务产品和读者,禁止写入思考过程、实现理由、调试记录、下一步计划 +- 内容只保留最终状态,不带讨论痕迹 + +### 提交前自检(每行新增文本都要过一遍) + +问:「一个从没看过这场对话的工程师,只看最终代码, +这行文字还提供增量信息吗?」 +答「否」→ 删掉。 diff --git a/CLAUDE.md b/CLAUDE.md index 1a7af2f5..eaebeedd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 所有输出内容必须使用中文(代码、文件路径、命令等技术内容除外)。 +面向用户解释架构或状态变化时,这类从后端状态转换为前端显示状态的过程统一称为“状态映射”,不要使用晦涩的同义术语。 + ## Commands Package manager is **pnpm** (pnpm-lock.yaml / pnpm-workspace.yaml). 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/conversation-text.ts b/app/api/chat/conversation-text.ts deleted file mode 100644 index eeefaa01..00000000 --- a/app/api/chat/conversation-text.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { UIMessage } from "ai" -import { RESEARCH_ROUTER_CONTEXT_MESSAGES } from "@/constants/research" - -/** 只看最后一条 user 消息的文本 part,供高置信首步强制路由。 */ -export function latestUserText(messages: UIMessage[]): string { - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index] - if (message.role !== "user") continue - return message.parts - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n") - } - return "" -} - -/** Router 只取最近少量纯文本上下文,用于理解“这个/它”等指代。 */ -export function recentConversationText(messages: UIMessage[]): string { - return messages - .slice(-RESEARCH_ROUTER_CONTEXT_MESSAGES) - .map((message) => { - const text = message.parts - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n") - return `${message.role}: ${text}` - }) - .join("\n") -} 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 deleted file mode 100644 index 1c866dfc..00000000 --- a/app/api/chat/request-context.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { safeValidateUIMessages, type UIMessage } from "ai" -import type { ToolJSONSchema } from "assistant-stream" -import { z } from "zod" -import { getCurrentUserId } from "@/lib/auth/server" -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 -} - -const chatRequestEnvelopeSchema = z.object({ - messages: z.unknown(), - tools: z.record(z.string(), z.unknown()).optional(), - deepResearch: z.boolean().optional(), - threadChat: z.unknown().optional(), - modelId: z.unknown().optional(), - id: z.string().optional(), -}) - -function invalidChatRequest(message: string) { - return { - kind: "response" as const, - response: Response.json({ error: message }, { status: 400 }), - } -} - -type ChatRequestContextDependencies = { - currentUserId: typeof getCurrentUserId - getModel: typeof getChatModel - linearModelAllowed: typeof isLinearChatModelId - threadModelAllowed: typeof isThreadChatModelId - modelConfigured: typeof isModelConfigured - unbilledPreview: typeof isUnbilledPreviewModel - positiveBalance: typeof hasPositiveBalance -} - -const defaultDependencies: ChatRequestContextDependencies = { - currentUserId: getCurrentUserId, - getModel: getChatModel, - linearModelAllowed: isLinearChatModelId, - threadModelAllowed: isThreadChatModelId, - modelConfigured: isModelConfigured, - unbilledPreview: isUnbilledPreviewModel, - positiveBalance: hasPositiveBalance, -} - -/** 鉴权、解析并完成模型/余额门禁,返回可直接进入生成编排的请求上下文。 */ -export async function prepareChatRequestContext( - req: Request, - dependencies: ChatRequestContextDependencies = defaultDependencies -) { - const userId = await dependencies.currentUserId() - if (!userId) { - return { - kind: "response" as const, - response: Response.json( - { error: "请先登录后再使用对话功能。" }, - { status: 401 } - ), - } - } - - let input: unknown - try { - input = await req.json() - } catch { - return invalidChatRequest("请求体必须是有效 JSON。") - } - const envelope = chatRequestEnvelopeSchema.safeParse(input) - if (!envelope.success) - return invalidChatRequest("请求体缺少有效的 messages。") - - const validatedMessages = await safeValidateUIMessages({ - messages: envelope.data.messages, - }) - if ( - !validatedMessages.success || - validatedMessages.data.length === 0 || - validatedMessages.data.some((message) => message.role === "system") - ) - return invalidChatRequest("messages 必须是非空的 user/assistant 消息数组。") - - const body: ChatRequestBody = { - ...envelope.data, - messages: validatedMessages.data, - tools: envelope.data.tools as Record | undefined, - } - const rawModelId = body.modelId - if ( - rawModelId !== undefined && - (typeof rawModelId !== "string" || !dependencies.getModel(rawModelId)) - ) { - return { - kind: "response" as const, - response: Response.json({ error: "未知或无效的模型。" }, { status: 400 }), - } - } - - 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)) { - return { - kind: "response" as const, - response: Response.json( - { error: "该模型不支持线性对话,请选择可用模型后重试。" }, - { status: 400 } - ), - } - } - if (!dependencies.modelConfigured(model)) { - return { - kind: "response" as const, - response: Response.json( - { - error: `模型「${model.name}」未配置,请联系管理员在服务端配置对应 API Key 或可用网关。`, - }, - { status: 400 } - ), - } - } - - const isUnbilledPreview = dependencies.unbilledPreview(model) - if (!isUnbilledPreview && !(await dependencies.positiveBalance(userId))) { - return { - kind: "response" as const, - response: Response.json( - { error: "额度不足,请充值后再试。" }, - { status: 402 } - ), - } - } - - return { - kind: "ready" as const, - userId, - messages: body.messages, - tools: body.tools, - deepResearch: body.deepResearch, - threadChat, - linearThreadId: body.id, - modelId, - model, - isUnbilledPreview, - } -} diff --git a/app/api/chat/research-context.ts b/app/api/chat/research-context.ts deleted file mode 100644 index 7dfde842..00000000 --- a/app/api/chat/research-context.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { LanguageModel, UIMessage } from "ai" -import type { ModelCallTrace } from "@/lib/ai/model-call-logger" -import { - createResearchPlan, - resolveResearchRoute, - type ResearchRoute, -} from "@/lib/chat/research-router" -import { - latestUserText, - recentConversationText, -} from "@/app/api/chat/conversation-text" - -type ResearchContextInput = { - model: LanguageModel - messages: UIMessage[] - deepResearchRequested: boolean - searchReady: boolean - modelCallTrace?: ModelCallTrace -} - -type ResearchContextDependencies = { - resolveRoute: typeof resolveResearchRoute - createPlan: typeof createResearchPlan -} - -const defaultDependencies: ResearchContextDependencies = { - resolveRoute: resolveResearchRoute, - createPlan: createResearchPlan, -} - -/** 解析一次请求的联网路由与可选研究计划,不执行实际搜索。 */ -export async function resolveResearchContext( - { - model, - messages, - deepResearchRequested, - searchReady, - modelCallTrace, - }: ResearchContextInput, - dependencies: ResearchContextDependencies = defaultDependencies -) { - const latestText = latestUserText(messages) - const researchRoute: ResearchRoute = deepResearchRequested - ? searchReady - ? { - mode: "research", - reasonCode: "multi_source_research", - urls: [], - suggestedQueries: [], - } - : { - mode: "answer", - reasonCode: "search_unavailable", - urls: [], - suggestedQueries: [], - } - : await dependencies.resolveRoute({ - model, - latestUserText: latestText, - recentConversation: recentConversationText(messages), - searchReady, - modelCallTrace, - }) - const researchPlan = - researchRoute.mode === "research" - ? await dependencies.createPlan({ - model, - userRequest: latestText, - route: researchRoute, - modelCallTrace, - }) - : null - - return { latestText, researchRoute, researchPlan } -} diff --git a/app/api/chat/research-tool-capabilities.ts b/app/api/chat/research-tool-capabilities.ts deleted file mode 100644 index eef342d7..00000000 --- a/app/api/chat/research-tool-capabilities.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ResearchRouteMode } from "@/lib/chat/research-contract" - -export type ResearchToolName = "readUrl" | "webSearch" - -const RESEARCH_TOOL_NAMES_BY_MODE = { - answer: [], - fetch: ["readUrl"], - search: ["webSearch", "readUrl"], - research: ["webSearch", "readUrl"], -} as const satisfies Record - -/** 路由模式可暴露的联网能力;数组顺序同时定义首步强制工具。 */ -export function researchToolNames( - mode: ResearchRouteMode -): readonly ResearchToolName[] { - return RESEARCH_TOOL_NAMES_BY_MODE[mode] -} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts deleted file mode 100644 index bf256a67..00000000 --- a/app/api/chat/route.ts +++ /dev/null @@ -1,246 +0,0 @@ -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 { 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 { 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 搜索与网页深读可能形成多步循环,放宽单次请求时长上限。 -export const maxDuration = 300 - -export async function POST(req: Request) { - const requestContext = await prepareChatRequestContext(req) - if (requestContext.kind === "response") return requestContext.response - const { - userId, - 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 } - : {}), - } - 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, - searchReady, - threadChat: isThreadChat, - markdownArtifactRequested, - frontendToolSet: frontendTools(tools ?? {}), - }) - - // MiniMax 不接受 file part:先把附件(PDF→提取文本,其余→占位说明)转换为 text part - const resolvedMessages = await resolveAttachmentParts(authoritativeMessages) - - const system = buildChatSystemPrompt({ - threadChat: isThreadChat, - anchorText: authoritativeAnchorText, - markdownArtifactRequested, - researchMode: researchRoute.mode, - researchPlan, - deepResearchRequested: research, - searchReady, - }) - - const streamLifecycle = createStreamLifecycle({ - 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, - messages: await convertToModelMessages(resolvedMessages, { - tools: allTools, - }), - tools: allTools, - // 明确 Markdown 交付请求只强制第 0 步启动工具调用;后续步骤仍保留工具, - // 让模型在用户要求多份独立文档时,为每份文档分别创建一个 Artifact。 - prepareStep: createToolStepPolicy({ - isThreadChat, - markdownArtifactRequested, - researchMode: researchRoute.mode, - }), - maxOutputTokens: MAX_OUTPUT_TOKENS, - stopWhen: isStepCount(webToolsEnabled ? RESEARCH_MAX_STEPS : 5), - onError: streamLifecycle.onError, - onAbort: streamLifecycle.onAbort, - onEnd: streamLifecycle.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) { - writer.write({ - type: "data-research-plan", - id: "research-plan", - data: researchPlan, - }) - } - writer.merge( - result.toUIMessageStream({ - onError: (error) => { - console.error("[chat] 流内错误:", error) - return "An error occurred." - }, - messageMetadata: ({ part }) => - part.type === "finish" - ? buildUsageMetadata(modelId, part.totalUsage) - : undefined, - }) - ) - }, - 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 - } 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 deleted file mode 100644 index 28d9e87c..00000000 --- a/app/api/chat/stream-lifecycle.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 -} - -type StreamLifecycleDependencies = { - charge: typeof chargeUsage -} - -const defaultDependencies: StreamLifecycleDependencies = { - charge: chargeUsage, -} - -/** 请求级 stream usage/error 状态;handler 写入,持久化终态只读取 snapshot。 */ -export function createStreamLifecycle( - { - userId, - modelId, - model, - persistentGeneration, - unbilledPreview, - linearThreadId, - }: StreamLifecycleInput, - 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, - steps, - }: { - usage: { inputTokens?: number; outputTokens?: number } - providerMetadata?: ProviderMetadata - steps: readonly UsageStep[] - }) { - if (unbilledPreview) return - const costEvidence = usageCostEvidence({ - provider: model.provider, - steps, - providerMetadata, - }) - if ( - model.provider === "openrouter" && - costEvidence.source !== "openrouter" - ) { - console.warn( - `[chat] OpenRouter 成本元数据不完整,使用静态估值:${model.id}` - ) - } - if (persistentGeneration) { - capturedUsage = { - inputTokens: usage.inputTokens ?? 0, - outputTokens: usage.outputTokens ?? 0, - costEvidence, - } - return - } - await dependencies.charge({ - userId, - model: modelId, - inputTokens: usage.inputTokens ?? 0, - outputTokens: usage.outputTokens ?? 0, - threadId: linearThreadId ?? null, - costEvidence, - }) - }, - - snapshot() { - return { - capturedUsage, - modelStreamError, - abortedUsageUnavailable, - } - }, - } -} - -export type StreamLifecycle = ReturnType diff --git a/app/api/chat/surface-tools.ts b/app/api/chat/surface-tools.ts deleted file mode 100644 index 8bcb7d1e..00000000 --- a/app/api/chat/surface-tools.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { tool } from "ai" -import { z } from "zod" -import { - MARKDOWN_ARTIFACT_TOOL_DESCRIPTION, - MARKDOWN_ARTIFACT_TOOL_NAME, - markdownArtifactInputSchema, - type MarkdownArtifactToolResult, -} from "@/lib/chat/markdown-artifact" - -const getWeather = tool({ - description: "Get the current weather for a city.", - inputSchema: z.object({ - location: z.string().describe("City name, e.g. 'San Francisco'"), - }), - execute: async ({ location }) => { - const conditions = [ - "Sunny", - "Partly Cloudy", - "Cloudy", - "Light Rain", - "Clear", - ] - const seed = [...location].reduce((acc, c) => acc + c.charCodeAt(0), 0) - return { - location, - temperatureF: 55 + (seed % 35), - condition: conditions[seed % conditions.length], - humidity: 30 + (seed % 50), - asOf: new Date().toISOString(), - } - }, -}) - -const compareTable = tool({ - description: - "Render a comparison table for two or more items across one or more numeric metrics. Use whenever the user asks to compare things 'in a table' with real numeric data.", - inputSchema: z.object({ - title: z.string(), - unit: z.string().optional(), - columns: z - .array(z.string()) - .describe("Category labels, e.g. country names"), - series: z.array( - z.object({ - name: z.string(), - values: z - .array(z.number()) - .describe("One value per column, same order as columns"), - }) - ), - }), - execute: async (input) => input, -}) - -const createMarkdownArtifact = tool({ - description: MARKDOWN_ARTIFACT_TOOL_DESCRIPTION, - inputSchema: markdownArtifactInputSchema, - execute: async (): Promise => ({ created: true }), -}) - -/** 产品 surface 对应的基础工具;联网与前端工具由 route 在其上继续组合。 */ -export function surfaceTools(input: { - threadChat: boolean - markdownArtifactRequested: boolean -}) { - if (!input.threadChat) return { getWeather, compareTable } - return input.markdownArtifactRequested - ? { [MARKDOWN_ARTIFACT_TOOL_NAME]: createMarkdownArtifact } - : {} -} diff --git a/app/api/chat/system-prompt.ts b/app/api/chat/system-prompt.ts deleted file mode 100644 index a50eedf2..00000000 --- a/app/api/chat/system-prompt.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - DIRECT_FETCH_SYSTEM_PROMPT, - RESEARCH_SYSTEM_PROMPT, - WEB_ACCESS_SYSTEM_PROMPT, -} from "@/constants/research" -import { buildThreadChatSystem } from "@/lib/chat/thread-chat-prompt" -import { - researchPlanExecutionPrompt, - type ResearchPlan, - type ResearchRoute, -} from "@/lib/chat/research-router" - -type ChatSystemPromptInput = { - threadChat: boolean - anchorText: string | null - markdownArtifactRequested: boolean - researchMode: ResearchRoute["mode"] - researchPlan: ResearchPlan | null - deepResearchRequested: boolean - searchReady: boolean -} - -const SEARCH_UNAVAILABLE_PROMPT = - "用户开启了深度研究,但服务端未启用搜索服务,请如实告知该功能暂不可用,并基于已有知识尽力回答。" - -/** 将各能力拥有的 system 片段按既有优先顺序组合为单一服务端提示。 */ -export function buildChatSystemPrompt({ - threadChat, - anchorText, - markdownArtifactRequested, - researchMode, - researchPlan, - deepResearchRequested, - searchReady, -}: ChatSystemPromptInput): string { - return [ - threadChat - ? buildThreadChatSystem(anchorText, { - enableMarkdownArtifact: markdownArtifactRequested, - }) - : null, - researchMode === "fetch" ? DIRECT_FETCH_SYSTEM_PROMPT : null, - researchMode === "search" || researchMode === "research" - ? WEB_ACCESS_SYSTEM_PROMPT - : null, - researchMode === "research" ? RESEARCH_SYSTEM_PROMPT : null, - researchPlan ? researchPlanExecutionPrompt(researchPlan) : null, - deepResearchRequested && !searchReady ? SEARCH_UNAVAILABLE_PROMPT : null, - ] - .filter((part): part is string => part !== null) - .join("\n\n") -} 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/chat/tool-set.ts b/app/api/chat/tool-set.ts deleted file mode 100644 index 9ec60734..00000000 --- a/app/api/chat/tool-set.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ToolSet } from "ai" -import { readUrlTool, webSearchTool } from "@/lib/chat/research-tools" -import type { ResearchRoute } from "@/lib/chat/research-router" -import { surfaceTools } from "@/app/api/chat/surface-tools" -import { researchToolNames } from "@/app/api/chat/research-tool-capabilities" - -const RESEARCH_TOOLS = { - readUrl: readUrlTool, - webSearch: webSearchTool, -} - -type ChatToolSetInput = { - researchMode: ResearchRoute["mode"] - searchReady: boolean - threadChat: boolean - markdownArtifactRequested: boolean - /** assistant-ui 使用其内嵌 AI SDK 类型;只在最终组合出口统一适配。 */ - frontendToolSet?: Record -} - -/** 将各能力的私有工具集合组合成一次模型调用唯一可见的 ToolSet。 */ -export function buildChatToolSet({ - researchMode, - searchReady, - threadChat, - markdownArtifactRequested, - frontendToolSet, -}: ChatToolSetInput): { tools: ToolSet; webToolsEnabled: boolean } { - const webToolsEnabled = searchReady && researchMode !== "answer" - const routedWebTools = Object.fromEntries( - researchToolNames(researchMode).map((name) => [name, RESEARCH_TOOLS[name]]) - ) as ToolSet - - return { - webToolsEnabled, - tools: { - ...surfaceTools({ threadChat, markdownArtifactRequested }), - ...(webToolsEnabled ? routedWebTools : {}), - ...(frontendToolSet ?? {}), - } as ToolSet, - } -} diff --git a/app/api/chat/tool-step-policy.ts b/app/api/chat/tool-step-policy.ts deleted file mode 100644 index 6d284ce2..00000000 --- a/app/api/chat/tool-step-policy.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { MARKDOWN_ARTIFACT_TOOL_NAME } from "@/lib/chat/markdown-artifact" -import type { ResearchRoute } from "@/lib/chat/research-router" -import { - researchToolNames, - type ResearchToolName, -} from "@/app/api/chat/research-tool-capabilities" - -type ToolStepPolicyInput = { - isThreadChat: boolean - markdownArtifactRequested: boolean - researchMode: ResearchRoute["mode"] -} - -type RoutedToolName = ResearchToolName | typeof MARKDOWN_ARTIFACT_TOOL_NAME - -type ToolStep = { - activeTools: RoutedToolName[] - toolChoice?: { - type: "tool" - toolName: RoutedToolName - } -} - -/** - * 生成工具的逐步暴露策略:首步强制当前路由的联网工具;明确的 Markdown - * 交付仅在没有更高优先级联网动作时首步强制,后续步骤保留全部可用工具。 - */ -export function createToolStepPolicy({ - isThreadChat, - markdownArtifactRequested, - researchMode, -}: ToolStepPolicyInput) { - const activeWebTools = [...researchToolNames(researchMode)] - const activeTools: RoutedToolName[] = isThreadChat - ? [ - ...(markdownArtifactRequested ? [MARKDOWN_ARTIFACT_TOOL_NAME] : []), - ...activeWebTools, - ] - : activeWebTools - - if (activeTools.length === 0) return undefined - - return ({ stepNumber }: { stepNumber: number }): ToolStep => { - if (stepNumber === 0 && activeWebTools.length > 0) { - return { - activeTools, - toolChoice: { type: "tool", toolName: activeWebTools[0] }, - } - } - if (stepNumber === 0 && markdownArtifactRequested) { - return { - activeTools, - toolChoice: { - type: "tool", - toolName: MARKDOWN_ARTIFACT_TOOL_NAME, - }, - } - } - return { activeTools } - } -} diff --git a/app/api/title/route.ts b/app/api/title/route.ts deleted file mode 100644 index d333b16a..00000000 --- a/app/api/title/route.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { generateText } from "ai" -import { - ARK_BRANCH_TITLE_MAX_OUTPUT_TOKENS, - ARK_BRANCH_TITLE_MODEL, -} from "@/constants/ark" -import { MODEL_CALL_PURPOSE } from "@/constants/model-call" -import { arkCodingChatModel, isArkCodingConfigured } from "@/lib/ai/ark" -import { withModelCallLogging } from "@/lib/ai/model-call-logger" -import { - parseThreadTitleInput, - type ThreadTitleInput, -} from "@/lib/thread-chat/contracts/title-request" - -/** - * POST /api/title —— 主线与分支共用的异步语义标题生成。 - * - * body: - * - { kind: "main", question } - * - { kind: "branch", anchorText, question, answer } - * - * 返回:{ title: string | null } —— null 表示生成失败、未配置模型或输出为空; - * 客户端保留各自的回退标题。 - */ - -/** 喂给标题模型的首答摘录上限(字符):标题只需主旨,控制成本与延迟 */ -const ANSWER_EXCERPT_LIMIT = 600 -/** 同理,用户问题与分支锚点原文的截断上限 */ -const INPUT_EXCERPT_LIMIT = 200 - -function buildPrompt(input: ThreadTitleInput): string { - if (input.kind === "main") { - return ( - "这是一个新对话的首条用户消息。\n" + - `用户消息:「${input.question.slice(0, INPUT_EXCERPT_LIMIT)}」\n\n` + - "请根据用户消息所用的语言,为这个对话拟一个简短、清晰、便于扫描的标题,概括对话主题。" + - "中文和英文都使用自然短语;不要为了凑长度或限制长度而删词、截断。" + - "只输出标题本身,不要引号、标点、序号或任何解释。" - ) - } - - return ( - "这是一个分支对话:用户阅读 AI 回答时划选了一段文字,就它开启了分支讨论。\n" + - `被划选的文字:「${input.anchorText.slice(0, INPUT_EXCERPT_LIMIT)}」\n` + - `用户的问题:「${input.question.slice(0, INPUT_EXCERPT_LIMIT)}」\n` + - `首答摘录:「${input.answer.slice(0, ANSWER_EXCERPT_LIMIT)}」\n\n` + - "请根据用户问题所用的语言,为这个分支拟一个简短、清晰、便于扫描的标题,概括这轮讨论的主题。" + - "中文和英文都使用自然短语;不要为了凑长度或限制长度而删词、截断。" + - "只输出标题本身,不要引号、标点、序号或任何解释。" - ) -} - -/** 清洗模型输出:剥 推理段 / 引号 / 标点,取首个非空行;空则 null。 */ -function sanitizeTitle(raw: string): string | null { - const text = raw.replace(/[\s\S]*?(<\/think>|$)/g, "") - const line = text - .split("\n") - .map((value) => value.trim()) - .find((value) => value !== "") - if (!line) return null - const cleaned = line - .replace(/^[「『"'《【\s]+/, "") - .replace(/[」』"'》】。!?!?,,.…\s]+$/, "") - .trim() - return cleaned.length >= 2 ? cleaned : null -} - -export async function POST(req: Request) { - let body: unknown - try { - body = await req.json() - } catch { - return Response.json({ error: "请求体不是合法 JSON" }, { status: 400 }) - } - - const input = parseThreadTitleInput(body) - if (!input) { - return Response.json({ error: "标题请求参数无效" }, { status: 400 }) - } - - if (!isArkCodingConfigured()) return Response.json({ title: null }) - - try { - const { text } = await generateText({ - model: withModelCallLogging( - arkCodingChatModel(ARK_BRANCH_TITLE_MODEL), - MODEL_CALL_PURPOSE.threadTitle, - { requestId: crypto.randomUUID() } - ), - maxOutputTokens: ARK_BRANCH_TITLE_MAX_OUTPUT_TOKENS, - // 标题是可选增强;配额不足、鉴权失败等确定性错误不应额外消耗请求。 - maxRetries: 0, - prompt: buildPrompt(input), - }) - return Response.json({ title: sanitizeTitle(text) }) - } catch (error) { - console.warn("[title] 标题生成失败:", error) - return Response.json({ title: null }) - } -} diff --git a/app/api/v1/artifacts/[artifactId]/route.ts b/app/api/v1/artifacts/[artifactId]/route.ts new file mode 100644 index 00000000..ca72e094 --- /dev/null +++ b/app/api/v1/artifacts/[artifactId]/route.ts @@ -0,0 +1,13 @@ +import { loadArtifact } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function GET( + _request: Request, + context: { params: Promise<{ artifactId: string }> } +) { + const { artifactId } = await context.params + return withActor( + (actorId) => loadArtifact(actorId, artifactId), + "artifact_not_found" + ) +} diff --git a/app/api/v1/assistant-messages/[assistantMessageId]/events/route.ts b/app/api/v1/assistant-messages/[assistantMessageId]/events/route.ts new file mode 100644 index 00000000..9ec2382e --- /dev/null +++ b/app/api/v1/assistant-messages/[assistantMessageId]/events/route.ts @@ -0,0 +1,15 @@ +import { assistantEvents } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export const maxDuration = 300 + +export async function GET( + request: Request, + context: { params: Promise<{ assistantMessageId: string }> } +) { + const { assistantMessageId } = await context.params + return withActor( + (actorId) => assistantEvents(actorId, assistantMessageId, request), + "assistant_message_not_found" + ) +} diff --git a/app/api/v1/assistant-messages/[assistantMessageId]/stop/route.ts b/app/api/v1/assistant-messages/[assistantMessageId]/stop/route.ts new file mode 100644 index 00000000..32e5481e --- /dev/null +++ b/app/api/v1/assistant-messages/[assistantMessageId]/stop/route.ts @@ -0,0 +1,13 @@ +import { stopAssistant } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + _request: Request, + context: { params: Promise<{ assistantMessageId: string }> } +) { + const { assistantMessageId } = await context.params + return withActor( + (actorId) => stopAssistant(actorId, assistantMessageId), + "assistant_message_not_found" + ) +} diff --git a/app/api/v1/messages/[messageId]/edits/route.ts b/app/api/v1/messages/[messageId]/edits/route.ts new file mode 100644 index 00000000..3e300aeb --- /dev/null +++ b/app/api/v1/messages/[messageId]/edits/route.ts @@ -0,0 +1,13 @@ +import { editMessage } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + request: Request, + context: { params: Promise<{ messageId: string }> } +) { + const { messageId } = await context.params + return withActor( + (actorId) => editMessage(actorId, messageId, request), + "message_not_found" + ) +} diff --git a/app/api/v1/messages/[messageId]/feedback/route.ts b/app/api/v1/messages/[messageId]/feedback/route.ts new file mode 100644 index 00000000..af574d3b --- /dev/null +++ b/app/api/v1/messages/[messageId]/feedback/route.ts @@ -0,0 +1,13 @@ +import { setFeedback } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function PUT( + request: Request, + context: { params: Promise<{ messageId: string }> } +) { + const { messageId } = await context.params + return withActor( + (actorId) => setFeedback(actorId, messageId, request), + "message_not_found" + ) +} diff --git a/app/api/v1/messages/[messageId]/regenerations/route.ts b/app/api/v1/messages/[messageId]/regenerations/route.ts new file mode 100644 index 00000000..da2653ba --- /dev/null +++ b/app/api/v1/messages/[messageId]/regenerations/route.ts @@ -0,0 +1,13 @@ +import { regenerateMessage } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + request: Request, + context: { params: Promise<{ messageId: string }> } +) { + const { messageId } = await context.params + return withActor( + (actorId) => regenerateMessage(actorId, messageId, request), + "message_not_found" + ) +} diff --git a/app/api/v1/projects/[projectId]/archive/route.ts b/app/api/v1/projects/[projectId]/archive/route.ts new file mode 100644 index 00000000..63a77921 --- /dev/null +++ b/app/api/v1/projects/[projectId]/archive/route.ts @@ -0,0 +1,13 @@ +import { setProjectArchived } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + _request: Request, + context: { params: Promise<{ projectId: string }> } +) { + const { projectId } = await context.params + return withActor( + (actorId) => setProjectArchived(actorId, projectId, true), + "project_not_found" + ) +} diff --git a/app/api/v1/projects/[projectId]/bootstrap/route.ts b/app/api/v1/projects/[projectId]/bootstrap/route.ts new file mode 100644 index 00000000..00d25daf --- /dev/null +++ b/app/api/v1/projects/[projectId]/bootstrap/route.ts @@ -0,0 +1,13 @@ +import { bootstrapProject } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function GET( + _request: Request, + context: { params: Promise<{ projectId: string }> } +) { + const { projectId } = await context.params + return withActor( + (actorId) => bootstrapProject(actorId, projectId), + "project_not_found" + ) +} diff --git a/app/api/v1/projects/[projectId]/route.ts b/app/api/v1/projects/[projectId]/route.ts new file mode 100644 index 00000000..7e9aa0d3 --- /dev/null +++ b/app/api/v1/projects/[projectId]/route.ts @@ -0,0 +1,23 @@ +import { + deleteProject, + patchProject, +} from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +type Context = { params: Promise<{ projectId: string }> } + +export async function PATCH(request: Request, context: Context) { + const { projectId } = await context.params + return withActor( + (actorId) => patchProject(actorId, projectId, request), + "project_not_found" + ) +} + +export async function DELETE(_request: Request, context: Context) { + const { projectId } = await context.params + return withActor( + (actorId) => deleteProject(actorId, projectId), + "project_not_found" + ) +} diff --git a/app/api/v1/projects/[projectId]/unarchive/route.ts b/app/api/v1/projects/[projectId]/unarchive/route.ts new file mode 100644 index 00000000..6f9f7dea --- /dev/null +++ b/app/api/v1/projects/[projectId]/unarchive/route.ts @@ -0,0 +1,13 @@ +import { setProjectArchived } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + _request: Request, + context: { params: Promise<{ projectId: string }> } +) { + const { projectId } = await context.params + return withActor( + (actorId) => setProjectArchived(actorId, projectId, false), + "project_not_found" + ) +} diff --git a/app/api/v1/projects/route.ts b/app/api/v1/projects/route.ts new file mode 100644 index 00000000..d0c98e5c --- /dev/null +++ b/app/api/v1/projects/route.ts @@ -0,0 +1,10 @@ +import { createProject, listProjects } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function GET(request: Request) { + return withActor((actorId) => listProjects(actorId, request)) +} + +export async function POST(request: Request) { + return withActor((actorId) => createProject(actorId, request)) +} diff --git a/app/api/v1/threads/[threadId]/archive/route.ts b/app/api/v1/threads/[threadId]/archive/route.ts new file mode 100644 index 00000000..ebd1c9ac --- /dev/null +++ b/app/api/v1/threads/[threadId]/archive/route.ts @@ -0,0 +1,13 @@ +import { setThreadArchived } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + _request: Request, + context: { params: Promise<{ threadId: string }> } +) { + const { threadId } = await context.params + return withActor( + (actorId) => setThreadArchived(actorId, threadId, true), + "thread_not_found" + ) +} diff --git a/app/api/v1/threads/[threadId]/forks/route.ts b/app/api/v1/threads/[threadId]/forks/route.ts new file mode 100644 index 00000000..4edd8d1c --- /dev/null +++ b/app/api/v1/threads/[threadId]/forks/route.ts @@ -0,0 +1,13 @@ +import { forkThread } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + request: Request, + context: { params: Promise<{ threadId: string }> } +) { + const { threadId } = await context.params + return withActor( + (actorId) => forkThread(actorId, threadId, request), + "thread_not_found" + ) +} diff --git a/app/api/v1/threads/[threadId]/messages/route.ts b/app/api/v1/threads/[threadId]/messages/route.ts new file mode 100644 index 00000000..900844a2 --- /dev/null +++ b/app/api/v1/threads/[threadId]/messages/route.ts @@ -0,0 +1,23 @@ +import { + loadThreadMessages, + sendMessage, +} from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function GET(request: Request, context: Context) { + const { threadId } = await context.params + return withActor( + (actorId) => loadThreadMessages(actorId, threadId, request), + "thread_not_found" + ) +} + +export async function POST(request: Request, context: Context) { + const { threadId } = await context.params + return withActor( + (actorId) => sendMessage(actorId, threadId, request), + "thread_not_found" + ) +} diff --git a/app/api/v1/threads/[threadId]/route.ts b/app/api/v1/threads/[threadId]/route.ts new file mode 100644 index 00000000..dda89036 --- /dev/null +++ b/app/api/v1/threads/[threadId]/route.ts @@ -0,0 +1,13 @@ +import { patchThread } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function PATCH( + request: Request, + context: { params: Promise<{ threadId: string }> } +) { + const { threadId } = await context.params + return withActor( + (actorId) => patchThread(actorId, threadId, request), + "thread_not_found" + ) +} diff --git a/app/api/v1/threads/[threadId]/unarchive/route.ts b/app/api/v1/threads/[threadId]/unarchive/route.ts new file mode 100644 index 00000000..53c6d1b7 --- /dev/null +++ b/app/api/v1/threads/[threadId]/unarchive/route.ts @@ -0,0 +1,13 @@ +import { setThreadArchived } from "@/lib/thread-chat/api/server/handlers" +import { withActor } from "@/lib/thread-chat/api/server/http" + +export async function POST( + _request: Request, + context: { params: Promise<{ threadId: string }> } +) { + const { threadId } = await context.params + return withActor( + (actorId) => setThreadArchived(actorId, threadId, false), + "thread_not_found" + ) +} diff --git a/app/thread-chat/[projectId]/page.tsx b/app/thread-chat/[projectId]/page.tsx new file mode 100644 index 00000000..74dde1ba --- /dev/null +++ b/app/thread-chat/[projectId]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation" +import { idSchema } from "@/lib/thread-chat/api/contracts" +import { ThreadChatProjectProvider } from "@/lib/thread-chat/client/providers" +import { ThreadChatProject } from "../normalized/thread-chat-project" +import { threadChatMetadata } from "../page-metadata" + +export const metadata = threadChatMetadata + +/** Project ID 是 URL 中唯一的服务端领域身份;不存在或不属于当前 actor 时由 API 拒绝。 */ +export default async function ThreadChatProjectPage({ + params, +}: { + params: Promise<{ projectId: string }> +}) { + const { projectId } = await params + if (!idSchema.safeParse(projectId).success) notFound() + 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/branchable-chat.tsx b/app/thread-chat/branching/branchable-chat.tsx index 59716f7d..cfb80679 100644 --- a/app/thread-chat/branching/branchable-chat.tsx +++ b/app/thread-chat/branching/branchable-chat.tsx @@ -81,7 +81,7 @@ export function BranchableChat({ }: BranchableChatProps) { const thread = state.threads[threadId] if (!thread) return null - const isMain = threadId === "main" + const isMain = thread.parentId === null const chain = isMain ? [] : lineage(state, threadId) const inherited = isMain ? [] : collectInherited(state, thread) const childCount = thread.children.length @@ -193,7 +193,7 @@ export function BranchableChat({
讨论焦点 · 划选自 - {thread.parentId === "main" + {state.threads[thread.parentId ?? ""]?.parentId === null ? "主线" : `「${threadTitle(state, thread.parentId!)}」`} diff --git a/app/thread-chat/branching/selection/selection-bubble.tsx b/app/thread-chat/branching/selection/selection-bubble.tsx index 7bea8836..7c5927e5 100644 --- a/app/thread-chat/branching/selection/selection-bubble.tsx +++ b/app/thread-chat/branching/selection/selection-bubble.tsx @@ -291,6 +291,11 @@ export function SelectionBubble({
{preview && ( thread.parentId === null + )?.id ?? "main" + } sourceThreadId={sel.threadId} slots={slots} preview={preview} diff --git a/app/thread-chat/branching/selection/selection-placement-map.tsx b/app/thread-chat/branching/selection/selection-placement-map.tsx index b212b76c..8968c513 100644 --- a/app/thread-chat/branching/selection/selection-placement-map.tsx +++ b/app/thread-chat/branching/selection/selection-placement-map.tsx @@ -1,6 +1,7 @@ import type { PlacePreview, Slot } from "../../orchestration/columns/placement" export function SelectionPlacementMap({ + rootThreadId, sourceThreadId, slots, preview, @@ -8,6 +9,7 @@ export function SelectionPlacementMap({ titleOf, onToggleOverride, }: { + rootThreadId: string sourceThreadId: string slots: Slot[] preview: PlacePreview @@ -17,12 +19,12 @@ export function SelectionPlacementMap({ }) { const cells: React.ReactNode[] = [ , ] const ghost = (key: string) => ( diff --git a/app/thread-chat/branching/selection/use-assistant-text-selection.ts b/app/thread-chat/branching/selection/use-assistant-text-selection.ts index ef48a871..8d994231 100644 --- a/app/thread-chat/branching/selection/use-assistant-text-selection.ts +++ b/app/thread-chat/branching/selection/use-assistant-text-selection.ts @@ -59,10 +59,15 @@ export function useAssistantTextSelection({ const threadId = list?.dataset.list const msgId = messageElement?.dataset.msgId if (!threadId || !msgId) return + const sourceMessage = state.threads[threadId]?.messages.find( + (message) => message.id === msgId + ) if ( - !state.threads[threadId]?.messages.some( - (message) => message.id === msgId - ) + !sourceMessage || + sourceMessage.role !== "assistant" || + sourceMessage.status === "pending" || + sourceMessage.status === "streaming" || + sourceMessage.status === "error" ) { onSelectionChange(null) return diff --git a/app/thread-chat/chat/actions/message-action-commands.ts b/app/thread-chat/chat/actions/message-action-commands.ts index 2ab08758..987ff77e 100644 --- a/app/thread-chat/chat/actions/message-action-commands.ts +++ b/app/thread-chat/chat/actions/message-action-commands.ts @@ -1,7 +1,12 @@ import type { MessageFeedback, MessageFeedbackSummary } from "../../core/types" -import type { MessageActionFailureCode } from "@/lib/thread-chat/contracts/message-action-failure" -export type { MessageActionFailureCode } +export type MessageActionFailureCode = + | "invalid_turn" + | "not_latest_turn" + | "generation_conflict" + | "model_mismatch" + | "unauthorized" + | "network_error" export type GenerationActionResult = | { diff --git a/app/thread-chat/chat/actions/message-action-presentation.ts b/app/thread-chat/chat/actions/message-action-presentation.ts deleted file mode 100644 index 0aebc39d..00000000 --- a/app/thread-chat/chat/actions/message-action-presentation.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - activeLeafTurn, - activeMessagePath, - assistantTurnAlternatives, - childThreadSourceProvenance, -} from "../../core/selectors" -import type { ThreadTreeState } from "../../core/types" -import type { MessageActionViewState } from "./message-action-types" - -export function buildMessageActionViewState({ - state, - recoverableByUserMessageId, - feedbackByMessageId, -}: { - state: ThreadTreeState - recoverableByUserMessageId: MessageActionViewState["recoverableByUserMessageId"] - feedbackByMessageId: MessageActionViewState["feedbackByMessageId"] -}): MessageActionViewState { - const activePathByThreadId = new Map( - Object.values(state.threads).map((thread) => [ - thread.id, - activeMessagePath(thread).map((message) => message.id), - ]) - ) - const presentationByThreadId = new Map( - Object.values(state.threads).map((thread) => { - const latestTurn = activeLeafTurn(thread) - const alternatives = latestTurn?.assistantMessage - ? assistantTurnAlternatives(thread, latestTurn.assistantMessage.id).map( - (assistant) => ({ - assistantMessageId: assistant.id, - derivedThreadCount: thread.children.filter( - (childId) => - state.threads[childId]?.forkFromMsgId === assistant.id - ).length, - }) - ) - : [] - return [ - thread.id, - { - latestUserMessageId: latestTurn?.userMessage.id, - latestAssistantMessageId: latestTurn?.assistantMessage?.id, - alternatives, - sourceProvenance: childThreadSourceProvenance(state, thread.id), - }, - ] as const - }) - ) - - return { - recoverableByUserMessageId, - feedbackByMessageId, - activePathByThreadId, - presentationByThreadId, - } -} diff --git a/app/thread-chat/chat/actions/message-action-session-logic.ts b/app/thread-chat/chat/actions/message-action-session-logic.ts deleted file mode 100644 index c404f5c8..00000000 --- a/app/thread-chat/chat/actions/message-action-session-logic.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { MessageFeedback, MessageFeedbackSummary } from "../../core/types" -import type { RecoverableTurn } from "../../generation/types" - -export function indexRecoverableTurns(turns: readonly RecoverableTurn[]) { - return new Map(turns.map((turn) => [turn.userMessageId, turn])) -} - -export function indexMessageFeedbacks( - entries: readonly MessageFeedbackSummary[] -) { - return new Map(entries.map((entry) => [entry.messageId, entry.feedback])) -} - -export function withoutRecoverableTurn( - current: ReadonlyMap, - userMessageId: string -) { - const next = new Map(current) - next.delete(userMessageId) - return next -} - -export function withRecoverableTurn( - current: ReadonlyMap, - turn: RecoverableTurn -) { - const next = new Map(current) - next.set(turn.userMessageId, turn) - return next -} - -export function withMessageFeedback( - current: ReadonlyMap, - messageId: string, - feedback: MessageFeedback | null -) { - const next = new Map(current) - if (feedback) next.set(messageId, feedback) - else next.delete(messageId) - return next -} diff --git a/app/thread-chat/chat/actions/message-action-types.ts b/app/thread-chat/chat/actions/message-action-types.ts index 9a5c5198..2b6737e4 100644 --- a/app/thread-chat/chat/actions/message-action-types.ts +++ b/app/thread-chat/chat/actions/message-action-types.ts @@ -1,7 +1,20 @@ import type { Message, MessageFeedback } from "../../core/types" -import type { RecoverableTurn } from "../../generation/types" import type { ThreadMessageActionCommands } from "./message-action-commands" -import type { SourceProvenance } from "../../core/message-graph" + +export interface RecoverableTurn { + threadId: string + userMessageId: string + assistantMessageId?: string + reason: "missing_assistant" | "missing_generation" | "interrupted_generation" +} + +export interface SourceProvenance { + sourceThreadId: string + sourceMessageId: string + isOnActivePath: boolean + alternativeIndex: number | null + alternativeCount: number +} export const MESSAGE_ACTION_LABELS = { toolbar: "消息操作", diff --git a/app/thread-chat/chat/actions/use-message-actions.ts b/app/thread-chat/chat/actions/use-message-actions.ts deleted file mode 100644 index a08be4d7..00000000 --- a/app/thread-chat/chat/actions/use-message-actions.ts +++ /dev/null @@ -1,103 +0,0 @@ -"use client" - -import { useCallback, useMemo, useState } from "react" -import type { MessageFeedbackSummary, ThreadTreeState } from "../../core/types" -import type { RecoverableTurn } from "../../generation/types" -import type { ThreadMessageActionCommands } from "./message-action-commands" -import { buildMessageActionViewState } from "./message-action-presentation" -import { - indexMessageFeedbacks, - indexRecoverableTurns, - withMessageFeedback, - withRecoverableTurn, - withoutRecoverableTurn, -} from "./message-action-session-logic" - -export function useMessageActions({ - state, - version, - initialRecoverableTurns, - initialMessageFeedbacks, - commands, -}: { - state: ThreadTreeState - version: number - initialRecoverableTurns: RecoverableTurn[] - initialMessageFeedbacks: MessageFeedbackSummary[] - commands: ThreadMessageActionCommands -}) { - const [recoverableByUserMessageId, setRecoverableByUserMessageId] = useState( - () => indexRecoverableTurns(initialRecoverableTurns) - ) - const [feedbackByMessageId, setFeedbackByMessageId] = useState(() => - indexMessageFeedbacks(initialMessageFeedbacks) - ) - - const messageActionState = useMemo( - () => - buildMessageActionViewState({ - state, - recoverableByUserMessageId, - feedbackByMessageId, - }), - // state 对象原地变更,必须用 store version 作为派生键。 - // eslint-disable-next-line react-hooks/exhaustive-deps - [recoverableByUserMessageId, feedbackByMessageId, version] - ) - - const messageCommands = useMemo( - () => ({ - retryAssistant: commands.retryAssistant, - async retryUserTurn(threadId, userMessageId) { - const result = await commands.retryUserTurn(threadId, userMessageId) - if (result.ok) - setRecoverableByUserMessageId((current) => - withoutRecoverableTurn(current, userMessageId) - ) - return result - }, - async editAndRegenerate(threadId, userMessageId, text) { - const result = await commands.editAndRegenerate( - threadId, - userMessageId, - text - ) - if (result.ok) - setRecoverableByUserMessageId((current) => - withoutRecoverableTurn(current, userMessageId) - ) - return result - }, - switchTurnVariant: commands.switchTurnVariant, - async submitFeedback(threadId, messageId, feedback) { - const previous = feedbackByMessageId.get(messageId) ?? null - setFeedbackByMessageId((current) => - withMessageFeedback(current, messageId, feedback) - ) - try { - return await commands.submitFeedback(threadId, messageId, feedback) - } catch (error) { - setFeedbackByMessageId((current) => - withMessageFeedback(current, messageId, previous) - ) - throw error - } - }, - }), - [commands, feedbackByMessageId] - ) - - const registerRecoverableTurn = useCallback( - (turn: RecoverableTurn) => - setRecoverableByUserMessageId((current) => - withRecoverableTurn(current, turn) - ), - [] - ) - - return { - messageActionState, - messageCommands, - registerRecoverableTurn, - } -} diff --git a/app/thread-chat/core/regeneration.ts b/app/thread-chat/core/regeneration.ts deleted file mode 100644 index a94fa9d9..00000000 --- a/app/thread-chat/core/regeneration.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:regeneration patch 规则的唯一来源位于 lib/thread-chat/domain。 - */ -export * from "@/lib/thread-chat/domain/regeneration" diff --git a/app/thread-chat/core/store.ts b/app/thread-chat/core/store.ts index 1130fe73..38220f9f 100644 --- a/app/thread-chat/core/store.ts +++ b/app/thread-chat/core/store.ts @@ -1,421 +1,42 @@ /** - * core/store —— 外部可变 store(zustand vanilla 风格,零依赖,纯 TS)。 + * 只读 UI 投影 Store。 * - * 模型:会话树对象身份稳定、原地修改;每次 mutate 后 version++ 并通知订阅者, - * React 侧经 useSyncExternalStore 以 version 为快照触发重渲(见 use-thread-store.ts)。 - * 组件不允许直接改树,所有变更走这里的方法——这也是 demo 能通过 - * react-hooks/immutability 等规则的关键:mutation 全部收敛在非 React 代码里。 + * 规范化 `ThreadChatProjectStore` 是唯一领域权威;这个适配器只让既有 Canvas 组件读取 + * 投影树,并允许 `/new` 草稿在提交前切换展示模型。它不创建实体 ID、不写回整棵树、 + * 不发起网络请求,也不承载 Message/Thread 变更。 */ -import type { TextAnchor } from "@/lib/thread-chat/domain/text-anchor" -import type { - ArtifactSeed, - MarkdownGenerationProgress, - Message, - ThreadTreeState, -} from "./types" -import type { WebResearchActivity } from "@/lib/chat/web-research-activity" -import type { ResearchPlan, ResearchRoute } from "@/lib/chat/research-router" -import { - mergeGenerationResult, - type MergeGenerationResultInput, -} from "../generation/merge-result" -import type { PreparedTurnPatch } from "./regeneration" +import type { ThreadTreeState } from "./types" -export interface ForkInput { - /** 在哪个会话里划选的 */ - sourceThreadId: string - /** 划选的是哪条消息 */ - sourceMsgId: string - /** 被划选的原文(同时决定新会话标题与脚注锚点,= anchor.quote.exact) */ - anchorText: string - /** 文本锚点:渲染后 Markdown DOM 上的模糊恢复定位依据(采集失败时可缺省) */ - anchor?: TextAnchor +export interface ThreadStore { + getState(): ThreadTreeState + getVersion(): number + subscribe(listener: () => void): () => void + setThreadModel(threadId: string, modelId: string): void } -export interface ForkResult { - threadId: string - title: string -} - -/** - * 分支的默认标题:锚点原文截 13 字(fork 时的初始标题;异步语义标题 - * 生成前 / 失败时的兜底展示,也是壳层判断「还没生成过标题」的比对基准)。 - */ -export function defaultBranchTitle(anchorText: string): string { - return anchorText.length > 13 ? anchorText.slice(0, 13) + "…" : anchorText -} - -export type ThreadStore = ReturnType - export function createThreadStore( seed: ThreadTreeState, isValidModelId: (modelId: string) => boolean = () => true -) { - const state = seed +): ThreadStore { + const state = structuredClone(seed) let version = 0 const listeners = new Set<() => void>() - const notify = () => { - version++ - listeners.forEach((fn) => fn()) - } - - /** 活跃计数 + 最近访问(供 LRU 放置与 ⌘K「最近访问」chips 使用),不发通知 */ - const touchSilently = (id: string) => { - const t = state.threads[id] - if (!t) return - state.tick++ - t.lastActive = state.tick - if (id !== "main") - state.recents = [id, ...state.recents.filter((x) => x !== id)].slice(0, 6) - } - - /** 登记一个 artifact(含 id 分配与 tab 顺序),不发通知 */ - const registerSilently = ( - sourceThreadId: string, - sourceMessageId: string, - seed_: ArtifactSeed - ): string => { - const id = "a" + state.seq++ - state.artifacts[id] = { id, sourceThreadId, sourceMessageId, ...seed_ } - state.artifactOrder.push(id) - return id - } - - /** 从尾部反向查找消息(流式目标通常是最新消息,反向查找更快) */ - const findMessageFromTail = ( - messages: Message[], - msgId: string - ): Message | undefined => { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].id === msgId) return messages[i] - } - return undefined - } - return { getState: () => state, getVersion: () => version, - subscribe: (fn: () => void) => { - listeners.add(fn) - return () => { - listeners.delete(fn) - } + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) }, - - /** 标记某会话「刚被用过」:打开、发消息、被切换到时都要调 */ - touch(id: string) { - touchSilently(id) - notify() - }, - - /** 服务端已接受的生成 patch:一次通知内只追加节点并切换 head。 */ - applyPreparedTurn(patch: PreparedTurnPatch): boolean { - const thread = state.threads[patch.threadId] - if (!thread) return false - const existingIds = new Set(thread.messages.map((message) => message.id)) - if (patch.addedMessages.some((message) => existingIds.has(message.id))) - return false - thread.messages.push( - ...patch.addedMessages.map((message) => structuredClone(message)) - ) - thread.activeLeafMessageId = patch.nextActiveLeafMessageId - touchSilently(patch.threadId) - notify() - return true - }, - - setActiveLeaf(threadId: string, assistantMessageId: string): boolean { + setThreadModel(threadId, modelId) { const thread = state.threads[threadId] - const target = thread?.messages.find( - (message) => - message.id === assistantMessageId && message.role === "assistant" - ) - if (!thread || !target) return false - thread.activeLeafMessageId = target.id - touchSilently(threadId) - notify() - return true - }, - - /** 从一条消息的划选文字上开出新分支;新分支消息为空,首条回复由 chat-controller 触发流式生成 */ - fork(input: ForkInput): ForkResult | null { - const parent = state.threads[input.sourceThreadId] - if (!parent) return null - const srcMsg = parent.messages.find((m) => m.id === input.sourceMsgId) - if (!srcMsg) return null - - state.footnoteCounter++ - const id = "b" + state.seq++ - const depth = parent.depth + 1 - const title = defaultBranchTitle(input.anchorText) - - state.threads[id] = { - id, - modelId: parent.modelId, - parentId: input.sourceThreadId, - depth, - title, - anchorText: input.anchorText, - forkFromMsgId: input.sourceMsgId, - footnote: state.footnoteCounter, - children: [], - messages: [], - activeLeafMessageId: null, - lastActive: 0, - } - parent.children.push(id) - srcMsg.forks.push({ - text: input.anchorText, - num: state.footnoteCounter, - threadId: id, - depth, - anchor: input.anchor, - }) - - notify() - return { threadId: id, title } - }, - - /** 追加一条用户消息;返回消息 id,会话不存在时返回 null */ - appendUserMessage( - threadId: string, - text: string, - quote?: { text: string } - ): string | null { - const t = state.threads[threadId] - if (!t) return null - const id = "m" + state.seq++ - t.messages.push({ - id, - parentMessageId: t.activeLeafMessageId, - role: "user", - text, - forks: [], - ...(quote ? { quote } : {}), - }) - t.activeLeafMessageId = id - touchSilently(threadId) - notify() - return id - }, - - /** 新建一条 pending 的空 assistant 消息(流式回复的占位),返回消息 id */ - beginAssistantMessage( - threadId: string, - generationId?: string - ): string | null { - const t = state.threads[threadId] - if (!t) return null - const id = "m" + state.seq++ - t.messages.push({ - id, - parentMessageId: t.activeLeafMessageId, - role: "assistant", - text: "", - forks: [], - generationId, - backgroundGeneration: undefined, - status: "pending", - }) - t.activeLeafMessageId = id - notify() - return id - }, - - /** 给流式中的 assistant 消息追加一段文本增量 */ - appendAssistantDelta(threadId: string, msgId: string, delta: string): void { - const t = state.threads[threadId] - if (!t) return - const msg = findMessageFromTail(t.messages, msgId) - if (!msg) return - msg.text += delta - msg.status = "streaming" - notify() - }, - - /** 更新 Markdown 工具的临时生成进度;完整 Artifact 到达后会被原子清除。 */ - setMarkdownGenerationProgress( - threadId: string, - msgId: string, - progress: MarkdownGenerationProgress - ): void { - const t = state.threads[threadId] - if (!t) return - const msg = findMessageFromTail(t.messages, msgId) - if (!msg || msg.role !== "assistant") return - if (msg.status === "done" || msg.status === "error") return - msg.markdownGeneration = progress - msg.status = "streaming" - notify() - }, - - /** 聚合联网搜索/深读状态;同一 toolCallId 原位更新,保持真实调用顺序。 */ - setWebResearchActivity( - threadId: string, - msgId: string, - activity: WebResearchActivity - ): void { - const thread = state.threads[threadId] - if (!thread) return - const message = findMessageFromTail(thread.messages, msgId) - if (!message || message.role !== "assistant") return - if (message.status === "done" || message.status === "error") return - - const activities = message.webResearch ?? [] - if (message.webResearchTextOffset == null) - message.webResearchTextOffset = message.text.length - const index = activities.findIndex( - (item) => item.toolCallId === activity.toolCallId - ) - if (index === -1) activities.push(activity) - else activities[index] = { ...activities[index], ...activity } - message.webResearch = activities - message.status = "streaming" - notify() - }, - - /** 保存本轮联网路由决策;普通 answer 路由不改变可见状态。 */ - setResearchRoute( - threadId: string, - msgId: string, - route: ResearchRoute - ): void { - const thread = state.threads[threadId] - if (!thread) return - const message = findMessageFromTail(thread.messages, msgId) - if (!message || message.role !== "assistant") return - if (message.status === "done" || message.status === "error") return - message.researchRoute = route - notify() - }, - - /** 保存复杂研究的可审计计划摘要,不保存或展示模型原始思维链。 */ - setResearchPlan(threadId: string, msgId: string, plan: ResearchPlan): void { - const thread = state.threads[threadId] - if (!thread) return - const message = findMessageFromTail(thread.messages, msgId) - if (!message || message.role !== "assistant") return - if (message.status === "done" || message.status === "error") return - message.researchPlan = plan - message.status = "streaming" - notify() - }, - - /** 流式结束:标记消息完成 */ - finishAssistantMessage(threadId: string, msgId: string): void { - const t = state.threads[threadId] - if (!t) return - const msg = findMessageFromTail(t.messages, msgId) - if (!msg) return - msg.markdownGeneration = undefined - msg.webResearch = msg.webResearch?.map((activity) => ({ - ...activity, - status: "complete", - })) - msg.status = "done" - touchSilently(threadId) - notify() - }, - - /** 流式失败:标记错误(已收到的文本保留) */ - failAssistantMessage( - threadId: string, - msgId: string, - message: string - ): void { - const t = state.threads[threadId] - if (!t) return - const msg = findMessageFromTail(t.messages, msgId) - if (!msg) return - msg.markdownGeneration = undefined - msg.webResearch = msg.webResearch?.map((activity) => ({ - ...activity, - status: "complete", - })) - msg.status = "error" - msg.error = message - notify() - }, - - /** 轮询/加载终态的 generationId CAS 合并;旧 attempt 返回 false 且零写入。 */ - applyGenerationResult(input: MergeGenerationResultInput): boolean { - const merged = mergeGenerationResult(state, input) - if (merged === state) return false - Object.assign(state, merged) - notify() - return true - }, - - /** - * 写入模型成功生成的语义标题。成功状态与“已尝试”分离:主线失败时继续使用 - * 首条消息派生的回退标题。更新随整树防抖存盘,列头和会话列表同步重渲。 - */ - setGeneratedThreadTitle(threadId: string, title: string): void { - const t = state.threads[threadId] - if (!t) return - const v = title.trim() - if (!v || (t.title === v && t.titleGenerated)) return - t.title = v - t.titleGenerated = true - notify() - }, - - /** - * 原子记录一次主线或分支自动标题生成尝试。该标记随整棵树持久化,失败也不在 - * 刷新后重试,以避免可选功能反复消耗模型配额。 - */ - markTitleGenerationAttempted(threadId: string): boolean { - const t = state.threads[threadId] - if (!t || t.titleGenerationAttempted) return false - t.titleGenerationAttempted = true - notify() - return true - }, - - /** MVP 模型策略:仅根 Thread 可切换;分支由 fork 继承且保持锁定。 */ - setThreadModel(threadId: string, modelId: string): void { - const thread = state.threads[threadId] - if ( - !thread || - thread.parentId !== null || - !isValidModelId(modelId) || - thread.modelId === modelId - ) + if (!thread || !isValidModelId(modelId) || thread.modelId === modelId) return thread.modelId = modelId - notify() - }, - - /** 单独登记一个 artifact(fork 之外的入口,预留) */ - registerArtifact( - sourceThreadId: string, - sourceMessageId: string, - seed_: ArtifactSeed - ): string { - const id = registerSilently(sourceThreadId, sourceMessageId, seed_) - notify() - return id - }, - - /** 原子登记 artifact 并绑定到产生它的 assistant 消息;目标无效时零写入。 */ - attachArtifactToMessage( - threadId: string, - messageId: string, - seed_: ArtifactSeed - ): string | null { - const thread = state.threads[threadId] - if (!thread) return null - const message = findMessageFromTail(thread.messages, messageId) - if (!message || message.role !== "assistant") return null - const id = registerSilently(threadId, messageId, seed_) - message.artifactIds = [...(message.artifactIds ?? []), id] - message.markdownGeneration = undefined - // 完整工具输入已经到达:即使尚无正文,也不再显示 pending 三点占位。 - if (message.status === "pending") message.status = "streaming" - notify() - return id + version++ + listeners.forEach((listener) => listener()) }, } } diff --git a/app/thread-chat/generation/generation-reconciliation-logic.ts b/app/thread-chat/generation/generation-reconciliation-logic.ts deleted file mode 100644 index ca1da423..00000000 --- a/app/thread-chat/generation/generation-reconciliation-logic.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { MergeGenerationResultInput } from "./merge-result" -import type { ThreadTreeState } from "../core/types" -import { - isActiveGenerationStatus, - type GenerationSummary, - type RecoverableTurn, -} from "./types" - -export { isActiveGenerationStatus as isGenerationInFlight } from "./types" - -export function initialGenerationIds( - generations: readonly GenerationSummary[] -): Set { - return new Set( - generations - .filter((generation) => isActiveGenerationStatus(generation.status)) - .map((generation) => generation.id) - ) -} - -export function messageGenerationIds(state: ThreadTreeState): string[] { - return Object.values(state.threads).flatMap((thread) => - thread.messages.flatMap((message) => - message.role === "assistant" && - (message.status === "pending" || message.status === "streaming") && - message.generationId - ? [message.generationId] - : [] - ) - ) -} - -/** 将 owner-scoped generation 终态投影成 store 的窄 CAS 命令。 */ -export function terminalGenerationResultInput( - generation: GenerationSummary -): MergeGenerationResultInput | null { - if (!generation.result) return null - return { - threadId: generation.threadId, - assistantMessageId: generation.assistantMessageId, - generationId: generation.id, - result: generation.result, - } -} - -/** generation 记录消失时,只定位仍由它拥有的本地 pending turn。 */ -export function missingGenerationTurn( - state: ThreadTreeState, - generationId: string -): (RecoverableTurn & { assistantMessageId: string }) | null { - for (const thread of Object.values(state.threads)) { - const assistant = thread.messages.find( - (message) => - message.role === "assistant" && - message.generationId === generationId && - (message.status === "pending" || message.status === "streaming") - ) - if (!assistant) continue - const user = thread.messages.find( - (message) => - message.role === "user" && message.id === assistant.parentMessageId - ) - if (!user) return null - return { - threadId: thread.id, - userMessageId: user.id, - assistantMessageId: assistant.id, - reason: "missing_generation", - } - } - return null -} diff --git a/app/thread-chat/generation/merge-result.ts b/app/thread-chat/generation/merge-result.ts deleted file mode 100644 index 01829c8c..00000000 --- a/app/thread-chat/generation/merge-result.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:generation result 合并能力位于 lib/thread-chat/application。 - */ -export * from "@/lib/thread-chat/application/merge-generation-result" diff --git a/app/thread-chat/generation/project-result.ts b/app/thread-chat/generation/project-result.ts deleted file mode 100644 index f93dd913..00000000 --- a/app/thread-chat/generation/project-result.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:generation stream result 投影位于 lib/thread-chat/application。 - */ -export * from "@/lib/thread-chat/application/project-generation-result" diff --git a/app/thread-chat/generation/reconcile-turns.ts b/app/thread-chat/generation/reconcile-turns.ts deleted file mode 100644 index 75aea987..00000000 --- a/app/thread-chat/generation/reconcile-turns.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:generation 加载协调服务位于 lib/thread-chat/application。 - */ -export * from "@/lib/thread-chat/application/reconcile-turns" diff --git a/app/thread-chat/generation/types.ts b/app/thread-chat/generation/types.ts deleted file mode 100644 index a252aab7..00000000 --- a/app/thread-chat/generation/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:generation 共享类型的唯一来源位于 lib/thread-chat/domain。 - */ -export * from "@/lib/thread-chat/domain/generation" diff --git a/app/thread-chat/generation/use-generation-reconciliation.ts b/app/thread-chat/generation/use-generation-reconciliation.ts deleted file mode 100644 index 8af88b8f..00000000 --- a/app/thread-chat/generation/use-generation-reconciliation.ts +++ /dev/null @@ -1,97 +0,0 @@ -"use client" - -import { useEffect, useRef } from "react" -import { - GENERATION_CLIENT_POLL_MS, - GENERATION_ERRORS, - GENERATION_HIDDEN_POLL_MS, -} from "@/constants/generation" -import { fetchWithAuth } from "@/lib/auth/session-recovery" -import type { ThreadStore } from "../core/store" -import type { GenerationSummary, RecoverableTurn } from "./types" -import { - initialGenerationIds, - isGenerationInFlight, - messageGenerationIds, - missingGenerationTurn, - terminalGenerationResultInput, -} from "./generation-reconciliation-logic" - -export function useGenerationReconciliation({ - store, - version, - initialGenerations, - registerRecoverableTurn, - isGenerationStreamingLocally, -}: { - store: ThreadStore - version: number - initialGenerations: GenerationSummary[] - registerRecoverableTurn(turn: RecoverableTurn): void - isGenerationStreamingLocally(generationId: string): boolean -}) { - const generationIdsRef = useRef(initialGenerationIds(initialGenerations)) - - useEffect(() => { - for (const generationId of messageGenerationIds(store.getState())) - generationIdsRef.current.add(generationId) - }, [version, store]) - - useEffect(() => { - let cancelled = false - let timer: ReturnType | null = null - - const schedule = () => { - if (cancelled) return - timer = setTimeout( - poll, - document.hidden ? GENERATION_HIDDEN_POLL_MS : GENERATION_CLIENT_POLL_MS - ) - } - const poll = async () => { - for (const generationId of [...generationIdsRef.current]) { - if (cancelled) return - // 本页已有 SSE 消费者时不发重复 GET;流结束/断开后下一轮恢复权威协调。 - if (isGenerationStreamingLocally(generationId)) continue - try { - const response = await fetchWithAuth( - `/api/branch-generations/${generationId}` - ) - if (response.status === 404) { - generationIdsRef.current.delete(generationId) - const recoverable = missingGenerationTurn( - store.getState(), - generationId - ) - if (recoverable) { - store.failAssistantMessage( - recoverable.threadId, - recoverable.assistantMessageId, - GENERATION_ERRORS.backgroundInterrupted - ) - registerRecoverableTurn(recoverable) - } - continue - } - if (!response.ok) continue - const data = (await response.json()) as { - generation: GenerationSummary - } - if (isGenerationInFlight(data.generation.status)) continue - - generationIdsRef.current.delete(generationId) - const resultInput = terminalGenerationResultInput(data.generation) - if (resultInput) store.applyGenerationResult(resultInput) - } catch (error) { - console.warn("[thread-chat] generation 轮询失败,将继续重试", error) - } - } - schedule() - } - schedule() - return () => { - cancelled = true - if (timer) clearTimeout(timer) - } - }, [store, registerRecoverableTurn, isGenerationStreamingLocally]) -} diff --git a/app/thread-chat/layout.tsx b/app/thread-chat/layout.tsx index 79dd8484..c7260d07 100644 --- a/app/thread-chat/layout.tsx +++ b/app/thread-chat/layout.tsx @@ -1,8 +1,10 @@ import { redirect } from "next/navigation" import { getSession } from "@/lib/auth/server" import { ROUTES, signInWithRedirect } from "@/constants/routes" +import { ThreadChatAppProvider } from "@/lib/thread-chat/client/providers" +import "./thread-chat.css" -// 旗舰访问门禁:一处服务端 layout 同时包住 /thread-chat 跳板与 /thread-chat/[treeId], +// 旗舰访问门禁:一处服务端 layout 同时包住 /thread-chat 跳板与 /thread-chat/[projectId], // 用「真会话」判定(getSession),未登录即 302 到带回跳的登录页。 // 用 server layout 而非 middleware:项目已主动撤除 middleware,且 better-auth 在 edge // 只建议查 cookie 存在性(非真校验);server layout 与 /account 页同构、做真会话校验, @@ -14,5 +16,5 @@ export default async function ThreadChatLayout({ }) { const session = await getSession() if (!session) redirect(signInWithRedirect(ROUTES.flagship)) - return <>{children} + return {children} } diff --git a/app/thread-chat/net/boot/thread-chat-boot.ts b/app/thread-chat/net/boot/thread-chat-boot.ts deleted file mode 100644 index 4c220a1e..00000000 --- a/app/thread-chat/net/boot/thread-chat-boot.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { resolveThreadChatModelId } from "@/constants/model" -import { emptySeedState } from "../../core/seed" -import type { ThreadTreeState } from "../../core/types" -import { sanitizeLoadedState, type LoadedTree } from "../persistence/persist" - -export function threadChatBootSeed( - loaded: Pick -): ThreadTreeState { - return loaded.state - ? sanitizeLoadedState( - loaded.state, - resolveThreadChatModelId, - loaded.generations - ) - : emptySeedState() -} - -/** 网络已成功但服务端快照仍不可解析时,也必须完成 boot 并降级为空树。 */ -export function threadChatBootSeedOrFallback( - loaded: Pick, - onInvalidState: (error: unknown) => void = (error) => - console.warn( - "[thread-chat] 分支树快照无效,以空树降级启动(本次不恢复历史):", - error - ) -): ThreadTreeState { - try { - return threadChatBootSeed(loaded) - } catch (error) { - onInvalidState(error) - return emptySeedState() - } -} diff --git a/app/thread-chat/net/boot/use-thread-chat-boot.ts b/app/thread-chat/net/boot/use-thread-chat-boot.ts deleted file mode 100644 index 8d917f1e..00000000 --- a/app/thread-chat/net/boot/use-thread-chat-boot.ts +++ /dev/null @@ -1,49 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import type { MessageFeedbackSummary, ThreadTreeState } from "../../core/types" -import type { GenerationSummary, RecoverableTurn } from "../../generation/types" -import { - loadTree, - loadUiState, - rememberTreeId, - type TreeUiState, -} from "../persistence/persist" -import { threadChatBootSeedOrFallback } from "./thread-chat-boot" - -export interface ThreadChatBoot { - seed: ThreadTreeState - ui: TreeUiState | null - customTitle: string | null - generations: GenerationSummary[] - messageFeedbacks: MessageFeedbackSummary[] - recoverableTurns: RecoverableTurn[] -} - -export function useThreadChatBoot(treeId: string): ThreadChatBoot | null { - const [boot, setBoot] = useState(null) - - useEffect(() => { - let cancelled = false - ;(async () => { - const loaded = await loadTree(treeId) - const seed = threadChatBootSeedOrFallback(loaded) - const ui = loadUiState(treeId, seed) - if (cancelled) return - rememberTreeId(treeId) - setBoot({ - seed, - ui, - customTitle: loaded.customTitle, - generations: loaded.generations, - messageFeedbacks: loaded.messageFeedbacks, - recoverableTurns: loaded.recoverableTurns, - }) - })() - return () => { - cancelled = true - } - }, [treeId]) - - return boot -} diff --git a/app/thread-chat/net/chat-controller.ts b/app/thread-chat/net/chat-controller.ts deleted file mode 100644 index 660582de..00000000 --- a/app/thread-chat/net/chat-controller.ts +++ /dev/null @@ -1,402 +0,0 @@ -/** - * net/chat-controller —— 会话的「发送 / 重试 / 中止」统一入口。 - * (分支首答不再由这里触发:开分支只预填 composer,用户回车确认后走普通 send。) - * - * 消费真实 /api/chat SSE(见 ui-stream.ts),把正文增量喂回 store 的细粒度 - * mutator(pending → streaming → done/error)。 - * - * 关键机制: - * - inflight:per-thread 的 AbortController,同一会话同时只允许一路在飞。 - * - 合帧缓冲:text-delta 不直接进 store,先攒进 buffer,用 rAF 合帧后每帧至多 - * 一次 appendAssistantDelta(即每帧至多一次 version++),避免高频 delta 全树重渲卡顿; - * 页面不可见 / 无 rAF 环境降级为 setTimeout(50ms)。finish/error/abort 前强制 flush 残余。 - * - 归属校验(isOwner):所有对目标消息的写入都要求「inflight 仍指向本次 controller」, - * 使 retry(先 abort 旧流、复位、再起新流)时,旧流的残余 delta / 收尾不会误写新流的消息。 - * - error chunk 的容错语义:实测 /api/chat 的流中会夹杂零星「瞬时」error chunk - * (疑似 MiniMax 个别 chunk 经 @ai-sdk/openai-compatible 解析失败,被 - * toUIMessageStreamResponse 掩码为 "An error occurred." 后发出),之后正文 - * text-delta / Markdown Artifact 继续到达并正常 finish。因此 onError 不立即判死:只记录 lastError - * (后到覆盖先到)并继续收流;终态统一裁决——收到过任何正文即按成功 finish - * (瞬时 error 忽略并 console.warn 留痕),正文/Artifact 都没有且有 error 用 lastError - * fail,两者都没有且无 error 也 fail。中止时即使已有部分输出也落为 error, - * 避免未完成消息暴露复制和评价操作;用户可通过错误态的重试入口重新生成。 - */ - -import type { ThreadStore } from "../core/store" -import { buildRequestBody } from "./prompt/prompt" -import { consumeUIMessageStream } from "./stream/ui-stream" -import type { MessageFeedback, MessageFeedbackSummary } from "../core/types" -import { GENERATION_ERRORS } from "@/constants/generation" -import { - getKnownTreeRevision, - setKnownTreeRevision, -} from "./persistence/persist" -import { activeLeafTurn } from "../core/message-graph" -import { submitMessageFeedback } from "./commands/message-feedback-command" -import { switchActiveLeaf } from "./commands/switch-active-leaf-command" -import { requestGenerationStop } from "./commands/stop-generation-command" -import { requestChatGeneration } from "./commands/chat-generation-command" -import { - ABORTED_ERROR, - createAssistantStreamRuntime, -} from "./stream/assistant-stream-runtime" -import { - prepareAssistantRetry, - prepareUserEdit, - prepareUserTurnRetry, - type PreparedRegenerationAction, - type PreparedRegenerationStart, -} from "./commands/regeneration-command" -import { createLocalGenerationExecutions } from "./stream/local-generation-executions" -import type { - GenerationActionResult, - ThreadMessageActionCommands, - VariantSwitchResult, -} from "../chat/actions/message-action-commands" - -export type { - GenerationActionResult, - MessageActionFailureCode, - VariantSwitchResult, -} from "../chat/actions/message-action-commands" - -/** 网络异常(非中止)的兜底错误文案 */ -const NETWORK_ERROR = "网络请求失败,请重试" - -export type ChatController = ReturnType & - ThreadMessageActionCommands - -/** 判断是否为「中止」类异常 */ -function isAbortError(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - (err as { name?: string }).name === "AbortError" - ) -} - -export interface ChatControllerOptions { - treeId: string - /** 严格整树存盘:失败必须 reject,确保不会调用付费模型。 */ - persistNow(): Promise - onError?(message: string): void -} - -export function createChatController( - store: ThreadStore, - options: ChatControllerOptions -) { - /** 每个会话同一时间只允许一路在飞,并供后台协调排除本页已有的 SSE。 */ - const localExecutions = createLocalGenerationExecutions() - - /** - * 对某会话的某条 assistant 消息发起真实流式请求。 - * 普通发送已由 beginAssistantMessage 备好目标;变体操作在服务端接受后原子应用 patch。 - */ - function startAssistant( - threadId: string, - msgId: string, - userMessageId: string, - generationId: string, - action?: PreparedRegenerationAction - ): Promise { - const execution = localExecutions.begin(threadId, generationId) - const { controller, isOwner } = execution - const { signal } = controller - - const streamRuntime = createAssistantStreamRuntime({ - store, - threadId, - messageId: msgId, - isOwner, - }) - - let streamHandedOff = false - return (async () => { - try { - if (!action) { - try { - await options.persistNow() - } catch (error) { - console.error("[thread-chat] 发送前持久化屏障失败", error) - streamRuntime.fail(GENERATION_ERRORS.persistenceBarrier) - return { - ok: false, - code: "persistence_failed", - message: GENERATION_ERRORS.persistenceBarrier, - } - } - } - if (signal.aborted) - return { ok: false, code: "network_error", message: ABORTED_ERROR } - - const state = store.getState() - const thread = state.threads[threadId] - if (!thread) { - streamRuntime.fail("会话不存在") - return { ok: false, code: "not_found", message: "会话不存在" } - } - - const body = buildRequestBody(state, thread, msgId, { - treeId: options.treeId, - userMessageId, - generationId, - intent: action?.intent ?? { kind: "persisted-turn" }, - }) - const command = await requestChatGeneration({ body, signal }) - if (command.kind === "replayed") { - // 同 generation 请求重放:服务端已在执行或已终态,不启动第二次模型; - // 保留 pending,由 generation 轮询取得权威状态。 - if (action) store.applyPreparedTurn(action.patch) - return { - ok: true, - generationId, - userMessageId, - assistantMessageId: msgId, - ...(action?.sourceUserMessageId - ? { sourceUserMessageId: action.sourceUserMessageId } - : {}), - ...(action?.sourceAssistantMessageId - ? { sourceAssistantMessageId: action.sourceAssistantMessageId } - : {}), - } - } - if (command.kind === "rejected") { - if (!action) streamRuntime.fail(command.failure.message) - return command.failure - } - const res = command.response - if (command.revision !== null) - setKnownTreeRevision(options.treeId, command.revision) - if (action && !store.applyPreparedTurn(action.patch)) { - return { - ok: false, - code: "generation_conflict", - message: "服务端已接受生成,但本地消息图需要刷新", - } - } - - const accepted: GenerationActionResult = { - ok: true, - generationId, - userMessageId, - assistantMessageId: msgId, - ...(action?.sourceUserMessageId - ? { sourceUserMessageId: action.sourceUserMessageId } - : {}), - ...(action?.sourceAssistantMessageId - ? { sourceAssistantMessageId: action.sourceAssistantMessageId } - : {}), - } - - if (action) { - streamHandedOff = true - void (async () => { - try { - await consumeUIMessageStream(res, streamRuntime.handlers, signal) - if (signal.aborted) streamRuntime.settleByAbort() - else streamRuntime.settleByOutcome() - } catch (error) { - if (signal.aborted || isAbortError(error)) - streamRuntime.settleByAbort() - else streamRuntime.fail(NETWORK_ERROR) - } finally { - streamRuntime.cancel() - localExecutions.clearIfOwner(threadId, controller) - } - })() - return accepted - } - - await consumeUIMessageStream(res, streamRuntime.handlers, signal) - if (signal.aborted) { - // 被 abort:consume 静默返回、onFinish 不触发——有正文保留 finish,零正文标可重试错误 - streamRuntime.settleByAbort() - } else { - // 正常结束时 handlers.onFinish 已 settle(幂等);这里兜底走同一套终态裁决 - streamRuntime.settleByOutcome() - } - return accepted - } catch (err) { - if (signal.aborted || isAbortError(err)) { - streamRuntime.settleByAbort() // 中止:有正文保留 finish,零正文标可重试错误 - } else { - if (!action) streamRuntime.fail(NETWORK_ERROR) // fetch reject 等 - } - return { - ok: false, - code: "network_error", - message: isAbortError(err) ? ABORTED_ERROR : NETWORK_ERROR, - } - } finally { - if (!streamHandedOff) { - streamRuntime.cancel() - // 仅当 inflight 仍指向本次 controller 时才清除,避免 retry 竞态误删新流的条目 - localExecutions.clearIfOwner(threadId, controller) - } - } - })() - } - - /** 只断开本地 fetch 消费者;不会向服务端表达 Stop。 */ - function detachThread(threadId: string): void { - localExecutions.detach(threadId) - } - - function startPreparedRegeneration(start: PreparedRegenerationStart) { - detachThread(start.threadId) - return startAssistant( - start.threadId, - start.messageId, - start.userMessageId, - start.generationId, - start.action - ) - } - - function activeAssistant(threadId: string) { - const thread = store.getState().threads[threadId] - if (!thread) return null - const turn = activeLeafTurn(thread) - const message = turn?.assistantMessage - if ( - !message || - (message.status !== "pending" && message.status !== "streaming") - ) - return null - return { message, index: thread.messages.indexOf(message) } - } - - async function requestStop(threadId: string): Promise { - const active = activeAssistant(threadId) - const generationId = active?.message.generationId - if (!generationId) return false - const result = await requestGenerationStop(generationId) - if (!result.ok) { - options.onError?.(result.message) - return false - } - detachThread(threadId) - return true - } - - return { - /** 在会话里发一条用户消息并触发流式回复;同会话已有在飞请求时直接忽略 */ - send(threadId: string, text: string, quote?: { text: string }): void { - if (localExecutions.hasThread(threadId) || activeAssistant(threadId)) - return - const userMessageId = store.appendUserMessage(threadId, text, quote) - if (!userMessageId) return - const generationId = crypto.randomUUID() - const msgId = store.beginAssistantMessage(threadId, generationId) - if (!msgId) return - void startAssistant(threadId, msgId, userMessageId, generationId) - }, - - /** 兼容旧宿主的 retry 入口;新语义为追加 sibling assistant。 */ - retry(threadId: string, msgId: string): void { - const prepared = prepareAssistantRetry(store.getState(), { - threadId, - sourceAssistantMessageId: msgId, - assistantMessageId: crypto.randomUUID(), - generationId: crypto.randomUUID(), - }) - if (!prepared.ok) return - void startPreparedRegeneration(prepared.start) - }, - - async retryAssistant( - threadId: string, - assistantMessageId: string - ): Promise { - const prepared = prepareAssistantRetry(store.getState(), { - threadId, - sourceAssistantMessageId: assistantMessageId, - assistantMessageId: crypto.randomUUID(), - generationId: crypto.randomUUID(), - }) - if (!prepared.ok) return prepared - return startPreparedRegeneration(prepared.start) - }, - - async retryUserTurn( - threadId: string, - userMessageId: string - ): Promise { - const prepared = prepareUserTurnRetry(store.getState(), { - threadId, - userMessageId, - assistantMessageId: crypto.randomUUID(), - generationId: crypto.randomUUID(), - }) - if (!prepared.ok) return prepared - return startPreparedRegeneration(prepared.start) - }, - - async editAndRegenerate( - threadId: string, - userMessageId: string, - text: string - ): Promise { - const prepared = prepareUserEdit(store.getState(), { - threadId, - sourceUserMessageId: userMessageId, - text, - userMessageId: crypto.randomUUID(), - assistantMessageId: crypto.randomUUID(), - generationId: crypto.randomUUID(), - }) - if (!prepared.ok) return prepared - return startPreparedRegeneration(prepared.start) - }, - - async switchTurnVariant( - threadId: string, - assistantMessageId: string - ): Promise { - const result = await switchActiveLeaf({ - treeId: options.treeId, - threadId, - assistantMessageId, - baseRevision: getKnownTreeRevision(options.treeId), - }) - if (!result.ok) return result - setKnownTreeRevision(options.treeId, result.revision) - if (!store.setActiveLeaf(threadId, assistantMessageId)) - return { - ok: false, - code: "generation_conflict", - message: "本地消息图需要刷新", - } - return result - }, - - async submitFeedback( - threadId: string, - messageId: string, - feedback: MessageFeedback | null - ): Promise { - return submitMessageFeedback({ - treeId: options.treeId, - threadId, - messageId, - feedback, - }) - }, - - /** 只有该显式操作才请求服务端停止模型;服务端确认后再断开本地流。 */ - stop(threadId: string): void { - void requestStop(threadId) - }, - - /** 页面卸载只 detach 本地消费者,服务端 generation 继续执行与计费。 */ - detachAll(): void { - localExecutions.detachAll() - }, - - /** reconciliation 用:本页已有 SSE 消费者时不再为同一 generation 发轮询 GET。 */ - isGenerationStreamingLocally(generationId: string): boolean { - return localExecutions.isGenerationActive(generationId) - }, - } -} diff --git a/app/thread-chat/net/commands/chat-generation-command.ts b/app/thread-chat/net/commands/chat-generation-command.ts deleted file mode 100644 index dc7aa51c..00000000 --- a/app/thread-chat/net/commands/chat-generation-command.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { handleUnauthorized } from "@/lib/auth/session-recovery" -import { messageActionFailureResponseSchema } from "@/lib/thread-chat/contracts/message-action-failure" -import type { GenerationActionResult } from "../../chat/actions/message-action-commands" - -type ChatGenerationCommandInput = { - body: unknown - signal: AbortSignal -} - -type ChatGenerationCommandDependencies = { - fetch: typeof globalThis.fetch - unauthorized(): void | Promise -} - -const defaultDependencies: ChatGenerationCommandDependencies = { - fetch: (input, init) => globalThis.fetch(input, init), - unauthorized: handleUnauthorized, -} - -export type ChatGenerationCommandResult = - | { kind: "replayed" } - | { kind: "stream"; response: Response; revision: number | null } - | { - kind: "rejected" - failure: Extract - } - -/** POST /api/chat,并把 HTTP 层结果归一为 replay、stream 或 rejected。 */ -export async function requestChatGeneration( - { body, signal }: ChatGenerationCommandInput, - dependencies: ChatGenerationCommandDependencies = defaultDependencies -): Promise { - const res = await dependencies.fetch("/api/chat", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - signal, - }) - - if (res.status === 202) return { kind: "replayed" } - if (!res.ok || !res.body) { - if (res.status === 401) void dependencies.unauthorized() - const payload = await res.json().catch(() => null) - const structured = messageActionFailureResponseSchema.safeParse(payload) - const stringMessage = - typeof payload === "object" && - payload !== null && - typeof (payload as Record).error === "string" - ? ((payload as Record).error as string) - : null - const message = - res.status === 401 - ? "登录已失效,正在跳转登录…" - : structured.success - ? structured.data.error.message - : (stringMessage ?? `请求失败(HTTP ${res.status})`) - return { - kind: "rejected", - failure: { - ok: false, - code: - res.status === 401 - ? "unauthorized" - : structured.success - ? structured.data.error.code - : "network_error", - message, - }, - } - } - - const revision = Number(res.headers.get("x-thread-tree-revision")) - return { - kind: "stream", - response: res, - revision: Number.isInteger(revision) ? revision : null, - } -} diff --git a/app/thread-chat/net/commands/message-feedback-command.ts b/app/thread-chat/net/commands/message-feedback-command.ts deleted file mode 100644 index 4da60f10..00000000 --- a/app/thread-chat/net/commands/message-feedback-command.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { fetchWithAuth } from "@/lib/auth/session-recovery" -import { - setMessageFeedbackErrorResponseSchema, - setMessageFeedbackSuccessResponseSchema, -} from "@/lib/thread-chat/contracts/message-feedback" -import type { MessageFeedback, MessageFeedbackSummary } from "../../core/types" - -type SubmitMessageFeedbackInput = { - treeId: string - threadId: string - messageId: string - feedback: MessageFeedback | null -} - -type SubmitMessageFeedbackDependencies = { - fetch: typeof fetchWithAuth -} - -const defaultDependencies: SubmitMessageFeedbackDependencies = { - fetch: fetchWithAuth, -} - -/** 持久化一条 assistant message 的反馈,并按共享契约校验响应。 */ -export async function submitMessageFeedback( - { treeId, threadId, messageId, feedback }: SubmitMessageFeedbackInput, - dependencies: SubmitMessageFeedbackDependencies = defaultDependencies -): Promise { - const res = await dependencies.fetch( - `/api/branch-trees/${treeId}/messages/${encodeURIComponent(messageId)}/feedback`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ threadId, feedback }), - } - ) - const responseBody = await res.json().catch(() => null) - if (!res.ok) { - const failure = - setMessageFeedbackErrorResponseSchema.safeParse(responseBody) - throw new Error( - failure.success - ? failure.data.error.message - : `feedback failed: ${res.status}` - ) - } - const success = - setMessageFeedbackSuccessResponseSchema.safeParse(responseBody) - if (!success.success) throw new Error("feedback response invalid") - return success.data.feedback -} diff --git a/app/thread-chat/net/commands/regeneration-command.ts b/app/thread-chat/net/commands/regeneration-command.ts deleted file mode 100644 index c676a718..00000000 --- a/app/thread-chat/net/commands/regeneration-command.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { ThreadTreeState } from "../../core/types" -import { - prepareRegenerationPatch, - type PreparedTurnPatch, -} from "../../core/regeneration" -import type { ThreadChatGenerationIntent } from "../../generation/types" -import type { GenerationActionResult } from "../../chat/actions/message-action-commands" - -export type PreparedRegenerationAction = { - intent: Exclude - patch: PreparedTurnPatch - sourceUserMessageId?: string - sourceAssistantMessageId?: string -} - -export type PreparedRegenerationStart = { - threadId: string - messageId: string - userMessageId: string - generationId: string - action: PreparedRegenerationAction -} - -export type RegenerationPreparationResult = - | { ok: true; start: PreparedRegenerationStart } - | Extract - -/** 为“重新生成 assistant”准备纯追加 patch 与启动参数,不修改 store。 */ -export function prepareAssistantRetry( - state: ThreadTreeState, - input: { - threadId: string - sourceAssistantMessageId: string - assistantMessageId: string - generationId: string - } -): RegenerationPreparationResult { - const thread = state.threads[input.threadId] - const source = thread?.messages.find( - (message) => message.id === input.sourceAssistantMessageId - ) - if (!thread || source?.role !== "assistant" || !source.parentMessageId) { - return { ok: false, code: "not_found", message: "回复不存在" } - } - - const intent = { - kind: "regenerate-assistant" as const, - sourceAssistantMessageId: input.sourceAssistantMessageId, - } - const patch = prepareRegenerationPatch(state, { - threadId: input.threadId, - userMessageId: source.parentMessageId, - assistantMessageId: input.assistantMessageId, - generationId: input.generationId, - intent, - }) - if (!patch) { - return { - ok: false, - code: "not_latest_turn", - message: "只能重新生成当前最后一轮回复", - } - } - - return { - ok: true, - start: { - threadId: input.threadId, - messageId: input.assistantMessageId, - userMessageId: source.parentMessageId, - generationId: input.generationId, - action: { - intent, - patch, - sourceAssistantMessageId: input.sourceAssistantMessageId, - }, - }, - } -} - -/** 为“重试孤立 user turn”准备 pending assistant patch,不修改 store。 */ -export function prepareUserTurnRetry( - state: ThreadTreeState, - input: { - threadId: string - userMessageId: string - assistantMessageId: string - generationId: string - } -): RegenerationPreparationResult { - const intent = { kind: "retry-orphan-user" as const } - const patch = prepareRegenerationPatch(state, { - threadId: input.threadId, - userMessageId: input.userMessageId, - assistantMessageId: input.assistantMessageId, - generationId: input.generationId, - intent, - }) - if (!patch) { - return { - ok: false, - code: "not_latest_turn", - message: "该消息已不是可恢复的最后一轮", - } - } - - return { - ok: true, - start: { - threadId: input.threadId, - messageId: input.assistantMessageId, - userMessageId: input.userMessageId, - generationId: input.generationId, - action: { - intent, - patch, - sourceUserMessageId: input.userMessageId, - }, - }, - } -} - -/** 为“编辑最后一条 user 后重新生成”准备 user+assistant 追加 patch。 */ -export function prepareUserEdit( - state: ThreadTreeState, - input: { - threadId: string - sourceUserMessageId: string - text: string - userMessageId: string - assistantMessageId: string - generationId: string - } -): RegenerationPreparationResult { - const intent = { - kind: "edit-last-user" as const, - sourceUserMessageId: input.sourceUserMessageId, - text: input.text, - } - const patch = prepareRegenerationPatch(state, { - threadId: input.threadId, - userMessageId: input.userMessageId, - assistantMessageId: input.assistantMessageId, - generationId: input.generationId, - intent, - }) - if (!patch) { - return { - ok: false, - code: "not_latest_turn", - message: "只能编辑当前最后一轮用户消息", - } - } - - return { - ok: true, - start: { - threadId: input.threadId, - messageId: input.assistantMessageId, - userMessageId: input.userMessageId, - generationId: input.generationId, - action: { - intent, - patch, - sourceUserMessageId: input.sourceUserMessageId, - }, - }, - } -} diff --git a/app/thread-chat/net/commands/stop-generation-command.ts b/app/thread-chat/net/commands/stop-generation-command.ts deleted file mode 100644 index e874e60c..00000000 --- a/app/thread-chat/net/commands/stop-generation-command.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { fetchWithAuth } from "@/lib/auth/session-recovery" - -export type StopGenerationResult = { ok: true } | { ok: false; message: string } - -type StopGenerationDependencies = { - fetch: typeof fetchWithAuth - logError(message: string, error: unknown): void -} - -const defaultDependencies: StopGenerationDependencies = { - fetch: fetchWithAuth, - logError: (message, error) => console.error(message, error), -} - -/** 请求服务端停止指定 generation;不负责断开本地 stream consumer。 */ -export async function requestGenerationStop( - generationId: string, - dependencies: StopGenerationDependencies = defaultDependencies -): Promise { - try { - const res = await dependencies.fetch( - `/api/branch-generations/${generationId}/stop`, - { method: "POST" } - ) - return res.ok - ? { ok: true } - : { - ok: false, - message: `停止失败(HTTP ${res.status}),生成仍在继续`, - } - } catch (error) { - dependencies.logError("[thread-chat] Stop 请求失败", error) - return { ok: false, message: "停止失败,生成仍在继续" } - } -} diff --git a/app/thread-chat/net/commands/switch-active-leaf-command.ts b/app/thread-chat/net/commands/switch-active-leaf-command.ts deleted file mode 100644 index 39f9ef42..00000000 --- a/app/thread-chat/net/commands/switch-active-leaf-command.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { fetchWithAuth } from "@/lib/auth/session-recovery" -import { - switchActiveLeafErrorResponseSchema, - switchActiveLeafSuccessResponseSchema, -} from "@/lib/thread-chat/contracts/switch-active-leaf" -import type { VariantSwitchResult } from "../../chat/actions/message-action-commands" - -const NETWORK_ERROR = "网络请求失败,请重试" - -type SwitchActiveLeafInput = { - treeId: string - threadId: string - assistantMessageId: string - baseRevision: number | null -} - -type SwitchActiveLeafDependencies = { - fetch: typeof fetchWithAuth -} - -const defaultDependencies: SwitchActiveLeafDependencies = { - fetch: fetchWithAuth, -} - -/** 请求服务端原子切换 active leaf;本地 revision/store 更新由调用者负责。 */ -export async function switchActiveLeaf( - { treeId, threadId, assistantMessageId, baseRevision }: SwitchActiveLeafInput, - dependencies: SwitchActiveLeafDependencies = defaultDependencies -): Promise { - try { - const res = await dependencies.fetch( - `/api/branch-trees/${treeId}/active-leaf`, - { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ threadId, assistantMessageId, baseRevision }), - } - ) - const responseBody = await res.json().catch(() => null) - if (!res.ok) { - const failure = - switchActiveLeafErrorResponseSchema.safeParse(responseBody) - return { - ok: false, - code: failure.success ? failure.data.error.code : "network_error", - message: failure.success - ? failure.data.error.message - : "切换回复版本失败", - } - } - const success = - switchActiveLeafSuccessResponseSchema.safeParse(responseBody) - if (!success.success) { - return { - ok: false, - code: "network_error", - message: "服务端未返回新的树修订号", - } - } - return { - ok: true, - threadId, - assistantMessageId, - revision: success.data.revision, - } - } catch { - return { ok: false, code: "network_error", message: NETWORK_ERROR } - } -} diff --git a/app/thread-chat/net/persistence/persist.ts b/app/thread-chat/net/persistence/persist.ts deleted file mode 100644 index 08b02860..00000000 --- a/app/thread-chat/net/persistence/persist.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * net/persist —— 分支树持久化的客户端一侧:加载 / 防抖存盘的网络调用、 - * treeId 记忆、加载期 sanitize、每棵树的工作台状态(localStorage)。 - * - * 职责边界: - * · 对话数据(ThreadTreeState)走 DB(/api/branch-trees/{treeId}),整树 JSON 存取; - * · 设备本地 UI 态(列槽/列宽/列数/放置策略/视图)按 treeId 分键走 localStorage—— - * 列宽与视口强相关、丢了无伤,不值得进 DB; - * · localStorage / fetch 只允许在客户端(effect 或事件回调)里调用本模块的函数。 - * - * 为什么需要 sanitizeLoadedState:防抖存盘可能恰好在流式中途落盘,而 AbortController - * 不跨页面存活。服务端 tree GET 会先按 generation sidecar 协调状态;客户端仍做滚动 - * 部署期的防御性收敛。没有匹配 generation 的 pending/streaming 回复一律保留已有 - * 正文与 Artifact,并转为可重试 error;不能删除 Retry 所需的 messageId,也不能把 - * 部分输出猜成完整回复。strict-v2 parser 会拒绝坏消息图与无归属 Artifact。 - */ - -import { - LAST_TREE_ID_KEY, - TREE_TITLE_FALLBACK, - TREE_TITLE_MAX_LEN, - TREE_UI_KEY_PREFIX, -} from "@/constants/thread-chat" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { fetchWithAuth } from "@/lib/auth/session-recovery" -import type { MessageFeedbackSummary, ThreadTreeState } from "../../core/types" -import type { GenerationSummary, RecoverableTurn } from "../../generation/types" -import type { PlacementMode, Slot } from "../../orchestration/columns/placement" -import { withoutTransientGenerationState } from "./transient-state" -import { readSaveTreeRevision, TreeRevisionError } from "./save-tree-response" -export { sanitizeLoadedState } from "./sanitize-loaded-state" -export { TreeRevisionError } from "./save-tree-response" - -export { isValidTreeId } - -/* ---------------- 「最近一棵」treeId 记忆(裸路径 /thread-chat 的跳转目标) ---------------- */ - -export function rememberTreeId(id: string): void { - try { - localStorage.setItem(LAST_TREE_ID_KEY, id) - } catch { - /* localStorage 不可用(隐私模式等):记忆失败无伤,裸路径会开新树 */ - } -} - -export function getLastTreeId(): string | null { - try { - const id = localStorage.getItem(LAST_TREE_ID_KEY) - return id && isValidTreeId(id) ? id : null - } catch { - return null - } -} - -/* ---------------- 整树加载 / 存盘(DB) ---------------- */ - -/** loadTree 的返回:state = 整树(未保存过为 null);customTitle = 用户重命名过的标题(未改过为 null) */ -export interface LoadedTree { - state: ThreadTreeState | null - revision: number - customTitle: string | null - generations: GenerationSummary[] - messageFeedbacks: MessageFeedbackSummary[] - recoverableTurns: RecoverableTurn[] -} - -const revisionByTreeId = new Map() - -export function getKnownTreeRevision(treeId: string): number { - return revisionByTreeId.get(treeId) ?? 0 -} - -export function setKnownTreeRevision(treeId: string, revision: number): void { - const current = revisionByTreeId.get(treeId) - revisionByTreeId.set( - treeId, - current === undefined ? revision : Math.max(current, revision) - ) -} - -/** GET 整树:未保存过 state 为 null(正常首访路径);请求失败也降级为空并 console.warn(空树启动) */ -export async function loadTree(id: string): Promise { - try { - const res = await fetchWithAuth(`/api/branch-trees/${id}`) - if (res.status === 404) { - setKnownTreeRevision(id, 0) - return { - state: null, - revision: 0, - customTitle: null, - generations: [], - messageFeedbacks: [], - recoverableTurns: [], - } - } - if (!res.ok) throw new Error(`GET /api/branch-trees ${res.status}`) - const data = (await res.json()) as LoadedTree - setKnownTreeRevision(id, data.revision ?? 0) - return { - state: data.state, - revision: data.revision ?? 0, - customTitle: data.customTitle ?? null, - generations: data.generations ?? [], - messageFeedbacks: data.messageFeedbacks ?? [], - recoverableTurns: data.recoverableTurns ?? [], - } - } catch (err) { - console.warn( - "[thread-chat] 加载分支树失败,以空树降级启动(本次不恢复历史):", - err - ) - setKnownTreeRevision(id, 0) - return { - state: null, - revision: 0, - customTitle: null, - generations: [], - messageFeedbacks: [], - recoverableTurns: [], - } - } -} - -/** PUT 整树 upsert:失败只 console.warn 不抛——持久化失败不能打断对话 */ -/* 每棵树一条客户端写链:saveTree / deleteTree 串行执行(codex review 两条 P2 的修复)—— - ① 慢的旧快照 PUT 不会后到覆盖新快照(同树写操作严格按入队序落库); - ② 删除总排在已入队/在飞的存盘之后,配合壳层「删除前置抑制位」堵死 DB 行复活竞态。 - 链上任务失败不断链(下一个任务照常执行);链清空后从 Map 摘除防泄漏。 */ -const writeChains = new Map>() -function enqueueTreeWrite(id: string, task: () => Promise): Promise { - const prev = writeChains.get(id) ?? Promise.resolve() - const run = prev.then(task, task) - const settled = run.then( - () => undefined, - () => undefined - ) - writeChains.set(id, settled) - void settled.then(() => { - if (writeChains.get(id) === settled) writeChains.delete(id) - }) - return run -} - -async function writeTree( - id: string, - state: ThreadTreeState, - title?: string -): Promise { - const res = await fetchWithAuth(`/api/branch-trees/${id}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - state, - title, - baseRevision: getKnownTreeRevision(id), - }), - }) - const revision = await readSaveTreeRevision(res) - if (revision !== null) setKnownTreeRevision(id, revision) -} - -export async function saveTree( - id: string, - state: ThreadTreeState, - title?: string, - onRevisionConflict?: (error: TreeRevisionError) => void -): Promise { - const persistedState = withoutTransientGenerationState(state) - return enqueueTreeWrite(id, async () => { - try { - await writeTree(id, persistedState, title) - } catch (err) { - if (err instanceof TreeRevisionError) onRevisionConflict?.(err) - console.warn("[thread-chat] 分支树存盘失败(下次变更会再试):", err) - } - }) -} - -/** 发送模型前的严格持久化屏障:与普通防抖写共用 per-tree 写链,但失败必须抛出。 */ -export async function saveTreeStrict( - id: string, - state: ThreadTreeState, - title?: string -): Promise { - const persistedState = withoutTransientGenerationState(state) - return enqueueTreeWrite(id, () => writeTree(id, persistedState, title)) -} - -/* ---------------- 树列表 / 重命名 / 删除(会话列表 UI,openspec: add-tree-list-ui) ---------------- */ - -/** GET /api/branch-trees 的条目形状(轻量列表,无 state) */ -export interface TreeListItem { - id: string - /** 展示标题:服务端已做 coalesce(custom_title, title, 兜底) */ - title: string - /** ISO 时间字符串(JSON 序列化后的 timestamp) */ - updatedAt: string - threadCount: number -} - -/** GET 树列表:失败返回空数组并 console.warn(弹层显示空态,不打断页面) */ -export async function listTrees(): Promise { - try { - const res = await fetchWithAuth("/api/branch-trees") - if (!res.ok) throw new Error(`GET /api/branch-trees ${res.status}`) - const data = (await res.json()) as { trees: TreeListItem[] } - return data.trees - } catch (err) { - console.warn("[thread-chat] 拉取树列表失败:", err) - return [] - } -} - -/** PATCH 重命名(只写 custom_title):失败抛错——调用方做乐观更新回滚 + toast */ -export async function renameTree(id: string, title: string): Promise { - const res = await fetchWithAuth(`/api/branch-trees/${id}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ title }), - }) - if (!res.ok) throw new Error(`PATCH /api/branch-trees ${res.status}`) -} - -/** DELETE 树(幂等):失败抛错——调用方保留条目 + toast */ -export async function deleteTree(id: string): Promise { - return enqueueTreeWrite(id, async () => { - const res = await fetchWithAuth(`/api/branch-trees/${id}`, { - method: "DELETE", - }) - if (!res.ok) throw new Error(`DELETE /api/branch-trees ${res.status}`) - }) -} - -/** 删除某棵树后的本地善后(design D4):清工作台记忆;「最近一棵」若指向它则清除 */ -export function cleanupAfterTreeDelete(id: string): void { - revisionByTreeId.delete(id) - try { - localStorage.removeItem(uiKeyOf(id)) - if (localStorage.getItem(LAST_TREE_ID_KEY) === id) - localStorage.removeItem(LAST_TREE_ID_KEY) - } catch { - /* localStorage 不可用:孤儿键无伤,忽略 */ - } -} - -/** - * 派生树标题:优先使用主线成功生成的完整语义标题;未生成或生成失败时回退到首条 - * user 消息的前 TREE_TITLE_MAX_LEN 字,无消息则使用兜底文案。 - */ -export function deriveTreeTitle(state: ThreadTreeState): string { - const main = state.threads.main - if (main?.titleGenerated && main.title.trim()) return main.title.trim() - const firstUser = main?.messages.find((m) => m.role === "user") - const text = firstUser?.text.trim() - return text ? text.slice(0, TREE_TITLE_MAX_LEN) : TREE_TITLE_FALLBACK -} - -/* ---------------- 每棵树的工作台状态(localStorage,按 treeId 分键) ---------------- */ - -/** 视图形态:列(并排深读)| 画布(纵览全树)。与 thread-chat-demo 共用 */ -export type ViewMode = "columns" | "canvas" - -/** 一棵树的工作台状态:随树恢复的「桌面摆法」,不进 DB 的对话数据 */ -export interface TreeUiState { - /** 打开的分支列及折叠态(不含主线) */ - slots: Slot[] - /** 显式列宽(threadId → px),无条目的列自动均分 */ - widths: Record - /** 列数覆盖(null = 自适应) */ - forceCols: number | null - /** 列满放置策略 */ - mode: PlacementMode - /** 列 / 画布视图 */ - viewMode: ViewMode -} - -const uiKeyOf = (treeId: string) => `${TREE_UI_KEY_PREFIX}${treeId}` - -/** 存工作台状态(调用方负责防抖);写失败无伤,静默 */ -export function saveUiState(treeId: string, ui: TreeUiState): void { - try { - localStorage.setItem(uiKeyOf(treeId), JSON.stringify(ui)) - } catch { - /* 忽略:布局记忆丢失无伤,重摆即可 */ - } -} - -/** - * 读工作台状态并对照加载回来的树数据校验: - * slots / widths 里引用的 threadId 必须仍存在(失配过滤);字段形状不对整体作废。 - * 返回 null = 无可用记忆,回默认布局(只开主线)。 - */ -export function loadUiState( - treeId: string, - state: ThreadTreeState -): TreeUiState | null { - let raw: string | null - try { - raw = localStorage.getItem(uiKeyOf(treeId)) - } catch { - return null - } - if (!raw) return null - try { - const parsed = JSON.parse(raw) as Partial - const slots: Slot[] = Array.isArray(parsed.slots) - ? parsed.slots - .filter( - (s): s is Slot => - !!s && - typeof s.id === "string" && - typeof s.folded === "boolean" && - s.id !== "main" && - state.threads[s.id] !== undefined - ) - .map((s) => ({ id: s.id, folded: s.folded })) - : [] - const widths: Record = {} - if (parsed.widths && typeof parsed.widths === "object") { - for (const [id, w] of Object.entries(parsed.widths)) { - if (typeof w === "number" && Number.isFinite(w) && state.threads[id]) - widths[id] = w - } - } - return { - slots, - widths, - forceCols: typeof parsed.forceCols === "number" ? parsed.forceCols : null, - mode: parsed.mode === "fold" ? "fold" : "replace", - viewMode: parsed.viewMode === "canvas" ? "canvas" : "columns", - } - } catch { - return null // 记忆损坏:整体作废,回默认布局 - } -} diff --git a/app/thread-chat/net/persistence/sanitize-loaded-state.ts b/app/thread-chat/net/persistence/sanitize-loaded-state.ts deleted file mode 100644 index 5fd7970a..00000000 --- a/app/thread-chat/net/persistence/sanitize-loaded-state.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { Message, Thread, ThreadTreeState } from "../../core/types" -import { parseThreadTreeState } from "../../core/message-graph" -import { GENERATION_ERRORS } from "@/constants/generation" -import { - isActiveGenerationStatus, - type GenerationSummary, -} from "../../generation/types" - -/** - * 纯函数:防御性收敛流式残留并校验 Artifact 三方关系。 - * 没有匹配 active generation 的中断 assistant 一律保留内容并转为可重试 error; - * 绝不把部分正文或 Artifact 猜成完整回复。 - */ -export function sanitizeLoadedState( - inputState: ThreadTreeState, - resolveModelId: (modelId: string | undefined) => string, - activeGenerations: readonly Pick< - GenerationSummary, - "id" | "threadId" | "assistantMessageId" | "status" - >[] = [] -): ThreadTreeState { - const state = parseThreadTreeState(inputState) - let changed = false - const activeByMessage = new Map( - activeGenerations - .filter((generation) => isActiveGenerationStatus(generation.status)) - .map((generation) => [ - `${generation.threadId}:${generation.assistantMessageId}`, - generation.id, - ]) - ) - const threads: Record = {} - const referencedArtifactIds = new Set() - - for (const [id, thread] of Object.entries(state.threads)) { - let threadChanged = false - const messages: Message[] = [] - const modelId = resolveModelId(thread.modelId) - threadChanged ||= modelId !== thread.modelId - - for (const message of thread.messages) { - const validArtifactIds = - message.role === "assistant" - ? [...new Set(message.artifactIds ?? [])].filter((artifactId) => { - const artifact = state.artifacts[artifactId] - return ( - artifact?.sourceThreadId === id && - artifact.sourceMessageId === message.id - ) - }) - : [] - const hadArtifactIds = (message.artifactIds?.length ?? 0) > 0 - const artifactRefsChanged = - hadArtifactIds !== validArtifactIds.length > 0 || - (message.artifactIds?.length ?? 0) !== validArtifactIds.length || - validArtifactIds.some( - (artifactId, index) => message.artifactIds?.[index] !== artifactId - ) - const hasTransientGeneration = message.markdownGeneration !== undefined - let nextMessage = - artifactRefsChanged || hasTransientGeneration - ? { - ...message, - artifactIds: validArtifactIds.length - ? validArtifactIds - : undefined, - markdownGeneration: undefined, - } - : message - - if ( - nextMessage.role === "assistant" && - (nextMessage.status === "pending" || nextMessage.status === "streaming") - ) { - const activeGenerationId = activeByMessage.get( - `${id}:${nextMessage.id}` - ) - if (activeGenerationId) { - if ( - nextMessage.generationId !== activeGenerationId || - nextMessage.backgroundGeneration !== true - ) { - nextMessage = { - ...nextMessage, - generationId: activeGenerationId, - backgroundGeneration: true, - } - threadChanged = true - } - messages.push(nextMessage) - validArtifactIds.forEach((artifactId) => - referencedArtifactIds.add(artifactId) - ) - } else { - threadChanged = true - nextMessage = { - ...nextMessage, - status: "error", - error: GENERATION_ERRORS.backgroundInterrupted, - backgroundGeneration: undefined, - } - messages.push(nextMessage) - validArtifactIds.forEach((artifactId) => - referencedArtifactIds.add(artifactId) - ) - } - } else { - messages.push(nextMessage) - validArtifactIds.forEach((artifactId) => - referencedArtifactIds.add(artifactId) - ) - } - threadChanged ||= artifactRefsChanged || hasTransientGeneration - } - - threads[id] = threadChanged ? { ...thread, modelId, messages } : thread - changed ||= threadChanged - } - - const artifacts = Object.fromEntries( - Object.entries(state.artifacts).filter(([artifactId]) => - referencedArtifactIds.has(artifactId) - ) - ) - if (Object.keys(artifacts).length !== Object.keys(state.artifacts).length) - changed = true - - const orderedIds = new Set() - const artifactOrder = state.artifactOrder.filter((artifactId) => { - if (!artifacts[artifactId] || orderedIds.has(artifactId)) return false - orderedIds.add(artifactId) - return true - }) - referencedArtifactIds.forEach((artifactId) => { - if (artifacts[artifactId] && !orderedIds.has(artifactId)) { - orderedIds.add(artifactId) - artifactOrder.push(artifactId) - } - }) - if ( - artifactOrder.length !== state.artifactOrder.length || - artifactOrder.some( - (artifactId, index) => artifactId !== state.artifactOrder[index] - ) - ) - changed = true - - return changed ? { ...state, threads, artifacts, artifactOrder } : state -} diff --git a/app/thread-chat/net/persistence/save-tree-response.ts b/app/thread-chat/net/persistence/save-tree-response.ts deleted file mode 100644 index 8d3a71a9..00000000 --- a/app/thread-chat/net/persistence/save-tree-response.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - saveTreeErrorResponseSchema, - saveTreeSuccessResponseSchema, - type TreeWriteRevisionErrorCode, -} from "@/lib/thread-chat/contracts/save-tree" - -export class TreeRevisionError extends Error { - constructor( - readonly code: TreeWriteRevisionErrorCode, - readonly currentRevision?: number - ) { - super( - code === "tree_revision_conflict" - ? "该对话已在其他页面更新" - : "当前页面缺少树修订号" - ) - this.name = "TreeRevisionError" - } -} - -/** 解释整树 PUT 响应;无效的 2xx body 保持历史语义,不推进本地 revision。 */ -export async function readSaveTreeRevision( - response: Response -): Promise { - const body = await response.json().catch(() => null) - const failure = saveTreeErrorResponseSchema.safeParse(body) - - if (response.status === 409) - throw new TreeRevisionError( - "tree_revision_conflict", - failure.success && failure.data.error.code === "tree_revision_conflict" - ? failure.data.error.currentRevision - : undefined - ) - if (response.status === 428) throw new TreeRevisionError("revision_required") - if (!response.ok) throw new Error(`PUT /api/branch-trees ${response.status}`) - - const success = saveTreeSuccessResponseSchema.safeParse(body) - return success.success ? success.data.revision : null -} diff --git a/app/thread-chat/net/persistence/transient-state.ts b/app/thread-chat/net/persistence/transient-state.ts deleted file mode 100644 index 9adacaf1..00000000 --- a/app/thread-chat/net/persistence/transient-state.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Message, Thread, ThreadTreeState } from "../../core/types" - -/** - * 防止 Markdown 半成品进度进入 branch_trees.state。仅在确有临时字段时浅克隆, - * 正常完成态保持原对象,避免每次存盘无意义复制整棵树。 - */ -export function withoutTransientGenerationState( - state: ThreadTreeState -): ThreadTreeState { - let stateChanged = false - const threads: Record = {} - - for (const [threadId, thread] of Object.entries(state.threads)) { - let threadChanged = false - const messages: Message[] = thread.messages.map((message) => { - if (message.markdownGeneration === undefined) return message - threadChanged = true - const persisted = { ...message } - delete persisted.markdownGeneration - return persisted - }) - threads[threadId] = threadChanged ? { ...thread, messages } : thread - stateChanged ||= threadChanged - } - - return stateChanged ? { ...state, threads } : state -} diff --git a/app/thread-chat/net/persistence/tree-save-gate.ts b/app/thread-chat/net/persistence/tree-save-gate.ts deleted file mode 100644 index 534954a4..00000000 --- a/app/thread-chat/net/persistence/tree-save-gate.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface TreeSaveGate { - markPending(): void - finishDebounce(): boolean - takePendingFlush(): boolean - setSuppressed(value: boolean): void - isSuppressed(): boolean -} - -export function createTreeSaveGate(): TreeSaveGate { - let pending = false - let suppressed = false - - return { - markPending() { - pending = true - }, - finishDebounce() { - pending = false - return !suppressed - }, - takePendingFlush() { - if (!pending || suppressed) return false - pending = false - return true - }, - setSuppressed(value) { - suppressed = value - if (value) pending = false - }, - isSuppressed() { - return suppressed - }, - } -} diff --git a/app/thread-chat/net/persistence/use-tree-persistence.ts b/app/thread-chat/net/persistence/use-tree-persistence.ts deleted file mode 100644 index 7db2f92e..00000000 --- a/app/thread-chat/net/persistence/use-tree-persistence.ts +++ /dev/null @@ -1,61 +0,0 @@ -"use client" - -import { useCallback, useEffect, useRef } from "react" -import { TREE_SAVE_DEBOUNCE_MS } from "@/constants/thread-chat" -import type { ThreadStore } from "../../core/store" -import { deriveTreeTitle, saveTree } from "./persist" -import { createTreeSaveGate } from "./tree-save-gate" - -export function useTreePersistence({ - treeId, - store, - version, - onRevisionConflict, -}: { - treeId: string - store: ThreadStore - version: number - onRevisionConflict(): void -}) { - const initialVersionRef = useRef(version) - const gateRef = useRef(createTreeSaveGate()) - const onRevisionConflictRef = useRef(onRevisionConflict) - - useEffect(() => { - onRevisionConflictRef.current = onRevisionConflict - }, [onRevisionConflict]) - - useEffect(() => { - if (version === initialVersionRef.current) return - gateRef.current.markPending() - const timer = setTimeout(() => { - if (!gateRef.current.finishDebounce()) return - const state = store.getState() - void saveTree(treeId, state, deriveTreeTitle(state), () => { - onRevisionConflictRef.current() - }) - }, TREE_SAVE_DEBOUNCE_MS) - return () => clearTimeout(timer) - }, [version, treeId, store]) - - useEffect( - () => () => { - if (!gateRef.current.takePendingFlush()) return - const state = store.getState() - void saveTree(treeId, state, deriveTreeTitle(state), () => { - window.location.reload() - }) - }, - [treeId, store] - ) - - const setTreeSaveSuppressed = useCallback((value: boolean) => { - gateRef.current.setSuppressed(value) - }, []) - const isTreeSaveSuppressed = useCallback( - () => gateRef.current.isSuppressed(), - [] - ) - - return { setTreeSaveSuppressed, isTreeSaveSuppressed } -} diff --git a/app/thread-chat/net/prompt/message-context.ts b/app/thread-chat/net/prompt/message-context.ts deleted file mode 100644 index b80faa89..00000000 --- a/app/thread-chat/net/prompt/message-context.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:Thread Chat 模型上下文编译位于 lib/thread-chat/application。 - */ -export * from "@/lib/thread-chat/application/compile-thread-chat-messages" diff --git a/app/thread-chat/net/prompt/message-serialization.ts b/app/thread-chat/net/prompt/message-serialization.ts deleted file mode 100644 index aa463908..00000000 --- a/app/thread-chat/net/prompt/message-serialization.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:模型上下文消息序列化位于 lib/thread-chat/application。 - */ -export * from "@/lib/thread-chat/application/serialize-message-for-model" diff --git a/app/thread-chat/net/prompt/prompt-pure.ts b/app/thread-chat/net/prompt/prompt-pure.ts deleted file mode 100644 index 8d0177c1..00000000 --- a/app/thread-chat/net/prompt/prompt-pure.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 兼容入口:Thread Chat prompt policy 位于 lib/thread-chat/application。 - */ -export * from "@/lib/thread-chat/application/prompt-policy" diff --git a/app/thread-chat/net/prompt/prompt.ts b/app/thread-chat/net/prompt/prompt.ts deleted file mode 100644 index 5b80995a..00000000 --- a/app/thread-chat/net/prompt/prompt.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * net/prompt —— 把会话树状态编译成发给 /api/chat 的请求体(纯函数,无副作用)。 - * - * 消息序: - * 1. collectInherited:沿 lineage 向上继承的上文(映射为 user/assistant), - * 受 INHERITED_CHAR_BUDGET 字符预算约束(D8,超预算从最旧丢弃并插省略说明)。 - * 2. 当前会话已有消息(排除本次流式占位、error 消息、空正文的 assistant 消息)。 - * 分支的首问不在这里合成:留空开分支时壳层把 kickoffQuestion 预填进 composer、 - * 用户回车确认后作为真实 user 消息进入 store;气泡带问开分支时壳层 fork 后直接 - * chat.send——两条路径的首问都随消息列表自然进入 payload。 - * - * system 归服务端所有:AI SDK v7 的 streamText 不允许 messages 里出现 system 角色 - * (安全默认值,防客户端注入任意 system 指令)。因此客户端只在请求体上带 - * `threadChat: { anchorText }` 模式标记,结构化风格段与分支焦点段由 - * /api/chat 服务端的 buildThreadChatSystem(lib/chat/thread-chat-prompt.ts)统一构造。 - * - * 关于类型:这里自定义了轻量的 UIMessageLike,而不 `import type { UIMessage } from "ai"`。 - * 理由——请求体只是一段 JSON,字段校验发生在服务端的 convertToModelMessages; - * 客户端只需构造出结构匹配的对象即可,自定义最小类型既能保持 demo「零外部 import」 - * 的风格,也避免把 ai 的 UIMessage 泛型(带 metadata/dataParts 等)拖进客户端类型面。 - */ - -import type { Thread, ThreadTreeState } from "../../core/types" -import { kickoffQuestion } from "./prompt-pure" -import { serializeMessageForModel } from "./message-serialization" -import type { ThreadChatGenerationIntent } from "../../generation/types" -import type { ThreadChatGenerationIdentity } from "@/lib/thread-chat/contracts/generation-identity" -import { - compileThreadChatMessages, - type UIMessageLike, -} from "./message-context" - -// kickoff 文案模板定义在叶子模块 prompt-pure.ts(e2e 需 node 直载),这里保持原导入面 -export { kickoffQuestion } -export { serializeMessageForModel } - -/** 发给 /api/chat 的最小消息形状(结构匹配 ai 的 UIMessage,仅用纯文本 part) */ -export type { UIMessageLike } from "./message-context" - -/** /api/chat 的 thread-chat 模式请求体 */ -export interface ThreadChatRequestBody { - messages: UIMessageLike[] - /** 当前 Thread 拥有的模型;服务端仍会按统一注册表严格校验。 */ - modelId: string - /** 模式标记:服务端据此构造纯文本 system(anchorText 非空时追加分支焦点段) */ - threadChat: ThreadChatGenerationIdentity -} - -/** - * 组装本次请求的完整 body(messages + threadChat 模式标记)。 - * 主线 anchorText 为 null 时也照发 threadChat 字段——有意为之: - * 让主线同样吃到服务端的结构化风格 system,只是不带分支焦点段。 - * @param excludeMsgId 本次流式回复的占位消息 id(当前 pending/streaming 的空 assistant),需排除 - */ -export function buildRequestBody( - state: ThreadTreeState, - thread: Thread, - excludeMsgId: string, - identity: { - treeId: string - userMessageId: string - generationId: string - intent: ThreadChatGenerationIntent - } -): ThreadChatRequestBody { - const anchor = thread.anchorText?.trim() ? thread.anchorText : null - const messages = compileThreadChatMessages({ - state, - threadId: thread.id, - excludeAssistantMessageId: excludeMsgId, - }) - - return { - messages, - modelId: thread.modelId, - threadChat: { - anchorText: anchor, - treeId: identity.treeId, - threadId: thread.id, - userMessageId: identity.userMessageId, - assistantMessageId: excludeMsgId, - generationId: identity.generationId, - intent: identity.intent, - }, - } -} diff --git a/app/thread-chat/net/stream/assistant-delta-buffer.ts b/app/thread-chat/net/stream/assistant-delta-buffer.ts deleted file mode 100644 index 4b356864..00000000 --- a/app/thread-chat/net/stream/assistant-delta-buffer.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { ThreadStore } from "../../core/store" - -/** 页面不可见 / 无 requestAnimationFrame 时的降级刷新间隔(毫秒)。 */ -const FALLBACK_FLUSH_MS = 50 - -type AssistantDeltaBufferInput = { - store: ThreadStore - threadId: string - messageId: string - isOwner(): boolean -} - -/** 将高频正文 delta 与 Markdown 进度合并为每帧至多一次 store 更新。 */ -export function createAssistantDeltaBuffer({ - store, - threadId, - messageId, - isOwner, -}: AssistantDeltaBufferInput) { - let pendingText = "" - let pendingMarkdownProgress: - Parameters[2] | null = null - let frame: number | null = null - let usingAnimationFrame = false - - const flush = () => { - if (!pendingText && !pendingMarkdownProgress) return - if (!isOwner()) { - pendingText = "" - pendingMarkdownProgress = null - return - } - if (pendingText) { - const delta = pendingText - pendingText = "" - store.appendAssistantDelta(threadId, messageId, delta) - } - if (pendingMarkdownProgress) { - const progress = pendingMarkdownProgress - pendingMarkdownProgress = null - store.setMarkdownGenerationProgress(threadId, messageId, progress) - } - } - - const onFrame = () => { - frame = null - flush() - } - const canUseAnimationFrame = () => - typeof requestAnimationFrame !== "undefined" && - !(typeof document !== "undefined" && document.hidden) - const schedule = () => { - if (frame !== null) return - if (canUseAnimationFrame()) { - usingAnimationFrame = true - frame = requestAnimationFrame(onFrame) - } else { - usingAnimationFrame = false - frame = setTimeout(onFrame, FALLBACK_FLUSH_MS) as unknown as number - } - } - - return { - appendText(delta: string) { - pendingText += delta - schedule() - }, - - setMarkdownProgress( - progress: Parameters[2] - ) { - if (progress.phase === "starting") { - pendingMarkdownProgress = null - store.setMarkdownGenerationProgress(threadId, messageId, progress) - return - } - pendingMarkdownProgress = progress - schedule() - }, - - clearMarkdownProgress() { - pendingMarkdownProgress = null - }, - - flush, - - cancel() { - if (frame === null) return - if (usingAnimationFrame) cancelAnimationFrame(frame) - else clearTimeout(frame) - frame = null - }, - } -} diff --git a/app/thread-chat/net/stream/assistant-output.ts b/app/thread-chat/net/stream/assistant-output.ts deleted file mode 100644 index c71d8dfb..00000000 --- a/app/thread-chat/net/stream/assistant-output.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface AssistantOutputProgress { - receivedTextChars: number - attachedArtifactCount: number -} - -/** 正文与已原子绑定的 Artifact 都属于可保留的 assistant 输出。 */ -export function hasAssistantOutput(progress: AssistantOutputProgress): boolean { - return progress.receivedTextChars > 0 || progress.attachedArtifactCount > 0 -} diff --git a/app/thread-chat/net/stream/assistant-stream-runtime.ts b/app/thread-chat/net/stream/assistant-stream-runtime.ts deleted file mode 100644 index cb8d8da0..00000000 --- a/app/thread-chat/net/stream/assistant-stream-runtime.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { ThreadStore } from "../../core/store" -import type { ArtifactSeed } from "../../core/types" -import { hasAssistantOutput } from "./assistant-output" -import { createAssistantDeltaBuffer } from "./assistant-delta-buffer" -import type { UIStreamHandlers } from "./ui-stream" - -/** 流正常结束但正文和 Artifact 都为空时的可重试错误。 */ -const EMPTY_REPLY_ERROR = "未收到任何回复,请重试" -/** 用户停止或本地 detach 后统一使用的错误终态。 */ -export const ABORTED_ERROR = "已停止生成" - -type AssistantStreamRuntimeInput = { - store: ThreadStore - threadId: string - messageId: string - isOwner(): boolean -} - -/** 组合 delta buffer、SSE handlers 与一次性终态裁决。 */ -export function createAssistantStreamRuntime({ - store, - threadId, - messageId, - isOwner, -}: AssistantStreamRuntimeInput) { - const deltaBuffer = createAssistantDeltaBuffer({ - store, - threadId, - messageId, - isOwner, - }) - let settled = false - let lastError: string | null = null - let receivedChars = 0 - let attachedArtifactCount = 0 - - const settle = (apply: () => void) => { - if (settled) return - settled = true - deltaBuffer.cancel() - if (!isOwner()) return - deltaBuffer.flush() - apply() - } - - const settleByOutcome = () => { - settle(() => { - if ( - hasAssistantOutput({ - receivedTextChars: receivedChars, - attachedArtifactCount, - }) - ) { - if (lastError !== null) { - console.warn( - "[thread-chat] 流中出现瞬时 error chunk(已忽略):", - lastError - ) - } - store.finishAssistantMessage(threadId, messageId) - } else if (lastError !== null) { - store.failAssistantMessage(threadId, messageId, lastError) - } else { - store.failAssistantMessage(threadId, messageId, EMPTY_REPLY_ERROR) - } - }) - } - - const settleByAbort = () => { - settle(() => store.failAssistantMessage(threadId, messageId, ABORTED_ERROR)) - } - - const handlers: UIStreamHandlers = { - onTextDelta(delta) { - if (settled) return - receivedChars += delta.replace(/\s/g, "").length - deltaBuffer.appendText(delta) - }, - onMarkdownArtifactProgress(event) { - if (settled || !isOwner()) return - deltaBuffer.setMarkdownProgress(event) - }, - onMarkdownArtifact(event) { - if (settled || !isOwner()) return - deltaBuffer.clearMarkdownProgress() - const seed: ArtifactSeed = { - kind: "markdown", - title: event.input.title, - content: event.input.content, - } - if (store.attachArtifactToMessage(threadId, messageId, seed) !== null) { - attachedArtifactCount++ - } - }, - onWebResearchActivity(activity) { - if (settled || !isOwner()) return - deltaBuffer.flush() - store.setWebResearchActivity(threadId, messageId, activity) - }, - onResearchRoute(route) { - if (settled || !isOwner()) return - store.setResearchRoute(threadId, messageId, route) - }, - onResearchPlan(plan) { - if (settled || !isOwner()) return - store.setResearchPlan(threadId, messageId, plan) - }, - onError(message) { - if (settled) return - lastError = message - }, - onFinish() { - settleByOutcome() - }, - } - - return { - handlers, - settleByOutcome, - settleByAbort, - fail(message: string) { - settle(() => store.failAssistantMessage(threadId, messageId, message)) - }, - cancel() { - deltaBuffer.cancel() - }, - } -} diff --git a/app/thread-chat/net/stream/local-generation-executions.ts b/app/thread-chat/net/stream/local-generation-executions.ts deleted file mode 100644 index ea915ddb..00000000 --- a/app/thread-chat/net/stream/local-generation-executions.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** 本页正在消费的 generation 流注册表:按 thread 互斥,并提供 generation 只读查询。 */ -export function createLocalGenerationExecutions() { - const byThread = new Map< - string, - { generationId: string; controller: AbortController } - >() - const activeGenerationIds = new Set() - - function begin(threadId: string, generationId: string) { - const controller = new AbortController() - const previous = byThread.get(threadId) - if (previous) activeGenerationIds.delete(previous.generationId) - byThread.set(threadId, { generationId, controller }) - activeGenerationIds.add(generationId) - return { - controller, - isOwner: () => byThread.get(threadId)?.controller === controller, - } - } - - function clearIfOwner(threadId: string, controller: AbortController): void { - const current = byThread.get(threadId) - if (current?.controller === controller) { - byThread.delete(threadId) - activeGenerationIds.delete(current.generationId) - } - } - - function detach(threadId: string): void { - byThread.get(threadId)?.controller.abort() - } - - return { - begin, - clearIfOwner, - detach, - hasThread: (threadId: string) => byThread.has(threadId), - isGenerationActive: (generationId: string) => - activeGenerationIds.has(generationId), - detachAll: () => - byThread.forEach((execution) => execution.controller.abort()), - } -} diff --git a/app/thread-chat/net/stream/ui-stream.ts b/app/thread-chat/net/stream/ui-stream.ts deleted file mode 100644 index df566887..00000000 --- a/app/thread-chat/net/stream/ui-stream.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * net/ui-stream —— AI SDK v7「UI Message Stream」的纯 TS 消费器(无 React、无 DOM 依赖)。 - * - * 服务端 `toUIMessageStreamResponse()` 以 SSE 输出:每个事件是一行 - * `data: ${JSON.stringify(chunk)}`,事件之间用空行(\n\n)分隔,整条流以 - * `data: [DONE]` 收尾。本模块把这条字节流切成一个个 chunk,按类型分派: - * - text-delta → onTextDelta(chunk.delta)(正文增量) - * - error → onError(chunk.errorText) - * - finish / 流自然结束 → onFinish(只回调一次) - * - tool-input-start(createMarkdownArtifact) → 立即发出不可点击的生成占位态 - * - tool-input-delta → 解析局部 JSON,发出标题/字符/行数/章节进度 - * - tool-input-available(createMarkdownArtifact) → onMarkdownArtifact - * - webSearch / readUrl 的 tool-* → onWebResearchActivity(聚合活动与来源) - * - reasoning-* / 其它 tool-* / start / 其它未知类型 → 静默跳过 - * (MiniMax 的 已被服务端 extractReasoningMiddleware 抽成 reasoning-* chunk, - * 本 demo 只渲染「思考中…」指示器,不展示 reasoning 内容,故这里丢弃。) - * - * signal 被 abort 时静默返回(不外抛 AbortError),已收到的文本由上层保留。 - */ - -import { - createMarkdownArtifactEventDispatcher, - createMarkdownArtifactProgressDispatcher, - type MarkdownArtifactProgressEvent, - type MarkdownArtifactStreamEvent, -} from "../../../../lib/chat/markdown-artifact" -import { - createWebResearchActivityDispatcher, - type WebResearchActivity, -} from "../../../../lib/chat/web-research-activity" -import { - isResearchPlanStreamEvent, - isResearchRouteStreamEvent, -} from "../../../../lib/chat/research-events" -import type { - ResearchPlan, - ResearchRoute, -} from "../../../../lib/chat/research-router" - -export type { - MarkdownArtifactStreamEvent, - MarkdownArtifactProgressEvent, - ToolInputDeltaChunk, - ToolInputStartChunk, - ToolInputAvailableChunk, -} from "../../../../lib/chat/markdown-artifact" - -export interface UIStreamHandlers { - /** 收到一段正文增量(text-delta.delta) */ - onTextDelta(delta: string): void - /** 收到完整且已校验、响应内去重后的 Markdown Artifact 工具输入 */ - onMarkdownArtifact(event: MarkdownArtifactStreamEvent): void - /** Markdown 工具开始或参数增量解析后的临时进度(不持久化) */ - onMarkdownArtifactProgress(event: MarkdownArtifactProgressEvent): void - /** 联网搜索/深读调用的聚合状态与来源结果。 */ - onWebResearchActivity(activity: WebResearchActivity): void - /** 后端编排器给出的结构化联网路由。 */ - onResearchRoute(route: ResearchRoute): void - /** 复杂研究的结构化计划摘要。 */ - onResearchPlan(plan: ResearchPlan): void - /** 收到 error chunk(errorText 缺失时给出兜底文案) */ - onError(message: string): void - /** finish chunk 或流自然结束时回调;实现内部保证只触发一次 */ - onFinish(): void -} - -/** 判断是否为「中止」类异常(fetch/reader 在 abort 时抛出) */ -function isAbortError(err: unknown): boolean { - return err instanceof DOMException - ? err.name === "AbortError" - : typeof err === "object" && - err !== null && - (err as { name?: string }).name === "AbortError" -} - -export async function consumeUIMessageStream( - res: Response, - handlers: UIStreamHandlers, - signal: AbortSignal -): Promise { - const body = res.body - if (!body) return - - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = "" - let finished = false - const dispatchMarkdownArtifact = createMarkdownArtifactEventDispatcher( - handlers.onMarkdownArtifact - ) - const dispatchMarkdownArtifactProgress = - createMarkdownArtifactProgressDispatcher( - handlers.onMarkdownArtifactProgress - ) - const dispatchWebResearchActivity = createWebResearchActivityDispatcher( - handlers.onWebResearchActivity - ) - - // onFinish 只回调一次(finish chunk 与「流自然结束」可能都想触发) - const emitFinish = () => { - if (finished) return - finished = true - handlers.onFinish() - } - - /** 处理一个 SSE 事件文本;返回 true 表示遇到 [DONE],应终止整条流 */ - const handleEvent = async (rawEvent: string): Promise => { - // 一个事件可能包含多行;SSE 规范里同一事件的多个 data: 行以 \n 拼接 - const dataLines: string[] = [] - for (const line of rawEvent.split("\n")) { - if (!line.startsWith("data:")) continue // 注释行(:...)、event:/id: 等一律忽略 - let d = line.slice(5) - if (d.startsWith(" ")) d = d.slice(1) // 去掉 "data:" 后的单个前导空格 - dataLines.push(d) - } - if (dataLines.length === 0) return false - - const payload = dataLines.join("\n") - if (payload === "[DONE]") return true - - let chunk: unknown - try { - chunk = JSON.parse(payload) - } catch { - return false // 半个/损坏的 JSON:跳过(跨 chunk 的半行由 buffer 兜住,正常不会到这) - } - - if (await dispatchMarkdownArtifactProgress(chunk)) return false - if (dispatchMarkdownArtifact(chunk)) return false - if (dispatchWebResearchActivity(chunk)) return false - if (isResearchRouteStreamEvent(chunk)) { - handlers.onResearchRoute(chunk.data) - return false - } - if (isResearchPlanStreamEvent(chunk)) { - handlers.onResearchPlan(chunk.data) - return false - } - - if (typeof chunk !== "object" || chunk === null) return false - const value = chunk as { - type?: string - delta?: unknown - errorText?: unknown - } - switch (value.type) { - case "text-delta": - if (typeof value.delta === "string") handlers.onTextDelta(value.delta) - break - case "error": - handlers.onError( - typeof value.errorText === "string" && value.errorText - ? value.errorText - : "流式响应发生错误" - ) - break - case "finish": - emitFinish() - break - default: - break // reasoning-* / 非联网 tool-* / start / text-start / text-end / 未知类型:静默跳过 - } - return false - } - - try { - let done = false - while (!done) { - if (signal.aborted) return // 中止:静默返回,保留已收文本 - const { done: streamDone, value } = await reader.read() - if (streamDone) break - buffer += decoder.decode(value, { stream: true }) - // 按空行切出完整事件,最后半个事件留在 buffer 里等下一片 - let sep: number - while ((sep = buffer.indexOf("\n\n")) !== -1) { - const rawEvent = buffer.slice(0, sep) - buffer = buffer.slice(sep + 2) - if (await handleEvent(rawEvent)) { - done = true // 遇到 [DONE] - break - } - } - } - // 流自然结束:处理可能残留的、无末尾空行的最后一个事件 - if (!signal.aborted && buffer.trim().length > 0) await handleEvent(buffer) - } catch (err) { - if (signal.aborted || isAbortError(err)) return // 中止:静默 - throw err - } finally { - try { - reader.releaseLock() - } catch { - // 忽略:reader 可能已随流关闭 - } - } - - if (!signal.aborted) emitFinish() // 流自然结束但没有 finish chunk 时兜底 -} diff --git a/app/thread-chat/net/titles/thread-title-candidate.ts b/app/thread-chat/net/titles/thread-title-candidate.ts deleted file mode 100644 index 9552a473..00000000 --- a/app/thread-chat/net/titles/thread-title-candidate.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { hasRenderableAssistantOutput } from "../../core/selectors" -import { defaultBranchTitle } from "../../core/store" -import type { Thread, ThreadTreeState } from "../../core/types" -import { serializeMessageForModel } from "../prompt/message-serialization" -import type { ThreadTitleInput } from "./thread-title" - -export interface ThreadTitleCandidate { - threadId: string - input: ThreadTitleInput -} - -function mainTitleCandidate(thread: Thread): ThreadTitleCandidate | null { - if (thread.id !== "main" || thread.titleGenerationAttempted) return null - const firstUserMessage = thread.messages.find( - (message) => message.role === "user" && message.text.trim() - ) - if (!firstUserMessage) return null - return { - threadId: thread.id, - input: { kind: "main", question: firstUserMessage.text }, - } -} - -function branchTitleCandidate( - state: ThreadTreeState, - thread: Thread -): ThreadTitleCandidate | null { - if (!thread.parentId || !thread.anchorText) return null - if (thread.titleGenerationAttempted) return null - if (thread.title !== defaultBranchTitle(thread.anchorText)) return null - - const question = thread.messages.find((message) => message.role === "user") - const answer = thread.messages.find( - (message) => - message.role === "assistant" && - message.status === "done" && - hasRenderableAssistantOutput(state, message) - ) - if (!question || !answer) return null - - return { - threadId: thread.id, - input: { - kind: "branch", - anchorText: thread.anchorText, - question: question.text, - answer: serializeMessageForModel(state, answer) ?? answer.text, - }, - } -} - -export function threadTitleCandidate( - state: ThreadTreeState, - thread: Thread -): ThreadTitleCandidate | null { - return thread.id === "main" - ? mainTitleCandidate(thread) - : branchTitleCandidate(state, thread) -} diff --git a/app/thread-chat/net/titles/thread-title.ts b/app/thread-chat/net/titles/thread-title.ts deleted file mode 100644 index 9ff8987b..00000000 --- a/app/thread-chat/net/titles/thread-title.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 会话标题生成的客户端请求。服务端保留完整标题;展示层按自己的布局决定是否省略。 - */ -import type { ThreadTitleInput } from "@/lib/thread-chat/contracts/title-request" - -export type { ThreadTitleInput } from "@/lib/thread-chat/contracts/title-request" - -/** - * 请求一次主线或分支标题生成。模型不可用或未生成有效标题时返回 null; - * 调用方保留相应的回退标题即可。 - */ -export async function requestThreadTitle( - input: ThreadTitleInput -): Promise { - const res = await fetch("/api/title", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - }) - if (!res.ok) throw new Error(`POST /api/title ${res.status}`) - const data = (await res.json()) as { title?: string | null } - const title = typeof data.title === "string" ? data.title.trim() : "" - return title || null -} diff --git a/app/thread-chat/net/titles/use-thread-titles.ts b/app/thread-chat/net/titles/use-thread-titles.ts deleted file mode 100644 index 6a97b672..00000000 --- a/app/thread-chat/net/titles/use-thread-titles.ts +++ /dev/null @@ -1,64 +0,0 @@ -"use client" - -import { useEffect, useRef } from "react" -import { THREAD_TITLE_ATTEMPT_STORAGE_KEY_PREFIX } from "@/constants/thread-chat" -import type { ThreadStore } from "../../core/store" -import { requestThreadTitle } from "./thread-title" -import { threadTitleCandidate } from "./thread-title-candidate" - -function attemptStorageKey(treeId: string, threadId: string): string { - return `${THREAD_TITLE_ATTEMPT_STORAGE_KEY_PREFIX}${treeId}:${threadId}` -} - -function hasAttemptInCurrentTab(treeId: string, threadId: string): boolean { - try { - return sessionStorage.getItem(attemptStorageKey(treeId, threadId)) === "1" - } catch { - return false - } -} - -function rememberAttemptInCurrentTab(treeId: string, threadId: string): void { - try { - sessionStorage.setItem(attemptStorageKey(treeId, threadId), "1") - } catch { - // sessionStorage 被禁用时,仍由持久化到树状态的标记防重。 - } -} - -/** 主线与分支都只自动生成一次标题;主线首条用户消息可立即触发。 */ -export function useThreadTitles({ - treeId, - store, - version, -}: { - treeId: string - store: ThreadStore - version: number -}) { - const requestedThreadIdsRef = useRef(new Set()) - - useEffect(() => { - const state = store.getState() - for (const thread of Object.values(state.threads)) { - if (requestedThreadIdsRef.current.has(thread.id)) continue - if (hasAttemptInCurrentTab(treeId, thread.id)) continue - const candidate = threadTitleCandidate(state, thread) - if (!candidate) continue - - if (!store.markTitleGenerationAttempted(candidate.threadId)) continue - requestedThreadIdsRef.current.add(candidate.threadId) - rememberAttemptInCurrentTab(treeId, candidate.threadId) - void requestThreadTitle(candidate.input) - .then((title) => { - if (title) store.setGeneratedThreadTitle(candidate.threadId, title) - }) - .catch((error) => { - console.warn( - "[thread-chat] 会话标题生成失败(保留回退标题):", - error - ) - }) - } - }, [treeId, version, store]) -} diff --git a/app/thread-chat/new/page.tsx b/app/thread-chat/new/page.tsx new file mode 100644 index 00000000..7991b878 --- /dev/null +++ b/app/thread-chat/new/page.tsx @@ -0,0 +1,13 @@ +import { NewProjectDraftProvider } from "@/lib/thread-chat/client/providers" +import { threadChatMetadata } from "../page-metadata" +import { ThreadChatNew } from "../normalized/thread-chat-new" + +export const metadata = threadChatMetadata + +export default function NewThreadChatProjectPage() { + return ( + + + + ) +} diff --git a/app/thread-chat/normalized/project-list.tsx b/app/thread-chat/normalized/project-list.tsx new file mode 100644 index 00000000..3b8dee9e --- /dev/null +++ b/app/thread-chat/normalized/project-list.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useEffect, useMemo, useState, type RefObject } from "react" +import { useRouter } from "next/navigation" +import { ListTodo } from "lucide-react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import { Dialog, DialogPortal } from "@/components/ui/dialog" +import { + CUSTOM_TITLE_MAX_LEN, + THREAD_CHAT_SHORTCUTS, +} from "@/constants/thread-chat" +import { + useThreadChatAppRuntime, +} from "@/lib/thread-chat/client/providers" +import { useThreadChatAppStore } from "@/lib/thread-chat/client/hooks" +import { threadChatRoutes } from "@/lib/thread-chat/api/routes" +import { dialogCloseToShell } from "../orchestration/overlays/dialog-close-to-shell" +import { ShortcutHint } from "../orchestration/overlays/shortcut-hint" +import { TreeListRow } from "../orchestration/navigation/tree-list-row" + +export function ProjectList({ + currentProjectId, + closing = false, + container, + onClose, + onToast, +}: { + currentProjectId: string | null + closing?: boolean + container?: RefObject + onClose(): void + onToast(message: string): void +}) { + const router = useRouter() + const runtime = useThreadChatAppRuntime() + const catalog = useThreadChatAppStore((state) => state.catalog) + const [editingId, setEditingId] = useState(null) + const [draft, setDraft] = useState("") + const [confirmId, setConfirmId] = useState(null) + const [deletingId, setDeletingId] = useState(null) + + useEffect(() => { + void runtime.commands.loadProjectCatalog({ reset: true }) + }, [runtime]) + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if (editingId !== null) { + event.stopPropagation() + setEditingId(null) + } else if (confirmId !== null) { + event.stopPropagation() + setConfirmId(null) + } + } + document.addEventListener("keydown", onKey, true) + return () => document.removeEventListener("keydown", onKey, true) + }, [confirmId, editingId]) + + const rows = useMemo(() => { + const items = catalog.orderedProjectIds + .map((id) => catalog.projectsById[id]) + .filter(Boolean) + return [ + ...items.filter((item) => item.id === currentProjectId), + ...items.filter((item) => item.id !== currentProjectId), + ] + }, [catalog.orderedProjectIds, catalog.projectsById, currentProjectId]) + + async function rename(projectId: string) { + const previous = catalog.projectsById[projectId] + const title = draft.trim() + setEditingId(null) + if (!previous || !title || title === previous.displayTitle) return + if (title.length > CUSTOM_TITLE_MAX_LEN) { + onToast(`标题最长 ${CUSTOM_TITLE_MAX_LEN} 字,未保存`) + return + } + runtime.appStore.getState().upsertProjectSummary({ + ...previous, + displayTitle: title, + }) + try { + const project = await runtime.api.patchProject({ + projectId, + customTitle: title, + }) + runtime.appStore.getState().upsertProjectSummary({ + ...previous, + displayTitle: project.customTitle ?? project.autoTitle ?? "新对话", + updatedAt: project.updatedAt, + }) + runtime.projectRuntimeRegistry + .peek(projectId) + ?.store.getState() + .applyProject(project) + } catch { + runtime.appStore.getState().upsertProjectSummary(previous) + onToast("重命名失败,已恢复原名") + } + } + + async function remove(projectId: string) { + setConfirmId(null) + setDeletingId(projectId) + try { + await runtime.commands.deleteProject(projectId) + onClose() + if (projectId === currentProjectId) + runtime.navigation.replace(threadChatRoutes.newProject()) + else onToast("对话已删除") + } catch { + onToast("删除失败,请重试") + } finally { + setDeletingId(null) + } + } + + const loading = catalog.loadState.status === "idle" || catalog.loadState.status === "loading" + + return ( + + + + setConfirmId(null)} + > +
+ + 对话列表 + +
+
+ {loading &&
加载中…
} + {catalog.loadState.status === "error" && ( + + )} + {!loading && + rows.map((project) => ( + { + onClose() + if (project.id !== currentProjectId) + router.push(threadChatRoutes.project(project.id)) + }} + onDraftChange={setDraft} + onCancelEdit={() => setEditingId(null)} + onCommitEdit={() => void rename(project.id)} + onStartEdit={() => { + setConfirmId(null) + setEditingId(project.id) + setDraft(project.displayTitle) + }} + onRequestDelete={() => { + setEditingId(null) + setConfirmId(project.id) + }} + onConfirmDelete={() => void remove(project.id)} + onCancelDelete={() => setConfirmId(null)} + /> + ))} + {!loading && rows.length === 0 && ( +
还没有对话——发出第一条消息即可创建
+ )} +
+
+ 点击切换 + 悬停条目可重命名 / 删除 + + 关闭 + +
+
+
+
+ ) +} diff --git a/app/thread-chat/normalized/project-view-model.ts b/app/thread-chat/normalized/project-view-model.ts new file mode 100644 index 00000000..1d6a62c8 --- /dev/null +++ b/app/thread-chat/normalized/project-view-model.ts @@ -0,0 +1,299 @@ +import { DEFAULT_THREAD_CHAT_MODEL_ID } from "@/constants/model" +import type { TextAnchor } from "@/lib/thread-chat/domain/text-anchor" +import type { ThreadChatProjectStore } from "@/lib/thread-chat/client/types" +import type { + Artifact, + Message, + MessageStatus, + Thread, + ThreadTreeState, +} from "../core/types" + +type LoosePart = { + type?: unknown + text?: unknown + input?: unknown + output?: unknown +} + +export interface ArtifactHint { + id: string + title: string + kind: "markdown" + sourceThreadId?: string + sourceMessageId?: string +} + +export function withThreadModel( + state: ThreadTreeState, + threadId: string, + modelId: string +): ThreadTreeState { + const thread = state.threads[threadId] + if (!thread || thread.modelId === modelId) return state + return { + ...state, + threads: { + ...state.threads, + [threadId]: { ...thread, modelId }, + }, + } +} + +export function textFromParts(parts: readonly unknown[] | null | undefined) { + return (parts ?? []) + .flatMap((part) => { + const candidate = part as LoosePart + return candidate.type === "text" && typeof candidate.text === "string" + ? [candidate.text] + : [] + }) + .join("") +} + +export function artifactHintsFromParts( + parts: readonly unknown[] | null | undefined +): ArtifactHint[] { + const hints = new Map() + for (const part of parts ?? []) { + const candidate = part as LoosePart + if (candidate.type !== "dynamic-tool") continue + const output = candidate.output as { artifactId?: unknown } | undefined + if (typeof output?.artifactId !== "string") continue + const input = candidate.input as { title?: unknown } | undefined + hints.set(output.artifactId, { + id: output.artifactId, + title: + typeof input?.title === "string" && input.title.trim() + ? input.title.trim() + : "Markdown", + kind: "markdown", + }) + } + return [...hints.values()] +} + +function displayTitle(value: string | null | undefined, fallback: string) { + const title = value?.trim() + return title ? title : fallback +} + +function artifactContent(content: unknown): string { + if (typeof content === "string") return content + return JSON.stringify(content, null, 2) +} + +function statusOf( + state: ThreadChatProjectStore, + messageId: string, + text: string +): { status?: MessageStatus; error?: string; backgroundGeneration?: boolean } { + const run = state.runs.byAssistantMessageId[messageId] + const resumed = state.runs.resumedAssistantMessageIds[messageId] === true + if (!run) + return state.entities.messagesById[messageId]?.finalizedAt + ? { status: "done" } + : { status: "pending" } + if (run.status === "queued") + return { + status: "pending", + ...(resumed ? { backgroundGeneration: true } : {}), + } + if (run.status === "running") + return { + status: "streaming", + ...(resumed ? { backgroundGeneration: true } : {}), + } + if (run.status === "completed" || (run.status === "stopped" && text)) + return { status: "done" } + return { + status: "error", + error: + run.error?.message ?? + (run.status === "stopped" ? "生成已停止" : "生成失败"), + } +} + +function threadDepths(state: ThreadChatProjectStore) { + const memo = new Map() + const visit = (threadId: string, path = new Set()): number => { + const known = memo.get(threadId) + if (known !== undefined) return known + if (path.has(threadId)) return 0 + const thread = state.entities.threadsById[threadId] + if (!thread?.parentThreadId) { + memo.set(threadId, 0) + return 0 + } + const nextPath = new Set(path).add(threadId) + const depth = visit(thread.parentThreadId, nextPath) + 1 + memo.set(threadId, depth) + return depth + } + for (const threadId of Object.keys(state.entities.threadsById)) + visit(threadId) + return memo +} + +function branchFootnotes(state: ThreadChatProjectStore) { + const ordered = Object.values(state.entities.threadsById) + .filter((thread) => thread.parentThreadId !== null) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id) + ) + return new Map(ordered.map((thread, index) => [thread.id, index + 1])) +} + +function modelForThread(state: ThreadChatProjectStore, threadId: string) { + const ids = state.entities.messageIdsByThreadId[threadId] ?? [] + for (let index = ids.length - 1; index >= 0; index--) { + const run = state.runs.byAssistantMessageId[ids[index]] + if (run?.modelId) return run.modelId + } + return DEFAULT_THREAD_CHAT_MODEL_ID +} + +function sourceAnchor(quote: string | undefined): TextAnchor | undefined { + return quote ? { quote: { exact: quote, prefix: "", suffix: "" } } : undefined +} + +export function projectLegacyTreeView( + state: ThreadChatProjectStore +): ThreadTreeState { + const depths = threadDepths(state) + const footnotes = branchFootnotes(state) + const childrenByParent = new Map() + for (const thread of Object.values(state.entities.threadsById)) { + if (!thread.parentThreadId) continue + const children = childrenByParent.get(thread.parentThreadId) ?? [] + children.push(thread.id) + childrenByParent.set(thread.parentThreadId, children) + } + for (const children of childrenByParent.values()) + children.sort((left, right) => { + const leftThread = state.entities.threadsById[left] + const rightThread = state.entities.threadsById[right] + return ( + leftThread.createdAt.localeCompare(rightThread.createdAt) || + left.localeCompare(right) + ) + }) + + const artifactHints = new Map() + const threads: Record = {} + for (const entity of Object.values(state.entities.threadsById)) { + const ids = state.entities.messageIdsByThreadId[entity.id] ?? [] + const visible = ids + .map((id) => state.entities.messagesById[id]) + .filter( + (message) => + Boolean(message) && + message.supersededAt === null && + !state.readModels.replacementSupersededMessageIds[message.id] + ) + const childThreads = (childrenByParent.get(entity.id) ?? []).map( + (id) => state.entities.threadsById[id] + ) + const messages: Message[] = visible.map((entityMessage, index) => { + const run = state.runs.byAssistantMessageId[entityMessage.id] + const persistedText = textFromParts(entityMessage.parts) + const checkpointText = textFromParts(run?.checkpointParts) + const text = persistedText || checkpointText + const hints = artifactHintsFromParts( + entityMessage.parts ?? run?.checkpointParts + ) + for (const hint of hints) + artifactHints.set(hint.id, { + ...hint, + sourceThreadId: entityMessage.threadId, + sourceMessageId: entityMessage.id, + }) + const forks = childThreads + .filter((child) => child.sourceMessageId === entityMessage.id) + .map((child) => ({ + text: child.forkSourceSnapshot?.quote ?? "", + num: footnotes.get(child.id) ?? 0, + threadId: child.id, + depth: depths.get(child.id) ?? 1, + anchor: sourceAnchor(child.forkSourceSnapshot?.quote), + })) + return { + id: entityMessage.id, + parentMessageId: index === 0 ? null : visible[index - 1].id, + role: entityMessage.role, + text, + forks, + ...(entityMessage.role === "assistant" + ? { + generationId: entityMessage.id, + artifactIds: hints.map((hint) => hint.id), + ...statusOf(state, entityMessage.id, text), + } + : index === 0 && entity.parentThreadId + ? { + quote: entity.forkSourceSnapshot?.quote + ? { text: entity.forkSourceSnapshot.quote } + : undefined, + } + : null), + } + }) + const quote = entity.forkSourceSnapshot?.quote ?? null + threads[entity.id] = { + id: entity.id, + modelId: modelForThread(state, entity.id), + parentId: entity.parentThreadId, + depth: depths.get(entity.id) ?? 0, + title: + entity.parentThreadId === null + ? "主线" + : displayTitle( + entity.customTitle ?? entity.autoTitle, + quote ?? "新分支" + ), + anchorText: quote, + forkFromMsgId: entity.sourceMessageId, + footnote: footnotes.get(entity.id) ?? null, + children: childrenByParent.get(entity.id) ?? [], + messages, + activeLeafMessageId: messages.at(-1)?.id ?? null, + lastActive: + state.ui.lastActivatedOrderBySlotId[ + state.ui.columnSlots.find((slot) => slot.threadId === entity.id) + ?.slotId ?? "root" + ] ?? 0, + } + } + + const artifacts: Record = {} + for (const hint of artifactHints.values()) { + const loaded = state.entities.artifactsById[hint.id] + const sourceMessage = loaded + ? state.entities.messagesById[loaded.sourceMessageId] + : undefined + artifacts[hint.id] = { + id: hint.id, + title: loaded?.title ?? hint.title, + kind: + loaded?.kind === "code" || loaded?.kind === "note" + ? loaded.kind + : "markdown", + content: loaded ? artifactContent(loaded.content) : "", + sourceThreadId: sourceMessage?.threadId ?? hint.sourceThreadId ?? "", + sourceMessageId: loaded?.sourceMessageId ?? hint.sourceMessageId ?? "", + } + } + + return { + schemaVersion: 2, + threads, + artifacts, + artifactOrder: [...artifactHints.keys()], + recents: state.ui.columnSlots.map((slot) => slot.threadId).toReversed(), + footnoteCounter: footnotes.size, + seq: 1, + tick: state.ui.activationClock, + } +} diff --git a/app/thread-chat/normalized/thread-chat-new.tsx b/app/thread-chat/normalized/thread-chat-new.tsx new file mode 100644 index 00000000..bf693098 --- /dev/null +++ b/app/thread-chat/normalized/thread-chat-new.tsx @@ -0,0 +1,222 @@ +"use client" + +import dynamic from "next/dynamic" +import { useEffect, useMemo, useRef, useState } from "react" +import { DEFAULT_THREAD_CHAT_MODEL_ID } from "@/constants/model" +import { + useNewProjectDraftStore, + useSubmitNewProjectDraft, +} from "@/lib/thread-chat/client/hooks" +import { emptySeedState } from "../core/seed" +import { createThreadStore } from "../core/store" +import { ThreadColumns } from "../orchestration/columns/thread-columns" +import { BranchableChat } from "../branching/branchable-chat" +import { ThreadChatTopbar } from "../orchestration/navigation/thread-chat-topbar" +import { useColumnViewport } from "../orchestration/columns/use-column-viewport" +import { useWorkspaceOverlays } from "../orchestration/overlays/use-workspace-overlays" +import { HelpPanel, UsageHint } from "../orchestration/overlays/help-panel" +import { + useWorkspaceToast, + WorkspaceToast, +} from "../orchestration/overlays/workspace-toast" +import { ArtifactDrawer } from "../orchestration/artifacts/artifact-drawer" +import { ProjectList } from "./project-list" +import { withThreadModel } from "./project-view-model" + +const NEW_PROJECT_VIEW = emptySeedState() +const EMPTY_ACTION_VIEW = { + recoverableByUserMessageId: new Map(), + feedbackByMessageId: new Map(), + activePathByThreadId: new Map(), + presentationByThreadId: new Map(), +} + +const ThreadCanvas = dynamic( + () => + import("../orchestration/canvas/thread-canvas").then( + (module) => module.ThreadCanvas + ), + { + ssr: false, + loading: () =>
画布加载中…
, + } +) + +export function ThreadChatNew() { + const submit = useSubmitNewProjectDraft() + const status = useNewProjectDraftStore((state) => state.status) + const error = useNewProjectDraftStore((state) => state.error) + const setDraftParts = useNewProjectDraftStore((state) => state.setDraftParts) + const setRequestedModelId = useNewProjectDraftStore( + (state) => state.setRequestedModelId + ) + const [modelId, setModelId] = useState(DEFAULT_THREAD_CHAT_MODEL_ID) + const [hintDismissed, setHintDismissed] = useState(false) + const [viewMode, setViewMode] = useState<"columns" | "canvas">("columns") + const [forceCols, setForceCols] = useState(null) + const [placementMode, setPlacementMode] = useState<"replace" | "fold">( + "replace" + ) + const [canvasStore] = useState(() => createThreadStore(emptySeedState())) + const projected = useMemo( + () => withThreadModel(NEW_PROJECT_VIEW, "main", modelId), + [modelId] + ) + useEffect( + () => + canvasStore.subscribe(() => + setModelId(canvasStore.getState().threads.main.modelId) + ), + [canvasStore] + ) + useEffect( + () => canvasStore.setThreadModel("main", modelId), + [canvasStore, modelId] + ) + const canvasViewState = useMemo(() => ({ pins: new Map() }), []) + const { windowWidth } = useColumnViewport() + const { + rootRef, + treeList, + closeTreeList, + toggleTreeList, + helpPanel, + closeHelpPanel, + openHelpPanel, + drawerOpen, + toggleGlobalSwitcher, + toggleDrawer, + closeDrawer, + } = useWorkspaceOverlays() + const colsRef = useRef(null) + const { toast, showToast, dismissToast } = useWorkspaceToast() + const hint = !hintDismissed ? ( + setHintDismissed(true)} /> + ) : null + + const create = (text: string) => { + setDraftParts([{ type: "text", text }]) + setRequestedModelId(modelId) + void submit() + } + + return ( +
+ showToast("当前就是全新对话,直接开聊吧")} + onToggleTreeList={toggleTreeList} + onOpenHelp={openHelpPanel} + onShowColumns={() => setViewMode("columns")} + onShowCanvas={() => setViewMode("canvas")} + onForceCols={setForceCols} + onPlacementModeChange={setPlacementMode} + onToggleThreadTree={toggleGlobalSwitcher} + onToggleMarkdown={toggleDrawer} + /> + {viewMode === "columns" ? ( + ( + undefined} + onOpenArtifact={() => undefined} + onCrumbNav={() => undefined} + onOpenSwitcher={() => undefined} + onOpenSubtree={() => undefined} + onCollapse={() => undefined} + busy={status === "submitting"} + onStop={() => undefined} + onModelChange={setModelId} + onSend={create} + /> + )} + onExpandStrip={() => undefined} + onCommitWidths={() => undefined} + onResetWidths={() => undefined} + /> + ) : ( + setViewMode("columns")} + onOpenArtifact={() => undefined} + chat={{ + send: (_threadId, text) => create(text), + stop: () => undefined, + retry: () => undefined, + retryAssistant: async () => ({ + ok: false, + code: "invalid_turn", + message: "还没有可重试的回复", + }), + retryUserTurn: async () => ({ + ok: false, + code: "invalid_turn", + message: "还没有可重试的提问", + }), + editAndRegenerate: async () => ({ + ok: false, + code: "invalid_turn", + message: "还没有可编辑的提问", + }), + switchTurnVariant: async () => ({ + ok: false, + code: "invalid_turn", + message: "还没有可切换的回复", + }), + submitFeedback: async () => null, + }} + messageActionState={EMPTY_ACTION_VIEW} + /> + )} + {error && ( +
+ {error.message} +
+ )} + {treeList && ( + + )} + {helpPanel && ( + + )} + undefined} + onLocate={() => undefined} + /> + +
+ ) +} diff --git a/app/thread-chat/normalized/thread-chat-project.tsx b/app/thread-chat/normalized/thread-chat-project.tsx new file mode 100644 index 00000000..b9c1a741 --- /dev/null +++ b/app/thread-chat/normalized/thread-chat-project.tsx @@ -0,0 +1,747 @@ +"use client" + +import dynamic from "next/dynamic" +import { useRouter } from "next/navigation" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { DEFAULT_THREAD_CHAT_MODEL_ID } from "@/constants/model" +import { + selectThreadColumnHeaderView, + selectThreadColumnView, +} from "@/lib/thread-chat/client/selectors" +import { useThreadChatProjectRuntime } from "@/lib/thread-chat/client/providers" +import { useThreadChatStore } from "@/lib/thread-chat/client/hooks" +import type { ThreadChatProjectStore } from "@/lib/thread-chat/client/types" +import type { ThreadStore } from "../core/store" +import type { MessageActionViewState } from "../chat/actions/message-action-types" +import type { ThreadMessageActionCommands } from "../chat/actions/message-action-commands" +import type { SelectionInfo } from "../branching/selection/selection-bubble" +import type { PlacementHint, Slot } from "../orchestration/columns/placement" +import type { SwitcherMode } from "../orchestration/navigation/thread-switcher" +import { useColumnViewport } from "../orchestration/columns/use-column-viewport" +import { ThreadColumns } from "../orchestration/columns/thread-columns" +import { ThreadChatTopbar } from "../orchestration/navigation/thread-chat-topbar" +import { ThreadSwitcher } from "../orchestration/navigation/thread-switcher" +import { useWorkspaceOverlays } from "../orchestration/overlays/use-workspace-overlays" +import { HelpPanel, UsageHint } from "../orchestration/overlays/help-panel" +import { + useWorkspaceToast, + WorkspaceToast, +} from "../orchestration/overlays/workspace-toast" +import { BranchableChat } from "../branching/branchable-chat" +import { SelectionBubble } from "../branching/selection/selection-bubble" +import { ArtifactDrawer } from "../orchestration/artifacts/artifact-drawer" +import { ProjectList } from "./project-list" +import { projectLegacyTreeView, withThreadModel } from "./project-view-model" +import { useProjectWorkbench } from "./use-project-workbench" +import { kickoffQuestion } from "@/lib/thread-chat/application/prompt-policy" + +const ThreadCanvas = dynamic( + () => + import("../orchestration/canvas/thread-canvas").then( + (module) => module.ThreadCanvas + ), + { + ssr: false, + loading: () =>
画布加载中…
, + } +) + +const EMPTY_SLOTS: Slot[] = [] + +function useProjectedThreadStore( + state: ThreadChatProjectStore, + projected: ReturnType, + onModelChange: (modelId: string) => void +): ThreadStore { + const rootThreadId = Object.values(state.entities.threadsById).find( + (thread) => thread.parentThreadId === null + )?.id + return useMemo( + () => + ({ + getState: () => projected, + getVersion: () => 0, + subscribe: () => () => undefined, + setThreadModel: (threadId: string, modelId: string) => { + if (rootThreadId === threadId) onModelChange(modelId) + }, + }) as unknown as ThreadStore, + [onModelChange, projected, rootThreadId] + ) +} + +function messageActionView( + state: ThreadChatProjectStore, + projected: ReturnType +): MessageActionViewState { + const activePathByThreadId = new Map() + const presentationByThreadId = new Map() + for (const thread of Object.values(projected.threads)) { + const ids = thread.messages.map((message) => message.id) + activePathByThreadId.set(thread.id, ids) + const latestUser = thread.messages.findLast( + (message) => message.role === "user" + ) + const latestAssistant = thread.messages.findLast( + (message) => message.role === "assistant" + ) + presentationByThreadId.set(thread.id, { + latestUserMessageId: latestUser?.id, + latestAssistantMessageId: latestAssistant?.id, + alternatives: latestAssistant + ? [{ assistantMessageId: latestAssistant.id, derivedThreadCount: 0 }] + : [], + sourceProvenance: null, + }) + } + return { + recoverableByUserMessageId: new Map(), + feedbackByMessageId: new Map( + Object.values(state.entities.feedbackByMessageId) + .filter((feedback) => feedback.value !== null) + .map((feedback) => [feedback.messageId, feedback.value!]) + ), + activePathByThreadId, + presentationByThreadId, + } +} + +function scopeError(state: ThreadChatProjectStore, scope: string) { + const command = state.requests.commandByScope[scope] + return command?.status === "error" ? command.error.message : null +} + +function currentSlotIdForThread( + state: ThreadChatProjectStore, + threadId: string +) { + return state.ui.columnSlots.find((slot) => slot.threadId === threadId)?.slotId +} + +export function ThreadChatProject({ projectId }: { projectId: string }) { + const router = useRouter() + const runtime = useThreadChatProjectRuntime() + const state = useThreadChatStore((snapshot) => snapshot) + const bootstrapReady = state.requests.bootstrap.status === "ready" + useProjectWorkbench(runtime, bootstrapReady) + const [rootModelOverride, setRootModelId] = useState(null) + const root = Object.values(state.entities.threadsById).find( + (thread) => thread.parentThreadId === null + ) + const latestRootModelId = root + ? (state.entities.messageIdsByThreadId[root.id] ?? []) + .toReversed() + .map((messageId) => state.runs.byAssistantMessageId[messageId]?.modelId) + .find(Boolean) + : undefined + const rootModelId = + rootModelOverride ?? latestRootModelId ?? DEFAULT_THREAD_CHAT_MODEL_ID + const projected = useMemo(() => { + const view = projectLegacyTreeView(state) + return root ? withThreadModel(view, root.id, rootModelId) : view + }, [root, rootModelId, state]) + const projectedStore = useProjectedThreadStore( + state, + projected, + setRootModelId + ) + const actionView = useMemo( + () => messageActionView(state, projected), + [projected, state] + ) + const { windowWidth, autoColumnCount } = useColumnViewport() + const totalColumns = state.ui.forceColumnCount ?? autoColumnCount + const maxExpanded = Math.max(0, totalColumns - 1) + const colsRef = useRef(null) + const [flashId, setFlashId] = useState(null) + const flashTimer = useRef(null) + const [hintDismissed, setHintDismissed] = useState(false) + const [focusNode, setFocusNode] = useState<{ id: string; n: number } | null>( + null + ) + const focusSequence = useRef(0) + const canvasViewState = useMemo( + () => ({ + pins: new Map(Object.entries(state.ui.canvasPins)), + onPinsChange: (pins: ReadonlyMap) => { + const current = runtime.store.getState().ui.canvasPins + for (const threadId of Object.keys(current)) + if (!pins.has(threadId)) + runtime.store.getState().setCanvasPin(threadId, null) + for (const [threadId, point] of pins) + runtime.store.getState().setCanvasPin(threadId, point) + }, + }), + [runtime, state.ui.canvasPins] + ) + const { toast, showToast, dismissToast } = useWorkspaceToast() + const { + rootRef, + selection, + setSelection, + switcher, + closeSwitcher, + toggleGlobalSwitcher, + openColumnSwitcher, + openSubtree, + treeList, + closeTreeList, + toggleTreeList, + helpPanel, + closeHelpPanel, + openHelpPanel, + drawerOpen, + activeArtifactId, + setActiveArtifactId, + openArtifact, + toggleDrawer, + closeDrawer, + } = useWorkspaceOverlays() + + useEffect(() => { + if (!bootstrapReady) return + const visible = [ + Object.values(state.entities.threadsById).find( + (thread) => thread.parentThreadId === null + )?.id, + ...state.ui.columnSlots.map((slot) => slot.threadId), + ].filter((threadId): threadId is string => Boolean(threadId)) + for (const threadId of visible) + void runtime.commands.ensureThreadMessages(threadId) + }, [ + bootstrapReady, + runtime, + state.entities.threadsById, + state.ui.columnSlots, + ]) + + useEffect(() => { + const expanded = state.ui.columnSlots.filter((slot) => !slot.folded) + const excess = expanded.length - maxExpanded + if (excess <= 0) return + for (const slot of expanded.slice(0, excess)) + runtime.store.getState().closeColumn(slot.slotId) + }, [maxExpanded, runtime, state.ui.columnSlots]) + + useEffect( + () => () => { + if (flashTimer.current !== null) window.clearTimeout(flashTimer.current) + }, + [] + ) + + const flash = useCallback((threadId: string) => { + setFlashId(threadId) + if (flashTimer.current !== null) window.clearTimeout(flashTimer.current) + flashTimer.current = window.setTimeout(() => setFlashId(null), 950) + }, []) + + const rootThread = Object.values(state.entities.threadsById).find( + (thread) => thread.parentThreadId === null + ) + const slots: Slot[] = state.ui.columnSlots.map((slot) => ({ + id: slot.threadId, + folded: slot.folded, + })) + const widths: Record = {} + if (rootThread && state.ui.rootColumnWidthPx !== null) + widths[rootThread.id] = state.ui.rootColumnWidthPx + for (const slot of state.ui.columnSlots) + if (slot.widthPx !== null) widths[slot.threadId] = slot.widthPx + + const slotIdForThread = useCallback( + (threadId: string): "root" | string => + rootThread?.id === threadId + ? "root" + : (state.ui.columnSlots.find((slot) => slot.threadId === threadId) + ?.slotId ?? "root"), + [rootThread?.id, state.ui.columnSlots] + ) + + const openThread = useCallback( + (threadId: string, sourceThreadId: string | null = null) => { + if (rootThread?.id === threadId) { + flash(threadId) + return + } + runtime.store + .getState() + .openThread( + threadId, + sourceThreadId ? slotIdForThread(sourceThreadId) : "root", + { maxExpanded } + ) + runtime.store.getState().setViewMode("columns") + flash(threadId) + }, + [flash, maxExpanded, rootThread?.id, runtime, slotIdForThread] + ) + + const activeAssistantId = (threadId: string) => + projected.threads[threadId]?.messages + .toReversed() + .find( + (message) => + message.role === "assistant" && + (message.status === "pending" || message.status === "streaming") + )?.id + + const messageCommands = useMemo( + () => ({ + async retryAssistant(threadId, assistantMessageId) { + await runtime.commands.regenerateMessage( + assistantMessageId, + rootModelId + ) + const error = scopeError( + runtime.store.getState(), + `regenerate:${assistantMessageId}` + ) + return error + ? { ok: false, code: "network_error", message: error } + : { + ok: true, + generationId: assistantMessageId, + userMessageId: "", + assistantMessageId, + } + }, + async retryUserTurn() { + return { + ok: false, + code: "invalid_turn", + message: "请编辑消息后重试", + } + }, + async editAndRegenerate(threadId, userMessageId, text) { + await runtime.commands.editMessage( + userMessageId, + [{ type: "text", text }], + rootModelId + ) + const error = scopeError( + runtime.store.getState(), + `edit:${userMessageId}` + ) + return error + ? { ok: false, code: "network_error", message: error } + : { + ok: true, + generationId: userMessageId, + userMessageId, + assistantMessageId: userMessageId, + } + }, + async switchTurnVariant() { + return { + ok: false, + code: "invalid_turn", + message: "该回复版本已被替换", + } + }, + async submitFeedback(threadId, messageId, feedback) { + await runtime.commands.setFeedback(messageId, feedback) + const value = + runtime.store.getState().entities.feedbackByMessageId[messageId] + return value?.value + ? { + messageId, + feedback: value.value, + updatedAt: value.updatedAt, + } + : null + }, + }), + [rootModelId, runtime] + ) + + const send = useCallback( + async (threadId: string, text: string) => { + await runtime.commands.sendMessage( + threadId, + [{ type: "text", text }], + rootModelId + ) + const error = scopeError(runtime.store.getState(), `send:${threadId}`) + if (error) showToast(error) + }, + [rootModelId, runtime, showToast] + ) + + const handleFork = useCallback( + async ( + selection: SelectionInfo, + hint?: PlacementHint, + question?: string + ) => { + const before = new Set( + Object.keys(runtime.store.getState().entities.threadsById) + ) + const sourceSlotId = slotIdForThread(selection.threadId) + await runtime.commands.forkThread({ + sourceSlotId, + placement: { + maxExpanded, + keepSource: hint?.keepSource, + targetSlotId: hint?.targetId + ? currentSlotIdForThread(runtime.store.getState(), hint.targetId) + : undefined, + }, + sourceThreadId: selection.threadId, + sourceMessageId: selection.msgId, + anchor: { + exactQuote: selection.text, + ...(selection.anchor.position + ? { textPosition: selection.anchor.position } + : {}), + }, + }) + const current = runtime.store.getState() + const created = Object.values(current.entities.threadsById).find( + (thread) => + !before.has(thread.id) && thread.sourceMessageId === selection.msgId + ) + if (!created) { + const error = scopeError(current, `fork:${selection.msgId}`) + showToast(error ?? "开启分支失败,请重试") + return + } + if (question?.trim()) await send(created.id, question.trim()) + if (state.ui.viewMode === "canvas") { + setFocusNode({ id: created.id, n: ++focusSequence.current }) + showToast( + `已开启分支 · ${projected.threads[created.id]?.title ?? "新分支"}` + ) + } else { + flash(created.id) + } + }, + [ + flash, + maxExpanded, + projected.threads, + runtime, + send, + showToast, + slotIdForThread, + state.ui.viewMode, + ] + ) + + if ( + state.requests.bootstrap.status === "idle" || + state.requests.bootstrap.status === "loading" + ) + return ( +
+
对话加载中…
+
+ ) + + if (state.requests.bootstrap.status === "error") + return ( +
+
+ 对话加载失败:{state.requests.bootstrap.error.message} ·{" "} + +
+
+ ) + + const mainHasMessage = rootThread + ? (state.entities.messageIdsByThreadId[rootThread.id]?.length ?? 0) > 0 + : false + const mainSubtitle = + state.entities.project?.customTitle ?? + state.entities.project?.autoTitle ?? + "新对话" + const hintVisible = !hintDismissed && !mainHasMessage + const hintNode = hintVisible ? ( + setHintDismissed(true)} /> + ) : null + const branchCount = Math.max( + 0, + Object.keys(state.entities.threadsById).length - 1 + ) + const markdownCount = state.readModels.artifactSummary?.byKind.markdown ?? 0 + const activeArtifactLoadState = activeArtifactId + ? state.requests.artifactById[activeArtifactId] + : undefined + + const pickSwitcherRow = (row: { id: string }, mode: SwitcherMode) => { + closeSwitcher() + if (mode.kind === "column") { + const slot = state.ui.columnSlots[mode.vpIndex] + if (!slot) return + if (row.id === rootThread?.id) + runtime.store.getState().closeColumn(slot.slotId) + else runtime.store.getState().switchColumnThread(slot.slotId, row.id) + flash(row.id) + return + } + openThread(row.id, mode.kind === "subtree" ? mode.rootId : null) + } + + const canvasChat = { + send: (threadId: string, text: string) => void send(threadId, text), + stop: (threadId: string) => { + const id = activeAssistantId(threadId) + if (id) void runtime.commands.stopAssistant(id) + }, + retry: (threadId: string, messageId: string) => + void runtime.commands.regenerateMessage(messageId, rootModelId), + ...messageCommands, + } + + return ( +
+ router.push("/thread-chat/new")} + onToggleTreeList={toggleTreeList} + onOpenHelp={openHelpPanel} + onShowColumns={() => runtime.store.getState().setViewMode("columns")} + onShowCanvas={() => runtime.store.getState().setViewMode("canvas")} + onForceCols={(count) => + runtime.store.getState().setForceColumnCount(count) + } + onPlacementModeChange={(mode) => { + runtime.store.getState().setPlacementMode(mode) + if (mode === "replace") + for (const slot of runtime.store.getState().ui.columnSlots) + if (slot.folded) + runtime.store.getState().setColumnFolded(slot.slotId, false) + }} + onToggleThreadTree={toggleGlobalSwitcher} + onToggleMarkdown={toggleDrawer} + /> + + {state.ui.viewMode === "columns" ? ( + { + const slot = runtime.store + .getState() + .ui.columnSlots.find( + (candidate) => candidate.threadId === threadId + ) + if (slot) + runtime.store.getState().setColumnFolded(slot.slotId, false) + }} + onCommitWidths={(patch) => { + const widthsBySlot: Partial< + Record<"root" | string, number | null> + > = {} + for (const [threadId, width] of Object.entries(patch)) + widthsBySlot[slotIdForThread(threadId)] = width + runtime.store.getState().commitColumnWidths(widthsBySlot) + }} + onResetWidths={() => { + const reset: Partial> = { + root: null, + } + for (const slot of runtime.store.getState().ui.columnSlots) + reset[slot.slotId] = null + runtime.store.getState().commitColumnWidths(reset) + }} + renderThread={(threadId, viewportIndex) => { + const slotId = + viewportIndex < 0 + ? "root" + : state.ui.columnSlots[viewportIndex]?.slotId + if (!slotId) return null + const columnView = selectThreadColumnView(state, slotId) + const headerView = selectThreadColumnHeaderView(state, slotId) + const loadingIntro = + columnView.status === "loading" ? ( +
对话加载中…
+ ) : columnView.status === "error" ? ( + + ) : undefined + const busy = Boolean(activeAssistantId(threadId)) + return ( +
+ openThread(target, threadId)} + onOpenArtifact={(artifactId) => { + void runtime.commands.ensureArtifact(artifactId) + openArtifact(artifactId) + }} + onCrumbNav={(target) => { + if (target === rootThread?.id) { + runtime.store.getState().closeColumn(slotId) + } else { + const existing = runtime.store + .getState() + .ui.columnSlots.find( + (candidate) => candidate.threadId === target + ) + if (existing && existing.slotId !== slotId) { + runtime.store.getState().closeColumn(slotId) + runtime.store.getState().focusColumn(existing.slotId) + } else { + runtime.store + .getState() + .switchColumnThread(slotId, target) + } + } + flash(target) + }} + onOpenSwitcher={(button) => + openColumnSwitcher(viewportIndex, button) + } + onOpenSubtree={(button) => openSubtree(threadId, button)} + onCollapse={() => + runtime.store.getState().closeColumn(slotId) + } + busy={busy} + onRetry={(message) => + void runtime.commands.regenerateMessage( + message.id, + rootModelId + ) + } + onStop={() => { + const id = activeAssistantId(threadId) + if (id) void runtime.commands.stopAssistant(id) + }} + composerPrefill={ + projected.threads[threadId]?.messages.length === 0 + ? kickoffQuestion( + projected.threads[threadId]?.anchorText ?? "" + ) + : undefined + } + onModelChange={setRootModelId} + onSend={(text) => void send(threadId, text)} + messageActionState={actionView} + messageCommands={messageCommands} + /> +
+ ) + }} + /> + ) : ( + { + runtime.store.getState().setViewMode("columns") + openThread(threadId) + }} + onOpenArtifact={(artifactId) => { + void runtime.commands.ensureArtifact(artifactId) + openArtifact(artifactId) + }} + /> + )} + + + void handleFork(selection, hint, question) + } + slots={state.ui.viewMode === "canvas" ? EMPTY_SLOTS : slots} + mode={state.ui.placementMode} + maxExpanded={maxExpanded} + lastActiveOf={(threadId) => + projected.threads[threadId]?.lastActive ?? 0 + } + /> + + {treeList && ( + + )} + {switcher && ( + + )} + {helpPanel && ( + + )} + { + void runtime.commands.ensureArtifact(artifactId) + setActiveArtifactId(artifactId) + }} + onLocate={(threadId) => openThread(threadId)} + loadState={ + activeArtifactLoadState?.status === "error" + ? { + status: "error", + message: activeArtifactLoadState.error.message, + onRetry: () => { + if (activeArtifactId) + void runtime.commands.ensureArtifact(activeArtifactId) + }, + } + : activeArtifactLoadState + } + /> + +
+ ) +} diff --git a/app/thread-chat/normalized/use-project-workbench.ts b/app/thread-chat/normalized/use-project-workbench.ts new file mode 100644 index 00000000..aea02151 --- /dev/null +++ b/app/thread-chat/normalized/use-project-workbench.ts @@ -0,0 +1,51 @@ +"use client" + +import { useEffect, useRef } from "react" +import { + createWorkbenchSnapshot, + parseWorkbenchSnapshot, + projectWorkbenchStorageKey, +} from "@/lib/thread-chat/client/workbench-persistence" +import type { ThreadChatProjectRuntime } from "@/lib/thread-chat/client/types" + +const SAVE_DELAY_MS = 180 + +export function useProjectWorkbench( + runtime: ThreadChatProjectRuntime, + bootstrapReady: boolean +) { + const restored = useRef(false) + + useEffect(() => { + if (!bootstrapReady || restored.current) return + restored.current = true + const snapshot = parseWorkbenchSnapshot( + window.localStorage.getItem(projectWorkbenchStorageKey(runtime.projectId)) + ) + if (snapshot) runtime.store.getState().restoreWorkbenchSnapshot(snapshot) + else runtime.store.getState().resetWorkbenchToDefault() + }, [bootstrapReady, runtime]) + + useEffect(() => { + if (!bootstrapReady || !restored.current) return + let timer: number | null = null + const save = () => { + timer = null + window.localStorage.setItem( + projectWorkbenchStorageKey(runtime.projectId), + JSON.stringify(createWorkbenchSnapshot(runtime.store.getState())) + ) + } + const unsubscribe = runtime.store.subscribe(() => { + if (timer !== null) window.clearTimeout(timer) + timer = window.setTimeout(save, SAVE_DELAY_MS) + }) + return () => { + unsubscribe() + if (timer !== null) { + window.clearTimeout(timer) + save() + } + } + }, [bootstrapReady, runtime]) +} diff --git a/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx b/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx index ed2e51bc..4b53debc 100644 --- a/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx +++ b/app/thread-chat/orchestration/artifacts/artifact-drawer.tsx @@ -24,6 +24,9 @@ export interface ArtifactDrawerProps { onSelect: (id: string) => void /** 定位来源会话(壳层用 openBranchUI 打开) */ onLocate: (threadId: string, sourceMessageId: string) => void + loadState?: + | { status: "idle" | "loading" | "ready" } + | { status: "error"; message: string; onRetry: () => void } } export function ArtifactDrawer({ @@ -33,6 +36,7 @@ export function ArtifactDrawer({ onClose, onSelect, onLocate, + loadState, }: ArtifactDrawerProps) { const titleId = useId() const closeButtonRef = useRef(null) @@ -123,20 +127,28 @@ export function ArtifactDrawer({ )}
+ {a && loadState?.status === "loading" && ( +
Markdown 加载中…
+ )} + {a && loadState?.status === "error" && ( + + )} {!a && (
还没有 Markdown——在主线或分支里生成后会出现在这里。
)} - {a && a.kind === "code" &&
{a.content}
} - {a && a.kind === "note" && ( + {a && loadState?.status !== "loading" && loadState?.status !== "error" && a.kind === "code" &&
{a.content}
} + {a && loadState?.status !== "loading" && loadState?.status !== "error" && a.kind === "note" && (
{a.content.split("\n\n").map((p, i) => (

{p}

))}
)} - {a && a.kind === "markdown" && ( + {a && loadState?.status !== "loading" && loadState?.status !== "error" && a.kind === "markdown" && (
diff --git a/app/thread-chat/orchestration/canvas/canvas-actions.ts b/app/thread-chat/orchestration/canvas/canvas-actions.ts index bb07443c..ec55979e 100644 --- a/app/thread-chat/orchestration/canvas/canvas-actions.ts +++ b/app/thread-chat/orchestration/canvas/canvas-actions.ts @@ -5,7 +5,7 @@ import type { ThreadTreeState } from "../../core/types" import type { MessageActionViewState } from "../../chat/actions/message-action-types" import type { ThreadMessageActionCommands } from "../../chat/actions/message-action-commands" -/** 壳层用 chat-controller 组装后注入画布的会话动作。 */ +/** 壳层用 Application Commands 组装后注入画布的会话动作。 */ export interface CanvasChatActions extends ThreadMessageActionCommands { send: (threadId: string, text: string) => void stop: (threadId: string) => void diff --git a/app/thread-chat/orchestration/canvas/canvas-node.tsx b/app/thread-chat/orchestration/canvas/canvas-node.tsx index 4a8d406a..8735d873 100644 --- a/app/thread-chat/orchestration/canvas/canvas-node.tsx +++ b/app/thread-chat/orchestration/canvas/canvas-node.tsx @@ -12,7 +12,7 @@ * 消息渲染复用列模式全套(AnchoredAssistantBody:Markdown + SmoothText + 锚点 * 手绘 effect,D2)并挂列模式的划选 DOM 契约(.msg-list[data-list] / * .message[data-msg-id] / .bubble[data-role]),document 级划选气泡零改动生效; - * 发送 / 停止 / 重试经 CanvasActionsContext 直达壳层 chat-controller(D3); + * 发送 / 停止 / 重试经 CanvasActionsContext 直达 Application Commands; * 手势共处(D5):面板挂 nodrag/nowheel(选字不拖节点、内滚不缩放画布), * 双击 stopPropagation 不误触「回列模式」。 * diff --git a/app/thread-chat/orchestration/canvas/thread-canvas.tsx b/app/thread-chat/orchestration/canvas/thread-canvas.tsx index ce8d83d5..ccb96aba 100644 --- a/app/thread-chat/orchestration/canvas/thread-canvas.tsx +++ b/app/thread-chat/orchestration/canvas/thread-canvas.tsx @@ -8,7 +8,7 @@ * * Phase 2 节点内对话(openspec: add-canvas-conversations): * · 单击选中节点 = 展开外挂面板(canvas-node 的 CanvasExpand); - * · CanvasActionsContext 注入 send/abort/retry(壳层 chat-controller)+ 画布内 + * · CanvasActionsContext 注入 send/abort/retry(Application Commands)+ 画布内 * 聚焦(focusThread)+ 树快照读取,穿过 React Flow 直达自定义节点(D3); * · focusNode:{id,n}(壳层在画布内 fork 时置值,n 递增去重)→ selectNode + * setCenter 平滑跟随(D4);偏移按 LR 布局适配(见 focusThread 注释)。 @@ -72,7 +72,7 @@ export interface ThreadCanvasProps { onOpenThread: (threadId: string) => void /** 打开全局 Markdown 面板并选中对应交付物。 */ onOpenArtifact: (artifactId: string) => void - /** 会话动作(send/abort/retry):壳层用 chat-controller 组装(D3,同一发送链路) */ + /** 会话动作(send/abort/retry):壳层用 Application Commands 组装。 */ chat: CanvasChatActions messageActionState: MessageActionViewState /** 画布内 fork 的视口跟随指令:壳层每次 fork 置 {id, n}(n 递增去重), @@ -128,7 +128,7 @@ function CanvasFlow({ [selectNode, setCenter, getZoom] ) - /* 节点面板的动作面(D3):chat 三件套直达壳层 chat-controller; + /* 节点面板的动作面:chat 三件套直达 Application Commands; getState 供面板渲染读树快照(锚点 title 文案等) */ const actions = useMemo( () => ({ diff --git a/app/thread-chat/orchestration/canvas/use-canvas-layout.ts b/app/thread-chat/orchestration/canvas/use-canvas-layout.ts index e7829360..25769194 100644 --- a/app/thread-chat/orchestration/canvas/use-canvas-layout.ts +++ b/app/thread-chat/orchestration/canvas/use-canvas-layout.ts @@ -31,7 +31,7 @@ import { } from "../../core/selectors" import type { MessageActionViewState } from "../../chat/actions/message-action-types" import { accentOf, dotColorOf, dvar } from "../../theme" -import { kickoffQuestion } from "../../net/prompt/prompt-pure" +import { kickoffQuestion } from "@/lib/thread-chat/application/prompt-policy" import type { CanvasCardData, CanvasCardNode } from "./canvas-node" import { CANVAS_CARD_ANCHOR_CHROME_HEIGHT, @@ -48,6 +48,8 @@ import { canvasLayoutPositions } from "./canvas-layout" */ export interface CanvasViewState { pins: ReadonlyMap + /** 新 Runtime 可把 pin 变更提交到 Project Store;旧壳层不传时仍保留原行为。 */ + onPinsChange?(pins: ReadonlyMap): void } /** 写宿主镜像:对长寿对象的突变收敛在非 React 代码里(与 core/store 同一约定) */ @@ -56,6 +58,7 @@ function persistPins( pins: ReadonlyMap ): void { host.pins = pins + host.onPinsChange?.(pins) } /* ---------------- 卡片尺寸估算(精确尺寸与 CSS 共用 canvas-card-dimensions) ---------------- */ @@ -166,9 +169,9 @@ function buildBaseGraph( ) : undefined const data: CanvasCardData = { - isMain: t.id === "main", + isMain: t.parentId === null, title: t.title, - subtitle: t.id === "main" ? mainSubtitle : null, + subtitle: t.parentId === null ? mainSubtitle : null, depth: t.depth, footnote: t.footnote, anchor: t.anchorText ? clip(t.anchorText, 40) : null, @@ -229,7 +232,10 @@ function buildBaseGraph( } t.children.forEach(walk) } - walk("main") + const root = Object.values(state.threads).find( + (thread) => thread.parentId === null + ) + if (root) walk(root.id) return { nodes, edges, sizes } } diff --git a/app/thread-chat/orchestration/columns/thread-columns.tsx b/app/thread-chat/orchestration/columns/thread-columns.tsx index 5016621e..c46ed743 100644 --- a/app/thread-chat/orchestration/columns/thread-columns.tsx +++ b/app/thread-chat/orchestration/columns/thread-columns.tsx @@ -33,7 +33,7 @@ function ColumnShell({ width?: number children: React.ReactNode }) { - const isMain = thread.id === "main" + const isMain = thread.parentId === null return (
thread.parentId === null + ) // 展平为渲染单元(主线 + 各槽位),在相邻两个「展开列」之间插入分割线 const cells: { thread: Thread; folded: boolean; vpIndex: number }[] = [] diff --git a/app/thread-chat/orchestration/columns/use-column-slots.ts b/app/thread-chat/orchestration/columns/use-column-slots.ts deleted file mode 100644 index f5eb0911..00000000 --- a/app/thread-chat/orchestration/columns/use-column-slots.ts +++ /dev/null @@ -1,219 +0,0 @@ -"use client" - -import { useEffect, useRef, useState } from "react" -import type { ThreadStore } from "../../core/store" -import { - normalizeForReplace, - place, - trimSlots, - type PlaceEffect, - type PlacementHint, - type PlacementMode, - type Slot, -} from "./placement" - -export interface UseColumnSlotsArgs { - store: ThreadStore - /** 展开列上限(= 总列数 - 主线一列) */ - maxExpanded: number - /** 列满策略:替换⑥ / 细条⑤ */ - mode: PlacementMode - /** 可选初始槽位(工作台记忆恢复用,调用方已校验 threadId 存在性) */ - initialSlots?: Slot[] - /** 可选初始列宽映射;不传 = 全部自动均分 */ - initialWidths?: Record -} - -/** 从宽度映射里删掉若干条目;全都不存在时保留原引用。 */ -function omitWidths( - widths: Record, - ids: readonly string[] -): Record { - if (!ids.some((id) => widths[id] !== undefined)) return widths - return Object.fromEntries( - Object.entries(widths).filter(([id]) => !ids.includes(id)) - ) -} - -/** 列槽、显式列宽与放置策略组成的视口状态能力。 */ -export function useColumnSlots({ - store, - maxExpanded, - mode, - initialSlots, - initialWidths, -}: UseColumnSlotsArgs) { - const [slots, setSlots] = useState(initialSlots ?? []) - /** 显式列宽(px,threadId → width):有值的列以 flex-basis 承载宽度,无值 = 自动均分。 - 拖拽/键盘 commit 以整行为单位落条目(fill 模型下 basis 总和==容器才无跳动), - 双击复位删除整行条目。条目跟随「槽位空间」走:替换/原地切换会话时转移给新 id; - 收起/裁掉清条目;fold/unfold 保留条目(细条固定 30px 不参与)。 */ - const [widths, setWidths] = useState>( - initialWidths ?? {} - ) - const [flash, setFlash] = useState<{ id: string; n: number } | null>(null) - const flashSequence = useRef(0) - const colsRef = useRef(null) - - // 窗口变窄 / 强制列数调小时:从左裁掉最早的槽(细条一并参与,见 trimSlots)。 - // 这是 React 官方的「渲染期间调整派生状态」写法:条件自熄,比 effect 少一轮往返。 - const effectiveSlots = trimSlots(slots, maxExpanded) - if (effectiveSlots.length !== slots.length) { - setSlots(effectiveSlots) - const kept = new Set(effectiveSlots.map((slot) => slot.id)) - const dropped = slots - .filter((slot) => !kept.has(slot.id)) - .map((slot) => slot.id) - if (dropped.length) setWidths((current) => omitWidths(current, dropped)) - } - - /** 闪烁提示某列(并滚动到可视区)。 */ - const flashThread = (id: string) => - setFlash({ id, n: ++flashSequence.current }) - - useEffect(() => { - if (!flash) return - const element = colsRef.current?.querySelector( - `.column[data-thread-id="${flash.id}"]` - ) - element?.scrollIntoView({ - inline: "nearest", - block: "nearest", - behavior: "smooth", - }) - const timer = setTimeout(() => setFlash(null), 950) - return () => clearTimeout(timer) - }, [flash]) - - /** 统一放置入口;返回发生的替换/折叠副作用供上层提示。 */ - function openThread( - id: string, - sourceId: string | null, - hint?: PlacementHint - ): PlaceEffect { - store.touch(id) - const state = store.getState() - const { slots: next, effect } = place(mode, effectiveSlots, id, { - sourceId, - maxExpanded, - lastActiveOf: (threadId) => state.threads[threadId]?.lastActive ?? 0, - hint, - }) - setSlots(next) - if (effect.kind === "replaced") { - setWidths((current) => { - const inherited = current[effect.replacedId] - const rest = omitWidths(current, [effect.replacedId, id]) - return inherited !== undefined ? { ...rest, [id]: inherited } : rest - }) - } - flashThread(id) - return effect - } - - /** 列内导航:面包屑 = collapse(收起重复列);切换器 = swap(交换两列)。 */ - function navColumn( - viewportIndex: number, - targetId: string, - duplicate: "collapse" | "swap" = "collapse" - ) { - const next = effectiveSlots.map((slot) => ({ ...slot })) - const fromId = next[viewportIndex].id - if (targetId === "main") { - next.splice(viewportIndex, 1) - setSlots(next) - setWidths((current) => omitWidths(current, [fromId])) - flashThread("main") - return - } - store.touch(targetId) - const other = next.findIndex((slot) => slot.id === targetId) - if (other >= 0 && other !== viewportIndex) { - if (duplicate === "swap") { - const otherId = next[other].id - next[other].id = next[viewportIndex].id - next[viewportIndex].id = otherId - setWidths((current) => { - const fromWidth = current[fromId] - const targetWidth = current[targetId] - if (fromWidth === undefined && targetWidth === undefined) - return current - return { - ...omitWidths(current, [fromId, targetId]), - ...(fromWidth !== undefined ? { [targetId]: fromWidth } : null), - ...(targetWidth !== undefined ? { [fromId]: targetWidth } : null), - } - }) - } else { - next[other].folded = false - next.splice(viewportIndex, 1) - setWidths((current) => omitWidths(current, [fromId])) - } - } else { - next[viewportIndex].id = targetId - if (fromId !== targetId) { - setWidths((current) => { - const fromWidth = current[fromId] - const rest = omitWidths(current, [fromId, targetId]) - return fromWidth !== undefined - ? { ...rest, [targetId]: fromWidth } - : rest - }) - } - } - setSlots(next) - flashThread(targetId) - } - - function closeColumn(viewportIndex: number) { - const next = effectiveSlots.map((slot) => ({ ...slot })) - const removed = next.splice(viewportIndex, 1) - setSlots(next) - if (removed.length) - setWidths((current) => - omitWidths( - current, - removed.map((slot) => slot.id) - ) - ) - } - - /** 撤销 replace 策略:整体恢复替换前的槽位。 */ - function restoreSlots(previous: Slot[]) { - setSlots(previous) - } - - /** fold → replace:细条全部展开,从左裁掉超限列。 */ - function normalizeToReplace(): string[] { - const { slots: next, dropped } = normalizeForReplace( - effectiveSlots, - maxExpanded - ) - setSlots(next) - if (dropped.length) setWidths((current) => omitWidths(current, dropped)) - return dropped - } - - function commitWidths(patch: Record) { - setWidths((current) => ({ ...current, ...patch })) - } - - function resetWidths(ids: string[]) { - setWidths((current) => omitWidths(current, ids)) - } - - return { - slots: effectiveSlots, - widths, - flashId: flash?.id ?? null, - colsRef, - openThread, - navColumn, - closeColumn, - restoreSlots, - flashThread, - normalizeToReplace, - commitWidths, - resetWidths, - } -} diff --git a/app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx b/app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx index 5d35edc1..2e4f499b 100644 --- a/app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx +++ b/app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx @@ -9,13 +9,14 @@ import { Waypoints, } from "lucide-react" import { THREAD_CHAT_SHORTCUTS } from "@/constants/thread-chat" -import type { ViewMode } from "../../net/persistence/persist" import type { PlacementMode } from "../columns/placement" import { AccountButton } from "./account-button" import { ShortcutHint } from "../overlays/shortcut-hint" import { COL_MIN_W } from "../columns/use-column-viewport" import { columnCountChoices } from "./thread-chat-topbar-logic" +type ViewMode = "columns" | "canvas" + export function ThreadChatTopbar({ viewMode, showHelp, diff --git a/app/thread-chat/orchestration/navigation/thread-switcher-panel.tsx b/app/thread-chat/orchestration/navigation/thread-switcher-panel.tsx index e1cb70d7..ebd580d4 100644 --- a/app/thread-chat/orchestration/navigation/thread-switcher-panel.tsx +++ b/app/thread-chat/orchestration/navigation/thread-switcher-panel.tsx @@ -61,7 +61,7 @@ export function ThreadSwitcherPanel({ }, [hi]) const statusOf = (id: string): { label: string } | null => { - if (id === "main") return { label: "锚定" } + if (state.threads[id]?.parentId === null) return { label: "锚定" } const index = slots.findIndex((slot) => slot.id === id) if (index < 0) return null return { label: slots[index].folded ? "细条" : `第 ${index + 2} 列` } diff --git a/app/thread-chat/orchestration/navigation/tree-list-row.tsx b/app/thread-chat/orchestration/navigation/tree-list-row.tsx index 7af0ab9a..ce97b41e 100644 --- a/app/thread-chat/orchestration/navigation/tree-list-row.tsx +++ b/app/thread-chat/orchestration/navigation/tree-list-row.tsx @@ -2,7 +2,13 @@ import { Check, Pencil, Trash2, X } from "lucide-react" import { CUSTOM_TITLE_MAX_LEN } from "@/constants/thread-chat" -import type { TreeListItem } from "../../net/persistence/persist" + +export interface TreeListItem { + id: string + title: string + updatedAt: string + threadCount: number +} /** 相对时间:「刚刚 / N 分钟前 / N 小时前 / N 天前 / M月D日」 */ function relativeTime(iso: string): string { diff --git a/app/thread-chat/orchestration/navigation/tree-list.tsx b/app/thread-chat/orchestration/navigation/tree-list.tsx deleted file mode 100644 index 84abe394..00000000 --- a/app/thread-chat/orchestration/navigation/tree-list.tsx +++ /dev/null @@ -1,274 +0,0 @@ -"use client" -/** - * orchestration/tree-list —— 会话列表弹层(⌘⇧K / 顶栏「对话列表」按钮)。 - * - * 视觉沿用 ⌘K 切换器的 swx 弹层语言(tlx-* 类在 CSS 里复用同一套 token); - * 数据每次打开现拉(design D3:无缓存/无轮询,壳层以重挂方式打开保证归零)。 - * · 条目 = 展示标题(coalesce 双轨,服务端已做)+ 相对更新时间 + 分支数徽标; - * · 当前树高亮置顶——尚未入库(空树未保存)时以本地信息合成「未保存」条目; - * · 内联重命名(悬停铅笔 → 输入框,Enter 提交 / Esc 取消 / 失焦放弃): - * 乐观更新,PATCH 失败回滚 + 壳层 toast(design D5); - * · 删除二段确认(垃圾桶 → 变「确认删除」,点它处 / Esc 复位),成功后就地 - * 清理 localStorage 善后;删的是当前树时把「下一站」交回壳层跳转(design D4)。 - * - * Esc 语义:编辑态 / 确认态先被本组件的捕获期监听消费(stopPropagation), - * 其余 Esc 冒泡到壳层关闭链(弹层在链的最外层,先关它)。 - * - * 外壳是 shadcn/ui Dialog(Base UI):借它的 data-starting/ending-style 过渡状态机 - * 做进出双向动效(样式仍是 .tc 纸面 token,见 thread-chat.css)。Esc 的内建关闭被 - * dialogCloseToShell 取消并放行冒泡——上面这条捕获期 stopPropagation 的 Esc 链依然 - * 先于 Dialog 与壳层生效,行为不变。 - */ - -import React, { useEffect, useState } from "react" -import { ListTodo } from "lucide-react" -import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" -import { Dialog, DialogPortal } from "@/components/ui/dialog" -import { - CUSTOM_TITLE_MAX_LEN, - THREAD_CHAT_SHORTCUTS, -} from "@/constants/thread-chat" -import { dialogCloseToShell } from "../overlays/dialog-close-to-shell" -import { ShortcutHint } from "../overlays/shortcut-hint" -import { TreeListRow } from "./tree-list-row" -import { - cleanupAfterTreeDelete, - deleteTree, - listTrees, - renameTree, - type TreeListItem, -} from "../../net/persistence/persist" - -export interface TreeListProps { - /** 当前打开的树(用于高亮置顶与「未保存」合成) */ - currentTreeId: string - /** 当前树的本地合成信息:未入库时用它拼「未保存」条目 */ - currentTitle: string - currentThreadCount: number - /** 点击非当前树条目:壳层负责跳转(组件已先自关) */ - onSwitch: (treeId: string) => void - /** 删除的是当前树:nextTreeId = 剩余最近一棵(null = 一棵不剩,开新树) */ - onDeleteCurrent: (nextTreeId: string | null) => void - /** 删除当前树前抑制其存盘(true),失败时恢复(false)——防 DELETE 期间防抖 PUT 复活 */ - onSuppressCurrentSave?: (suppressed: boolean) => void - /** 重命名成功且改的是当前树时回调新标题——壳层用它同步主线列头副标题 */ - onRenamedCurrent?: (title: string) => void - onClose: () => void - /** 轻提示(沿用壳层 toast) */ - onToast: (msg: string) => void - /** 壳层的退场标记:true = Dialog 置 open=false 播放关闭动画(随后壳层卸载本组件) */ - closing?: boolean - /** Dialog Portal 的挂载点(.tc 根):保证 .swx / .tlx 选择器与纸面 CSS 变量继续生效 */ - container?: React.RefObject -} - -export function TreeList({ - currentTreeId, - currentTitle, - currentThreadCount, - onSwitch, - onDeleteCurrent, - onSuppressCurrentSave, - onRenamedCurrent, - onClose, - onToast, - closing = false, - container, -}: TreeListProps) { - /** null = 拉取中 */ - const [items, setItems] = useState(null) - /** 内联重命名中的树 id + 草稿 */ - const [editingId, setEditingId] = useState(null) - const [draft, setDraft] = useState("") - /** 二段删除确认中的树 id */ - const [confirmId, setConfirmId] = useState(null) - /** 删除请求进行中的树 id(防连点) */ - const [deletingId, setDeletingId] = useState(null) - - // 打开现拉(组件每次打开重挂,天然只拉一次) - useEffect(() => { - let cancelled = false - void listTrees().then((trees) => { - if (!cancelled) setItems(trees) - }) - return () => { - cancelled = true - } - }, []) - - // Esc:编辑态 / 确认态在捕获期先于壳层关闭链被消费 - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key !== "Escape") return - if (editingId !== null) { - e.stopPropagation() - setEditingId(null) - } else if (confirmId !== null) { - e.stopPropagation() - setConfirmId(null) - } - } - document.addEventListener("keydown", onKey, true) - return () => document.removeEventListener("keydown", onKey, true) - }, [editingId, confirmId]) - - /* ---------- 列表拼装:当前树置顶(未入库合成「未保存」条目) ---------- */ - const saved = items ?? [] - const currentSaved = saved.find((t) => t.id === currentTreeId) ?? null - const rest = saved.filter((t) => t.id !== currentTreeId) - const currentRow: TreeListItem = currentSaved ?? { - id: currentTreeId, - title: currentTitle, - updatedAt: "", - threadCount: currentThreadCount, - } - const rows: { item: TreeListItem; isCurrent: boolean; unsaved: boolean }[] = [ - { item: currentRow, isCurrent: true, unsaved: currentSaved === null }, - ...rest.map((item) => ({ item, isCurrent: false, unsaved: false })), - ] - - /* ---------- 内联重命名:乐观更新 + 失败回滚(design D5) ---------- */ - function startEdit(item: TreeListItem) { - setConfirmId(null) - setEditingId(item.id) - setDraft(item.title) - } - function commitEdit(id: string) { - const prev = - id === currentTreeId - ? currentRow.title - : (saved.find((t) => t.id === id)?.title ?? "") - const next = draft.trim() - setEditingId(null) - if (next === "" || next === prev) return - if (next.length > CUSTOM_TITLE_MAX_LEN) { - onToast(`标题最长 ${CUSTOM_TITLE_MAX_LEN} 字,未保存`) - return - } - // 乐观改本地列表;未入库的当前树没有可 PATCH 的行,直接提示 - if (id === currentTreeId && currentSaved === null) { - onToast("当前对话尚未保存,发出第一条消息后才能重命名") - return - } - setItems((list) => - (list ?? []).map((t) => (t.id === id ? { ...t, title: next } : t)) - ) - renameTree(id, next) - .then(() => { - // 改的是当前树:通知壳层同步本地 customTitle(主线列头副标题即时更新) - if (id === currentTreeId) onRenamedCurrent?.(next) - }) - .catch(() => { - setItems((list) => - (list ?? []).map((t) => (t.id === id ? { ...t, title: prev } : t)) - ) - onToast("重命名失败,已恢复原名") - }) - } - - /* ---------- 二段删除 + 善后(design D4) ---------- */ - async function doDelete(id: string) { - setConfirmId(null) - setDeletingId(id) - // 删的是当前树:先行抑制存盘(codex review:DELETE 往返期间防抖定时器可能触发 PUT, - // 晚到的 upsert 会复活刚删的行——抑制位挡新写,persist 写链保证在飞旧写先于 DELETE 落库) - if (id === currentTreeId) onSuppressCurrentSave?.(true) - try { - await deleteTree(id) - } catch { - if (id === currentTreeId) onSuppressCurrentSave?.(false) // 删除失败:树还在,恢复存盘 - setDeletingId(null) - onToast("删除失败,请重试") - return - } - cleanupAfterTreeDelete(id) // 清工作台记忆 + 悬空「最近一棵」指针 - const remaining = (items ?? []).filter((t) => t.id !== id) - setItems(remaining) - setDeletingId(null) - if (id === currentTreeId) { - // 跳剩余最近一棵(列表本就按 updated_at 降序);一棵不剩开新树 - const next = remaining.find((t) => t.id !== currentTreeId) - onDeleteCurrent(next?.id ?? null) - } else { - onToast("对话已删除") - } - } - - return ( - // Dialog 受控 open:closing 期间置 false 触发 data-ending-style 退场(Base UI 保持 - // Popup 挂载到 transition 结束)。modal=false + disablePointerDismissal 复刻旧行为: - // 不锁滚动 / 不困焦点 / 点外关闭由 Backdrop 的 onMouseDown 自己接;initialFocus=false - // 保持「打开不夺焦点」的旧语义(重命名输入框的 autoFocus 不受影响)。 - - - - {/* 面板内任意处 mousedown 复位删除确认态(确认按钮自身已 stopPropagation) */} - setConfirmId(null)} - > -
- - 对话列表 - -
-
- {items === null &&
加载中…
} - {items !== null && - rows.map(({ item, isCurrent, unsaved }) => { - const editing = editingId === item.id - const confirming = confirmId === item.id - return ( - { - onClose() - if (!isCurrent) onSwitch(item.id) - }} - onDraftChange={setDraft} - onCancelEdit={() => setEditingId(null)} - onCommitEdit={() => commitEdit(item.id)} - onStartEdit={() => startEdit(item)} - onRequestDelete={() => { - setEditingId(null) - setConfirmId(item.id) - }} - onConfirmDelete={() => void doDelete(item.id)} - onCancelDelete={() => setConfirmId(null)} - /> - ) - })} - {items !== null && rows.length === 1 && rows[0].unsaved && ( -
- 还没有保存过的对话——发出第一条消息即自动保存 -
- )} -
-
- 点击切换 - 悬停条目可重命名 / 删除 - - 关闭 - -
-
-
-
- ) -} diff --git a/app/thread-chat/orchestration/workspace/branch-workspace-actions.ts b/app/thread-chat/orchestration/workspace/branch-workspace-actions.ts deleted file mode 100644 index b15a2106..00000000 --- a/app/thread-chat/orchestration/workspace/branch-workspace-actions.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { activeLeafTurn, threadTitle, type TreeRow } from "../../core/selectors" -import type { ThreadStore } from "../../core/store" -import type { ThreadTreeState } from "../../core/types" -import type { SelectionInfo } from "../../branching/selection/use-assistant-text-selection" -import { kickoffQuestion } from "../../net/prompt/prompt" -import type { ChatController } from "../../net/chat-controller" -import type { PlacementHint, PlacementMode } from "../columns/placement" -import type { SwitcherMode } from "../navigation/thread-switcher" -import type { useColumnSlots } from "../columns/use-column-slots" -import type { ViewMode } from "../../net/persistence/persist" - -type ColumnWorkspace = ReturnType - -/** 页面视图可直接消费的分支导航命令组合。 */ -export function createBranchWorkspaceActions({ - state, - store, - chat, - columns, - viewMode, - mode, - setMode, - showColumnsView, - focusCanvasNode, - closeSwitcher, - showToast, -}: { - state: ThreadTreeState - store: ThreadStore - chat: ChatController - columns: ColumnWorkspace - viewMode: ViewMode - mode: PlacementMode - setMode(mode: PlacementMode): void - showColumnsView(): void - focusCanvasNode(threadId: string): void - closeSwitcher(): void - showToast(message: string, undo?: () => void): void -}) { - function openBranchUI( - id: string, - sourceId?: string | null, - hint?: PlacementHint - ) { - showColumnsView() - if (id === "main") { - columns.flashThread("main") - return - } - const effect = columns.openThread(id, sourceId ?? null, hint) - if (effect.kind === "replaced") { - showToast( - `第 ${effect.idx + 2} 列已替换:「${threadTitle(state, effect.replacedId)}」→「${threadTitle(state, id)}」`, - () => { - columns.restoreSlots(effect.prevSlots) - columns.flashThread(effect.replacedId) - } - ) - } else if (effect.kind === "folded") { - showToast( - `已打开「${threadTitle(state, id)}」,「${threadTitle(state, effect.foldedId)}」已折叠为细条` - ) - } - } - - function handleFork( - selection: SelectionInfo, - hint?: PlacementHint, - question?: string - ) { - const fork = store.fork({ - sourceThreadId: selection.threadId, - sourceMsgId: selection.msgId, - anchorText: selection.text, - anchor: selection.anchor, - }) - if (!fork) return - const trimmedQuestion = question?.trim() - if (trimmedQuestion) - chat.send(fork.threadId, trimmedQuestion, { text: selection.text }) - if (viewMode === "canvas") { - focusCanvasNode(fork.threadId) - showToast(`已开启分支 · ${fork.title}`) - return - } - const effect = columns.openThread(fork.threadId, selection.threadId, hint) - if (effect.kind === "replaced") { - showToast( - `已开启分支「${fork.title}」,替换了第 ${effect.idx + 2} 列的「${threadTitle(state, effect.replacedId)}」`, - () => { - columns.restoreSlots(effect.prevSlots) - columns.flashThread(effect.replacedId) - } - ) - } else if (effect.kind === "folded") { - showToast( - `已开启分支「${fork.title}」,「${threadTitle(state, effect.foldedId)}」已折叠为细条` - ) - } else { - showToast(`已开启分支 · ${fork.title}`) - } - } - - function changeMode(nextMode: PlacementMode) { - if (nextMode === mode) return - setMode(nextMode) - if (nextMode !== "replace") return - const dropped = columns.normalizeToReplace() - if (dropped.length) - showToast( - `已切回替换⑥:细条全部展开后,超出列数的「${dropped.map((id) => threadTitle(state, id)).join("」「")}」已收起` - ) - } - - function pickRow(row: TreeRow, switcherMode: SwitcherMode) { - closeSwitcher() - if (switcherMode.kind === "column") { - if (columns.slots[switcherMode.vpIndex]?.id === row.id) { - columns.flashThread(row.id) - return - } - columns.navColumn(switcherMode.vpIndex, row.id, "swap") - } else if (switcherMode.kind === "subtree") { - openBranchUI(row.id, switcherMode.rootId) - } else { - openBranchUI(row.id, null) - } - } - - function isThreadBusy(threadId: string): boolean { - const thread = state.threads[threadId] - const last = thread ? activeLeafTurn(thread)?.assistantMessage : null - return Boolean( - last && (last.status === "pending" || last.status === "streaming") - ) - } - - function composerPrefillFor(threadId: string): string | undefined { - const thread = state.threads[threadId] - return thread?.anchorText && thread.messages.length === 0 - ? kickoffQuestion(thread.anchorText) - : undefined - } - - return { - openBranchUI, - handleFork, - changeMode, - pickRow, - isThreadBusy, - composerPrefillFor, - } -} diff --git a/app/thread-chat/orchestration/workspace/ui-state-snapshot.ts b/app/thread-chat/orchestration/workspace/ui-state-snapshot.ts deleted file mode 100644 index 4e2ab5e9..00000000 --- a/app/thread-chat/orchestration/workspace/ui-state-snapshot.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { TreeUiState } from "../../net/persistence/persist" - -export function createTreeUiStateSnapshot(state: TreeUiState): TreeUiState { - return { - slots: state.slots, - widths: state.widths, - forceCols: state.forceCols, - mode: state.mode, - viewMode: state.viewMode, - } -} diff --git a/app/thread-chat/orchestration/workspace/use-thread-chat-runtime.ts b/app/thread-chat/orchestration/workspace/use-thread-chat-runtime.ts deleted file mode 100644 index 1cc07b6d..00000000 --- a/app/thread-chat/orchestration/workspace/use-thread-chat-runtime.ts +++ /dev/null @@ -1,99 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { isThreadChatModelId } from "@/constants/model" -import { createThreadStore } from "../../core/store" -import { useThreadStore } from "../../core/use-thread-store" -import type { MessageFeedbackSummary, ThreadTreeState } from "../../core/types" -import type { GenerationSummary, RecoverableTurn } from "../../generation/types" -import { useGenerationReconciliation } from "../../generation/use-generation-reconciliation" -import { useMessageActions } from "../../chat/actions/use-message-actions" -import { createChatController } from "../../net/chat-controller" -import { - deriveTreeTitle, - saveTreeStrict, - TreeRevisionError, -} from "../../net/persistence/persist" -import { useThreadTitles } from "../../net/titles/use-thread-titles" -import { useTreePersistence } from "../../net/persistence/use-tree-persistence" - -/** - * 页面壳使用的 thread-chat 运行时组合根:store、命令、generation 协调与持久化 - * 在这里接线,视图只消费组合后的能力。 - */ -export function useThreadChatRuntime({ - treeId, - initialState, - initialGenerations, - initialMessageFeedbacks, - initialRecoverableTurns, - onToast, -}: { - treeId: string - initialState: ThreadTreeState - initialGenerations: GenerationSummary[] - initialMessageFeedbacks: MessageFeedbackSummary[] - initialRecoverableTurns: RecoverableTurn[] - onToast: (message: string) => void -}) { - const [store] = useState(() => - createThreadStore(initialState, isThreadChatModelId) - ) - const version = useThreadStore(store) - const state = store.getState() - const reloadAfterRevisionConflict = () => { - onToast("其他标签页已更新,正在重新加载…") - window.location.reload() - } - - const [chat] = useState(() => - createChatController(store, { - treeId, - persistNow: () => { - const current = store.getState() - return saveTreeStrict(treeId, current, deriveTreeTitle(current)).catch( - (error) => { - if (error instanceof TreeRevisionError) - reloadAfterRevisionConflict() - throw error - } - ) - }, - onError: onToast, - }) - ) - const { messageActionState, messageCommands, registerRecoverableTurn } = - useMessageActions({ - state, - version, - initialRecoverableTurns, - initialMessageFeedbacks, - commands: chat, - }) - useGenerationReconciliation({ - store, - version, - initialGenerations, - registerRecoverableTurn, - isGenerationStreamingLocally: chat.isGenerationStreamingLocally, - }) - useEffect(() => () => chat.detachAll(), [chat]) - - const { setTreeSaveSuppressed, isTreeSaveSuppressed } = useTreePersistence({ - treeId, - store, - version, - onRevisionConflict: reloadAfterRevisionConflict, - }) - useThreadTitles({ treeId, store, version }) - - return { - store, - state, - chat, - messageActionState, - messageCommands, - setTreeSaveSuppressed, - isTreeSaveSuppressed, - } -} diff --git a/app/thread-chat/orchestration/workspace/use-thread-chat-workspace.ts b/app/thread-chat/orchestration/workspace/use-thread-chat-workspace.ts deleted file mode 100644 index ecaa5921..00000000 --- a/app/thread-chat/orchestration/workspace/use-thread-chat-workspace.ts +++ /dev/null @@ -1,104 +0,0 @@ -"use client" - -import { useCallback, useMemo, useRef, useState } from "react" -import type { ThreadStore } from "../../core/store" -import type { ThreadMessageActionCommands } from "../../chat/actions/message-action-commands" -import type { ChatController } from "../../net/chat-controller" -import type { TreeUiState, ViewMode } from "../../net/persistence/persist" -import type { CanvasChatActions } from "../canvas/canvas-actions" -import type { CanvasViewState } from "../canvas/use-canvas-layout" -import { useColumnSlots } from "../columns/use-column-slots" -import { useColumnViewport } from "../columns/use-column-viewport" -import type { PlacementMode } from "../columns/placement" -import { useUiStatePersistence } from "./use-ui-state-persistence" - -/** 列/画布工作台的响应式状态、持久化与画布命令适配组合。 */ -export function useThreadChatWorkspace({ - treeId, - store, - chat, - messageCommands, - initialUi, - isSaveSuppressed, -}: { - treeId: string - store: ThreadStore - chat: ChatController - messageCommands: ThreadMessageActionCommands - initialUi: TreeUiState | null - isSaveSuppressed(): boolean -}) { - const { windowWidth, autoColumnCount } = useColumnViewport() - const [forceCols, setForceCols] = useState( - initialUi?.forceCols ?? null - ) - const totalCols = forceCols ?? autoColumnCount - const maxExpanded = totalCols - 1 - const [mode, setMode] = useState(initialUi?.mode ?? "replace") - const columns = useColumnSlots({ - store, - maxExpanded, - mode, - initialSlots: initialUi?.slots, - initialWidths: initialUi?.widths, - }) - - const [viewMode, setViewMode] = useState( - initialUi?.viewMode ?? "columns" - ) - const [focusNode, setFocusNode] = useState<{ id: string; n: number } | null>( - null - ) - const focusSequence = useRef(0) - const showColumnsView = useCallback(() => { - setViewMode("columns") - setFocusNode(null) - }, []) - const focusCanvasNode = useCallback((id: string) => { - setFocusNode({ id, n: ++focusSequence.current }) - }, []) - - const canvasChat = useMemo( - () => ({ - send: chat.send, - stop: chat.stop, - retry: chat.retry, - retryAssistant: messageCommands.retryAssistant, - retryUserTurn: messageCommands.retryUserTurn, - editAndRegenerate: messageCommands.editAndRegenerate, - switchTurnVariant: messageCommands.switchTurnVariant, - submitFeedback: messageCommands.submitFeedback, - }), - [chat, messageCommands] - ) - const [canvasViewState] = useState(() => ({ - pins: new Map(), - })) - - useUiStatePersistence({ - treeId, - slots: columns.slots, - widths: columns.widths, - forceCols, - mode, - viewMode, - isSaveSuppressed, - }) - - return { - windowWidth, - forceCols, - setForceCols, - maxExpanded, - mode, - setMode, - columns, - viewMode, - setViewMode, - focusNode, - focusCanvasNode, - showColumnsView, - canvasChat, - canvasViewState, - } -} diff --git a/app/thread-chat/orchestration/workspace/use-ui-state-persistence.ts b/app/thread-chat/orchestration/workspace/use-ui-state-persistence.ts deleted file mode 100644 index 6c2a447e..00000000 --- a/app/thread-chat/orchestration/workspace/use-ui-state-persistence.ts +++ /dev/null @@ -1,31 +0,0 @@ -"use client" - -import { useEffect } from "react" -import { UI_SAVE_DEBOUNCE_MS } from "@/constants/thread-chat" -import type { TreeUiState } from "../../net/persistence/persist" -import { saveUiState } from "../../net/persistence/persist" -import { createTreeUiStateSnapshot } from "./ui-state-snapshot" - -export function useUiStatePersistence({ - treeId, - slots, - widths, - forceCols, - mode, - viewMode, - isSaveSuppressed, -}: TreeUiState & { - treeId: string - isSaveSuppressed(): boolean -}) { - useEffect(() => { - const timer = setTimeout(() => { - if (isSaveSuppressed()) return - saveUiState( - treeId, - createTreeUiStateSnapshot({ slots, widths, forceCols, mode, viewMode }) - ) - }, UI_SAVE_DEBOUNCE_MS) - return () => clearTimeout(timer) - }, [treeId, slots, widths, forceCols, mode, viewMode, isSaveSuppressed]) -} diff --git a/app/thread-chat/page-metadata.ts b/app/thread-chat/page-metadata.ts index 34f07128..9f9f3b2b 100644 --- a/app/thread-chat/page-metadata.ts +++ b/app/thread-chat/page-metadata.ts @@ -1,6 +1,6 @@ import type { Metadata } from "next" -/** /thread-chat 与 /thread-chat/[treeId] 共用的页面 metadata(两个路由是同一页面的跳板与本体) */ +/** /thread-chat 与 /thread-chat/[projectId] 共用的页面 metadata。 */ export const threadChatMetadata: Metadata = { title: "Thread Chat · 分支对话", description: diff --git a/app/thread-chat/page.tsx b/app/thread-chat/page.tsx index ce2d5503..145716a9 100644 --- a/app/thread-chat/page.tsx +++ b/app/thread-chat/page.tsx @@ -1,9 +1,9 @@ -import { TreeRedirect } from "./tree-redirect" +import { ProjectRedirect } from "./project-redirect" import { threadChatMetadata } from "./page-metadata" export const metadata = threadChatMetadata -/** 裸路径入口跳板:replace 到「最近一棵」或新生成的 /thread-chat/{treeId} */ +/** 裸路径入口跳板:replace 到最近 Project;没有 Project 时进入 `/new`。 */ export default function ThreadChatPage() { - return + return } diff --git a/app/thread-chat/project-redirect.tsx b/app/thread-chat/project-redirect.tsx new file mode 100644 index 00000000..83b4746d --- /dev/null +++ b/app/thread-chat/project-redirect.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import "./thread-chat.css" +import { useThreadChatAppRuntime } from "@/lib/thread-chat/client/providers" +import { threadChatRoutes } from "@/lib/thread-chat/api/routes" + +/** 裸路径只读取服务端 Project Catalog;客户端不生成或记忆领域实体 ID。 */ +export function ProjectRedirect() { + const router = useRouter() + const runtime = useThreadChatAppRuntime() + useEffect(() => { + let cancelled = false + void runtime.commands.loadProjectCatalog({ reset: true }).then(() => { + if (cancelled) return + const projectId = runtime.appStore.getState().catalog.orderedProjectIds[0] + router.replace( + projectId + ? threadChatRoutes.project(projectId) + : threadChatRoutes.newProject() + ) + }) + return () => { + cancelled = true + } + }, [router, runtime]) + return ( +
+
正在打开对话…
+
+ ) +} diff --git a/app/thread-chat/thread-chat-demo.tsx b/app/thread-chat/thread-chat-demo.tsx deleted file mode 100644 index 16d8ca83..00000000 --- a/app/thread-chat/thread-chat-demo.tsx +++ /dev/null @@ -1,406 +0,0 @@ -"use client" -/** - * -------------------------------------------------------------------------- - * Thread Chat · 分支对话(方案⑥ 自适应列 + 列满策略:替换⑥ / 细条⑤) - * -------------------------------------------------------------------------- - * 顶层壳:只负责状态编排与各层拼装,具体能力分四层实现—— - * · core/ headless 会话树 store + 选择器(useSyncExternalStore 绑定); - * · chat/ 单会话视图(消息列表 + composer),不知道树/列/分支; - * · branching/ 把「分支能力」注入 chat:锚点/脚注/面包屑/继承上文/划选气泡; - * · orchestration/ 视图编排:列视图(放置策略:替换⑥/细条⑤、切换器、Artifact 抽屉) - * 与画布视图(thread-canvas,React Flow 全树纵览,懒加载)两个平级视图层。 - * - * 「打开某会话」的统一意图入口是 openBranchUI:脚注 / ⌘K / 每列 ⇄ / 子树弹层 / - * Artifact 定位来源 / 画布双击节点全部走它——画布模式下先切回列视图(打开 = 去列里读), - * 列满时按当前策略替换(可撤销)或折叠细条。 - * - * 持久化(loader + inner 拆分):默认导出 ThreadChatDemo 通过 useThreadChatBoot - * 完成远端加载、strict-v2 清理与工作台恢复,随后才渲染 ThreadChatDemoInner - * (store 以已存状态为种子一次性创建)。inner 订阅 store - * version,useTreePersistence 防抖整树 PUT(流式高频跳变合并)+ 卸载 flush; - * 工作台状态(列槽/列宽/列数/策略/视图)按 treeId 分键防抖写 localStorage。 - * -------------------------------------------------------------------------- - */ - -import dynamic from "next/dynamic" -import { useRouter } from "next/navigation" -import React, { useState } from "react" -import "./thread-chat.css" -import { activePathArtifacts } from "./core/selectors" -import type { - Message, - MessageFeedbackSummary, - ThreadTreeState, -} from "./core/types" -import { deriveTreeTitle, type TreeUiState } from "./net/persistence/persist" -import type { GenerationSummary } from "./generation/types" -import type { RecoverableTurn } from "./generation/types" -import { useThreadChatBoot } from "./net/boot/use-thread-chat-boot" -import { BranchableChat } from "./branching/branchable-chat" -import { SelectionBubble } from "./branching/selection/selection-bubble" -import { type Slot } from "./orchestration/columns/placement" -import { ThreadColumns } from "./orchestration/columns/thread-columns" -import { ThreadSwitcher } from "./orchestration/navigation/thread-switcher" -import { TreeList } from "./orchestration/navigation/tree-list" -import { ArtifactDrawer } from "./orchestration/artifacts/artifact-drawer" -import { HelpPanel, UsageHint } from "./orchestration/overlays/help-panel" -import { ThreadChatTopbar } from "./orchestration/navigation/thread-chat-topbar" -import { useWorkspaceOverlays } from "./orchestration/overlays/use-workspace-overlays" -import { - useWorkspaceToast, - WorkspaceToast, -} from "./orchestration/overlays/workspace-toast" -import { useThreadChatRuntime } from "./orchestration/workspace/use-thread-chat-runtime" -import { useThreadChatWorkspace } from "./orchestration/workspace/use-thread-chat-workspace" -import { createBranchWorkspaceActions } from "./orchestration/workspace/branch-workspace-actions" - -/** 画布视图层懒加载:React Flow 只在首次进入画布模式时才落地(且跳过 SSR) */ -const ThreadCanvas = dynamic( - () => - import("./orchestration/canvas/thread-canvas").then((m) => m.ThreadCanvas), - { - ssr: false, - loading: () =>
画布加载中…
, - } -) - -/** 主线列头副标题的兜底:整棵树还没有任何用户消息(也没被重命名)时展示 */ -const SUBTITLE_FALLBACK = "新对话" - -/** 画布模式喂给划选气泡的空列槽(稳定引用):画布 fork 不占列槽(D4), - 气泡据此不渲染迷你列条(hasMap=false),提交路径由 handleFork 按视图分流 */ -const EMPTY_SLOTS: Slot[] = [] - -/** - * 默认导出的 loader:先完成远端加载(GET → sanitize → 读工作台记忆)再渲染 inner。 - * 加载失败 / 未命中都以空树降级(loadTree 内部已 console.warn),不阻塞页面。 - * treeId 变化由上层路由的 key={treeId} 整体重挂,不在此处处理切树。 - */ -export function ThreadChatDemo({ treeId }: { treeId: string }) { - const boot = useThreadChatBoot(treeId) - - if (!boot) { - return ( -
-
对话加载中…
-
- ) - } - return ( - - ) -} - -interface ThreadChatDemoInnerProps { - treeId: string - /** store 种子:已 sanitize 的持久化状态,或空树 */ - initialState: ThreadTreeState - /** 该树的工作台记忆(loader 已校验),null = 默认布局(只开主线) */ - initialUi: TreeUiState | null - /** 用户重命名过的标题(未改过为 null)——主线列头副标题优先展示 */ - initialCustomTitle?: string | null - initialGenerations: GenerationSummary[] - initialMessageFeedbacks: MessageFeedbackSummary[] - initialRecoverableTurns: RecoverableTurn[] -} - -export function ThreadChatDemoInner({ - treeId, - initialState, - initialUi, - initialCustomTitle = null, - initialGenerations, - initialMessageFeedbacks, - initialRecoverableTurns, -}: ThreadChatDemoInnerProps) { - const router = useRouter() - - const { toast, showToast, dismissToast } = useWorkspaceToast() - const { - store, - state, - chat, - messageActionState, - messageCommands, - setTreeSaveSuppressed, - isTreeSaveSuppressed, - } = useThreadChatRuntime({ - treeId, - initialState, - initialGenerations, - initialMessageFeedbacks, - initialRecoverableTurns, - onToast: showToast, - }) - - const { - windowWidth: winW, - forceCols, - setForceCols, - maxExpanded, - mode, - setMode, - columns: cols, - viewMode, - setViewMode, - focusNode, - focusCanvasNode, - showColumnsView, - canvasChat, - canvasViewState, - } = useThreadChatWorkspace({ - treeId, - store, - chat, - messageCommands, - initialUi, - isSaveSuppressed: isTreeSaveSuppressed, - }) - - /* ---------- 主线列头副标题:customTitle(用户重命名)→ 自动标题 / 派生回退 → 兜底 ---------- - customTitle 本地态由对话列表的 onRenamedCurrent 同步(重命名当前树立即生效,无需重载) */ - const [customTitle, setCustomTitle] = useState( - initialCustomTitle - ) - const mainHasMessage = (state.threads.main?.messages.length ?? 0) > 0 - const mainSubtitle = - customTitle ?? (mainHasMessage ? deriveTreeTitle(state) : SUBTITLE_FALLBACK) - - /* ---------- 其余 UI 状态 ---------- */ - /* 首次内联提示:仅「未关过 && 还没开始聊」时可见;顶栏帮助另走 Dialog。 */ - const [hintDismissed, setHintDismissed] = useState(false) - const { - rootRef: tcRootRef, - selection: sel, - setSelection: setSel, - switcher, - closeSwitcher, - toggleGlobalSwitcher, - openColumnSwitcher, - openSubtree, - treeList, - closeTreeList, - toggleTreeList, - helpPanel, - closeHelpPanel, - openHelpPanel, - drawerOpen, - activeArtifactId: activeArt, - setActiveArtifactId: setActiveArt, - openArtifact, - toggleDrawer, - closeDrawer, - } = useWorkspaceOverlays() - - const { - openBranchUI, - handleFork, - changeMode, - pickRow, - isThreadBusy, - composerPrefillFor, - } = createBranchWorkspaceActions({ - state, - store, - chat, - columns: cols, - viewMode, - mode, - setMode, - showColumnsView, - focusCanvasNode, - closeSwitcher, - showToast, - }) - - /* ---------- 主线 hint 卡片:仅整棵树还没有任何消息时展示(判 main 即可—— - 分支必经主线产生),首条消息一出现即随派生状态消失;× 可提前手动关。 ---------- */ - const hintVisible = !hintDismissed && !mainHasMessage - const hintNode = hintVisible ? ( - setHintDismissed(true)} /> - ) : null - - /* ---------- 顶栏数据 ---------- */ - const branchCount = Object.keys(state.threads).length - 1 - const markdownCount = activePathArtifacts(state).reduce( - (count, artifact) => count + (artifact.kind === "markdown" ? 1 : 0), - 0 - ) - - return ( -
- { - // 空树已经是新对话;反复点击不应让 URL 持续变化。 - if (!mainHasMessage) { - showToast("当前就是全新对话,直接开聊吧") - return - } - router.push(`/thread-chat/${crypto.randomUUID()}`) - }} - onToggleTreeList={toggleTreeList} - onOpenHelp={openHelpPanel} - onShowColumns={showColumnsView} - onShowCanvas={() => setViewMode("canvas")} - onForceCols={setForceCols} - onPlacementModeChange={changeMode} - onToggleThreadTree={toggleGlobalSwitcher} - onToggleMarkdown={toggleDrawer} - /> - - {viewMode === "columns" ? ( - openBranchUI(id, null)} - onCommitWidths={cols.commitWidths} - onResetWidths={cols.resetWidths} - renderThread={(threadId, vpIndex) => ( - - openBranchUI(target, threadId, opts) - } - onOpenArtifact={openArtifact} - onCrumbNav={(target) => - cols.navColumn(vpIndex, target, "collapse") - } - onOpenSwitcher={(btn) => openColumnSwitcher(vpIndex, btn)} - onOpenSubtree={(btn) => openSubtree(threadId, btn)} - onCollapse={() => cols.closeColumn(vpIndex)} - busy={isThreadBusy(threadId)} - composerPrefill={composerPrefillFor(threadId)} - onModelChange={(modelId) => - store.setThreadModel(threadId, modelId) - } - onRetry={(msg: Message) => chat.retry(threadId, msg.id)} - onStop={() => chat.stop(threadId)} - onSend={(text) => chat.send(threadId, text)} - messageActionState={messageActionState} - messageCommands={messageCommands} - /> - )} - /> - ) : ( - openBranchUI(id, null)} - onOpenArtifact={openArtifact} - /> - )} - - {/* 划选气泡两种视图都在(画布面板消息与列模式同一套 .md-body 划选 DOM 契约, - openspec: add-canvas-conversations)。列模式:列槽上下文喂迷你列条,预览与 - 提交共用 placement 规则;画布模式:喂空槽(不渲染列条,fork 不占列槽 D4)。 */} - state.threads[id]?.lastActive ?? 0} - /> - - {treeList !== null && ( - router.push(`/thread-chat/${id}`)} - onSuppressCurrentSave={(v) => { - // 删除前置位(失败恢复):挡住防抖回调与卸载 flush 的新写; - // 已在飞的 PUT 由 persist 写链保证先于 DELETE 落库,两头闭环 - setTreeSaveSuppressed(v) - }} - onDeleteCurrent={(nextId) => { - // 当前树已被删除:抑制卸载 flush / 防抖尾巴的回写(否则 DB 行复活), - // 再跳剩余最近一棵;一棵不剩则开新 UUID。replace 不给被删 URL 留历史。 - setTreeSaveSuppressed(true) - closeTreeList() - router.replace(`/thread-chat/${nextId ?? crypto.randomUUID()}`) - }} - onRenamedCurrent={setCustomTitle} - onToast={showToast} - /> - )} - - {switcher && ( - - )} - - {helpPanel && ( - - )} - - { - const sourceThread = state.threads[threadId] - if ( - sourceThread && - sourceThread.activeLeafMessageId !== sourceMessageId - ) - void chat - .switchTurnVariant(threadId, sourceMessageId) - .then((result) => { - if (result.ok) openBranchUI(threadId, null) - }) - else openBranchUI(threadId, null) - }} - /> - - -
- ) -} diff --git a/app/thread-chat/tree-redirect.tsx b/app/thread-chat/tree-redirect.tsx deleted file mode 100644 index 9a913c71..00000000 --- a/app/thread-chat/tree-redirect.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client" -/** - * 裸路径 /thread-chat 的入口跳板:客户端 effect 里读 localStorage 的「最近一棵」 - * treeId(无则生成新 UUID),router.replace 到 /thread-chat/{treeId}。 - * replace 不留历史——回退键不会弹回跳板页。localStorage 只在 effect 里碰(避免 - * SSR/hydration 问题),跳转前渲染 .tc 风格的一行轻量占位。 - */ - -import { useEffect } from "react" -import { useRouter } from "next/navigation" -import "./thread-chat.css" -import { getLastTreeId } from "./net/persistence/persist" - -export function TreeRedirect() { - const router = useRouter() - useEffect(() => { - const id = getLastTreeId() ?? crypto.randomUUID() - router.replace(`/thread-chat/${id}`) - }, [router]) - return ( -
-
正在打开对话…
-
- ) -} diff --git a/constants/generation.ts b/constants/generation.ts index 7d956261..348b9e33 100644 --- a/constants/generation.ts +++ b/constants/generation.ts @@ -37,4 +37,4 @@ export const GENERATION_ERRORS = { streamFailed: "生成失败,请重试。", } as const -export const GENERATION_BACKGROUND_LABEL = "正在后台生成,完成后显示" +export const GENERATION_BACKGROUND_LABEL = "正在后台继续生成" diff --git a/drizzle.test.config.ts b/drizzle.test.config.ts new file mode 100644 index 00000000..7016bf62 --- /dev/null +++ b/drizzle.test.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "drizzle-kit" +import { DB_SCHEMA } from "./lib/db/pg-schema" +import { + assertSafeTestDatabaseUrl, + loadTestDatabaseEnvironment, +} from "./scripts/lib/test-database-safety.mjs" + +loadTestDatabaseEnvironment() + +const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL) + +export default defineConfig({ + schema: "./lib/db/schema.ts", + out: "./drizzle-test", + dialect: "postgresql", + dbCredentials: { url: testDatabaseUrl }, + schemaFilter: [DB_SCHEMA], +}) diff --git a/e2e/thread-chat/README.md b/e2e/thread-chat/README.md deleted file mode 100644 index d1217487..00000000 --- a/e2e/thread-chat/README.md +++ /dev/null @@ -1,265 +0,0 @@ -# thread-chat e2e 验收脚本 - -`/thread-chat`(分支对话页)的验收脚本,均无需测试框架。 - -注:verify-live / verify-bubble-composer 直接 import 产品代码(`.ts`)生成断言 -期望值(kickoff 预填文案、默认分支标题等——文案改一处,测试期望自动跟随), -因此需要带 `--experimental-strip-types` 运行(与 text-anchor.test.mjs 同一机制)。 - -## text-anchor.test.mjs — 锚点定位纯函数用例(无需 dev server) - -```bash -node --experimental-strip-types e2e/thread-chat/text-anchor.test.mjs -``` - -覆盖 `app/thread-chat/branching/selection/text-anchor.ts` 纯字符串层的三层降级定位 -(position → exact → fuzzy):position 直接命中、exact 多处命中经 prefix/suffix -上下文消歧、**fuzzy 原文被改几个字后仍以 score≥阈值 命中正确区间**、 -阈值抬高 / 彻底无关锚点判定丢失(返回 null)、fuzzySubstring 单字错漏容忍。 - -## prompt-budget.test.mjs — 继承段字符预算纯函数用例(无需 dev server) - -```bash -node --experimental-strip-types e2e/thread-chat/prompt-budget.test.mjs -``` - -覆盖 `app/thread-chat/net/prompt/prompt-pure.ts` 的继承段预算截断(openspec: -add-bubble-composer D8):预算内不截断、超预算从最旧整条丢弃(顺序保持)、 -恰好等于预算的边界、**最新 1 条独超预算仍保留(保底 1 条)**、省略说明与 -kickoff 文案形状。 - -## Markdown Artifact 纯链路测试(无需 dev server) - -```bash -node --experimental-strip-types e2e/thread-chat/markdown-artifact.test.mjs -node --experimental-strip-types e2e/thread-chat/markdown-artifact-state.test.mjs -``` - -覆盖 Zod schema、整份外层 fence 归一化、双语高置信意图正反例、工具事件守卫与 -`toolCallId` 去重、`tool-input-start/delta` 局部 JSON 进度、真实字符/行数/最近章节、 -store 临时进度与完整 Artifact 原子替换、存盘剥离、Artifact-only 终态、retry 清理、 -加载 sanitize 及后续模型上下文序列化。 - -## Generation 刷新持久化测试(P0) - -结构化投影与整树合并是无数据库纯测试: - -```bash -node --import tsx e2e/thread-chat/generation-persistence.test.mjs -``` - -覆盖正文、Markdown Artifact、联网来源/研究上下文、partial error、空回复、确定性 -Artifact id、重复投影/合并幂等、fork 保留、旧 attempt CAS、缺消息读修复,以及只有 -服务端 active generation 能保留 pending 的加载 sanitize。 - -repository/finalize 是开发数据库测试;读取 `.env.local` 后运行,脚本创建随机测试用户 -与树,并在 `finally` 中级联清理: - -```bash -node --env-file=.env.local --import tsx e2e/thread-chat/generation-db.test.mjs -``` - -覆盖并发重复 start、单 current attempt、supersede、Stop-vs-complete、stale heartbeat、 -跨用户 404 语义,以及 superseded attempt 的结果审计与 finalize 重入只扣费一次。 - -浏览器手工验收必须使用当前 checkout 的 dev server:发送可控慢回复后刷新,页面应显示 -“正在后台生成,完成后显示”,服务端继续生成且只计费一次,轮询后原子显示完整结构化 -结果。明确 Stop 才会中止;普通刷新不得产生 stopped。P0 不恢复错过的逐 token 动画,只 -恢复后台状态和最终答案。 - -## 消息操作与轻量消息 DAG - -无需浏览器的纯行为测试: - -```bash -node --import tsx e2e/thread-chat/message-graph.test.mjs -node --import tsx e2e/thread-chat/reconcile-turns.test.mjs -node --import tsx e2e/thread-chat/regeneration-patch.test.mjs -node --import tsx e2e/thread-chat/message-actions-controller.test.mjs -node --import tsx e2e/thread-chat/message-action-availability.test.mjs -``` - -覆盖 strict schema-v2 拒绝旧线性树、active/exact-source path、回复版本、Artifact 来源、恢复状态、 -不可变 edit/regenerate patch,以及 controller 接受/拒绝/冲突/反馈命令。 - -以下脚本连接 `.env.local` 的开发数据库并在 `finally` 中清理随机测试用户: - -```bash -node --env-file=.env.local --import tsx e2e/thread-chat/generation-actions-db.test.mjs -node --env-file=.env.local --import tsx e2e/thread-chat/tree-revision-db.test.mjs -node --env-file=.env.local --import tsx e2e/thread-chat/tree-deletion-db.test.mjs -node --env-file=.env.local --import tsx e2e/thread-chat/tree-ownership-db.test.mjs -node --env-file=.env.local --import tsx e2e/thread-chat/tree-save-db.test.mjs -node --env-file=.env.local --import tsx e2e/thread-chat/tree-rename-db.test.mjs -node --env-file=.env.local --import tsx e2e/thread-chat/message-feedback-db.test.mjs -``` - -覆盖 generation intent 的原子落库、幂等 replay、running attempt supersede、terminal -source/Artifact 保留、active-leaf CAS、跨用户拒绝、generation-vs-switch revision -竞态、删除与 generation start 的行锁串行化及幂等删除、历史无主树的单次原子认领、 -整树保存的 owner/revision CAS,以及 message feedback 的 set/repeat/switch/clear、 -用户命名与派生标题隔离、完成态约束和 owner isolation。 -真实 UI 验收按仓库规则使用 `ego-browser nodejs` 访问 `localhost:4040`,不得用 -本目录旧的 Playwright 脚本替代本 change 的浏览器验收。 - -## Markdown Artifact 浏览器验收(mock API) - -前提:dev server 与本机 Chrome。脚本在浏览器层 mock 对话与整树持久化 API,不需要 -数据库、登录账号或模型额度。 - -```bash -CHROMIUM_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ -BASE_URL=http://localhost:4040 \ -node e2e/thread-chat/verify-markdown-artifact.mjs -``` - -覆盖列视图/Canvas 共用卡片、Artifact-only 无空气泡、右侧 GFM 预览、停止/重试、 -整树保存与刷新恢复、坏引用/孤儿清理,以及“修改刚才的 Markdown”的上下文回放。 - -## Shiki 代码高亮浏览器验收(mock API) - -前提:dev server、本机 Chrome,以及已登录会话的 Playwright storage state。脚本 mock -客户端 `/api/**`,因此真正运行的是当前 `/thread-chat` 的 `MarkdownBody`、Shiki -动态加载、Artifact drawer 和锚点交互;但服务端 layout 在请求进入浏览器拦截前已经 -校验真实会话,所以仍需登录态,不需要模型额度。 - -```bash -CHROMIUM_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ -STORAGE_STATE=/path/to/authenticated-storage-state.json \ -BASE_URL=http://localhost:4040 \ -node e2e/thread-chat/verify-syntax-highlighting.mjs -``` - -覆盖稳定消息与静态 Artifact 的 Shiki DOM、未知语言 plaintext fallback、代码内 -`", - language: "future-lang", - themeMode: "dark", -}) -ok("未知语言返回 plaintext", unknownResult.status === "plaintext") -ok("plaintext 不生成 HAST", unknownResult.hast === null) -ok( - "plaintext 保留完整原始代码", - unknownResult.code === "" -) - -const shellSessionResult = await highlightMarkdownCode({ - code: "$ echo must-stay-plaintext", - language: "shell-session", - themeMode: "light", -}) -ok("shell-session 高亮请求返回 plaintext", shellSessionResult.status === "plaintext") - -const staleBeforeStart = await highlightMarkdownCode({ - code: "const stale = true", - language: "ts", - themeMode: "dark", - isCurrent: () => false, -}) -ok("已过期 revision 不启动/提交高亮", staleBeforeStart.status === "stale") - -let current = true -const racingResultPromise = highlightMarkdownCode({ - code: "const revision: number = 1", - language: "ts", - themeMode: "dark", - isCurrent: () => current, -}) -current = false -const racingResult = await racingResultPromise -ok("初始化期间过期的 revision 被丢弃", racingResult.status === "stale") - -const highlighted = await highlightMarkdownCode({ - code: "const value = 1", - language: "js", - meta: "{1}", - themeMode: "light", -}) -ok("受支持语言生成 HAST", highlighted.status === "highlighted") -ok("高亮结果不使用 raw HTML 节点", !JSON.stringify(highlighted.hast).includes('"type":"raw"')) -ok( - "light 模式使用 Vitesse Light", - JSON.stringify(highlighted.hast).includes("vitesse-light") -) -ok( - "fence meta transformer 标记目标行", - JSON.stringify(highlighted.hast).includes("highlighted") -) -ok("高亮结果仍保留复制用原始代码", highlighted.code === "const value = 1") - -const darkHighlighted = await highlightMarkdownCode({ - code: "const dark = true", - language: "ts", - themeMode: "dark", -}) -ok( - "dark 模式使用 Vitesse Dark", - JSON.stringify(darkHighlighted.hast).includes("vitesse-dark") -) - -let attempts = 0 -const getRetryable = createRetryableSingleton(async () => { - attempts += 1 - if (attempts === 1) throw new Error("first initialization failed") - return { ready: true } -}) -const firstAttempt = await Promise.allSettled([getRetryable(), getRetryable()]) -ok( - "并发初始化失败只执行一次 factory", - attempts === 1 && firstAttempt.every((result) => result.status === "rejected") -) -const retried = await getRetryable() -ok("初始化失败后可重试", attempts === 2 && retried.ready) -ok("成功结果被 singleton 复用", (await getRetryable()) === retried && attempts === 2) - -process.exit(failed) diff --git a/e2e/thread-chat/text-anchor.test.mjs b/e2e/thread-chat/text-anchor.test.mjs deleted file mode 100644 index 0d912f0b..00000000 --- a/e2e/thread-chat/text-anchor.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -/** - * text-anchor 纯字符串层用例:验证锚点三层降级定位(position → exact → fuzzy)。 - * 只 import 纯字符串 API(不碰 DOM),可直接跑: - * node --experimental-strip-types e2e/thread-chat/text-anchor.test.mjs - */ -import { - buildQuoteSelector, - fuzzySubstring, - locateOffsets, - normalizeWhitespace, -} from "../../app/thread-chat/branching/selection/text-anchor.ts" - -let failed = 0 -const ok = (label, cond) => { - console.log(`${cond ? "PASS" : "FAIL"} ${label}`) - if (!cond) failed = 1 -} - -/* 同一短语「量子纠缠」出现 3 次的源文本(位置动态计算) */ -const text = - "量子纠缠是量子力学的核心现象,量子纠缠让两个粒子彼此关联,量子纠缠违反贝尔不等式。" -const idx = [...text.matchAll(/量子纠缠/g)].map((m) => m.index) -ok(`前置:短语出现 3 次(位置 ${idx.join("/")})`, idx.length === 3) - -/* 1) position 命中:position 指向第 2 次出现,切片与 exact 等价 → strategy=position */ -{ - const at = idx[1] - const anchor = { - quote: buildQuoteSelector(text, at, at + 4), - position: { start: at, end: at + 4 }, - } - const r = locateOffsets(text, anchor) - ok( - "position:直接命中第 2 次出现(strategy=position, score=1)", - r !== null && r.strategy === "position" && r.start === at && r.score === 1 - ) -} - -/* 2) exact 多处命中 → prefix/suffix 上下文消歧:无 position,靠上下文选中第 3 次 */ -{ - const at = idx[2] - const anchor = { - quote: buildQuoteSelector(text, at, at + 4), // prefix/suffix 采自第 3 次出现 - // 故意不给 position,强制走 exact 层 - } - const r = locateOffsets(text, anchor) - ok( - "exact:多处命中经 prefix/suffix 消歧 → 选中第 3 次(strategy=exact)", - r !== null && r.strategy === "exact" && r.start === idx[2] - ) -} - -/* 3) fuzzy:原文被改几个字后 exact 搜不到,仍以 score≥阈值 命中正确区间 */ -const longText = - "纠缠态在被测量之前并不具有确定的取值,这一点是理解量子力学非定域性的关键所在。" -{ - // 把「确定」改成「明确」、「关键」改成「要点」:exact 搜不到,fuzzy 应命中原句区间 - const mutated = - "纠缠态在被测量之前并不具有明确的取值,这一点是理解量子力学非定域性的要点所在" - const anchor = { quote: { exact: mutated, prefix: "", suffix: "" } } - const r = locateOffsets(longText, anchor) - const slice = r ? longText.slice(r.start, r.end) : "" - ok( - `fuzzy:改字后仍命中(strategy=fuzzy, score=${r ? r.score.toFixed(3) : "null"} ≥ 0.7)`, - r !== null && r.strategy === "fuzzy" && r.score >= 0.7 - ) - ok( - "fuzzy:命中区间落在原句上(含未改动的稳定尾串)", - slice.includes("这一点是理解量子力学") - ) -} - -/* 4) 低于阈值判丢失:同一改字锚点,把阈值抬到 0.98 → score 达不到 → 返回 null */ -{ - const mutated = - "纠缠态在被测量之前并不具有明确的取值,这一点是理解量子力学非定域性的要点所在" - const anchor = { quote: { exact: mutated, prefix: "", suffix: "" } } - const r = locateOffsets(longText, anchor, { fuzzyThreshold: 0.98 }) - ok("阈值 0.98:改动超过容忍 → 判定丢失(返回 null)", r === null) -} - -/* 5) 彻底无关的锚点:fuzzy 相似度很低 → 默认阈值下也判丢失 */ -{ - const anchor = { - quote: { - exact: "这是一段与原文毫不相干的陌生句子内容", - prefix: "", - suffix: "", - }, - } - const r = locateOffsets(longText, anchor) - ok("无关锚点:默认阈值下判定丢失(返回 null)", r === null) -} - -/* 6) fuzzySubstring 直接单测:空白归一化后子串近似匹配 */ -{ - const hay = normalizeWhitespace("alpha beta gamma delta") - const m = fuzzySubstring(hay, "beta gama") // gamma 少一个 m - ok( - "fuzzySubstring:容忍单字错漏,定位到 beta gamma 区间", - m !== null && hay.slice(m.start, m.end).startsWith("beta ") - ) -} - -process.exit(failed) diff --git a/e2e/thread-chat/thread-chat-boot.test.mjs b/e2e/thread-chat/thread-chat-boot.test.mjs deleted file mode 100644 index 2fdeaaba..00000000 --- a/e2e/thread-chat/thread-chat-boot.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict" -import { - threadChatBootSeed, - threadChatBootSeedOrFallback, -} from "../../app/thread-chat/net/boot/thread-chat-boot.ts" - -const empty = threadChatBootSeed({ state: null, generations: [] }) -assert.equal(empty.schemaVersion, 2) -assert.deepEqual(Object.keys(empty.threads), ["main"]) -assert.equal(empty.threads.main.activeLeafMessageId, null) - -const stored = structuredClone(empty) -stored.threads.main.title = "restored" -const restored = threadChatBootSeed({ state: stored, generations: [] }) -assert.equal(restored.threads.main.title, "restored") -assert.equal(restored.schemaVersion, 2) - -const invalidWarnings = [] -const fallback = threadChatBootSeedOrFallback( - { - state: { ...stored, schemaVersion: 1 }, - generations: [], - }, - (error) => invalidWarnings.push(error) -) -assert.equal(fallback.schemaVersion, 2) -assert.deepEqual(Object.keys(fallback.threads), ["main"]) -assert.equal(invalidWarnings.length, 1) - -console.log( - "PASS thread chat boot selects empty, sanitized, or invalid-state fallback seed" -) diff --git a/e2e/thread-chat/thread-chat-topbar.test.mjs b/e2e/thread-chat/thread-chat-topbar.test.mjs deleted file mode 100644 index cf47dc6a..00000000 --- a/e2e/thread-chat/thread-chat-topbar.test.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" -import { columnCountChoices } from "../../app/thread-chat/orchestration/navigation/thread-chat-topbar-logic.ts" - -assert.deepEqual(columnCountChoices(null), [ - { value: "auto", label: "自适应", active: true }, - { value: 2, label: "2", active: false }, - { value: 3, label: "3", active: false }, - { value: 4, label: "4", active: false }, -]) -assert.deepEqual( - columnCountChoices(3).filter((choice) => choice.active), - [{ value: 3, label: "3", active: true }] -) - -const [topbar, variantPicker] = await Promise.all([ - readFile( - new URL( - "../../app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx", - import.meta.url - ), - "utf8" - ), - readFile( - new URL( - "../../app/thread-chat/chat/actions/turn-variant-picker.tsx", - import.meta.url - ), - "utf8" - ), -]) -assert.match(topbar, /aria-label="视图模式"/) -assert.match(topbar, /aria-label="列数"/) -assert.match(topbar, /aria-pressed=\{viewMode === "columns"\}/) -assert.match(topbar, /aria-pressed=\{choice\.active\}/) -assert.match(topbar, /aria-pressed=\{placementMode === "replace"\}/) -assert.match(variantPicker, /role="group"[\s\S]*aria-label="回复版本切换"/) - -console.log( - "PASS Thread Chat topbar exposes one active auto/forced column choice" -) diff --git a/e2e/thread-chat/thread-generation-context.test.mjs b/e2e/thread-chat/thread-generation-context.test.mjs deleted file mode 100644 index 7e69a250..00000000 --- a/e2e/thread-chat/thread-generation-context.test.mjs +++ /dev/null @@ -1,170 +0,0 @@ -import assert from "node:assert/strict" -import { prepareThreadGenerationContext } from "../../app/api/chat/thread-generation-context.ts" - -const messages = [ - { id: "u0", role: "user", parts: [{ type: "text", text: "client" }] }, -] -const identity = { - treeId: "11111111-1111-4111-8111-111111111111", - threadId: "main", - userMessageId: "u1", - assistantMessageId: "a1", - generationId: "22222222-2222-4222-8222-222222222222", - intent: { kind: "persisted-turn" }, -} - -function dependencies(overrides = {}) { - return { - async prepare() { - assert.fail("prepare should be overridden for a valid Thread request") - }, - summarize: (generation) => generation, - compile: () => [], - createController: () => new AbortController(), - register() {}, - unregister() {}, - observe: () => ({ stop() {}, done: Promise.resolve() }), - async settleInitializationFailure() {}, - startErrorResponse: () => Response.json({ mapped: true }, { status: 409 }), - ...overrides, - } -} - -const linear = await prepareThreadGenerationContext( - { - userId: "user-1", - modelId: "minimax-m2", - messages, - threadChat: undefined, - }, - dependencies() -) -assert.equal(linear.kind, "ready") -assert.equal(linear.persistence, null) -assert.equal(linear.authoritativeMessages, messages) -assert.equal(linear.generationController, null) - -const existingGeneration = { id: identity.generationId, status: "streaming" } -const duplicate = await prepareThreadGenerationContext( - { - userId: "user-1", - modelId: "glm-5.3", - messages, - threadChat: identity, - }, - dependencies({ - async prepare(input) { - assert.equal(input.userId, "user-1") - assert.equal(input.modelId, "glm-5.3") - assert.equal(input.generationId, identity.generationId) - return { created: false, generation: existingGeneration } - }, - summarize(generation) { - return { id: generation.id, status: generation.status } - }, - }) -) -assert.equal(duplicate.kind, "response") -assert.equal(duplicate.response.status, 202) -assert.deepEqual(await duplicate.response.json(), { - generation: existingGeneration, -}) - -const authoritativeMessages = [ - { id: "persisted", role: "user", parts: [{ type: "text", text: "db" }] }, -] -const controller = new AbortController() -const observer = { stop() {}, done: Promise.resolve() } -const registrations = [] -const success = await prepareThreadGenerationContext( - { - userId: "user-1", - modelId: "glm-5.3", - messages, - threadChat: identity, - }, - dependencies({ - async prepare() { - return { - created: true, - revision: 7, - state: { - threads: { main: { anchorText: " authoritative anchor " } }, - }, - } - }, - compile(input) { - assert.equal(input.threadId, "main") - assert.equal(input.excludeAssistantMessageId, "a1") - return authoritativeMessages - }, - createController: () => controller, - register(generationId, receivedController) { - registrations.push([generationId, receivedController]) - }, - observe: () => observer, - }) -) -assert.equal(success.kind, "ready") -assert.equal(success.persistence.generationId, identity.generationId) -assert.equal(success.authoritativeMessages, authoritativeMessages) -assert.equal(success.authoritativeAnchorText, " authoritative anchor ") -assert.equal(success.preparedRevision, 7) -assert.equal(success.generationController, controller) -assert.equal(success.generationObserver, observer) -assert.deepEqual(registrations, [[identity.generationId, controller]]) - -const mappedFailure = await prepareThreadGenerationContext( - { - userId: "user-1", - modelId: "glm-5.3", - messages, - threadChat: identity, - }, - dependencies({ - async prepare() { - throw new Error("conflict") - }, - }) -) -assert.equal(mappedFailure.kind, "response") -assert.equal(mappedFailure.response.status, 409) -assert.deepEqual(await mappedFailure.response.json(), { mapped: true }) - -const initializationSettlements = [] -const initializationFailure = await prepareThreadGenerationContext( - { - userId: "user-1", - modelId: "glm-5.3", - messages, - threadChat: identity, - unbilledPreview: false, - }, - dependencies({ - async prepare() { - return { - created: true, - revision: 8, - state: { threads: { main: { anchorText: null } } }, - } - }, - compile() { - throw new Error("compile failed after generation creation") - }, - async settleInitializationFailure(input) { - initializationSettlements.push(input) - }, - }) -) -assert.equal(initializationFailure.kind, "response") -assert.equal(initializationFailure.response.status, 500) -assert.equal(initializationSettlements.length, 1) -assert.equal( - initializationSettlements[0].persistence.generationId, - identity.generationId -) -assert.equal(initializationSettlements[0].usageUnavailable, true) - -console.log( - "PASS thread generation context owns start idempotency, authoritative state, cancellation, and post-start settlement" -) diff --git a/e2e/thread-chat/thread-switcher-panel.test.mjs b/e2e/thread-chat/thread-switcher-panel.test.mjs deleted file mode 100644 index edb2171f..00000000 --- a/e2e/thread-chat/thread-switcher-panel.test.mjs +++ /dev/null @@ -1,93 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" -import React from "react" -import { renderToStaticMarkup } from "react-dom/server" -import test from "node:test" - -import { ThreadSwitcherPanel } from "../../app/thread-chat/orchestration/navigation/thread-switcher-panel.tsx" - -const thread = (overrides) => ({ - id: "main", - modelId: "glm-5.3", - parentId: null, - depth: 0, - title: "Main research", - anchorText: null, - forkFromMsgId: null, - footnote: null, - children: ["branch"], - messages: [], - activeLeafMessageId: null, - lastActive: 1, - ...overrides, -}) -const state = { - schemaVersion: 2, - threads: { - main: thread({}), - branch: thread({ - id: "branch", - parentId: "main", - depth: 1, - title: "Focused branch", - anchorText: "selected source", - footnote: 1, - children: [], - }), - }, - artifacts: {}, - artifactOrder: [], - recents: ["branch"], - footnoteCounter: 1, - seq: 1, - tick: 1, -} -const noop = () => {} - -await test("global panel renders search, recents, tree rows, and placement status", () => { - const html = renderToStaticMarkup( - React.createElement(ThreadSwitcherPanel, { - state, - mode: { kind: "global" }, - slots: [{ id: "branch", folded: false }], - recents: ["branch"], - onPick: noop, - }) - ) - - assert.match(html, /搜索会话(标题 \/ 划选原文)/) - assert.match(html, /最近访问/) - assert.match(html, /Main research/) - assert.match(html, /Focused branch/) - assert.match(html, /第 2 列/) -}) - -await test("subtree panel owns its title, empty state, and compact footer", () => { - const html = renderToStaticMarkup( - React.createElement(ThreadSwitcherPanel, { - state, - mode: { kind: "subtree", rootId: "branch", x: 10, y: 20 }, - slots: [], - recents: [], - onPick: noop, - }) - ) - - assert.match(html, /『Focused branch』的子分支/) - assert.match(html, /还没有子分支/) - assert.match(html, /点击行打开(列满走当前策略)/) - assert.doesNotMatch(html, / { - const shell = await readFile( - new URL( - "../../app/thread-chat/orchestration/navigation/thread-switcher.tsx", - import.meta.url - ), - "utf8" - ) - - assert.match(shell, / - message.role === "assistant" - ? { ...message, status: "streaming" } - : message - ), - }), - null -) - -const originalFetch = globalThis.fetch -let capturedRequest = null -try { - globalThis.fetch = async (input, init) => { - capturedRequest = { input, init } - return Response.json({ title: " Unified title " }) - } - assert.equal( - await requestThreadTitle({ kind: "main", question: "hello" }), - "Unified title" - ) -} finally { - globalThis.fetch = originalFetch -} - -assert.equal(capturedRequest.input, "/api/title") -assert.equal(capturedRequest.init.method, "POST") -assert.deepEqual(JSON.parse(capturedRequest.init.body), { - kind: "main", - question: "hello", -}) -assert.equal( - parseThreadTitleInput({ - anchorText, - question: "legacy request", - answer: "missing explicit kind", - }), - null -) -assert.deepEqual( - parseThreadTitleInput({ - kind: "branch", - anchorText, - question: "explicit request", - answer: "accepted", - }), - { - kind: "branch", - anchorText, - question: "explicit request", - answer: "accepted", - } -) - -console.log( - "PASS thread title candidates and client request share the unified explicit title contract" -) diff --git a/e2e/thread-chat/thread-tree-schema-source.test.mjs b/e2e/thread-chat/thread-tree-schema-source.test.mjs deleted file mode 100644 index e7f17e5f..00000000 --- a/e2e/thread-chat/thread-tree-schema-source.test.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" -import test from "node:test" - -import { THREAD_TREE_SCHEMA_VERSION } from "../../constants/thread-chat.ts" - -const typesUrl = new URL( - "../../lib/thread-chat/domain/types.ts", - import.meta.url -) -const seedUrl = new URL("../../app/thread-chat/core/seed.ts", import.meta.url) -const treeRouteUrl = new URL( - "../../app/api/branch-trees/[treeId]/route.ts", - import.meta.url -) - -test("thread tree type, parser, and seed share one schema version", async () => { - const [types, seed, treeRoute] = await Promise.all([ - readFile(typesUrl, "utf8"), - readFile(seedUrl, "utf8"), - readFile(treeRouteUrl, "utf8"), - ]) - - assert.equal(THREAD_TREE_SCHEMA_VERSION, 2) - assert.match(types, /schemaVersion:\s*typeof THREAD_TREE_SCHEMA_VERSION/) - assert.match(seed, /schemaVersion:\s*THREAD_TREE_SCHEMA_VERSION/) - assert.doesNotMatch(types, /schemaVersion:\s*2/) - assert.doesNotMatch(seed, /schemaVersion:\s*2/) - assert.doesNotMatch(treeRoute, /incomingSchemaVersion\s*!==\s*2/) - assert.match(treeRoute, /incomingSchemaVersion\s*!==\s*THREAD_TREE_SCHEMA_VERSION/) -}) diff --git a/e2e/thread-chat/tool-step-policy.test.mjs b/e2e/thread-chat/tool-step-policy.test.mjs deleted file mode 100644 index 0a17bac5..00000000 --- a/e2e/thread-chat/tool-step-policy.test.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "node:assert/strict" -import { createToolStepPolicy } from "../../app/api/chat/tool-step-policy.ts" -import { researchToolNames } from "../../app/api/chat/research-tool-capabilities.ts" - -assert.deepEqual(researchToolNames("answer"), []) -assert.deepEqual(researchToolNames("fetch"), ["readUrl"]) -assert.deepEqual(researchToolNames("search"), ["webSearch", "readUrl"]) -assert.deepEqual(researchToolNames("research"), ["webSearch", "readUrl"]) - -assert.equal( - createToolStepPolicy({ - isThreadChat: false, - markdownArtifactRequested: false, - researchMode: "answer", - }), - undefined -) - -const fetchPolicy = createToolStepPolicy({ - isThreadChat: true, - markdownArtifactRequested: true, - researchMode: "fetch", -}) -assert.deepEqual(fetchPolicy?.({ stepNumber: 0 }), { - activeTools: ["createMarkdownArtifact", "readUrl"], - toolChoice: { type: "tool", toolName: "readUrl" }, -}) -assert.deepEqual(fetchPolicy?.({ stepNumber: 1 }), { - activeTools: ["createMarkdownArtifact", "readUrl"], -}) - -for (const researchMode of ["search", "research"]) { - const policy = createToolStepPolicy({ - isThreadChat: false, - markdownArtifactRequested: false, - researchMode, - }) - assert.deepEqual(policy?.({ stepNumber: 0 }), { - activeTools: ["webSearch", "readUrl"], - toolChoice: { type: "tool", toolName: "webSearch" }, - }) - assert.deepEqual(policy?.({ stepNumber: 2 }), { - activeTools: ["webSearch", "readUrl"], - }) -} - -const markdownPolicy = createToolStepPolicy({ - isThreadChat: true, - markdownArtifactRequested: true, - researchMode: "answer", -}) -assert.deepEqual(markdownPolicy?.({ stepNumber: 0 }), { - activeTools: ["createMarkdownArtifact"], - toolChoice: { type: "tool", toolName: "createMarkdownArtifact" }, -}) -assert.deepEqual(markdownPolicy?.({ stepNumber: 1 }), { - activeTools: ["createMarkdownArtifact"], -}) - -console.log( - "PASS chat tool step policy preserves route priority and later-step availability" -) diff --git a/e2e/thread-chat/topbar-css-ownership.test.mjs b/e2e/thread-chat/topbar-css-ownership.test.mjs deleted file mode 100644 index d8114807..00000000 --- a/e2e/thread-chat/topbar-css-ownership.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict" -import { existsSync, readFileSync } from "node:fs" - -const topbar = readFileSync( - new URL("../../app/thread-chat/styles/topbar.css", import.meta.url), - "utf8" -) -const canvas = readFileSync( - new URL("../../app/thread-chat/styles/canvas.css", import.meta.url), - "utf8" -) -const entry = readFileSync( - new URL("../../app/thread-chat/thread-chat.css", import.meta.url), - "utf8" -) -const legacyHelpUrl = new URL( - "../../app/thread-chat/styles/topbar-help.css", - import.meta.url -) - -assert.equal(topbar.match(/\.tc \.seg button\.mode\s*\{/g)?.length, 1) -assert.match( - topbar, - /\.tc \.seg button\.mode\s*\{[\s\S]*?display:\s*inline-flex;[\s\S]*?align-items:\s*center;[\s\S]*?gap:\s*5px;/ -) -assert.doesNotMatch(canvas, /\.seg button\.mode/) -assert.equal(topbar.match(/\.tc \.tbtn\.help\s*\{/g)?.length, 1) -assert.equal(topbar.match(/\.tc \.tbtn\.help:hover\s*\{/g)?.length, 1) -assert.doesNotMatch(entry, /topbar-help\.css/) -assert.equal(existsSync(legacyHelpUrl), false) - -console.log("PASS topbar controls are owned only by topbar.css") diff --git a/e2e/thread-chat/tree-deletion-db.test.mjs b/e2e/thread-chat/tree-deletion-db.test.mjs deleted file mode 100644 index 4e653a63..00000000 --- a/e2e/thread-chat/tree-deletion-db.test.mjs +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Atomic tree deletion and generation-start race: - * node --env-file=.env.local --import tsx e2e/thread-chat/tree-deletion-db.test.mjs - */ -import assert from "node:assert/strict" -import { randomUUID } from "node:crypto" -import { eq } from "drizzle-orm" -import { db } from "../../lib/db/index.ts" -import { branchGenerations, branchTrees, user } from "../../lib/db/schema.ts" -import { - GenerationRepositoryError, - prepareGeneration, -} from "../../lib/thread-chat-generation/start-generation-repository.ts" -import { deleteOwnedTreeIfIdle } from "../../lib/thread-chat-generation/tree-repository.ts" - -const suffix = randomUUID() -const userId = `tree-deletion-${suffix}` -const treeId = randomUUID() -const generationId = randomUUID() - -const state = { - schemaVersion: 2, - threads: { - main: { - id: "main", - modelId: "glm-5.3", - parentId: null, - depth: 0, - title: "主线", - anchorText: null, - forkFromMsgId: null, - footnote: null, - children: [], - messages: [ - { - id: "u1", - parentMessageId: null, - role: "user", - text: "并发删除测试", - forks: [], - }, - { - id: "a1", - parentMessageId: "u1", - role: "assistant", - text: "", - forks: [], - generationId, - status: "pending", - }, - ], - activeLeafMessageId: "a1", - lastActive: 1, - }, - }, - artifacts: {}, - artifactOrder: [], - recents: [], - footnoteCounter: 0, - seq: 2, - tick: 1, -} - -async function run() { - await db.insert(user).values({ - id: userId, - name: "tree deletion test", - email: `${userId}@example.test`, - emailVerified: true, - }) - await db.insert(branchTrees).values({ id: treeId, userId, state }) - - const [deleteAttempt, startAttempt] = await Promise.allSettled([ - deleteOwnedTreeIfIdle({ userId, treeId }), - prepareGeneration({ - userId, - treeId, - threadId: "main", - modelId: "glm-5.3", - userMessageId: "u1", - assistantMessageId: "a1", - generationId, - intent: { kind: "persisted-turn" }, - }), - ]) - - assert.equal(deleteAttempt.status, "fulfilled") - if (deleteAttempt.value === "deleted") { - assert.equal(startAttempt.status, "rejected") - assert.ok(startAttempt.reason instanceof GenerationRepositoryError) - assert.equal(startAttempt.reason.code, "not_found") - } else { - assert.equal(deleteAttempt.value, "generation_running") - assert.equal(startAttempt.status, "fulfilled") - assert.equal(startAttempt.value.created, true) - - await db - .update(branchGenerations) - .set({ status: "completed", finishedAt: new Date() }) - .where(eq(branchGenerations.id, generationId)) - assert.equal( - await deleteOwnedTreeIfIdle({ userId, treeId }), - "deleted" - ) - } - - assert.equal( - await deleteOwnedTreeIfIdle({ userId, treeId }), - "not_found", - "repeated deletion must remain idempotent" - ) - assert.equal( - (await db.select().from(branchTrees).where(eq(branchTrees.id, treeId))) - .length, - 0 - ) - - console.log( - "PASS tree deletion serializes with generation start and remains idempotent" - ) -} - -try { - await run() -} finally { - await db.delete(user).where(eq(user.id, userId)) -} diff --git a/e2e/thread-chat/tree-list-db.test.mjs b/e2e/thread-chat/tree-list-db.test.mjs deleted file mode 100644 index 4c27d52d..00000000 --- a/e2e/thread-chat/tree-list-db.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Owner-scoped tree list projection: - * node --env-file=.env.local --import tsx e2e/thread-chat/tree-list-db.test.mjs - */ -import assert from "node:assert/strict" -import { randomUUID } from "node:crypto" -import { inArray } from "drizzle-orm" -import { db } from "../../lib/db/index.ts" -import { branchTrees, user } from "../../lib/db/schema.ts" -import { listOwnedTreeSummaries } from "../../lib/thread-chat-generation/tree-repository.ts" - -const suffix = randomUUID() -const ownerId = `tree-list-owner-${suffix}` -const strangerId = `tree-list-stranger-${suffix}` -const treeIds = [randomUUID(), randomUUID(), randomUUID()] - -const stateWithThreads = (threads) => ({ schemaVersion: 2, threads }) - -async function run() { - await db.insert(user).values([ - { - id: ownerId, - name: "tree list owner", - email: `${ownerId}@example.test`, - emailVerified: true, - }, - { - id: strangerId, - name: "tree list stranger", - email: `${strangerId}@example.test`, - emailVerified: true, - }, - ]) - - const older = new Date("2026-01-01T00:00:00.000Z") - const newer = new Date("2026-01-02T00:00:00.000Z") - await db.insert(branchTrees).values([ - { - id: treeIds[0], - userId: ownerId, - title: "Derived title", - customTitle: "Custom title", - state: stateWithThreads({ main: {}, child: {} }), - updatedAt: older, - }, - { - id: treeIds[1], - userId: ownerId, - title: null, - customTitle: null, - state: stateWithThreads([]), - updatedAt: newer, - }, - { - id: treeIds[2], - userId: strangerId, - title: "Private stranger tree", - state: stateWithThreads({ main: {} }), - updatedAt: new Date("2026-01-03T00:00:00.000Z"), - }, - ]) - - const rows = await listOwnedTreeSummaries(ownerId) - - assert.deepEqual( - rows.map(({ id, title, threadCount }) => ({ id, title, threadCount })), - [ - { id: treeIds[1], title: "未命名对话", threadCount: 0 }, - { id: treeIds[0], title: "Custom title", threadCount: 2 }, - ] - ) - assert.deepEqual( - rows.map(({ updatedAt }) => updatedAt.toISOString()), - [newer.toISOString(), older.toISOString()] - ) - - console.log( - "PASS tree list preserves owner isolation, title fallback, poison-row defense, count, and ordering" - ) -} - -try { - await run() -} finally { - await db.delete(branchTrees).where(inArray(branchTrees.id, treeIds)) - await db.delete(user).where(inArray(user.id, [ownerId, strangerId])) -} diff --git a/e2e/thread-chat/tree-list-repository-ownership.test.mjs b/e2e/thread-chat/tree-list-repository-ownership.test.mjs deleted file mode 100644 index ad94b7af..00000000 --- a/e2e/thread-chat/tree-list-repository-ownership.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" - -const route = await readFile( - new URL("../../app/api/branch-trees/route.ts", import.meta.url), - "utf8" -) -const repository = await readFile( - new URL( - "../../lib/thread-chat-generation/tree-repository.ts", - import.meta.url - ), - "utf8" -) - -assert.match(route, /listOwnedTreeSummaries\(userId\)/) -assert.doesNotMatch(route, /drizzle-orm|@\/lib\/db|branchTrees/) -assert.match(repository, /function listOwnedTreeSummaries/) -assert.match(repository, /jsonb_object_keys/) -assert.match(repository, /\.limit\(100\)/) - -console.log("PASS tree list route delegates persistence to the repository") diff --git a/e2e/thread-chat/tree-list-row.test.mjs b/e2e/thread-chat/tree-list-row.test.mjs deleted file mode 100644 index c3d7ea0e..00000000 --- a/e2e/thread-chat/tree-list-row.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" -import React from "react" -import { renderToStaticMarkup } from "react-dom/server" -import test from "node:test" - -import { CUSTOM_TITLE_MAX_LEN } from "../../constants/thread-chat.ts" -import { TreeListRow } from "../../app/thread-chat/orchestration/navigation/tree-list-row.tsx" - -const noop = () => {} -const baseProps = { - item: { - id: "tree-1", - title: "Research notes", - updatedAt: "", - threadCount: 3, - }, - isCurrent: true, - unsaved: false, - editing: false, - confirming: false, - deleting: false, - draft: "Research notes", - onSelect: noop, - onDraftChange: noop, - onCancelEdit: noop, - onCommitEdit: noop, - onStartEdit: noop, - onRequestDelete: noop, - onConfirmDelete: noop, - onCancelDelete: noop, -} - -await test("saved tree row owns title, current badge, branch count, and actions", () => { - const html = renderToStaticMarkup(React.createElement(TreeListRow, baseProps)) - - assert.match(html, /Research notes/) - assert.match(html, />当前 { - const unsaved = renderToStaticMarkup( - React.createElement(TreeListRow, { ...baseProps, unsaved: true }) - ) - assert.match(unsaved, />未保存 { - const html = renderToStaticMarkup( - React.createElement(TreeListRow, { ...baseProps, confirming: true }) - ) - - assert.match(html, /确认删除/) - assert.match(html, /title="取消"/) - assert.doesNotMatch(html, /title="重命名"|title="删除此对话"/) -}) - -await test("tree list composes the row instead of owning its icon actions", async () => { - const source = await readFile( - new URL( - "../../app/thread-chat/orchestration/navigation/tree-list.tsx", - import.meta.url - ), - "utf8" - ) - - assert.match(source, / ({ - id, - name: `tree claimant ${index}`, - email: `${id}@example.test`, - emailVerified: true, - })) - ) - await db.insert(branchTrees).values({ id: treeId, userId: null, state }) - - const claims = await Promise.all( - claimantIds.map((userId) => - loadOwnedOrClaimLegacyTree({ userId, treeId }) - ) - ) - assert.equal( - claims.filter(Boolean).length, - 1, - "only one concurrent exact-URL visitor may claim the legacy tree" - ) - - const winnerIndex = claims.findIndex(Boolean) - const winnerId = claimantIds[winnerIndex] - const loserId = claimantIds[1 - winnerIndex] - const [persisted] = await db - .select({ userId: branchTrees.userId }) - .from(branchTrees) - .where(eq(branchTrees.id, treeId)) - assert.equal(persisted.userId, winnerId) - assert.ok(await loadOwnedOrClaimLegacyTree({ userId: winnerId, treeId })) - assert.equal( - await loadOwnedOrClaimLegacyTree({ userId: loserId, treeId }), - null, - "a claimed tree must never transfer to a second user" - ) - - console.log( - "PASS legacy tree exact-URL claim is atomic, sticky, and owner-isolated" - ) -} - -try { - await run() -} finally { - await db.delete(branchTrees).where(eq(branchTrees.id, treeId)) - await db.delete(user).where(inArray(user.id, claimantIds)) -} diff --git a/e2e/thread-chat/tree-persistence-write.test.mjs b/e2e/thread-chat/tree-persistence-write.test.mjs deleted file mode 100644 index 4fd66f54..00000000 --- a/e2e/thread-chat/tree-persistence-write.test.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" - -import { emptySeedState } from "../../app/thread-chat/core/seed.ts" -import { - getKnownTreeRevision, - saveTree, - saveTreeStrict, - setKnownTreeRevision, -} from "../../app/thread-chat/net/persistence/persist.ts" - -const source = await readFile( - new URL("../../app/thread-chat/net/persistence/persist.ts", import.meta.url), - "utf8" -) -assert.equal( - source.match(/method: "PUT"/g)?.length, - 1, - "one shared PUT primitive should own the tree write protocol" -) - -const treeId = "11111111-1111-4111-8111-111111111111" -const calls = [] -const originalFetch = globalThis.fetch -globalThis.fetch = async (url, init) => { - calls.push({ url, init, body: JSON.parse(init.body) }) - return Response.json({ ok: true, revision: calls.length }) -} - -try { - setKnownTreeRevision(treeId, 0) - await saveTreeStrict(treeId, emptySeedState(), "strict") - await saveTree(treeId, emptySeedState(), "best effort") - // /api/chat 可能在上面的第二次存盘之后才返回启动时捕获的旧 revision; - // 旧响应不得让下一次存盘倒退到已经失效的 baseRevision。 - setKnownTreeRevision(treeId, 1) - await saveTreeStrict(treeId, emptySeedState(), "after stale response") -} finally { - globalThis.fetch = originalFetch -} - -assert.equal(calls.length, 3) -assert.deepEqual( - calls.map((call) => [call.init.method, call.body.title, call.body.baseRevision]), - [ - ["PUT", "strict", 0], - ["PUT", "best effort", 1], - ["PUT", "after stale response", 2], - ] -) -assert.equal(getKnownTreeRevision(treeId), 3) - -console.log( - "PASS tree saves share one PUT primitive and stale responses cannot regress revision" -) diff --git a/e2e/thread-chat/tree-rename-db.test.mjs b/e2e/thread-chat/tree-rename-db.test.mjs deleted file mode 100644 index aff02bbe..00000000 --- a/e2e/thread-chat/tree-rename-db.test.mjs +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Owner-scoped tree rename: - * node --env-file=.env.local --import tsx e2e/thread-chat/tree-rename-db.test.mjs - */ -import assert from "node:assert/strict" -import { randomUUID } from "node:crypto" -import { eq, inArray } from "drizzle-orm" -import { db } from "../../lib/db/index.ts" -import { branchTrees, user } from "../../lib/db/schema.ts" -import { renameOwnedTree } from "../../lib/thread-chat-generation/tree-repository.ts" - -const suffix = randomUUID() -const ownerId = `tree-rename-owner-${suffix}` -const strangerId = `tree-rename-stranger-${suffix}` -const treeId = randomUUID() - -const state = { - schemaVersion: 2, - threads: { - main: { - id: "main", - modelId: "glm-5.3", - parentId: null, - depth: 0, - title: "主线", - anchorText: null, - forkFromMsgId: null, - footnote: null, - children: [], - messages: [], - activeLeafMessageId: null, - lastActive: 1, - }, - }, - artifacts: {}, - artifactOrder: [], - recents: [], - footnoteCounter: 0, - seq: 0, - tick: 1, -} - -async function run() { - await db.insert(user).values([ - { - id: ownerId, - name: "tree rename owner", - email: `${ownerId}@example.test`, - emailVerified: true, - }, - { - id: strangerId, - name: "tree rename stranger", - email: `${strangerId}@example.test`, - emailVerified: true, - }, - ]) - await db.insert(branchTrees).values({ - id: treeId, - userId: ownerId, - title: "Derived title", - state, - }) - - assert.equal( - await renameOwnedTree({ - userId: strangerId, - treeId, - customTitle: "Hijacked", - }), - false - ) - assert.equal( - await renameOwnedTree({ - userId: ownerId, - treeId, - customTitle: "My research", - }), - true - ) - assert.equal( - await renameOwnedTree({ - userId: ownerId, - treeId: randomUUID(), - customTitle: "Missing", - }), - false - ) - - const [persisted] = await db - .select({ - title: branchTrees.title, - customTitle: branchTrees.customTitle, - }) - .from(branchTrees) - .where(eq(branchTrees.id, treeId)) - assert.equal(persisted.title, "Derived title") - assert.equal(persisted.customTitle, "My research") - - console.log("PASS tree rename is owner-scoped and preserves derived title") -} - -try { - await run() -} finally { - await db.delete(branchTrees).where(eq(branchTrees.id, treeId)) - await db.delete(user).where(inArray(user.id, [ownerId, strangerId])) -} diff --git a/e2e/thread-chat/tree-revision-db.test.mjs b/e2e/thread-chat/tree-revision-db.test.mjs deleted file mode 100644 index 63ee28e2..00000000 --- a/e2e/thread-chat/tree-revision-db.test.mjs +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Revision-controlled message graph commands: - * node --import tsx e2e/thread-chat/tree-revision-db.test.mjs - */ -import assert from "node:assert/strict" -import { randomUUID } from "node:crypto" -import { eq } from "drizzle-orm" -import { db } from "../../lib/db/index.ts" -import { branchTrees, user } from "../../lib/db/schema.ts" -import { prepareGeneration } from "../../lib/thread-chat-generation/start-generation-repository.ts" -import { - switchActiveLeafForOwner, - TreeCommandError, -} from "../../lib/thread-chat-generation/tree-repository.ts" - -const suffix = randomUUID() -const ownerId = `tree-revision-owner-${suffix}` -const strangerId = `tree-revision-stranger-${suffix}` -const treeId = randomUUID() - -const state = { - schemaVersion: 2, - threads: { - main: { - id: "main", - modelId: "glm-5.2", - parentId: null, - depth: 0, - title: "主线", - anchorText: null, - forkFromMsgId: null, - footnote: null, - children: [], - messages: [ - { - id: "u1", - parentMessageId: null, - role: "user", - text: "问题", - forks: [], - }, - { - id: "a1", - parentMessageId: "u1", - role: "assistant", - text: "版本 A", - forks: [], - status: "done", - }, - { - id: "a2", - parentMessageId: "u1", - role: "assistant", - text: "版本 B", - forks: [], - status: "done", - }, - ], - activeLeafMessageId: "a1", - lastActive: 1, - }, - }, - artifacts: {}, - artifactOrder: [], - recents: [], - footnoteCounter: 0, - seq: 3, - tick: 1, -} - -async function expectTreeError(promise, code, currentRevision) { - await assert.rejects( - promise, - (error) => - error instanceof TreeCommandError && - error.code === code && - (currentRevision === undefined || - error.currentRevision === currentRevision) - ) -} - -async function run() { - await db.insert(user).values([ - { - id: ownerId, - name: "tree revision owner", - email: `${ownerId}@example.test`, - emailVerified: true, - }, - { - id: strangerId, - name: "tree revision stranger", - email: `${strangerId}@example.test`, - emailVerified: true, - }, - ]) - await db.insert(branchTrees).values({ - id: treeId, - userId: ownerId, - state, - revision: 0, - }) - - const switched = await switchActiveLeafForOwner({ - userId: ownerId, - treeId, - threadId: "main", - assistantMessageId: "a2", - baseRevision: 0, - }) - assert.equal(switched.revision, 1) - assert.equal(switched.thread.activeLeafMessageId, "a2") - - await expectTreeError( - switchActiveLeafForOwner({ - userId: ownerId, - treeId, - threadId: "main", - assistantMessageId: "a1", - baseRevision: 0, - }), - "tree_revision_conflict", - 1 - ) - await expectTreeError( - switchActiveLeafForOwner({ - userId: strangerId, - treeId, - threadId: "main", - assistantMessageId: "a1", - baseRevision: 1, - }), - "not_found" - ) - await expectTreeError( - switchActiveLeafForOwner({ - userId: ownerId, - treeId, - threadId: "main", - assistantMessageId: "missing", - baseRevision: 1, - }), - "invalid_turn" - ) - - const generationId = randomUUID() - const prepared = await prepareGeneration({ - userId: ownerId, - treeId, - threadId: "main", - modelId: "glm-5.2", - userMessageId: "u1", - assistantMessageId: "a3", - generationId, - intent: { - kind: "regenerate-assistant", - sourceAssistantMessageId: "a2", - }, - }) - assert.equal(prepared.revision, 2) - - await expectTreeError( - switchActiveLeafForOwner({ - userId: ownerId, - treeId, - threadId: "main", - assistantMessageId: "a1", - baseRevision: 1, - }), - "tree_revision_conflict", - 2 - ) - - const [persisted] = await db - .select({ state: branchTrees.state, revision: branchTrees.revision }) - .from(branchTrees) - .where(eq(branchTrees.id, treeId)) - assert.equal(persisted.revision, 2) - assert.equal(persisted.state.threads.main.activeLeafMessageId, "a3") - assert.equal( - persisted.state.threads.main.messages.filter( - (message) => message.id === "a3" - ).length, - 1 - ) - - console.log( - "PASS active-leaf CAS, stale/unauthorized rejection and generation race" - ) -} - -try { - await run() -} finally { - await db.delete(user).where(eq(user.id, ownerId)) - await db.delete(user).where(eq(user.id, strangerId)) -} diff --git a/e2e/thread-chat/tree-save-db.test.mjs b/e2e/thread-chat/tree-save-db.test.mjs deleted file mode 100644 index 1ab46617..00000000 --- a/e2e/thread-chat/tree-save-db.test.mjs +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Owner-scoped tree CAS upsert: - * node --env-file=.env.local --import tsx e2e/thread-chat/tree-save-db.test.mjs - */ -import assert from "node:assert/strict" -import { randomUUID } from "node:crypto" -import { eq, inArray } from "drizzle-orm" -import { db } from "../../lib/db/index.ts" -import { branchTrees, user } from "../../lib/db/schema.ts" -import { saveOwnedTree } from "../../lib/thread-chat-generation/tree-repository.ts" - -const suffix = randomUUID() -const ownerId = `tree-save-owner-${suffix}` -const strangerId = `tree-save-stranger-${suffix}` -const treeId = randomUUID() - -function state(title) { - return { - schemaVersion: 2, - threads: { - main: { - id: "main", - modelId: "glm-5.3", - parentId: null, - depth: 0, - title, - anchorText: null, - forkFromMsgId: null, - footnote: null, - children: [], - messages: [], - activeLeafMessageId: null, - lastActive: 1, - }, - }, - artifacts: {}, - artifactOrder: [], - recents: [], - footnoteCounter: 0, - seq: 0, - tick: 1, - } -} - -async function run() { - await db.insert(user).values([ - { - id: ownerId, - name: "tree save owner", - email: `${ownerId}@example.test`, - emailVerified: true, - }, - { - id: strangerId, - name: "tree save stranger", - email: `${strangerId}@example.test`, - emailVerified: true, - }, - ]) - - assert.deepEqual( - await saveOwnedTree({ - userId: ownerId, - treeId, - state: state("first"), - title: "First", - baseRevision: 0, - }), - { kind: "saved", revision: 1 } - ) - assert.deepEqual( - await saveOwnedTree({ - userId: ownerId, - treeId, - state: state("stale"), - title: "Stale", - baseRevision: 0, - }), - { kind: "conflict", revision: 1 } - ) - assert.deepEqual( - await saveOwnedTree({ - userId: strangerId, - treeId, - state: state("stranger"), - title: "Stranger", - baseRevision: 1, - }), - { kind: "not_found" } - ) - assert.deepEqual( - await saveOwnedTree({ - userId: ownerId, - treeId, - state: state("second"), - title: "Second", - baseRevision: 1, - }), - { kind: "saved", revision: 2 } - ) - - const [persisted] = await db - .select({ - userId: branchTrees.userId, - revision: branchTrees.revision, - title: branchTrees.title, - state: branchTrees.state, - }) - .from(branchTrees) - .where(eq(branchTrees.id, treeId)) - assert.equal(persisted.userId, ownerId) - assert.equal(persisted.revision, 2) - assert.equal(persisted.title, "Second") - assert.equal(persisted.state.threads.main.title, "second") - - console.log("PASS tree save owns create, revision CAS, and owner isolation") -} - -try { - await run() -} finally { - await db.delete(branchTrees).where(eq(branchTrees.id, treeId)) - await db.delete(user).where(inArray(user.id, [ownerId, strangerId])) -} diff --git a/e2e/thread-chat/tree-save-gate.test.mjs b/e2e/thread-chat/tree-save-gate.test.mjs deleted file mode 100644 index 6e6b32ea..00000000 --- a/e2e/thread-chat/tree-save-gate.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import assert from "node:assert/strict" -import { createTreeSaveGate } from "../../app/thread-chat/net/persistence/tree-save-gate.ts" - -const normal = createTreeSaveGate() -normal.markPending() -assert.equal(normal.takePendingFlush(), true) -assert.equal(normal.takePendingFlush(), false) - -const debounced = createTreeSaveGate() -debounced.markPending() -assert.equal(debounced.finishDebounce(), true) -assert.equal(debounced.takePendingFlush(), false) - -const deleted = createTreeSaveGate() -deleted.markPending() -deleted.setSuppressed(true) -assert.equal(deleted.isSuppressed(), true) -assert.equal(deleted.finishDebounce(), false) -assert.equal(deleted.takePendingFlush(), false) -deleted.setSuppressed(false) -assert.equal(deleted.isSuppressed(), false) - -console.log( - "PASS tree save gate flushes once and suppression clears pending resurrection writes" -) diff --git a/e2e/thread-chat/ui-state-snapshot.test.mjs b/e2e/thread-chat/ui-state-snapshot.test.mjs deleted file mode 100644 index eff038e6..00000000 --- a/e2e/thread-chat/ui-state-snapshot.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import assert from "node:assert/strict" -import { createTreeUiStateSnapshot } from "../../app/thread-chat/orchestration/workspace/ui-state-snapshot.ts" - -const slots = [{ id: "main", folded: false }] -const widths = { main: 420 } -const source = { - slots, - widths, - forceCols: 3, - mode: "replace", - viewMode: "canvas", - ignored: "not persisted", -} -const snapshot = createTreeUiStateSnapshot(source) - -assert.deepEqual(snapshot, { - slots, - widths, - forceCols: 3, - mode: "replace", - viewMode: "canvas", -}) -assert.equal(snapshot.slots, slots) -assert.equal(snapshot.widths, widths) -assert.equal("ignored" in snapshot, false) - -console.log( - "PASS UI persistence snapshots exactly the five tree-scoped workspace fields" -) diff --git a/e2e/thread-chat/umapis-models.test.mjs b/e2e/thread-chat/umapis-models.test.mjs deleted file mode 100644 index a6278e29..00000000 --- a/e2e/thread-chat/umapis-models.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -/** - * UMAPIS 模型注册与预览边界的纯验证: - * node --experimental-strip-types e2e/thread-chat/umapis-models.test.mjs - */ -import assert from "node:assert/strict" -import { - CHAT_MODELS, - THREAD_CHAT_MODELS, - UMAPIS_MODEL_IDS, - getChatModel, - isUnbilledPreviewModel, -} from "../../constants/model.ts" -import { MODEL_COST } from "../../constants/pricing.ts" -import { - DEFAULT_UMAPIS_BASE_URL, - getUMAPISApiKey, - isUMAPISConfigured, - normalizeUMAPISBaseURL, -} from "../../lib/ai/umapis.ts" - -const expectedModels = [ - ["claude-opus-4-6", "claude"], - ["claude-opus-4-6-thinking", "claude"], - ["claude-sonnet-4-6", "claude"], - ["claude-sonnet-4-6-thinking", "claude"], - ["claude-opus-4-7", "claude"], - ["claude-opus-4-7-thinking", "claude"], - ["claude-fable-5", "claude"], - ["claude-opus-5", "claude"], - ["claude-sonnet-5", "claude"], - ["claude-opus-4-8", "claude"], - ["claude-opus-4-8-thinking", "claude"], - ["claude-haiku-4-5", "claude"], - ["gemini-3.7-flash", "claude"], - ["grok-4.6", "claude"], - ["gpt-5.6-sol", "gpt"], - ["gpt-5.6-terra", "gpt"], -] -const models = CHAT_MODELS.filter((model) => model.provider === "umapis") - -assert.equal(models.length, expectedModels.length) -assert.deepEqual( - [...UMAPIS_MODEL_IDS], - expectedModels.map(([id]) => id) -) -assert.deepEqual( - models.map((model) => [model.upstreamModel, model.umapisCredentialGroup]), - expectedModels -) -assert.ok(models.every((model) => model.id.startsWith("umapis-"))) -assert.ok(models.every((model) => model.name.startsWith("UMAPIS · "))) -assert.ok(models.every((model) => model.unbilledPreview === true)) -assert.ok(models.every((model) => THREAD_CHAT_MODELS.includes(model))) -assert.ok(models.every((model) => !MODEL_COST[model.id])) -assert.ok(models.every(isUnbilledPreviewModel)) -assert.equal(isUnbilledPreviewModel(getChatModel("glm-5.3")), false) -assert.equal( - normalizeUMAPISBaseURL("https://www.umapis.com"), - DEFAULT_UMAPIS_BASE_URL -) -assert.equal( - normalizeUMAPISBaseURL("https://example.test/proxy/v1/"), - "https://example.test/proxy/v1" -) -assert.equal(normalizeUMAPISBaseURL(""), DEFAULT_UMAPIS_BASE_URL) - -const originalClaudeKey = process.env.UMAPIS_API_KEY_CLAUDE -const originalGptKey = process.env.UMAPIS_API_KEY_GPT -try { - process.env.UMAPIS_API_KEY_CLAUDE = " claude-test-key " - process.env.UMAPIS_API_KEY_GPT = " gpt-test-key " - assert.equal(getUMAPISApiKey("claude"), "claude-test-key") - assert.equal(getUMAPISApiKey("gpt"), "gpt-test-key") - assert.equal(isUMAPISConfigured("claude"), true) - assert.equal(isUMAPISConfigured("gpt"), true) - - delete process.env.UMAPIS_API_KEY_GPT - assert.equal(isUMAPISConfigured("gpt"), false) - assert.equal(isUMAPISConfigured("claude"), true) -} finally { - if (originalClaudeKey === undefined) delete process.env.UMAPIS_API_KEY_CLAUDE - else process.env.UMAPIS_API_KEY_CLAUDE = originalClaudeKey - if (originalGptKey === undefined) delete process.env.UMAPIS_API_KEY_GPT - else process.env.UMAPIS_API_KEY_GPT = originalGptKey -} - -console.log("PASS UMAPIS 注册、凭据分组、Prompt 输入可见性与未计费预览边界") diff --git a/e2e/thread-chat/usage-cost-evidence.test.mjs b/e2e/thread-chat/usage-cost-evidence.test.mjs deleted file mode 100644 index b32b6a81..00000000 --- a/e2e/thread-chat/usage-cost-evidence.test.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import assert from "node:assert/strict" -import { usageCostEvidence } from "../../lib/billing/usage-cost-evidence.ts" - -const gatewayMetadata = { gateway: { generationId: "gateway-generation" } } - -assert.deepEqual( - usageCostEvidence({ - provider: "openrouter", - steps: [ - { providerMetadata: { openrouter: { usage: { cost: 0.012 } } } }, - { providerMetadata: { openrouter: { usage: { cost: 0.008 } } } }, - ], - providerMetadata: gatewayMetadata, - }), - { source: "openrouter", costUsd: 0.02 } -) - -assert.deepEqual( - usageCostEvidence({ - provider: "openrouter", - steps: [{ providerMetadata: {} }], - providerMetadata: gatewayMetadata, - }), - { source: "vercel-gateway", generationId: "gateway-generation" } -) - -assert.deepEqual( - usageCostEvidence({ - provider: "ark", - steps: [{ providerMetadata: { openrouter: { usage: { cost: 999 } } } }], - providerMetadata: gatewayMetadata, - }), - { source: "vercel-gateway", generationId: "gateway-generation" } -) - -for (const providerMetadata of [ - undefined, - null, - {}, - { gateway: null }, - { gateway: { generationId: 42 } }, -]) { - assert.deepEqual( - usageCostEvidence({ - provider: "umapis", - steps: [], - providerMetadata, - }), - { source: "estimate" } - ) -} - -console.log( - "PASS usage cost evidence preserves OpenRouter, Gateway, and estimate priority" -) diff --git a/e2e/thread-chat/user-edit-command.test.mjs b/e2e/thread-chat/user-edit-command.test.mjs deleted file mode 100644 index e945bd4e..00000000 --- a/e2e/thread-chat/user-edit-command.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict" -import { prepareUserEdit } from "../../app/thread-chat/net/commands/regeneration-command.ts" - -function state(activeLeafMessageId = "a1") { - return { - schemaVersion: 2, - threads: { - main: { - id: "main", - modelId: "glm-5.3", - parentId: null, - depth: 0, - title: "主线", - anchorText: null, - forkFromMsgId: null, - footnote: null, - children: [], - messages: [ - { - id: "u1", - parentMessageId: null, - role: "user", - text: "original", - quote: { text: "quoted" }, - forks: [], - }, - { - id: "a1", - parentMessageId: "u1", - role: "assistant", - text: "answer", - forks: [], - status: "done", - }, - ], - activeLeafMessageId, - lastActive: 1, - }, - }, - artifacts: {}, - artifactOrder: [], - recents: [], - footnoteCounter: 0, - seq: 1, - tick: 1, - } -} - -const originalState = state() -const prepared = prepareUserEdit(originalState, { - threadId: "main", - sourceUserMessageId: "u1", - text: " edited question ", - userMessageId: "u2", - assistantMessageId: "a2", - generationId: "11111111-1111-4111-8111-111111111111", -}) -assert.equal(prepared.ok, true) -assert.equal(prepared.start.userMessageId, "u2") -assert.equal(prepared.start.action.intent.kind, "edit-last-user") -assert.equal(prepared.start.action.sourceUserMessageId, "u1") -assert.deepEqual(prepared.start.action.patch.addedMessages, [ - { - id: "u2", - parentMessageId: null, - role: "user", - text: "edited question", - forks: [], - quote: { text: "quoted" }, - }, - { - id: "a2", - parentMessageId: "u2", - role: "assistant", - text: "", - forks: [], - generationId: "11111111-1111-4111-8111-111111111111", - status: "pending", - }, -]) -assert.equal(originalState.threads.main.messages[0].text, "original") -assert.equal(originalState.threads.main.messages.length, 2) - -for (const input of [ - { sourceUserMessageId: "missing", text: "edited" }, - { sourceUserMessageId: "u1", text: " " }, -]) { - assert.deepEqual( - prepareUserEdit(state(), { - threadId: "main", - ...input, - userMessageId: "u2", - assistantMessageId: "a2", - generationId: "g2", - }), - { - ok: false, - code: "not_latest_turn", - message: "只能编辑当前最后一轮用户消息", - } - ) -} - -console.log( - "PASS user edit command preserves source/quote and prepares trimmed user plus pending assistant siblings" -) diff --git a/e2e/thread-chat/user-turn-retry-command.test.mjs b/e2e/thread-chat/user-turn-retry-command.test.mjs deleted file mode 100644 index 6d9dbf90..00000000 --- a/e2e/thread-chat/user-turn-retry-command.test.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict" -import { prepareUserTurnRetry } from "../../app/thread-chat/net/commands/regeneration-command.ts" - -function state(messages, activeLeafMessageId) { - return { - schemaVersion: 2, - threads: { - main: { - id: "main", - modelId: "glm-5.3", - parentId: null, - depth: 0, - title: "主线", - anchorText: null, - forkFromMsgId: null, - footnote: null, - children: [], - messages, - activeLeafMessageId, - lastActive: 1, - }, - }, - artifacts: {}, - artifactOrder: [], - recents: [], - footnoteCounter: 0, - seq: 1, - tick: 1, - } -} - -const orphanUser = { - id: "u1", - parentMessageId: null, - role: "user", - text: "question", - forks: [], -} -const prepared = prepareUserTurnRetry(state([orphanUser], "u1"), { - threadId: "main", - userMessageId: "u1", - assistantMessageId: "a2", - generationId: "11111111-1111-4111-8111-111111111111", -}) -assert.equal(prepared.ok, true) -assert.equal(prepared.start.action.intent.kind, "retry-orphan-user") -assert.equal(prepared.start.action.sourceUserMessageId, "u1") -assert.deepEqual(prepared.start.action.patch.addedMessages, [ - { - id: "a2", - parentMessageId: "u1", - role: "assistant", - text: "", - forks: [], - generationId: "11111111-1111-4111-8111-111111111111", - status: "pending", - }, -]) - -const completedAssistant = { - id: "a1", - parentMessageId: "u1", - role: "assistant", - text: "answer", - forks: [], - status: "done", -} -assert.deepEqual( - prepareUserTurnRetry(state([orphanUser, completedAssistant], "a1"), { - threadId: "main", - userMessageId: "u1", - assistantMessageId: "a2", - generationId: "g2", - }), - { - ok: false, - code: "not_latest_turn", - message: "该消息已不是可恢复的最后一轮", - } -) - -assert.deepEqual( - prepareUserTurnRetry(state([orphanUser], "u1"), { - threadId: "main", - userMessageId: "missing", - assistantMessageId: "a2", - generationId: "g2", - }), - { - ok: false, - code: "not_latest_turn", - message: "该消息已不是可恢复的最后一轮", - } -) - -console.log( - "PASS user-turn retry command prepares one pending assistant only for the recoverable active orphan" -) diff --git a/e2e/thread-chat/verify-bubble-composer.mjs b/e2e/thread-chat/verify-bubble-composer.mjs deleted file mode 100644 index 89993055..00000000 --- a/e2e/thread-chat/verify-bubble-composer.mjs +++ /dev/null @@ -1,631 +0,0 @@ -/** - * 气泡输入框(openspec: add-bubble-composer Phase A)真实后端端到端验收。 - * - * 前提同 verify-live:dev server 已起、MiniMax key 已配、本机有 Chromium。运行: - * CHROMIUM_PATH=... BASE_URL=http://localhost:4040 \ - * node --experimental-strip-types e2e/thread-chat/verify-bubble-composer.mjs - * (--experimental-strip-types:直接 import 产品代码的 kickoffQuestion / - * defaultBranchTitle 生成断言期望值。) - * - * 断言面(参考 playground verify6 + 本仓 IME/标题/预算关注点): - * · 气泡结构:输入框存在 / placeholder 提示可留空 / 弹出即聚焦; - * · 按钮文案四态:默认 / 有输入 / ⌘ 按住 / 列条 override(含优先级与复位); - * · Shift+Enter 换行不提交;长问题自增高 + 内滚不自毁气泡(scroll 放行修复); - * · 非来源列滚动不关气泡、来源列滚动仍关闭;输入中 Esc 关气泡、无消息入树; - * · IME:CDP imeSetComposition + keyCode 229 的 Enter 不提交,insertText 上屏后 - * 真实 Enter 才提交; - * · 带问 Enter:新列第 1 条 = 该 user 消息、第 2 条 assistant 流式、composer 无预填、 - * payload 契约(threadChat.anchorText / user 原文入 messages); - * · 留空 Enter:空分支 + composer 预填 kickoffQuestion() + 2 秒内无 /api/chat POST; - * · ⌘Enter keepSource:来源列保留、新列开在紧邻右侧、首条为 user 问题; - * · 异步分支标题:首答完成后标题变为完整语义标题(非锚点截断)、刷新后仍在、 - * 全程 /api/title 恰好请求一次; - * · 深树继承段预算的纯函数用例在 prompt-budget.test.mjs(无需 dev server,见 README)。 - * 走真实模型,回复内容非确定,断言只卡结构与契约;测试树跑完自动清理。 - */ -import { mkdirSync } from "node:fs" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" -import { chromium } from "playwright-core" -import { defaultBranchTitle } from "../../app/thread-chat/core/store.ts" -import { kickoffQuestion } from "../../lib/thread-chat/application/prompt-policy.ts" - -const here = dirname(fileURLToPath(import.meta.url)) -const shotsDir = join(here, "shots") -mkdirSync(shotsDir, { recursive: true }) -const SHOT = (n) => join(shotsDir, `${n}.png`) - -let failed = 0 -const ok = (label, cond, detail = "") => { - console.log( - `${cond ? "PASS" : "FAIL"} ${label}${detail ? `(${detail})` : ""}` - ) - if (!cond) failed = 1 -} -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) -/** node 侧轮询等待(等 PUT 计数等非页面条件) */ -async function waitUntil(fn, timeout = 8000, step = 200) { - const t0 = Date.now() - while (Date.now() - t0 < timeout) { - if (fn()) return true - await sleep(step) - } - return fn() -} - -const BASE_URL = process.env.BASE_URL || "http://localhost:4040" -const browser = await chromium.launch({ - executablePath: process.env.CHROMIUM_PATH || undefined, - headless: true, -}) -// 1920 宽 → 自适应 4 列(COL_MIN_W=430),⌘Enter keepSource 场景不触发列满替换 -const page = await browser.newPage({ viewport: { width: 1920, height: 950 } }) - -const chatPosts = [] // /api/chat POST payload(契约断言) -const treePuts = [] // /api/branch-trees PUT(防抖存盘观测) -function isBranchTitleRequest(req) { - if (!req.url().includes("/api/title") || req.method() !== "POST") return false - try { - return req.postDataJSON()?.kind === "branch" - } catch { - return false - } -} - -let titlePosts = 0 // /api/title 的 kind=branch POST(「至多一次」断言) -page.on("request", (req) => { - const url = req.url() - if (url.includes("/api/chat") && req.method() === "POST") { - try { - chatPosts.push(JSON.parse(req.postData() ?? "{}")) - } catch { - /* 非 JSON 忽略 */ - } - } - if (url.includes("/api/branch-trees/") && req.method() === "PUT") - treePuts.push(url) - if (isBranchTitleRequest(req)) titlePosts++ -}) -let titleResponses = 0 // 收尾时等标题请求收口,避免删树后被迟到的 PUT 复活 -page.on("response", (res) => { - if (isBranchTitleRequest(res.request())) titleResponses++ -}) -const pageErrors = [] -page.on("pageerror", (e) => pageErrors.push(String(e))) - -/* ---------- 工具:列内划选(needle 命中单个文本节点;needle=null 时自动挑 - 该列最后一条 assistant .md-body 里首个 ≥minLen 字的文本节点开头) ---------- */ -async function selectInColumn(colIdx, needle, minLen = 8) { - const picked = await page.evaluate( - async ([colIdx, needle, minLen]) => { - const cols = document.querySelectorAll(".tc .cols > .column") - const col = cols[colIdx] - const bodies = col?.querySelectorAll(".message.assistant .md-body") - const md = bodies?.[bodies.length - 1] - if (!md) return null - const walker = document.createTreeWalker(md, NodeFilter.SHOW_TEXT) - let node - let hit = null - let fallback = null - while ((node = walker.nextNode())) { - const t = node.textContent ?? "" - const i = needle ? t.indexOf(needle) : -1 - if (needle && i >= 0) { - hit = { node, start: i, end: i + needle.length } - break - } - if (!fallback && t.trim().length >= minLen) { - const s = t.indexOf(t.trim()) - fallback = { node, start: s, end: s + minLen } - } - } - const target = hit ?? fallback - if (!target) return null - target.node.parentElement?.scrollIntoView({ block: "center" }) - await new Promise((r) => setTimeout(r, 120)) - const range = document.createRange() - range.setStart(target.node, target.start) - range.setEnd(target.node, target.end) - const sel = window.getSelection() - sel.removeAllRanges() - sel.addRange(range) - document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })) - return (target.node.textContent ?? "").slice(target.start, target.end) - }, - [colIdx, needle, minLen] - ) - if (!picked) return null - await page - .locator(".tc .sel-bubble") - .waitFor({ state: "visible", timeout: 5000 }) - await page.waitForTimeout(150) - return picked -} - -/** 某列(0 基,仅数展开列)流式完成:发送键回到「发送」且末条 assistant 有正文 */ -async function waitColDone(colIdx, timeout = 120000) { - await page.waitForFunction( - (idx) => { - const cols = document.querySelectorAll(".tc .cols > .column") - const col = cols[idx] - const bubbles = col?.querySelectorAll(".message.assistant .bubble") - const last = bubbles?.[bubbles.length - 1] - const btn = col?.querySelector(".composer .send") - return ( - last && - last.textContent.trim().length > 20 && - btn && - !btn.classList.contains("stop") - ) - }, - colIdx, - { timeout } - ) - await page.waitForTimeout(150) // 平滑打字 snap 落地(同 verify-live 注释) -} - -/** 列消息快照:[{role, text}] */ -const colMsgs = (colIdx) => - page.evaluate((idx) => { - const cols = document.querySelectorAll(".tc .cols > .column") - return Array.from(cols[idx]?.querySelectorAll(".message") ?? []).map( - (el) => ({ - role: el.classList.contains("user") ? "user" : "assistant", - // 正文断言只看正文:排除 .msg-quote 引用条(方向 C 的划选引用展示件) - text: (() => { - const b = el.querySelector(".bubble") - if (!b) return "" - const c = b.cloneNode(true) - c.querySelector(".msg-quote")?.remove() - return (c.textContent ?? "").trim() - })(), - quote: - el.querySelector(".bubble .msg-quote")?.textContent?.trim() ?? null, - }) - ) - }, colIdx) - -/** 展开列标题序列(主线列头是 .ctitle.main,文本「主线」) */ -const colTitles = () => - page.evaluate(() => - Array.from(document.querySelectorAll(".tc .cols > .column")).map( - (el) => el.querySelector(".ctitle")?.textContent?.trim() ?? "" - ) - ) -const colCount = () => - page.evaluate(() => document.querySelectorAll(".tc .cols > .column").length) - -const bubbleTextarea = () => page.locator(".sel-bubble .ask textarea") -const bubbleLabel = () => page.locator(".sel-bubble > button").innerText() - -/* ================= 1. 主线真实首答(正文里埋两个可划选短语) ================= */ -await page.goto(`${BASE_URL}/thread-chat`, { waitUntil: "networkidle" }) -ok("页面加载:.tc 壳存在", (await page.locator(".tc").count()) === 1) - -const PHRASE_A = "量子纠缠现象无法用来传递任何信息" // 16 字 > 13:默认标题必带截断省略号 -const PHRASE_B = "贝尔不等式" -await page - .locator(".column") - .first() - .locator("textarea") - .fill( - "请较详细地讲解量子纠缠(小标题 + 分点),并务必在普通正文中(不加粗、不放进标题)" + - `原样包含这两个短语:「${PHRASE_A}」和「${PHRASE_B}」。` - ) -await page.locator(".column").first().locator("textarea").press("Enter") -await waitColDone(0) -ok("主线收到真实流式回复", true) - -/* ================= 2. 气泡结构 + 文案态(默认/有输入)+ 键位守卫 ================= */ -let anchorA = await selectInColumn(0, PHRASE_A, 16) -ok("划选主线正文(气泡浮出)", anchorA !== null, anchorA ?? "") -ok("气泡含输入框", (await bubbleTextarea().count()) === 1) -ok( - "输入框 placeholder 提示可留空", - ((await bubbleTextarea().getAttribute("placeholder")) ?? "").includes("留空") -) -ok( - "弹出即聚焦输入框", - await page.evaluate( - () => document.activeElement?.closest?.(".sel-bubble .ask") != null - ) -) -ok("空输入按钮文案 = 开启分支讨论", (await bubbleLabel()) === "开启分支讨论") -await bubbleTextarea().pressSequentially("测") -ok("有输入时文案 = 带着问题开分支", (await bubbleLabel()) === "带着问题开分支") -await bubbleTextarea().fill("") -ok("清空后文案恢复 = 开启分支讨论", (await bubbleLabel()) === "开启分支讨论") -await page.screenshot({ path: SHOT("bc-1-bubble-input") }) - -/* —— Shift+Enter 换行不提交 —— */ -await bubbleTextarea().pressSequentially("第一行") -await bubbleTextarea().press("Shift+Enter") -await bubbleTextarea().pressSequentially("第二行") -await page.waitForTimeout(150) -{ - const stillOpen = (await page.locator(".sel-bubble").count()) === 1 - const val = await bubbleTextarea().inputValue() - ok( - "Shift+Enter 换行不提交(气泡仍开、值含换行)", - stillOpen && val.includes("\n") && (await colCount()) === 1 - ) -} - -/* —— 长问题自增高 + textarea 内滚:气泡不自毁、输入不丢(scroll 放行修复) —— */ -const LONG_Q = "这个问题很长,".repeat(20) -await bubbleTextarea().fill(LONG_Q) -{ - const h = await bubbleTextarea().evaluate((ta) => ta.clientHeight) - ok(`长问题触发自增高(clamp 68px,当前 ${h}px)`, h >= 60 && h <= 72) - await bubbleTextarea().evaluate((ta) => { - ta.scrollTop = ta.scrollHeight // 触发气泡内部 scroll(capture 监听必须放行) - }) - await page.waitForTimeout(200) - const stillOpen = (await page.locator(".sel-bubble").count()) === 1 - const val = await bubbleTextarea().inputValue() - ok("textarea 内滚后气泡不自毁、输入完整保留", stillOpen && val === LONG_Q) - await page.screenshot({ path: SHOT("bc-2-long-question") }) -} - -/* —— 输入中 Esc:走壳层关闭链关气泡,无消息入树 —— */ -const postsBeforeEsc = chatPosts.length -await page.keyboard.press("Escape") -await page.waitForTimeout(200) -ok( - "输入中 Esc 关气泡、无新列、无 /api/chat 请求", - (await page.locator(".sel-bubble").count()) === 0 && - (await colCount()) === 1 && - chatPosts.length === postsBeforeEsc -) - -/* —— 来源消息列表真实滚动仍关气泡 —— */ -anchorA = await selectInColumn(0, PHRASE_A, 16) -ok("重新划选(回归用)", anchorA !== null) -await page.evaluate(() => { - const list = document.querySelector(".tc .msg-list") - list.scrollTop = Math.max(0, list.scrollTop - 120) -}) -await page.waitForTimeout(250) -ok( - "来源消息列表滚动仍关闭气泡", - (await page.locator(".sel-bubble").count()) === 0 -) - -/* ================= 3. IME 组合态守卫 + 带问 Enter 开分支 ================= */ -anchorA = await selectInColumn(0, PHRASE_A, 16) -ok("划选(带问路径)", anchorA !== null, anchorA ?? "") -const cdp = await page.context().newCDPSession(page) -await cdp.send("Input.imeSetComposition", { - text: "wenti", - selectionStart: 5, - selectionEnd: 5, -}) -// 组合态回车:真实 IME 里这次 keydown 的 keyCode 是 229(isComposing 同时为 true) -await cdp.send("Input.dispatchKeyEvent", { - type: "rawKeyDown", - key: "Enter", - code: "Enter", - windowsVirtualKeyCode: 229, - nativeVirtualKeyCode: 229, -}) -await cdp.send("Input.dispatchKeyEvent", { - type: "keyUp", - key: "Enter", - code: "Enter", -}) -await page.waitForTimeout(250) -ok( - "IME 组合态 Enter 不提交(气泡仍开、无新列)", - (await page.locator(".sel-bubble").count()) === 1 && (await colCount()) === 1 -) -await cdp.send("Input.insertText", { text: "问题" }) // 上屏(提交组合文本) -await page.waitForTimeout(150) -{ - const val = await bubbleTextarea().inputValue() - ok( - "insertText 上屏后输入框为已上屏文本(无组合残留)", - val.includes("问题") && !val.includes("wenti"), - JSON.stringify(val) - ) -} - -const Q1 = - "它为什么不能用来传递信息?请通俗解释,并在回答的普通正文中原样包含短语「超光速通信不可行」。" -await bubbleTextarea().fill(Q1) -const postsBeforeQ1 = chatPosts.length -await bubbleTextarea().press("Enter") -await page.waitForFunction( - () => document.querySelectorAll(".tc .cols > .column").length >= 2, - undefined, - { timeout: 10000 } -) -ok("带问 Enter:分支列打开", true) -{ - const msgs = await colMsgs(1) - ok( - "新列第 1 条 = 所输入问题(user 原文)", - msgs[0]?.role === "user" && msgs[0].text === Q1, - JSON.stringify(msgs[0] ?? null) - ) - ok( - "新列第 1 条带划选引用条(方向 C)", - msgs[0]?.quote === anchorA, - JSON.stringify(msgs[0]?.quote ?? null) - ) - ok("新列第 2 条 = assistant(流式首答已就位)", msgs[1]?.role === "assistant") - const composerVal = await page - .locator(".tc .cols > .column") - .nth(1) - .locator(".composer textarea") - .inputValue() - ok("带问路径 composer 无预填", composerVal === "") - await waitUntil(() => chatPosts.length > postsBeforeQ1, 5000) - const payload = chatPosts[chatPosts.length - 1] - const msgsInPayload = payload?.messages ?? [] - ok( - "payload 契约:threadChat.anchorText = 划选原文", - payload?.threadChat?.anchorText === anchorA - ) - // 锚点 grounding 契约:分支首条 user 在发送线上加「就我划选的这段话」前缀 - // (裸问题的指代会被模型就近解析到上文结尾——用户实测踩过),UI 仍显示原文 - const grounded = `就我划选的这段话:「${anchorA}」——${Q1}` - ok( - "payload 契约:分支首问带锚点 grounding 前缀(仅发送线)", - msgsInPayload.some( - (m) => m.role === "user" && m.parts?.[0]?.text === grounded - ) - ) - const ui = await page.evaluate(() => { - const cols = document.querySelectorAll(".tc .cols > .column") - const bubble = cols[1]?.querySelector(".message.user .bubble") - const quote = bubble?.querySelector(".msg-quote")?.textContent?.trim() ?? "" - const clone = bubble?.cloneNode(true) - clone?.querySelector(".msg-quote")?.remove() - return { quote, text: clone?.textContent?.trim() ?? "" } - }) - ok("UI 契约:user 气泡正文 = 原始问题(无前缀)", ui.text === Q1) - ok("UI 契约:气泡内引用条 = 划选原文(方向 C)", ui.quote === anchorA) - - /* 右侧列继续流式/自动贴底时,左侧仍可成为当前划选来源;其他列滚动不改变 - 来源选区坐标,不应关闭气泡,来源列滚动则必须关闭以免 rect 过期。 */ - const pickedWhileBranchActive = await selectInColumn(0, PHRASE_A, 16) - ok( - "右侧分支回复期间仍可划选左侧正文", - pickedWhileBranchActive !== null, - pickedWhileBranchActive ?? "" - ) - await page.evaluate(() => { - const cols = document.querySelectorAll(".tc .cols > .column") - cols[1]?.querySelector(".msg-list")?.dispatchEvent(new Event("scroll")) - }) - await page.waitForTimeout(200) - ok( - "非来源列滚动不关闭划选气泡", - (await page.locator(".sel-bubble").count()) === 1 - ) - await page.evaluate(() => { - const cols = document.querySelectorAll(".tc .cols > .column") - cols[0]?.querySelector(".msg-list")?.dispatchEvent(new Event("scroll")) - }) - await page.waitForTimeout(200) - ok( - "来源列滚动仍关闭划选气泡", - (await page.locator(".sel-bubble").count()) === 0 - ) - - // 方向 C 的核心承诺:绑定关系是数据——刷新后引用条仍在(quote 随整树 JSON 落库) - await page.waitForTimeout(2000) // 过防抖存库 - await page.reload({ waitUntil: "networkidle" }) - await page.waitForSelector(".tc .message.user .bubble", { timeout: 15000 }) - const afterReload = await page.evaluate(() => { - const cols = document.querySelectorAll(".tc .cols > .column") - for (const col of cols) { - const q = col.querySelector(".message.user .bubble .msg-quote") - if (q) return q.textContent?.trim() ?? "" - } - // 分支列可能未在恢复布局里展开:全 DOM 兜底找 - return ( - document - .querySelector(".message.user .bubble .msg-quote") - ?.textContent?.trim() ?? "" - ) - }) - ok( - "持久化契约:刷新后引用条仍在(quote 是数据不是代码行为)", - afterReload === anchorA - ) -} -await page.screenshot({ path: SHOT("bc-3-question-branch") }) -await waitColDone(1) -const branchReply = await page.evaluate(() => { - const cols = document.querySelectorAll(".tc .cols > .column") - const bubbles = cols[1]?.querySelectorAll(".message.assistant .bubble") - return bubbles?.[bubbles.length - 1]?.textContent ?? "" -}) -ok("带问分支首答流式完成", branchReply.trim().length > 20) - -/* ================= 4. 异步分支标题:首答完成 → 语义标题 → 刷新仍在 ================= */ -const DEFAULT_TITLE_A = defaultBranchTitle(anchorA) -const titleChanged = await page - .waitForFunction( - (dft) => { - const cols = document.querySelectorAll(".tc .cols > .column") - const t = cols[1]?.querySelector(".ctitle")?.textContent?.trim() - return !!t && t !== dft - }, - DEFAULT_TITLE_A, - { timeout: 60000 } - ) - .then(() => true) - .catch(() => false) -const genTitle = (await colTitles())[1] -ok( - `分支标题异步变为语义标题(非锚点截断):「${genTitle}」`, - titleChanged && genTitle !== DEFAULT_TITLE_A && !genTitle.endsWith("…") -) -ok("语义标题至少包含 2 个字符", titleChanged && genTitle.length >= 2) -// 标题变更随整树防抖存盘(1.5s):必须等「标题变更之后」的那次 PUT—— -// 首答完成本身也会触发一次 PUT(标题请求彼时还在飞),拿它当依据会在 -// 带标题的 PUT 落库前刷新,DB 里还是默认标题(本脚本首版踩过的竞态) -const putsAtTitleChange = treePuts.length -ok( - "标题变更触发防抖整树 PUT", - await waitUntil(() => treePuts.length > putsAtTitleChange, 8000) -) -await sleep(800) // 等 PUT 响应落库 -const treeUrl = page.url() -await page.goto(treeUrl, { waitUntil: "networkidle" }) -await page.locator(".tc .cols > .column").nth(1).waitFor({ timeout: 10000 }) -{ - const titles = await colTitles() - ok( - "刷新后语义标题仍在(随树持久化)", - titleChanged && titles[1] === genTitle, - JSON.stringify(titles) - ) -} -await sleep(2500) // 「至多一次」:重载后不应因既有语义标题再次请求 -ok( - "全程 /api/title 的 kind=branch 恰好请求一次", - titlePosts === 1, - `实际 ${titlePosts}` -) -await page.screenshot({ path: SHOT("bc-4-async-title") }) - -/* ================= 5. 留空 Enter = 现有预填流原样保留 ================= */ -const anchorB = await selectInColumn(0, PHRASE_B, 6) -ok("划选(留空路径)", anchorB !== null, anchorB ?? "") -const postsBeforeEmpty = chatPosts.length -await bubbleTextarea().press("Enter") // 留空直接回车 -await page.waitForFunction( - () => document.querySelectorAll(".tc .cols > .column").length >= 3, - undefined, - { timeout: 10000 } -) -{ - const emptyColIdx = 2 - const prefill = await page - .locator(".tc .cols > .column") - .nth(emptyColIdx) - .locator(".composer textarea") - .inputValue() - ok( - "留空分支 composer 预填 kickoffQuestion()(期望值由产品代码生成)", - prefill === kickoffQuestion(anchorB), - JSON.stringify(prefill) - ) - const msgs = await colMsgs(emptyColIdx) - ok("留空分支消息区为空(未自动发请求)", msgs.length === 0) - await sleep(2000) - ok( - "留空开分支 2 秒内无新 /api/chat POST", - chatPosts.length === postsBeforeEmpty - ) -} -await page.screenshot({ path: SHOT("bc-5-empty-prefill") }) - -/* ================= 6. 文案四态(override / ⌘)+ ⌘Enter keepSource ================= */ -// 在带问分支(第 2 列)的首答里划选:优先埋好的短语,缺失则退回自动挑选 -const anchorC = - (await selectInColumn(1, "超光速通信不可行", 8)) ?? - (await selectInColumn(1, null, 8)) -ok("在带问分支列内划选(keepSource 用)", anchorC !== null, anchorC ?? "") -await bubbleTextarea().pressSequentially("对比一下?") -ok("有输入 → 带着问题开分支", (await bubbleLabel()) === "带着问题开分支") -// 新契约(用户定稿):按钮只表达动作、两态恒定;放置后果在 .place-hint 提示行 -const placeHint = async () => - await page - .locator(".sel-bubble .place-hint") - .innerText() - .catch(() => "") -await page.keyboard.down("Meta") -ok( - "⌘ 按住:按钮文案不变(动作恒定)", - (await bubbleLabel()) === "带着问题开分支" -) -ok( - "⌘ 按住:提示行 = 保留本列·右侧新开", - (await placeHint()).includes("保留本列") -) -await page.keyboard.up("Meta") -{ - // 列条 override(点非来源、未折叠小格):后果进提示行,按钮不变 - const cell = page.locator( - ".sel-bubble .smcell:not(.main):not(.src):not(.ghost):not(.folded)" - ) - ok("气泡含迷你列条(≥1 个可点小格)", (await cell.count()) >= 1) - const hintBefore = await placeHint() - ok( - "默认态提示行说明放置后果(替换/折叠/新开)", - /将?(默认)?(替换|折叠|新开)/.test(hintBefore), - hintBefore - ) - await cell.first().click() - const ovHint = await placeHint() - ok( - "override 态提示行 = 将替换/折叠『列名』", - /^将(替换|折叠)『.+』$/.test(ovHint), - ovHint - ) - ok( - "override 态按钮文案不变(动作恒定)", - (await bubbleLabel()) === "带着问题开分支" - ) - await cell.first().click() // 再点同格取消 - ok("取消 override → 提示行回落默认态", (await placeHint()) === hintBefore) -} -const Q2 = "把它和经典信道对比一下?" -await bubbleTextarea().fill(Q2) -const titlesBeforeMeta = await colTitles() -await page.keyboard.press("Meta+Enter") -await page.waitForFunction( - (n) => document.querySelectorAll(".tc .cols > .column").length === n + 1, - titlesBeforeMeta.length, - { timeout: 10000 } -) -{ - const titles = await colTitles() - ok( - "⌘Enter:来源列保留(第 2 列标题不变)", - titles[1] === titlesBeforeMeta[1], - JSON.stringify(titles) - ) - ok( - "⌘Enter:新列开在来源紧邻右侧且标题取锚点", - titles[2] === defaultBranchTitle(anchorC), - `期望「${defaultBranchTitle(anchorC)}」,实际「${titles[2]}」` - ) - const msgs = await colMsgs(2) - ok( - "⌘Enter 新列第 1 条 = user 问题(带问 + keepSource 组合成立)", - msgs[0]?.role === "user" && msgs[0].text === Q2 - ) -} -await page.screenshot({ path: SHOT("bc-6-meta-enter") }) - -/* ================= 7. 收尾:停掉在飞流、清理测试树 ================= */ -{ - const stopBtn = page - .locator(".tc .cols > .column") - .nth(2) - .locator(".composer .send.stop") - if ((await stopBtn.count()) > 0) await stopBtn.click() -} -ok( - "全程无页面错误(pageerror)", - pageErrors.length === 0, - pageErrors.join(" | ") -) -// 若停止时已有正文(finish→done),标题请求会随即发出:等它收口 + 防抖 PUT 落库 -// 再删行,否则迟到的标题 PUT 会把刚删的 DB 行复活 -await sleep(500) -await waitUntil(() => titleResponses >= titlePosts, 30000) -await sleep(2500) // 让最后一轮防抖 PUT 落库,再删行(写链保证 DELETE 排最后) -const ownTreeId = page.url().split("/").pop() -if (/^[0-9a-f-]{36}$/.test(ownTreeId)) { - await page.request.delete(`${BASE_URL}/api/branch-trees/${ownTreeId}`) - console.log(`已清理本次测试树 ${ownTreeId.slice(0, 8)}…`) -} -await browser.close() -console.log(failed ? "\n==== 存在 FAIL ====" : "\n==== 全部 PASS ====") -process.exit(failed) diff --git a/e2e/thread-chat/verify-canvas-chat.mjs b/e2e/thread-chat/verify-canvas-chat.mjs deleted file mode 100644 index 6116cc01..00000000 --- a/e2e/thread-chat/verify-canvas-chat.mjs +++ /dev/null @@ -1,610 +0,0 @@ -/** - * 画布 Phase 2(openspec: add-canvas-conversations)真实后端端到端验收。 - * - * 前提同 verify-live:dev server 已起、MiniMax key 已配、本机有 Chromium。运行: - * CHROMIUM_PATH=... BASE_URL=http://localhost:4040 \ - * node --experimental-strip-types e2e/thread-chat/verify-canvas-chat.mjs - * (--experimental-strip-types:直接 import 产品代码的 kickoffQuestion 生成 - * 空分支 composer 预填的断言期望值。) - * - * 断言面(参考 playground verify10 + 本仓富文本 / LR / zoom 关注点): - * · 单击节点展开外挂面板(消息列表 + composer),展开零重排(其余节点坐标不变, - * D1 外挂面板不参与 dagre);摘要收起; - * · 面板内追问真实流式:user + pending 立即入树、busy 时发送键变「停止」、 - * 完成后 Markdown 结构断言(.md-body 内有结构化元素——富文本契约 D2); - * · 手势共处(D5):面板内滚轮 = 列表内滚、画布 zoom 不变;空白处滚轮正常缩放; - * · zoom 0.75 / 1.5 两档:面板内划选 → 气泡按选区视口坐标正确定位(fixed 免疫 - * zoom),画布气泡无迷你列条(fork 不占列槽 D4); - * · 带问提交:新节点 + 边长出、focusNode 跟随(setCenter 后新节点整体可见、 - * zoom 不缩小)、新节点选中展开、面板首条 = 所提问题、首答流式完成; - * · 留空提交:新节点面板 composer 预填 kickoffQuestion()(语义同列模式); - * · 收起/再展开零重排(多节点下);选中节点 zIndex 抬升盖过兄弟卡(LR 遮挡); - * · 列槽隔离:回列视图仅主线一列(画布 fork 不占槽),主线正文可见 fork 脚注; - * · 双击面板不误触回列;双击节点卡回列模式(Phase 1 行为保留)。 - * 走真实模型,回复内容非确定,断言只卡结构与契约;测试树跑完自动清理。 - */ -import { mkdirSync } from "node:fs" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" -import { chromium } from "playwright-core" -import { kickoffQuestion } from "../../lib/thread-chat/application/prompt-policy.ts" - -const here = dirname(fileURLToPath(import.meta.url)) -const shotsDir = join(here, "shots") -mkdirSync(shotsDir, { recursive: true }) -const SHOT = (n) => join(shotsDir, `${n}.png`) - -let failed = 0 -const ok = (label, cond, detail = "") => { - console.log( - `${cond ? "PASS" : "FAIL"} ${label}${detail ? `(${detail})` : ""}` - ) - if (!cond) failed = 1 -} -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) -async function waitUntil(fn, timeout = 8000, step = 200) { - const t0 = Date.now() - while (Date.now() - t0 < timeout) { - if (await fn()) return true - await sleep(step) - } - return fn() -} - -const BASE_URL = process.env.BASE_URL || "http://localhost:4040" -const browser = await chromium.launch({ - executablePath: process.env.CHROMIUM_PATH || undefined, - headless: true, -}) -const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }) - -function isBranchTitleRequest(req) { - if (!req.url().includes("/api/title") || req.method() !== "POST") return false - try { - return req.postDataJSON()?.kind === "branch" - } catch { - return false - } -} - -let titlePosts = 0 -let titleResponses = 0 -page.on("request", (req) => { - if (isBranchTitleRequest(req)) titlePosts++ -}) -page.on("response", (res) => { - if (isBranchTitleRequest(res.request())) titleResponses++ -}) -const pageErrors = [] -page.on("pageerror", (e) => pageErrors.push(String(e))) - -/* ---------------- 画布通用工具 ---------------- */ - -/** 画布 zoom(解析 .react-flow__viewport 的 transform scale) */ -const getScale = () => - page.evaluate(() => { - const vp = document.querySelector(".react-flow__viewport") - const m = /scale\(([\d.]+)\)/.exec(vp?.style.transform ?? "") - return m ? Number(m[1]) : NaN - }) - -/** 全部节点的世界坐标快照:data-id → style.transform(zoom/pan 不影响它,重排才变) */ -const nodeTransforms = () => - page.evaluate(() => - Object.fromEntries( - Array.from(document.querySelectorAll(".react-flow__node")).map((el) => [ - el.getAttribute("data-id"), - el.style.transform, - ]) - ) - ) -const sameTransforms = (a, b) => - Object.keys(a).length === Object.keys(b).length && - Object.entries(a).every(([k, v]) => b[k] === v) - -/** 画布空白点(pane 上、不压节点 / 面板 / 控件)——滚轮缩放与点空白收起用 */ -const blankPoint = () => - page.evaluate(() => { - const wrap = document.querySelector(".canvas-wrap")?.getBoundingClientRect() - if (!wrap) return null - for (let y = wrap.top + 70; y < wrap.bottom - 70; y += 36) { - for (let x = wrap.left + 60; x < wrap.right - 60; x += 48) { - const el = document.elementFromPoint(x, y) - if ( - el && - el.closest(".react-flow__pane") && - !el.closest(".react-flow__node") && - !el.closest(".react-flow__panel") && - !el.closest(".react-flow__edge") - ) - return { x, y } - } - } - return null - }) - -/** 在空白处滚轮把 zoom 调到 target(d3-zoom:scale' = scale·2^(−deltaY·0.002)) */ -async function setZoom(target, tol = 0.04) { - for (let i = 0; i < 14; i++) { - const cur = await getScale() - if (Math.abs(cur - target) <= tol) return cur - const pt = await blankPoint() - if (!pt) return cur - await page.mouse.move(pt.x, pt.y) - const dy = Math.log2(cur / target) / 0.002 - await page.mouse.wheel(0, Math.max(-480, Math.min(480, dy))) - await sleep(140) - } - return getScale() -} - -/** 在外挂面板的 assistant .md-body 里划选文字(needle 命中;否则挑首个 ≥minLen 的 - 文本节点开头),dispatch mouseup 触发划选气泡;返回 { text, rect, iw, ih }。 - rect 必须在 mouseup 前采集:气泡弹出即聚焦输入框,Chromium 下焦点进 textarea - 会把 document selection 挪走(rect 归零),事后再读选区就测不到了 */ -async function selectInPanel(needle, minLen = 8) { - const picked = await page.evaluate( - async ([needle, minLen]) => { - const panel = document.querySelector(".canvas-expand") - const bodies = panel?.querySelectorAll(".message.assistant .md-body") - if (!bodies?.length) return null - let target = null - for (const md of bodies) { - const walker = document.createTreeWalker(md, NodeFilter.SHOW_TEXT) - let node - while ((node = walker.nextNode())) { - const t = node.textContent ?? "" - const i = needle ? t.indexOf(needle) : -1 - if (needle && i >= 0) { - target = { node, start: i, end: i + needle.length } - break - } - if (!needle && t.trim().length >= minLen) { - const s = t.indexOf(t.trim()) - target = { node, start: s, end: s + minLen } - break - } - } - if (target) break - } - if (!target) return null - target.node.parentElement?.scrollIntoView({ block: "center" }) - await new Promise((r) => setTimeout(r, 150)) - const range = document.createRange() - range.setStart(target.node, target.start) - range.setEnd(target.node, target.end) - const sel = window.getSelection() - sel.removeAllRanges() - sel.addRange(range) - const r = range.getBoundingClientRect() // mouseup 前采集(见函数头注) - document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })) - return { - text: (target.node.textContent ?? "").slice(target.start, target.end), - rect: { left: r.left, top: r.top, bottom: r.bottom }, - iw: window.innerWidth, - ih: window.innerHeight, - } - }, - [needle, minLen] - ) - if (!picked) return null - await page - .locator(".tc .sel-bubble") - .waitFor({ state: "visible", timeout: 5000 }) - await page.waitForTimeout(250) // 等 tc-pop 入场动画结束(期间 transform 偏移) - return picked -} - -/** 断言气泡定位 = 选区视口坐标推导(selection-bubble 的公式,画布 extraH=0) */ -async function assertBubblePos(tag, sel) { - const bub = await page.evaluate(() => { - const b = document.querySelector(".sel-bubble")?.getBoundingClientRect() - return b ? { left: b.left, top: b.top } : null - }) - if (!bub) { - ok(`${tag}:气泡可测量`, false) - return - } - const expLeft = Math.max(10, Math.min(sel.rect.left, sel.iw - 244)) - let expTop = sel.rect.bottom + 9 - if (expTop > sel.ih - 190) expTop = Math.max(10, sel.rect.top - 172) - ok( - `${tag}:气泡贴选区定位正确(fixed 视口坐标)`, - Math.abs(bub.left - expLeft) <= 2 && Math.abs(bub.top - expTop) <= 2, - `期望 (${expLeft.toFixed(1)}, ${expTop.toFixed(1)}),实际 (${bub.left.toFixed(1)}, ${bub.top.toFixed(1)})` - ) -} - -/** 等外挂面板流式完成:末条 assistant 有正文且发送键回到「发送」 */ -async function waitPanelDone(timeout = 120000) { - await page.waitForFunction( - () => { - const panel = document.querySelector(".canvas-expand") - const bubbles = panel?.querySelectorAll(".message.assistant .bubble") - const last = bubbles?.[bubbles.length - 1] - const btn = panel?.querySelector(".cv-send") - return ( - last && - (last.textContent ?? "").trim().length > 20 && - btn && - !btn.classList.contains("stop") - ) - }, - undefined, - { timeout } - ) - await page.waitForTimeout(200) // 平滑打字 snap 落地 -} - -/* ================= 0. 列模式种子:主线真实首答(埋可划选短语) ================= */ -await page.goto(`${BASE_URL}/thread-chat`, { waitUntil: "networkidle" }) -ok("页面加载:.tc 壳存在", (await page.locator(".tc").count()) === 1) - -const PHRASE_A = "量子纠缠现象无法用来传递任何信息" -const PHRASE_B = "贝尔不等式" -await page - .locator(".column") - .first() - .locator("textarea") - .fill( - "请较详细地讲解量子纠缠(用小标题和无序列表组织,回复要足够长),并务必在" + - `普通正文中(不加粗、不放进标题)原样包含这两个短语:「${PHRASE_A}」和「${PHRASE_B}」。` - ) -await page.locator(".column").first().locator("textarea").press("Enter") -await page.waitForFunction( - () => { - const col = document.querySelector(".tc .cols > .column") - const bubbles = col?.querySelectorAll(".message.assistant .bubble") - const last = bubbles?.[bubbles.length - 1] - const btn = col?.querySelector(".composer .send") - return ( - last && - (last.textContent ?? "").trim().length > 60 && - btn && - !btn.classList.contains("stop") - ) - }, - undefined, - { timeout: 120000 } -) -ok("主线收到真实流式回复", true) - -/* ================= 1. 进画布 → 单击展开外挂面板(零重排) ================= */ -await page.locator(".topbar button.mode", { hasText: "画布" }).click() -await page.waitForSelector(".react-flow__node", { timeout: 10000 }) -await page.waitForTimeout(700) // fitView 结算 -// 先缩到 ~0.7:单节点 fitView 时 zoom=1,卡下方展开的面板会探出视口底部 -await page.locator(".react-flow__controls-zoomout").click() -await page.locator(".react-flow__controls-zoomout").click() -await page.waitForTimeout(350) - -const t0 = await nodeTransforms() -await page.locator('.react-flow__node[data-id="main"]').click() -await page.waitForTimeout(350) -ok( - "单击节点:外挂面板出现(消息列表 + composer)", - (await page.locator(".canvas-expand").count()) === 1 && - (await page - .locator('.canvas-expand .msg-list[data-list="main"] .message') - .count()) === 2 && - (await page.locator(".canvas-expand .cv-composer textarea").count()) === 1 -) -ok( - "面板消息走列模式同款富文本(assistant 气泡内有 .md-body)", - (await page - .locator('.canvas-expand .bubble[data-role="assistant"] .md-body') - .count()) === 1 -) -ok( - "展开时卡片摘要收起(面板已含完整末条)", - (await page - .locator(".react-flow__node.selected .canvas-card > .sum") - .count()) === 0 -) -{ - const t1 = await nodeTransforms() - ok("展开零重排:节点坐标不变(面板不参与 dagre)", sameTransforms(t0, t1)) -} -await page.screenshot({ path: SHOT("cc-1-expand") }) - -/* ================= 2. 面板内追问:真实流式 + busy 停止语义 + Markdown 结构 ================= */ -const PHRASE_C = "退相干过程" -await page - .locator(".canvas-expand .cv-composer textarea") - .fill( - "在画布面板里追问:请用一个小标题加一个无序列表,简述测量为何导致纠缠态坍缩," + - `并在普通正文中原样包含短语「${PHRASE_C}」。` - ) -await page.locator(".canvas-expand .cv-composer textarea").press("Enter") -await page.waitForTimeout(400) -ok( - "面板发送:user + assistant 占位立即入树(共 4 条)", - (await page.locator(".canvas-expand .msg-list .message").count()) === 4 -) -ok( - "流式期间发送键变「停止」(busy 语义同列模式)", - (await page.locator(".canvas-expand .cv-send.stop").count()) === 1 -) -await waitPanelDone() -{ - const structured = await page.evaluate(() => { - const bodies = document.querySelectorAll( - ".canvas-expand .message.assistant .md-body" - ) - const md = bodies[bodies.length - 1] - return md - ? md.querySelectorAll("h1,h2,h3,h4,ul,ol,li,strong,code,table").length - : 0 - }) - ok( - "面板内首答流式完成且为 Markdown 结构(结构化元素 > 0)", - structured > 0, - `结构化元素 ${structured} 个` - ) - const meta = await page - .locator(".react-flow__node.selected .canvas-card .meta") - .innerText() - ok("卡片消息计数同步 = 4", meta.includes("4 条消息"), meta) -} -await page.screenshot({ path: SHOT("cc-2-panel-stream") }) - -/* ================= 3. 手势共处:面板内滚 ≠ 缩放,空白滚轮 = 缩放 ================= */ -{ - const list = page.locator(".canvas-expand .msg-list.mini") - const scrollable = await list.evaluate( - (el) => el.scrollHeight > el.clientHeight + 20 - ) - ok("面板列表可滚(clamp 内滚前提成立)", scrollable) - const st0 = await list.evaluate((el) => el.scrollTop) - const s0 = await getScale() - await list.hover() - await page.mouse.wheel(0, -160) - await sleep(250) - const st1 = await list.evaluate((el) => el.scrollTop) - const s1 = await getScale() - ok( - "面板内滚轮:列表内滚、画布 zoom 不变(nowheel)", - st1 < st0 && Math.abs(s1 - s0) < 1e-6, - `scrollTop ${st0}→${st1},zoom ${s0}→${s1}` - ) - const pt = await blankPoint() - await page.mouse.move(pt.x, pt.y) - await page.mouse.wheel(0, -160) - await sleep(250) - const s2 = await getScale() - ok("空白处滚轮:画布正常缩放", Math.abs(s2 - s1) > 0.01, `zoom ${s1}→${s2}`) -} - -/* ================= 4. zoom 0.75:面板内划选 → 气泡定位 + 无列条 ================= */ -{ - const z = await setZoom(0.75) - ok("zoom 调至 ≈0.75", Math.abs(z - 0.75) <= 0.05, `实际 ${z}`) - const picked = await selectInPanel(PHRASE_B, 5) - ok("zoom 0.75:面板内划选出气泡", picked !== null, picked?.text ?? "") - if (picked) await assertBubblePos("zoom 0.75", picked) - ok( - "画布气泡无迷你列条(fork 不占列槽)", - (await page.locator(".sel-bubble .slotmap").count()) === 0 - ) - ok( - "气泡含 Phase A 输入框", - (await page.locator(".sel-bubble .ask textarea").count()) === 1 - ) - await page.screenshot({ path: SHOT("cc-3-zoom075-bubble") }) - await page.keyboard.press("Escape") - await sleep(200) - ok( - "Esc 关气泡(画布内关闭链正常)", - (await page.locator(".sel-bubble").count()) === 0 - ) -} - -/* ================= 5. zoom 1.5:划选 → 带问提交 → 新节点 + 视口跟随 ================= */ -const Q2 = "画布里带问开分支:它和经典关联的本质区别是什么?请用列表简答。" -{ - const z = await setZoom(1.5) - ok("zoom 调至 ≈1.5", Math.abs(z - 1.5) <= 0.1, `实际 ${z}`) - const picked = await selectInPanel(PHRASE_A, 8) - ok("zoom 1.5:面板内划选出气泡", picked !== null, picked?.text ?? "") - if (picked) await assertBubblePos("zoom 1.5", picked) - await page.screenshot({ path: SHOT("cc-4-zoom150-bubble") }) - - const nodesBefore = await page.locator(".react-flow__node").count() - await page.locator(".sel-bubble .ask textarea").fill(Q2) - await page.locator(".sel-bubble .ask textarea").press("Enter") - await page.waitForFunction( - (n) => document.querySelectorAll(".react-flow__node").length === n + 1, - nodesBefore, - { timeout: 8000 } - ) - ok("带问提交:新节点长出(+1)", true) - ok( - "边 +1(父子边就位)", - (await page.locator(".react-flow__edge").count()) === 1 - ) - await sleep(700) // setCenter 动画(320ms)+ 结算余量 - const selId = await page.evaluate(() => - document - .querySelector(".react-flow__node.selected") - ?.getAttribute("data-id") - ) - ok("新节点自动选中(focusNode)", !!selId && selId !== "main", selId ?? "") - ok( - "仅一个外挂面板(单选语义,主线面板已收起)", - (await page.locator(".canvas-expand").count()) === 1 - ) - const firstMsg = page - .locator(".react-flow__node.selected .canvas-expand .msg-list .message") - .first() - ok( - "新节点面板首条 = 所提问题(user 原文)", - (await firstMsg.getAttribute("class"))?.includes("user") && - (await firstMsg.innerText()).includes(Q2.slice(0, 12)) - ) - const box = await page.locator(".react-flow__node.selected").boundingBox() - const vp = page.viewportSize() - ok( - "setCenter 跟随:新节点卡整体在视口内", - box && - box.x >= -2 && - box.y >= -2 && - box.x + box.width <= vp.width + 2 && - box.y + box.height <= vp.height + 2, - box - ? `x=${box.x.toFixed(0)} y=${box.y.toFixed(0)} w=${box.width.toFixed(0)} h=${box.height.toFixed(0)}` - : "无 box" - ) - const zAfter = await getScale() - ok("跟随不缩小 zoom(max(当前, 0.85))", zAfter >= 1.4, `zoom ${z}→${zAfter}`) - await waitPanelDone() - ok( - "新分支首答在面板内流式完成(Markdown 渲染)", - (await page.evaluate(() => { - const bodies = document.querySelectorAll( - ".canvas-expand .message.assistant .md-body" - ) - const last = bodies[bodies.length - 1] - return (last?.textContent ?? "").trim().length > 20 - })) === true - ) - await page.screenshot({ path: SHOT("cc-5-new-node") }) -} - -/* 分支首答完成会触发一次异步标题生成:等它收口(避免与后续划选/存盘竞态) */ -await waitUntil(() => titleResponses >= titlePosts, 30000) -await sleep(400) - -/* ================= 6. 留空提交:新节点面板 composer 预填(同列模式语义) ================= */ -let emptyAnchor = null -{ - // 回主线面板再划选(第二个分支也挂主线,形成同 rank 兄弟节点,供遮挡与重排断言) - await setZoom(0.8) - await page.locator('.react-flow__node[data-id="main"]').click() - await page.waitForTimeout(300) - emptyAnchor = await selectInPanel(PHRASE_C, 5) - ok( - "主线面板再划选(留空路径)", - emptyAnchor !== null, - emptyAnchor?.text ?? "" - ) - const nodesBefore = await page.locator(".react-flow__node").count() - await page.locator(".sel-bubble .ask textarea").press("Enter") // 留空直接回车 - await page.waitForFunction( - (n) => document.querySelectorAll(".react-flow__node").length === n + 1, - nodesBefore, - { timeout: 8000 } - ) - await sleep(700) - const prefill = await page - .locator(".react-flow__node.selected .canvas-expand .cv-composer textarea") - .inputValue() - ok( - "留空分支:新节点面板 composer 预填 kickoffQuestion()(期望值由产品代码生成)", - prefill === kickoffQuestion(emptyAnchor.text), - JSON.stringify(prefill) - ) - ok( - "留空分支消息区为空(未自动发请求)", - (await page - .locator(".react-flow__node.selected .canvas-expand .msg-list .message") - .count()) === 0 - ) -} - -/* ================= 7. 多节点下收起/再展开零重排 + zIndex 遮挡 ================= */ -{ - const before = await nodeTransforms() - const pt = await blankPoint() - await page.mouse.click(pt.x, pt.y) // 点空白:取消选中 = 收起面板 - await sleep(300) - ok("点空白收起面板", (await page.locator(".canvas-expand").count()) === 0) - const afterCollapse = await nodeTransforms() - ok("收起零重排:全部节点坐标不变", sameTransforms(before, afterCollapse)) - - // 展开「上方的兄弟分支」:其面板向下悬垂,盖住下方兄弟卡(LR 特有遮挡面) - const upperSibling = await page.evaluate(() => { - const nodes = Array.from( - document.querySelectorAll('.react-flow__node:not([data-id="main"])') - ).map((el) => ({ - id: el.getAttribute("data-id"), - y: el.getBoundingClientRect().top, - })) - nodes.sort((a, b) => a.y - b.y) - return nodes[0]?.id ?? null - }) - ok("存在两个分支节点(兄弟同 rank)", upperSibling !== null) - await page.locator(`.react-flow__node[data-id="${upperSibling}"]`).click() - await sleep(300) - const afterExpand = await nodeTransforms() - ok("再展开零重排:全部节点坐标不变", sameTransforms(before, afterExpand)) - const zRaised = await page.evaluate(() => { - const sel = document.querySelector(".react-flow__node.selected") - const other = Array.from( - document.querySelectorAll(".react-flow__node:not(.selected)") - )[0] - return Number(sel?.style.zIndex || 0) > Number(other?.style.zIndex || 0) - }) - ok("选中节点 zIndex 抬升(面板盖过兄弟卡)", zRaised) - await page.screenshot({ path: SHOT("cc-6-occlusion") }) -} - -/* ================= 8. 列槽隔离:回列视图布局未变 ================= */ -{ - await page.locator(".topbar button.mode", { hasText: "列" }).click() - await page.waitForSelector(".tc .cols", { timeout: 5000 }) - await sleep(300) - const cols = await page.locator(".tc .cols > .column").count() - ok("画布 fork 不占列槽:回列后仅主线一列", cols === 1, `实际 ${cols} 列`) - const marks = await page.evaluate( - () => document.querySelectorAll(".tc .cols .md-body sup.fn-mark").length - ) - ok("画布 fork 的锚点脚注在列模式原文可见(≥2)", marks >= 2, `实际 ${marks}`) - await page.screenshot({ path: SHOT("cc-7-columns-isolated") }) -} - -/* ================= 9. 双击语义:面板内不误触,节点卡回列 ================= */ -{ - await page.locator(".topbar button.mode", { hasText: "画布" }).click() - await page.waitForSelector(".react-flow__node", { timeout: 10000 }) - await page.waitForTimeout(700) - await page.locator(".react-flow__controls-zoomout").click() - await page.waitForTimeout(300) - await page.locator('.react-flow__node[data-id="main"]').click() - await page.waitForTimeout(300) - ok( - "重进画布:单击主线再展开", - (await page.locator(".canvas-expand").count()) === 1 - ) - await page.locator(".canvas-expand .msg-list.mini").dblclick() - await sleep(400) - ok( - "面板内双击不误触回列(仍在画布)", - (await page.locator(".react-flow").count()) === 1 - ) - await page - .locator('.react-flow__node[data-id="main"] .canvas-card .chead') - .dblclick() - await sleep(500) - ok( - "双击节点卡回列模式(Phase 1 行为保留)", - (await page.locator(".react-flow").count()) === 0 && - (await page.locator(".tc .cols > .column").count()) >= 1 - ) -} - -/* ================= 10. 收尾 ================= */ -ok( - "全程无页面错误(pageerror)", - pageErrors.length === 0, - pageErrors.join(" | ") -) -await waitUntil(() => titleResponses >= titlePosts, 30000) -await sleep(2500) // 最后一轮防抖 PUT 落库后再删行 -const ownTreeId = page.url().split("/").pop() -if (/^[0-9a-f-]{36}$/.test(ownTreeId)) { - await page.request.delete(`${BASE_URL}/api/branch-trees/${ownTreeId}`) - console.log(`已清理本次测试树 ${ownTreeId.slice(0, 8)}…`) -} -await browser.close() -console.log(failed ? "\n==== 存在 FAIL ====" : "\n==== 全部 PASS ====") -process.exit(failed) diff --git a/e2e/thread-chat/verify-live.mjs b/e2e/thread-chat/verify-live.mjs deleted file mode 100644 index 8ae9a179..00000000 --- a/e2e/thread-chat/verify-live.mjs +++ /dev/null @@ -1,328 +0,0 @@ -/** - * ThreadChat(/thread-chat 分支对话页)真实后端端到端验收。 - * - * 运行前提: - * 1. dev server 已在 localhost:4040 跑着(pnpm dev); - * 2. .env.local 配好 MiniMax(MINIMAX_API_KEY / MINIMAX_BASE_URL / LLM_MODEL_ID); - * 3. 本机有 Chromium:优先取环境变量 CHROMIUM_PATH,否则用 playwright-core 默认发现逻辑。 - * 运行(默认 http://localhost:4040,可用 BASE_URL 覆盖;--experimental-strip-types - * 是因为脚本直接 import prompt-pure.ts 生成 kickoff 预填的期望值——文案改一处即全跟随): - * CHROMIUM_PATH=/opt/pw-browsers/chromium node --experimental-strip-types e2e/thread-chat/verify-live.mjs - * - * 断言覆盖:页面加载 → 主线真实流式回复(富文本 Markdown:.md-body 渲染出结构化元素、 - * 无裸 Markdown 记号)→ 划选渲染后的正文开分支(气泡)→ 分支列打开但不自动发请求 - * (composer 预填代拟问题 / 消息区为空 / 2 秒内无新 /api/chat POST)→ 回车确认后 kickoff - * 成为真实 user 气泡 + assistant 流式首答 → payload 契约(继承上文 / kickoff 以真实 user - * 消息在 messages 里 / threadChat.anchorText / 无 system 角色 / 无指令前缀折叠 / - * 无空 assistant)→ 主线源消息出现锚点高亮 / 脚注 → 分支内追问二轮流式。 - * 走真实模型,回复内容非确定,断言只卡结构与契约。截图输出到同目录 shots/(已 gitignore)。 - */ -import { mkdirSync } from "node:fs" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" -import { chromium } from "playwright-core" -import { kickoffQuestion } from "../../lib/thread-chat/application/prompt-policy.ts" - -const here = dirname(fileURLToPath(import.meta.url)) -const shotsDir = join(here, "shots") -mkdirSync(shotsDir, { recursive: true }) -const SHOT = (n) => join(shotsDir, `${n}.png`) -const ok = (label, cond) => { - console.log(`${cond ? "PASS" : "FAIL"} ${label}`) - if (!cond) process.exitCode = 1 -} - -const browser = await chromium.launch({ - executablePath: process.env.CHROMIUM_PATH || undefined, - headless: true, -}) -const page = await browser.newPage({ viewport: { width: 1600, height: 950 } }) - -// 记录发往 /api/chat 的 payload(校验请求契约用) -const payloads = [] -page.on("request", (req) => { - if (req.url().includes("/api/chat") && req.method() === "POST") { - try { - payloads.push(JSON.parse(req.postData() ?? "{}")) - } catch { - /* 非 JSON 忽略 */ - } - } -}) -page.on("pageerror", (e) => console.log("PAGEERROR:", e.message)) - -const BASE_URL = process.env.BASE_URL || "http://localhost:4040" -await page.goto(`${BASE_URL}/thread-chat`, { - waitUntil: "networkidle", -}) -ok("页面加载:.tc 壳存在", (await page.locator(".tc").count()) === 1) - -// ---- 1. 主线发消息,等真实流式回复完成 ---- -const composer = page.locator(".column").first().locator("textarea") -await composer.fill( - "请用小标题和分点列表,较详细地讲解量子纠缠(至少列 4 个要点)," + - "并务必在正文中出现「贝尔不等式」这个词。" -) -await composer.press("Enter") - -await page.waitForSelector(".tc .message.assistant", { timeout: 20000 }) -// 流式完成的判据:正文非空且发送键回到「发送」(busy 解除) -await page.waitForFunction( - () => { - const col = document.querySelector(".column") - const bubbles = col?.querySelectorAll(".message.assistant .bubble") - const last = bubbles?.[bubbles.length - 1] - const btn = col?.querySelector(".composer .send") - return ( - last && - last.textContent.trim().length > 20 && - btn && - !btn.classList.contains("stop") - ) - }, - undefined, - { timeout: 120000 } -) -// 平滑打字(useSmoothText)与「busy 解除」判据存在一帧级竞态:assistant.status 转为完成态、 -// display 尚未从追赶态 snap 到完整 target 的那一帧里,「发送键回到『发送』」就已经为真—— -// snap 发生在下一个 passive effect + 重渲染里,通常 <1 帧但不为 0。直接断言裸 Markdown 记号 -// 会偶发命中这个半途文本(截断处若正落在 "**" 中间会误判成"未渲染")。这里加一小段静置, -// 等 snap 的重渲染落地,不是掩盖真实回归——最终必然收敛到与 msg.text 完全一致的完整正文。 -await page.waitForTimeout(150) -const mainReply = await page - .locator(".column") - .first() - .locator(".message.assistant .bubble") - .last() - .innerText() -ok("主线收到真实流式回复(>20 字)", mainReply.trim().length > 20) -ok( - "回复包含「贝尔不等式」(可校验上下文真实来自模型)", - mainReply.includes("贝尔不等式") -) - -// 富文本断言:.md-body 存在、渲染出结构化元素、且正文无裸 Markdown 记号 -const mdInfo = await page.evaluate(() => { - const col = document.querySelector(".column") - const bubbles = col?.querySelectorAll(".message.assistant .bubble") - const last = bubbles?.[bubbles.length - 1] - const md = last?.querySelector(".md-body") - if (!md) return { hasMd: false } - const structural = md.querySelector( - "strong, em, ul, ol, h1, h2, h3, h4, code, table, blockquote" - ) - // 裸记号只在【代码块之外】检测——/
 的 textContent 合法保留 ** 与 #
-  // (如 Python 注释 # 或 shell 的 **),把它们算作未渲染记号会造成非确定性误报。
-  const clone = md.cloneNode(true)
-  clone.querySelectorAll("code, pre").forEach((n) => n.remove())
-  const prose = clone.textContent || ""
-  // 只有「系统性」记号才判为未渲染:渲染器坏掉时是成片 **/#(每处加粗、每个标题都漏),
-  // 而模型偶发输出的不成对 `**`(CommonMark 语义下字面保留)会留下 1–2 处孤记号,
-  // 那是内容非确定性、不是渲染回归——单跑两次就能出现,此前按「出现即 FAIL」误报。
-  const starHits = [...prose.matchAll(/\*\*/g)]
-  const hashHits = [...prose.matchAll(/(^|\n)#{1,6}\s/g)]
-  return {
-    hasMd: true,
-    structured: !!structural,
-    raw: starHits.length >= 3 || hashHits.length >= 2,
-    strayDetail: [...starHits, ...hashHits]
-      .slice(0, 3)
-      .map((m) => prose.slice(Math.max(0, m.index - 20), m.index + 22))
-      .join(" ⧸ "),
-  }
-})
-ok("assistant 正文进入 .md-body(Markdown 渲染容器)", mdInfo.hasMd)
-ok(
-  "assistant 正文渲染出结构化元素(strong / 列表 / 标题 / 代码等)",
-  mdInfo.structured === true
-)
-ok(
-  "assistant 正文无系统性裸 Markdown 记号(≥3 处 ** / ≥2 行首 #)",
-  mdInfo.raw === false
-)
-if (mdInfo.strayDetail)
-  console.log(
-    `      (零星记号上下文,通常为模型输出的不成对 **:${mdInfo.strayDetail})`
-  )
-await page.screenshot({ path: SHOT("1-main-reply") })
-
-// ---- 2. 划选回复文字 → 气泡 → 开分支 ----
-const anchor = "贝尔不等式"
-const selected = await page.evaluate(async (needle) => {
-  const bubbles = document.querySelectorAll(
-    ".column .message.assistant .bubble"
-  )
-  const bubble = bubbles[bubbles.length - 1]
-  const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT)
-  let node
-  while ((node = walker.nextNode())) {
-    const i = node.textContent.indexOf(needle)
-    if (i >= 0) {
-      // 先把命中处滚进视口(模拟真实用户看着划选),气泡定位才落在可视区
-      node.parentElement?.scrollIntoView({ block: "center" })
-      await new Promise((r) => setTimeout(r, 120))
-      const range = document.createRange()
-      range.setStart(node, i)
-      range.setEnd(node, i + needle.length)
-      const sel = window.getSelection()
-      sel.removeAllRanges()
-      sel.addRange(range)
-      document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }))
-      return true
-    }
-  }
-  return false
-}, anchor)
-ok("成功划选「贝尔不等式」", selected)
-
-await page.waitForSelector(".tc .sel-bubble", { timeout: 5000 })
-ok("划选气泡浮出", true)
-await page.screenshot({ path: SHOT("2-selection-bubble") })
-const postsBeforeFork = payloads.length
-await page.getByText("开启分支讨论").click()
-
-// ---- 分支列出现:不自动发请求,composer 预填代拟问题等用户确认 ----
-await page.waitForFunction(
-  () => document.querySelectorAll(".column").length >= 2,
-  undefined,
-  {
-    timeout: 10000,
-  }
-)
-ok("分支列已打开", true)
-
-// 预填期望值直接由产品代码的 kickoffQuestion() 生成(文案变更不再破测试)
-const kickoffExpected = kickoffQuestion(anchor)
-const branchCol = page.locator(".column").last()
-const prefillValue = await branchCol.locator("textarea").inputValue()
-ok("分支 composer 预填代拟问题(含锚点原文)", prefillValue === kickoffExpected)
-ok(
-  "分支消息区为空(未自动生成首答)",
-  (await branchCol.locator(".message").count()) === 0
-)
-await page.waitForTimeout(2000)
-ok(
-  "开分支后 2 秒内无新的 /api/chat POST(不再自动发请求)",
-  payloads.length === postsBeforeFork
-)
-await page.screenshot({ path: SHOT("3-branch-prefilled") })
-
-// ---- 回车确认:kickoff 成为真实 user 消息,assistant 流式首答 ----
-await branchCol.locator("textarea").press("Enter")
-await page.waitForFunction(
-  (expected) => {
-    const cols = document.querySelectorAll(".column")
-    const col = cols[cols.length - 1]
-    const userBubble = col?.querySelector(".message.user .bubble")
-    return userBubble && userBubble.textContent.trim() === expected
-  },
-  kickoffExpected,
-  { timeout: 10000 }
-)
-ok("回车后代拟问题成为真实 user 气泡", true)
-await page.waitForFunction(
-  () => {
-    const cols = document.querySelectorAll(".column")
-    const col = cols[cols.length - 1]
-    const bubbles = col?.querySelectorAll(".message.assistant .bubble")
-    const last = bubbles?.[bubbles.length - 1]
-    const btn = col?.querySelector(".composer .send")
-    return (
-      last &&
-      last.textContent.trim().length > 20 &&
-      btn &&
-      !btn.classList.contains("stop")
-    )
-  },
-  undefined,
-  { timeout: 120000 }
-)
-const branchReply = await page
-  .locator(".column")
-  .last()
-  .locator(".message.assistant .bubble")
-  .last()
-  .innerText()
-ok("分支首答已流式完成(>20 字)", branchReply.trim().length > 20)
-
-// 锚点定位生效:主线源消息在渲染后的 .md-body 上出现高亮或脚注(手绘 DOM)
-await page
-  .locator(
-    ".column .message.assistant .md-body [data-text-anchor-mark], .column .message.assistant .md-body sup.fn-mark"
-  )
-  .first()
-  .waitFor({ timeout: 5000 })
-  .catch(() => {})
-const markCount = await page
-  .locator(
-    ".column .message.assistant .md-body [data-text-anchor-mark], .column .message.assistant .md-body sup.fn-mark"
-  )
-  .count()
-ok(`主线源消息出现锚点高亮 / 脚注(当前 ${markCount} 个标记)`, markCount >= 1)
-await page.screenshot({ path: SHOT("4-branch-streamed") })
-
-// ---- 3. 校验分支请求 payload 契约 ----
-const branchPayload = payloads[payloads.length - 1]
-const msgs = branchPayload?.messages ?? []
-const texts = msgs.map((m) => m.parts?.[0]?.text ?? "")
-ok(
-  "分支请求含继承上文(主线的用户提问在 payload 里)",
-  texts.some((t) => t.includes("量子纠缠"))
-)
-ok(
-  "kickoff 以真实 user 消息形式在 messages 里(用户确认后入 store)",
-  msgs.some((m) => m.role === "user" && m.parts?.[0]?.text === kickoffExpected)
-)
-ok(
-  "分支请求带 threadChat.anchorText(system 归服务端构造)",
-  branchPayload?.threadChat?.anchorText === anchor
-)
-ok(
-  "user 消息干净(不再折叠指令前缀)",
-  !(msgs.find((m) => m.role === "user")?.parts?.[0]?.text ?? "").includes(
-    "Markdown"
-  )
-)
-ok(
-  "payload 无 system 角色",
-  msgs.every((m) => m.role !== "system")
-)
-ok(
-  "payload 无空 assistant 消息",
-  msgs.every(
-    (m) => m.role !== "assistant" || (m.parts?.[0]?.text ?? "").trim() !== ""
-  )
-)
-
-// ---- 4. 分支内追问(验证多轮 + kickoff 每轮重建) ----
-const branchComposer = page.locator(".column").last().locator("textarea")
-await branchComposer.fill("换一个通俗的比喻再解释一次。")
-await branchComposer.press("Enter")
-await page.waitForFunction(
-  () => {
-    const cols = document.querySelectorAll(".column")
-    const col = cols[cols.length - 1]
-    const bubbles = col?.querySelectorAll(".message.assistant .bubble")
-    return (
-      bubbles &&
-      bubbles.length >= 2 &&
-      bubbles[bubbles.length - 1].textContent.trim().length > 10
-    )
-  },
-  undefined,
-  { timeout: 120000 }
-)
-ok("分支内追问收到第二条流式回复", true)
-await page.screenshot({ path: SHOT("5-branch-followup") })
-
-console.log("\n--- 主线回复节选 ---\n" + mainReply.slice(0, 160))
-console.log("\n--- 分支首答节选 ---\n" + branchReply.slice(0, 160))
-
-// 自清理:持久化上线后本脚本每次跑都会经防抖存下一棵测试树,删掉避免污染开发库
-// (DELETE 幂等;treeId 从当前 URL 取——裸路径已被 replace 到 /thread-chat/{uuid})
-const ownTreeId = page.url().split("/").pop()
-if (/^[0-9a-f-]{36}$/.test(ownTreeId)) {
-  await page.request.delete(`${BASE_URL}/api/branch-trees/${ownTreeId}`)
-  console.log(`已清理本次测试树 ${ownTreeId.slice(0, 8)}…`)
-}
-await browser.close()
diff --git a/e2e/thread-chat/verify-markdown-artifact.mjs b/e2e/thread-chat/verify-markdown-artifact.mjs
deleted file mode 100644
index 8706d086..00000000
--- a/e2e/thread-chat/verify-markdown-artifact.mjs
+++ /dev/null
@@ -1,362 +0,0 @@
-/**
- * ThreadChat Markdown Artifact 的浏览器链路验收(后端与持久化 API 均在浏览器层 mock)。
- *
- * 运行:
- *   CHROMIUM_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
- *   BASE_URL=http://localhost:4040 \
- *   node e2e/thread-chat/verify-markdown-artifact.mjs
- */
-import { chromium } from "playwright-core"
-
-const BASE_URL = process.env.BASE_URL || "http://localhost:4040"
-const TREE_ID = "00000000-0000-4000-8000-000000000042"
-const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
-
-let failed = false
-function ok(label, condition, detail = "") {
-  console.log(
-    `${condition ? "PASS" : "FAIL"}  ${label}${detail ? `(${detail})` : ""}`
-  )
-  if (!condition) failed = true
-}
-
-function emptyState() {
-  return {
-    threads: {
-      main: {
-        id: "main",
-        parentId: null,
-        depth: 0,
-        title: "主线",
-        anchorText: null,
-        forkFromMsgId: null,
-        footnote: null,
-        children: [],
-        messages: [],
-        lastActive: 1,
-      },
-    },
-    artifacts: {},
-    artifactOrder: [],
-    recents: [],
-    footnoteCounter: 0,
-    seq: 1,
-    tick: 1,
-  }
-}
-
-function markdownSse(index) {
-  const title = index === 1 ? "发布计划" : `修订版 ${index}`
-  const content =
-    index === 1
-      ? "# 发布计划\n\n- 项目 A\n- 项目 B\n\n| 阶段 | 状态 |\n| --- | --- |\n| 开发 | 完成 |"
-      : `# ${title}\n\n- 已根据后续要求更新`
-  const inputText = JSON.stringify({ title, content })
-  const splitAt = Math.ceil(inputText.length / 2)
-  const chunks = [
-    { type: "text-delta", id: `text-${index}`, delta: "\n" },
-    {
-      type: "tool-input-start",
-      toolCallId: `call-${index}`,
-      toolName: "createMarkdownArtifact",
-    },
-    {
-      type: "tool-input-delta",
-      toolCallId: `call-${index}`,
-      inputTextDelta: inputText.slice(0, splitAt),
-    },
-    {
-      type: "tool-input-delta",
-      toolCallId: `call-${index}`,
-      inputTextDelta: inputText.slice(splitAt),
-    },
-    {
-      type: "tool-input-available",
-      toolCallId: `call-${index}`,
-      toolName: "createMarkdownArtifact",
-      input: { title, content },
-    },
-    { type: "finish" },
-  ]
-  return `${chunks
-    .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
-    .join("")}data: [DONE]\n\n`
-}
-
-const browser = await chromium.launch({
-  executablePath: process.env.CHROMIUM_PATH || undefined,
-  headless: true,
-})
-const context = await browser.newContext({
-  viewport: { width: 1600, height: 950 },
-})
-await context.addCookies([
-  {
-    name: "better-auth.session_token",
-    value: "thread-chat-markdown-e2e",
-    url: BASE_URL,
-  },
-])
-
-let persistedState = emptyState()
-let chatRequestCount = 0
-const chatBodies = []
-const savedStates = []
-
-await context.route("**/api/**", async (route) => {
-  const request = route.request()
-  const url = new URL(request.url())
-
-  if (url.pathname === `/api/branch-trees/${TREE_ID}`) {
-    if (request.method() === "GET") {
-      await route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify({ state: persistedState, customTitle: null }),
-      })
-      return
-    }
-    if (request.method() === "PUT") {
-      const body = request.postDataJSON()
-      persistedState = structuredClone(body.state)
-      savedStates.push(structuredClone(body.state))
-      await route.fulfill({
-        status: 200,
-        contentType: "application/json",
-        body: "{}",
-      })
-      return
-    }
-  }
-
-  if (url.pathname === "/api/branch-trees" && request.method() === "GET") {
-    await route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify({ trees: [] }),
-    })
-    return
-  }
-
-  if (url.pathname === "/api/chat" && request.method() === "POST") {
-    chatRequestCount++
-    chatBodies.push(request.postDataJSON())
-    const requestIndex = chatRequestCount
-
-    // 第三次请求保持 pending,供页面点击“停止”;retry 的第四次请求正常产出。
-    if (requestIndex === 3) {
-      await sleep(6_000)
-      try {
-        await route.fulfill({
-          status: 200,
-          contentType: "text/event-stream",
-          body: "data: [DONE]\n\n",
-        })
-      } catch {
-        // 浏览器 abort 后 route 已关闭,符合预期。
-      }
-      return
-    }
-
-    await route.fulfill({
-      status: 200,
-      headers: {
-        "content-type": "text/event-stream",
-        "cache-control": "no-cache",
-      },
-      body: markdownSse(requestIndex),
-    })
-    return
-  }
-
-  if (url.pathname === "/api/title") {
-    await route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify({ title: "Markdown 分支" }),
-    })
-    return
-  }
-
-  if (url.pathname === "/api/auth/get-session") {
-    await route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify({
-        user: { id: "e2e", name: "E2E", email: "e2e@example.com" },
-        session: { id: "e2e-session" },
-      }),
-    })
-    return
-  }
-
-  if (url.pathname === "/api/billing/summary") {
-    await route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify({ balanceMicros: 5_000_000, totalUsageMicros: 0 }),
-    })
-    return
-  }
-
-  await route.fulfill({
-    status: 404,
-    contentType: "application/json",
-    body: "{}",
-  })
-})
-
-const page = await context.newPage()
-const pageErrors = []
-page.on("pageerror", (error) => pageErrors.push(String(error)))
-
-try {
-  await page.goto(`${BASE_URL}/thread-chat/${TREE_ID}`, {
-    waitUntil: "networkidle",
-  })
-  const composer = page.locator(".column").first().locator("textarea")
-
-  await composer.fill("请帮我生成一个 Markdown,总结发布计划")
-  await composer.press("Enter")
-  const firstMessage = page.locator(".message.assistant").last()
-  await firstMessage.locator(".acard").waitFor({ state: "visible" })
-  ok("列视图:Markdown 卡片插入 assistant 消息", true)
-  ok(
-    "Artifact-only:不渲染空气泡",
-    (await firstMessage.locator('.bubble[data-role="assistant"]').count()) === 0
-  )
-  ok(
-    "卡片文案:标题、MARKDOWN、打开预览",
-    (await firstMessage.textContent()).includes("发布计划") &&
-      (await firstMessage.textContent()).includes("MARKDOWN") &&
-      (await firstMessage.textContent()).includes("打开预览")
-  )
-
-  await firstMessage.locator(".acard").click()
-  const drawer = page.locator(".art-drawer.open")
-  await drawer.waitFor({ state: "visible" })
-  ok("点击卡片打开右侧 Markdown 面板", (await drawer.count()) === 1)
-  ok("GFM:标题渲染", (await drawer.locator(".art-body h1").count()) === 1)
-  ok("GFM:列表渲染", (await drawer.locator(".art-body li").count()) === 2)
-  ok("GFM:表格渲染", (await drawer.locator(".art-body table").count()) === 1)
-
-  await page.waitForTimeout(1_900)
-  ok("防抖整树保存包含 Markdown", savedStates.length > 0)
-  ok(
-    "持久化:kind、消息关联、来源 thread 与 tab 顺序齐全",
-    persistedState.artifactOrder.length === 1 &&
-      persistedState.artifacts[persistedState.artifactOrder[0]]?.kind ===
-        "markdown" &&
-      persistedState.artifacts[persistedState.artifactOrder[0]]
-        ?.sourceThreadId === "main" &&
-      persistedState.threads.main.messages.some((message) =>
-        message.artifactIds?.includes(persistedState.artifactOrder[0])
-      ) &&
-      persistedState.threads.main.messages.every(
-        (message) => message.markdownGeneration === undefined
-      )
-  )
-
-  await page.reload({ waitUntil: "networkidle" })
-  await page.locator(".message.assistant .acard").waitFor({ state: "visible" })
-  ok("刷新:Markdown 卡片恢复", true)
-
-  const composerAfterReload = page
-    .locator(".column")
-    .first()
-    .locator("textarea")
-  await composerAfterReload.fill("请修改刚才的 Markdown,补充验收部分")
-  await composerAfterReload.press("Enter")
-  await page
-    .locator(".message.assistant .acard")
-    .nth(1)
-    .waitFor({ state: "visible" })
-  const replayed = JSON.stringify(chatBodies[1]?.messages ?? [])
-  ok(
-    "后续请求上下文包含旧文档标题与正文",
-    replayed.includes("[Markdown Artifact: 发布计划]") &&
-      replayed.includes("| 开发 | 完成 |")
-  )
-
-  await page.getByRole("button", { name: /画布/ }).click()
-  await page.locator('.react-flow__node[data-id="main"]').waitFor()
-  await page.locator('.react-flow__node[data-id="main"]').click()
-  const canvasCards = page.locator(".canvas-expand .acard")
-  await canvasCards.first().waitFor({ state: "visible" })
-  ok("画布:复用同一 Markdown 卡片", (await canvasCards.count()) === 2)
-  ok(
-    "画布 Artifact-only:不渲染空气泡",
-    (await page
-      .locator(".canvas-expand .message.assistant .bubble")
-      .count()) === 0
-  )
-  await canvasCards.last().click()
-  ok(
-    "画布卡片可打开全局面板",
-    (await page.locator(".art-drawer.open").count()) === 1
-  )
-
-  await page.locator(".art-drawer.open .art-x").click()
-  await page.locator(".art-drawer.open").waitFor({ state: "hidden" })
-  await page.getByRole("button", { name: /^列$/ }).click()
-  const columnComposer = page.locator(".column").first().locator("textarea")
-  await columnComposer.fill("开始一个可停止的请求")
-  await columnComposer.press("Enter")
-  const stopButton = page
-    .locator(".column")
-    .first()
-    .locator(".composer .send.stop")
-  await stopButton.waitFor({ state: "visible" })
-  await stopButton.click()
-  await page.getByText("已停止生成").waitFor({ state: "visible" })
-  ok("停止:零输出请求进入可重试错误态", true)
-  await page.getByRole("button", { name: "重试" }).last().click()
-  await page
-    .locator(".message.assistant .acard")
-    .nth(2)
-    .waitFor({ state: "visible" })
-  ok("重试:重新生成 Markdown 卡片", true)
-
-  await page.waitForTimeout(1_900)
-  const dirty = structuredClone(persistedState)
-  const firstAssistant = dirty.threads.main.messages.find(
-    (message) => message.role === "assistant" && message.artifactIds?.length
-  )
-  firstAssistant.status = "streaming"
-  firstAssistant.artifactIds.push("missing-artifact")
-  dirty.threads.main.messages.push({
-    id: "pending-empty",
-    role: "assistant",
-    text: "",
-    forks: [],
-    status: "pending",
-  })
-  dirty.artifacts.orphan = {
-    id: "orphan",
-    kind: "markdown",
-    title: "孤儿 Markdown",
-    content: "# 不应显示",
-    sourceThreadId: "main",
-  }
-  dirty.artifactOrder.unshift("orphan")
-  persistedState = dirty
-
-  await page.reload({ waitUntil: "networkidle" })
-  await page.locator(".message.assistant .acard").first().waitFor()
-  ok(
-    "sanitize:Artifact-only streaming 恢复 done 且空 pending 被删",
-    (await page.locator(".typing, .caret").count()) === 0 &&
-      (await page.locator('[data-msg-id="pending-empty"]').count()) === 0
-  )
-  ok(
-    "sanitize:坏引用与孤儿 Artifact 不显示",
-    (await page.getByText("孤儿 Markdown").count()) === 0 &&
-      !(await page.locator(".topbar").textContent()).includes("Markdown4")
-  )
-
-  ok(
-    "浏览器运行期无 pageerror",
-    pageErrors.length === 0,
-    pageErrors.join(" | ")
-  )
-} finally {
-  await context.close()
-  await browser.close()
-}
-
-if (failed) process.exitCode = 1
diff --git a/e2e/thread-chat/verify-markdown-model.mjs b/e2e/thread-chat/verify-markdown-model.mjs
deleted file mode 100644
index e395ca88..00000000
--- a/e2e/thread-chat/verify-markdown-model.mjs
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * 默认 MiniMax 的真实 Markdown 工具选择语料验收。会产生少量真实模型用量。
- * 运行:node --experimental-strip-types e2e/thread-chat/verify-markdown-model.mjs
- */
-import dotenv from "dotenv"
-
-dotenv.config({ path: ".env.local", quiet: true })
-
-const [{ createOpenAICompatible }, { generateText, isStepCount, tool }] =
-  await Promise.all([import("@ai-sdk/openai-compatible"), import("ai")])
-const {
-  MARKDOWN_ARTIFACT_TOOL_DESCRIPTION,
-  MARKDOWN_ARTIFACT_TOOL_NAME,
-  markdownArtifactInputSchema,
-} = await import("../../lib/chat/markdown-artifact.ts")
-const { THREAD_CHAT_SYSTEM } = await import("../../constants/thread-chat.ts")
-
-if (!process.env.MINIMAX_API_KEY) {
-  console.log("SKIP  MINIMAX_API_KEY 未配置")
-  process.exit(0)
-}
-
-const provider = createOpenAICompatible({
-  name: "minimax-markdown-e2e",
-  baseURL: process.env.MINIMAX_BASE_URL ?? "https://api.minimaxi.com/v1",
-  apiKey: process.env.MINIMAX_API_KEY,
-  includeUsage: true,
-})
-const model = provider(process.env.LLM_MODEL_ID ?? "MiniMax-M2")
-const createMarkdownArtifact = tool({
-  description: MARKDOWN_ARTIFACT_TOOL_DESCRIPTION,
-  inputSchema: markdownArtifactInputSchema,
-  execute: async () => ({ created: true }),
-})
-
-const cases = [
-  {
-    label: "中文显式交付",
-    prompt: "请把登录、导出和监控这三点整理成一份简短的 Markdown 文档。",
-    expected: true,
-  },
-  {
-    label: "英文显式交付",
-    prompt:
-      "Deliver a short .md release note for version 1.2: fixed login and added export.",
-    expected: true,
-  },
-  {
-    label: "等价改写表达",
-    prompt:
-      "我要把这段会议纪要直接存成 README.md:周五上线,负责人是 Alex,发布前跑回归测试。",
-    expected: true,
-  },
-  {
-    label: "概念问答反例",
-    prompt: "Markdown 是什么?请用两句话解释。",
-    expected: false,
-  },
-]
-
-let failed = false
-for (const item of cases) {
-  const result = await generateText({
-    model,
-    system: THREAD_CHAT_SYSTEM,
-    prompt: item.prompt,
-    tools: { [MARKDOWN_ARTIFACT_TOOL_NAME]: createMarkdownArtifact },
-    stopWhen: isStepCount(2),
-    prepareStep: ({ stepNumber }) =>
-      stepNumber === 0
-        ? { activeTools: [MARKDOWN_ARTIFACT_TOOL_NAME] }
-        : { activeTools: [] },
-    maxOutputTokens: 512,
-  })
-  const calls = result.steps.flatMap((step) => step.toolCalls)
-  const selected = calls.some(
-    (call) => call.toolName === MARKDOWN_ARTIFACT_TOOL_NAME
-  )
-  const once = calls.length <= 1
-  const pass = selected === item.expected && once
-  console.log(
-    `${pass ? "PASS" : "FAIL"}  ${item.label}:${selected ? "调用 Markdown 工具" : "普通回答"}${once ? "" : `,调用 ${calls.length} 次`}`
-  )
-  failed ||= !pass
-}
-
-if (failed) process.exitCode = 1
diff --git a/e2e/thread-chat/verify-persist.mjs b/e2e/thread-chat/verify-persist.mjs
deleted file mode 100644
index a910d4c2..00000000
--- a/e2e/thread-chat/verify-persist.mjs
+++ /dev/null
@@ -1,263 +0,0 @@
-/**
- * ThreadChat 分支树 DB 持久化端到端验收(openspec: add-branch-tree-persistence)。
- *
- * 前提与 verify-live.mjs 相同(dev server + MiniMax key + Chromium),另需
- * DATABASE_URL 可连且已应用 branch_trees 迁移。运行:
- *   CHROMIUM_PATH=... BASE_URL=http://localhost:4040 node e2e/thread-chat/verify-persist.mjs
- *
- * 断言链:裸路径 replace 到 /thread-chat/{uuid} → 主线流式 + 划选开分支(回车首答)
- * → 过防抖观测整树 PUT → 同 context 重载全恢复(消息/分支列=工作台记忆/锚点/无转圈)
- * → 全新 context 直访同 URL 恢复(URL 即身份)→ 新对话空树 + 原树回访仍在
- * → DB 行断言 → sanitize(直写脏快照后加载收敛)。测试树行跑完清理。
- */
-import { readFileSync } from "node:fs"
-import { dirname, join } from "node:path"
-import { fileURLToPath } from "node:url"
-import { chromium } from "playwright-core"
-import postgres from "postgres"
-
-const here = dirname(fileURLToPath(import.meta.url))
-const SHOT = (n) => join(here, "shots", `${n}.png`)
-const ok = (label, cond) => {
-  console.log(`${cond ? "PASS" : "FAIL"}  ${label}`)
-  if (!cond) process.exitCode = 1
-}
-const BASE = process.env.BASE_URL || "http://localhost:4040"
-
-// DATABASE_URL:优先环境变量,回退 .env.local
-const env = Object.fromEntries(
-  readFileSync(join(here, "../../.env.local"), "utf8")
-    .split("\n")
-    .filter((l) => l.includes("=") && !l.startsWith("#"))
-    .map((l) => {
-      const i = l.indexOf("=")
-      return [
-        l.slice(0, i).trim(),
-        l
-          .slice(i + 1)
-          .trim()
-          .replace(/^["']|["']$/g, ""),
-      ]
-    })
-)
-const sql = postgres(process.env.DATABASE_URL || env.DATABASE_URL, { max: 1 })
-const testTreeIds = []
-
-const browser = await chromium.launch({
-  executablePath: process.env.CHROMIUM_PATH || undefined,
-  headless: true,
-})
-
-// ---- 1. 裸路径 → replace 到 UUID URL ----
-const ctx1 = await browser.newContext()
-const page = await ctx1.newPage()
-await page.goto(`${BASE}/thread-chat`, { waitUntil: "networkidle" })
-await page.waitForURL(/\/thread-chat\/[0-9a-f-]{36}$/, { timeout: 10000 })
-const treeUrl = page.url()
-const treeId = treeUrl.split("/").pop()
-testTreeIds.push(treeId)
-ok("裸路径 replace 到 /thread-chat/{uuid}", /^[0-9a-f-]{36}$/.test(treeId))
-
-// ---- 2. 主线发消息 → 划选开分支 → 回车首答 ----
-const puts = []
-page.on("request", (r) => {
-  if (r.url().includes("/api/branch-trees/") && r.method() === "PUT")
-    puts.push(r.url())
-})
-await page
-  .locator(".column")
-  .first()
-  .locator("textarea")
-  .fill("用两三句话介绍二分查找,请包含「时间复杂度」这个词。")
-await page.locator(".column").first().locator("textarea").press("Enter")
-await page.waitForFunction(
-  () => {
-    const col = document.querySelector(".column")
-    const b = col?.querySelectorAll(".message.assistant .bubble")
-    const btn = col?.querySelector(".composer .send")
-    return (
-      b?.length &&
-      b[b.length - 1].textContent.trim().length > 20 &&
-      btn &&
-      !btn.classList.contains("stop")
-    )
-  },
-  undefined,
-  { timeout: 120000 }
-)
-await page.waitForTimeout(200)
-const selected = await page.evaluate((needle) => {
-  const md = [
-    ...document.querySelectorAll(".column .message.assistant .md-body"),
-  ].pop()
-  const w = document.createTreeWalker(md, NodeFilter.SHOW_TEXT)
-  let n
-  while ((n = w.nextNode())) {
-    const i = n.textContent.indexOf(needle)
-    if (i >= 0) {
-      const r = document.createRange()
-      r.setStart(n, i)
-      r.setEnd(n, i + needle.length)
-      const s = getSelection()
-      s.removeAllRanges()
-      s.addRange(r)
-      document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }))
-      return true
-    }
-  }
-  return false
-}, "时间复杂度")
-ok("划选「时间复杂度」", selected)
-await page
-  .locator(".tc .sel-bubble")
-  .waitFor({ state: "visible", timeout: 5000 })
-await page.waitForTimeout(300)
-await page.getByText("开启分支讨论").click()
-await page.waitForFunction(
-  () => document.querySelectorAll(".column").length >= 2,
-  undefined,
-  { timeout: 8000 }
-)
-await page.locator(".column").last().locator("textarea").press("Enter")
-await page.waitForFunction(
-  () => {
-    const cols = document.querySelectorAll(".column")
-    const col = cols[cols.length - 1]
-    const b = col?.querySelectorAll(".message.assistant .bubble")
-    const btn = col?.querySelector(".composer .send")
-    return (
-      b?.length &&
-      b[b.length - 1].textContent.trim().length > 20 &&
-      btn &&
-      !btn.classList.contains("stop")
-    )
-  },
-  undefined,
-  { timeout: 120000 }
-)
-ok("分支首答流式完成", true)
-
-// ---- 3. 过防抖 → 整树 PUT 发生 ----
-await page.waitForTimeout(2200)
-ok("防抖后发生整树 PUT", puts.length >= 1)
-
-// ---- 4. 同 context 重载:全恢复(含工作台记忆)----
-await page.goto(treeUrl, { waitUntil: "networkidle" })
-await page.waitForSelector(".tc .message.assistant", { timeout: 15000 })
-const restored = await page.evaluate(() => ({
-  cols: document.querySelectorAll(".column").length,
-  mainMsgs:
-    document.querySelector(".column")?.querySelectorAll(".message").length ?? 0,
-  anchors: document.querySelectorAll(
-    ".md-body [data-text-anchor-mark], .md-body sup.fn-mark"
-  ).length,
-  spinning: document.querySelectorAll(".typing, .caret").length,
-}))
-ok("重载:主线消息恢复", restored.mainMsgs >= 2)
-ok("重载:分支列仍开着(工作台记忆)", restored.cols >= 2)
-ok("重载:锚点高亮/脚注恢复", restored.anchors >= 1)
-ok("重载:无 pending 转圈残留", restored.spinning === 0)
-await page.screenshot({ path: SHOT("persist-restored") })
-await ctx1.close()
-
-// ---- 5. 全新 context(无 localStorage)直访同 URL ----
-const ctx2 = await browser.newContext()
-const p2 = await ctx2.newPage()
-await p2.goto(treeUrl, { waitUntil: "networkidle" })
-await p2.waitForSelector(".tc .message.assistant", { timeout: 15000 })
-const fresh = await p2.evaluate(() => ({
-  mainMsgs:
-    document.querySelector(".column")?.querySelectorAll(".message").length ?? 0,
-  anchors: document.querySelectorAll(
-    ".md-body [data-text-anchor-mark], .md-body sup.fn-mark"
-  ).length,
-}))
-ok("新 context 直访 URL:消息恢复(URL 即身份)", fresh.mainMsgs >= 2)
-ok("新 context 直访 URL:锚点恢复", fresh.anchors >= 1)
-
-// ---- 6. 新对话:空树,原树可回访 ----
-await p2.getByRole("button", { name: "新对话" }).click()
-await p2.waitForURL(
-  (u) =>
-    /\/thread-chat\/[0-9a-f-]{36}$/.test(u.toString()) &&
-    !u.toString().includes(treeId),
-  { timeout: 8000 }
-)
-testTreeIds.push(p2.url().split("/").pop())
-await p2.waitForTimeout(400)
-ok("新对话:空树", (await p2.locator(".message").count()) === 0)
-await p2.goto(treeUrl, { waitUntil: "networkidle" })
-await p2.waitForSelector(".tc .message.assistant", { timeout: 15000 })
-ok("原 URL 回访:原树仍在", (await p2.locator(".message").count()) >= 2)
-await ctx2.close()
-
-// ---- 7. DB 行断言 ----
-const rows =
-  await sql`SELECT state, title FROM branch_trees WHERE id = ${treeId}`
-ok("DB:treeId 行存在", rows.length === 1)
-const threads = rows[0] ? Object.keys(rows[0].state?.threads ?? {}) : []
-ok(
-  "DB:state.threads 含 main + 分支",
-  threads.includes("main") && threads.length >= 2
-)
-ok("DB:派生标题非空", !!rows[0]?.title)
-
-// ---- 8. sanitize:直写脏快照后加载收敛 ----
-const dirtyId = crypto.randomUUID()
-testTreeIds.push(dirtyId)
-const dirtyState = {
-  threads: {
-    main: {
-      id: "main",
-      parentId: null,
-      depth: 0,
-      title: "主线",
-      anchorText: null,
-      forkFromMsgId: null,
-      footnote: null,
-      children: [],
-      lastActive: 2,
-      messages: [
-        { id: "m1", role: "user", text: "测试", forks: [] },
-        {
-          id: "m2",
-          role: "assistant",
-          text: "这是流式到一半的内容",
-          forks: [],
-          status: "streaming",
-        },
-        { id: "m3", role: "assistant", text: "", forks: [], status: "pending" },
-      ],
-    },
-  },
-  artifacts: {},
-  artifactOrder: [],
-  recents: [],
-  footnoteCounter: 0,
-  seq: 4,
-  tick: 2,
-}
-await sql`INSERT INTO branch_trees (id, title, state) VALUES (${dirtyId}, '脏快照测试', ${sql.json(dirtyState)})`
-const ctx3 = await browser.newContext()
-const p3 = await ctx3.newPage()
-await p3.goto(`${BASE}/thread-chat/${dirtyId}`, { waitUntil: "networkidle" })
-await p3.waitForSelector(".tc .message", { timeout: 15000 })
-const sane = await p3.evaluate(() => ({
-  msgs: document.querySelectorAll(".message").length,
-  spinning: document.querySelectorAll(".typing, .caret").length,
-  lastText:
-    [...document.querySelectorAll(".message.assistant .bubble")].pop()
-      ?.textContent ?? "",
-}))
-ok("sanitize:空 pending 占位被删(3→2 条)", sane.msgs === 2)
-ok(
-  "sanitize:半截 streaming 以正文显示为 done",
-  sane.lastText.includes("流式到一半") && sane.spinning === 0
-)
-await ctx3.close()
-
-// ---- 清理测试树行 ----
-await sql`DELETE FROM branch_trees WHERE id = ANY(${testTreeIds})`
-console.log(`已清理 ${testTreeIds.length} 行测试树`)
-await sql.end()
-await browser.close()
diff --git a/e2e/thread-chat/verify-syntax-highlighting.mjs b/e2e/thread-chat/verify-syntax-highlighting.mjs
deleted file mode 100644
index d1ac2942..00000000
--- a/e2e/thread-chat/verify-syntax-highlighting.mjs
+++ /dev/null
@@ -1,288 +0,0 @@
-/**
- * Thread Chat Shiki 浏览器验收(业务 API 在浏览器层 mock;服务端 layout 仍校验会话)。
- *
- * 运行:
- *   CHROMIUM_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
- *   STORAGE_STATE=/path/to/authenticated-storage-state.json \
- *   BASE_URL=http://localhost:4040 \
- *   node e2e/thread-chat/verify-syntax-highlighting.mjs
- *
- * 覆盖真实 renderer,而非检查源码:稳定消息与 Artifact 的 Shiki DOM、未知语言
- * plaintext、HTML/script 文本转义、复制原文,以及高亮异步结算后的持久锚点恢复、
- * 锚点点击与正常文本选择。
- */
-import { chromium } from "playwright-core"
-
-const BASE_URL = process.env.BASE_URL || "http://localhost:4040"
-const STORAGE_STATE = process.env.STORAGE_STATE
-const TREE_ID = "00000000-0000-4000-8000-000000000043"
-const ANCHOR_TEXT = "可点击的持久锚点"
-const MESSAGE_CODE =
-  "const escaped = ''"
-const ARTIFACT_CODE =
-  "console.log('')"
-const SHELL_SESSION_CODE = "$ echo should-remain-plaintext"
-
-let failed = false
-function ok(label, condition, detail = "") {
-  console.log(
-    `${condition ? "PASS" : "FAIL"}  ${label}${detail ? `(${detail})` : ""}`
-  )
-  if (!condition) failed = true
-}
-
-function seededState() {
-  return {
-    threads: {
-      main: {
-        id: "main",
-        modelId: "minimax-m2",
-        parentId: null,
-        depth: 0,
-        title: "主线",
-        anchorText: null,
-        forkFromMsgId: null,
-        footnote: null,
-        children: ["branch-1"],
-        lastActive: 2,
-        messages: [
-          {
-            id: "message-1",
-            role: "assistant",
-            status: "done",
-            text: `# 安全与持久锚点\n\n这里有${ANCHOR_TEXT},用于验证异步高亮完成后仍能恢复脚注。\n\n\`\`\`ts {1}\n${MESSAGE_CODE}\n\`\`\`\n\n\`\`\`future-lang\n\n\`\`\`\n\n\`\`\`shell-session\n${SHELL_SESSION_CODE}\n\`\`\``,
-            forks: [
-              {
-                text: ANCHOR_TEXT,
-                num: 1,
-                threadId: "branch-1",
-                depth: 1,
-                anchor: {
-                  quote: { exact: ANCHOR_TEXT, prefix: "", suffix: "" },
-                },
-              },
-            ],
-            artifactIds: ["artifact-1"],
-          },
-        ],
-      },
-      "branch-1": {
-        id: "branch-1",
-        modelId: "minimax-m2",
-        parentId: "main",
-        depth: 1,
-        title: "锚点分支",
-        anchorText: ANCHOR_TEXT,
-        forkFromMsgId: "message-1",
-        footnote: 1,
-        children: [],
-        lastActive: 1,
-        messages: [
-          {
-            id: "branch-message-1",
-            role: "assistant",
-            status: "done",
-            text: "这是一条已持久化的分支回复。",
-            forks: [],
-          },
-        ],
-      },
-    },
-    artifacts: {
-      "artifact-1": {
-        id: "artifact-1",
-        kind: "markdown",
-        title: "含代码的静态 Artifact",
-        content: `# Artifact 安全性\n\n\`\`\`js\n${ARTIFACT_CODE}\n\`\`\``,
-        sourceThreadId: "main",
-      },
-    },
-    artifactOrder: ["artifact-1"],
-    recents: [],
-    footnoteCounter: 1,
-    seq: 4,
-    tick: 2,
-  }
-}
-
-const browser = await chromium.launch({
-  executablePath: process.env.CHROMIUM_PATH || undefined,
-  headless: true,
-})
-const context = await browser.newContext({
-  viewport: { width: 1600, height: 950 },
-  ...(STORAGE_STATE ? { storageState: STORAGE_STATE } : {}),
-})
-let persistedState = seededState()
-
-await context.addInitScript(() => {
-  Object.defineProperty(navigator, "clipboard", {
-    configurable: true,
-    value: {
-      writeText(text) {
-        globalThis.__threadChatCopiedCode = text
-        return Promise.resolve()
-      },
-    },
-  })
-})
-
-await context.route("**/api/**", async (route) => {
-  const request = route.request()
-  const url = new URL(request.url())
-
-  if (url.pathname === `/api/branch-trees/${TREE_ID}`) {
-    if (request.method() === "GET") {
-      await route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify({ state: persistedState, customTitle: null }),
-      })
-      return
-    }
-    if (request.method() === "PUT") {
-      persistedState = structuredClone(request.postDataJSON().state)
-      await route.fulfill({ contentType: "application/json", body: "{}" })
-      return
-    }
-  }
-
-  if (url.pathname === "/api/branch-trees" && request.method() === "GET") {
-    await route.fulfill({ contentType: "application/json", body: '{"trees":[]}' })
-    return
-  }
-  if (url.pathname === "/api/auth/get-session") {
-    await route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify({
-        user: { id: "e2e", name: "E2E", email: "e2e@example.com" },
-        session: { id: "e2e-session" },
-      }),
-    })
-    return
-  }
-  if (url.pathname === "/api/billing/summary") {
-    await route.fulfill({
-      contentType: "application/json",
-      body: '{"balanceMicros":5000000,"totalUsageMicros":0}',
-    })
-    return
-  }
-
-  await route.fulfill({ status: 404, contentType: "application/json", body: "{}" })
-})
-
-const page = await context.newPage()
-const pageErrors = []
-page.on("pageerror", (error) => pageErrors.push(String(error)))
-
-try {
-  await page.goto(`${BASE_URL}/thread-chat/${TREE_ID}`, { waitUntil: "networkidle" })
-  if (new URL(page.url()).pathname === "/sign-in") {
-    throw new Error(
-      "Thread Chat 的服务端 layout 需要真实会话;请通过 STORAGE_STATE 提供已登录的 Playwright storage state。"
-    )
-  }
-  const sourceMessage = page.locator('.column[data-thread-id="main"] .message.assistant')
-  await sourceMessage.locator(".md-body").waitFor({ state: "visible" })
-  await sourceMessage
-    .locator('.md-body[data-content-settled="true"]')
-    .waitFor({ state: "attached", timeout: 20_000 })
-
-  const messageCode = sourceMessage.locator(".md-code").filter({ hasText: MESSAGE_CODE })
-  const unknownCode = sourceMessage.locator(".md-code").filter({ hasText: "__thread_unknown_xss" })
-  const shellSessionCode = sourceMessage
-    .locator(".md-code")
-    .filter({ hasText: SHELL_SESSION_CODE })
-  ok("稳定消息:受支持语言在实际 renderer 中生成 Shiki DOM", (await messageCode.locator("pre.shiki").count()) === 1)
-  ok("未知语言:在实际 renderer 中保留 plaintext fallback", (await unknownCode.locator("pre.shiki").count()) === 0)
-  ok(
-    "未知语言:fence 标签完整保留为 future-lang",
-    (await unknownCode.locator(".lang").innerText()) === "future-lang"
-  )
-  ok(
-    "shell-session:不截成 shell 且不误用 Bash 高亮",
-    (await shellSessionCode.locator(".lang").innerText()) === "shell-session" &&
-      (await shellSessionCode.locator("pre.shiki").count()) === 0
-  )
-  ok(
-    "代码文本:Shiki renderer 保留原文",
-    (await messageCode.locator("code").innerText()) === MESSAGE_CODE
-  )
-  ok(
-    "安全:消息代码的 script 文本不会执行或插入 script 节点",
-    await page.evaluate(() =>
-      globalThis.__thread_message_xss === undefined &&
-      globalThis.__thread_unknown_xss === undefined &&
-      ![...document.scripts].some((script) => script.textContent?.includes("__thread_message_xss"))
-    )
-  )
-
-  await messageCode.locator("button.copy").click()
-  ok(
-    "复制:按钮写入未变形的原始代码",
-    (await page.evaluate(() => globalThis.__threadChatCopiedCode)) === MESSAGE_CODE
-  )
-
-  // 锚点的 DOM 手绘必须等 MarkdownBody 的异步 batch settled 后才发生。
-  const anchorMark = sourceMessage.locator('[data-text-anchor-mark="branch-1"]')
-  await anchorMark.waitFor({ state: "visible", timeout: 20_000 })
-  ok("结算:高亮完成后恢复持久锚点标记与脚注", (await sourceMessage.locator("sup.fn-mark").count()) === 1)
-
-  const selectionCreated = await page.evaluate((needle) => {
-    const root = document.querySelector('.column[data-thread-id="main"] .md-body')
-    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
-    let node
-    while ((node = walker.nextNode())) {
-      const index = node.textContent.indexOf(needle)
-      if (index < 0) continue
-      const range = document.createRange()
-      range.setStart(node, index)
-      range.setEnd(node, index + needle.length)
-      const selection = window.getSelection()
-      selection.removeAllRanges()
-      selection.addRange(range)
-      document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }))
-      return true
-    }
-    return false
-  }, ANCHOR_TEXT)
-  await page.locator(".sel-bubble").waitFor({ state: "visible" })
-  ok("交互:带 Shiki 代码的消息仍可正常划选正文", selectionCreated)
-  await page.keyboard.press("Escape")
-
-  await sourceMessage.locator("sup.fn-mark").click()
-  await page.locator('.column[data-thread-id="branch-1"]').waitFor({ state: "visible" })
-  ok("交互:持久锚点脚注点击仍会打开对应分支", true)
-
-  await sourceMessage.locator(".acard").click()
-  const drawer = page.locator(".art-drawer.open")
-  await drawer.waitFor({ state: "visible" })
-  await drawer
-    .locator('.md-body[data-content-settled="true"]')
-    .waitFor({ state: "attached", timeout: 20_000 })
-  const artifactCode = drawer.locator(".md-code").filter({ hasText: ARTIFACT_CODE })
-  ok("Artifact:静态 Markdown 同样生成 Shiki DOM", (await artifactCode.locator("pre.shiki").count()) === 1)
-  ok(
-    "安全:Artifact 代码的 script 文本不会执行",
-    await page.evaluate(() =>
-      globalThis.__thread_artifact_xss === undefined &&
-      ![...document.scripts].some((script) => script.textContent?.includes("__thread_artifact_xss"))
-    )
-  )
-
-  await page.reload({ waitUntil: "networkidle" })
-  const reloadedSource = page.locator('.column[data-thread-id="main"] .message.assistant')
-  await reloadedSource
-    .locator('.md-body[data-content-settled="true"]')
-    .waitFor({ state: "attached", timeout: 20_000 })
-  await reloadedSource
-    .locator('[data-text-anchor-mark="branch-1"]')
-    .waitFor({ state: "visible", timeout: 20_000 })
-  ok("刷新恢复:高亮消息的持久锚点在重新结算后再次绘制", true)
-  ok("浏览器无页面运行时错误", pageErrors.length === 0, pageErrors.join(" | "))
-} finally {
-  await context.close()
-  await browser.close()
-}
-
-process.exit(failed ? 1 : 0)
diff --git a/e2e/thread-chat/verify-tree-list.mjs b/e2e/thread-chat/verify-tree-list.mjs
deleted file mode 100644
index 82eef4ae..00000000
--- a/e2e/thread-chat/verify-tree-list.mjs
+++ /dev/null
@@ -1,351 +0,0 @@
-/**
- * ThreadChat 会话列表 UI 端到端验收(openspec: add-tree-list-ui)。
- *
- * 前提与 verify-persist.mjs 相同:dev server + MiniMax key + Chromium + DATABASE_URL
- * (已应用 0005 custom_title 迁移)。运行:
- *   CHROMIUM_PATH=... BASE_URL=http://localhost:4040 node e2e/thread-chat/verify-tree-list.mjs
- *
- * 断言链:SQL 直插三棵种子树 → 空树上 ⌘⇧K 打开列表(当前「未保存」条目置顶 +
- * 种子按 updated_at 降序 + 分支数徽标 + 点当前树仅关闭)→ 点击切换恢复 →
- * 内联重命名(Esc 取消 / Enter 提交乐观更新 + DB custom_title)→ **继续聊天触发
- * 防抖 PUT 后名字不被派生标题覆盖(design D1 的存在意义)** → 二段删除非当前树
- * (Esc / 点它处复位确认态 + localStorage 工作台记忆清理)→ 删除当前树跳转剩余
- * 最近一棵 + 「最近一棵」指针善后。测试树行 finally 清理。
- */
-import { readFileSync } from "node:fs"
-import { dirname, join } from "node:path"
-import { fileURLToPath } from "node:url"
-import { chromium } from "playwright-core"
-import postgres from "postgres"
-
-const here = dirname(fileURLToPath(import.meta.url))
-const SHOT = (n) => join(here, "shots", `${n}.png`)
-const ok = (label, cond) => {
-  console.log(`${cond ? "PASS" : "FAIL"}  ${label}`)
-  if (!cond) process.exitCode = 1
-}
-const BASE = process.env.BASE_URL || "http://localhost:4040"
-
-// DATABASE_URL:优先环境变量,回退 .env.local
-const env = Object.fromEntries(
-  readFileSync(join(here, "../../.env.local"), "utf8")
-    .split("\n")
-    .filter((l) => l.includes("=") && !l.startsWith("#"))
-    .map((l) => {
-      const i = l.indexOf("=")
-      return [
-        l.slice(0, i).trim(),
-        l
-          .slice(i + 1)
-          .trim()
-          .replace(/^["']|["']$/g, ""),
-      ]
-    })
-)
-const sql = postgres(process.env.DATABASE_URL || env.DATABASE_URL, { max: 1 })
-const testTreeIds = []
-
-/** 造一棵可被页面正常加载的最小种子树;withBranch = 主线外再挂一个分支(threadCount=2) */
-function seedState(label, withBranch) {
-  return {
-    threads: {
-      main: {
-        id: "main",
-        parentId: null,
-        depth: 0,
-        title: "主线",
-        anchorText: null,
-        forkFromMsgId: null,
-        footnote: null,
-        children: withBranch ? ["b1"] : [],
-        lastActive: 1,
-        messages: [
-          {
-            id: "m1",
-            role: "user",
-            text: `${label}主线首问的完整文本`,
-            forks: [],
-          },
-          {
-            id: "m2",
-            role: "assistant",
-            text: `${label}的回答正文,用于恢复断言。`,
-            forks: withBranch
-              ? [{ text: "回答正文", num: 1, threadId: "b1", depth: 1 }]
-              : [],
-            status: "done",
-          },
-        ],
-      },
-      ...(withBranch
-        ? {
-            b1: {
-              id: "b1",
-              parentId: "main",
-              depth: 1,
-              title: "回答正文",
-              anchorText: "回答正文",
-              forkFromMsgId: "m2",
-              footnote: 1,
-              children: [],
-              lastActive: 2,
-              messages: [],
-            },
-          }
-        : {}),
-    },
-    artifacts: {},
-    artifactOrder: [],
-    recents: withBranch ? ["b1"] : [],
-    footnoteCounter: withBranch ? 1 : 0,
-    seq: 10,
-    tick: 3,
-  }
-}
-
-const browser = await chromium.launch({
-  executablePath: process.env.CHROMIUM_PATH || undefined,
-  headless: true,
-})
-
-try {
-  // ---- 0. SQL 直插三棵种子树(A 最旧带分支 / C 居中 / B 最新) ----
-  const idA = crypto.randomUUID()
-  const idB = crypto.randomUUID()
-  const idC = crypto.randomUUID()
-  testTreeIds.push(idA, idB, idC)
-  await sql`INSERT INTO branch_trees (id, title, state, updated_at) VALUES
-    (${idA}, '种子甲', ${sql.json(seedState("种子甲", true))}, now() - interval '3 hours'),
-    (${idC}, '种子丙', ${sql.json(seedState("种子丙", false))}, now() - interval '2 hours'),
-    (${idB}, '种子乙', ${sql.json(seedState("种子乙", false))}, now() - interval '1 hour')`
-
-  const ctx = await browser.newContext()
-  const page = await ctx.newPage()
-  const puts = []
-  page.on("request", (r) => {
-    if (r.url().includes("/api/branch-trees/") && r.method() === "PUT")
-      puts.push(r.url())
-  })
-
-  // ---- 1. 空树(未保存)上 ⌘⇧K 打开列表 ----
-  const t0 = crypto.randomUUID()
-  testTreeIds.push(t0) // 正常不会入库(空树不写库),保险起见列入清理
-  await page.goto(`${BASE}/thread-chat/${t0}`, { waitUntil: "networkidle" })
-  await page.keyboard.press("Meta+Shift+K")
-  await page.locator(".swx.tlx").waitFor({ state: "visible", timeout: 5000 })
-  ok("⌘⇧K 打开会话列表弹层", true)
-  await page
-    .locator(".tlx-row")
-    .nth(3)
-    .waitFor({ state: "visible", timeout: 5000 })
-  const rows0 = await page.evaluate(() =>
-    [...document.querySelectorAll(".tlx-row")].map((r) => ({
-      cur: r.classList.contains("cur"),
-      unsaved: !!r.querySelector(".tlx-unsaved"),
-      title: r.querySelector(".t")?.textContent ?? "",
-      badge: r.querySelector(".tlx-badge")?.textContent?.trim() ?? null,
-      time: r.querySelector(".tlx-time")?.textContent ?? null,
-    }))
-  )
-  // 开发库可能存有真实树,断言不假设「只有种子」:种子齐全 + 相对顺序正确即可
-  const iB = rows0.findIndex((r) => r.title === "种子乙")
-  const iC = rows0.findIndex((r) => r.title === "种子丙")
-  const iA = rows0.findIndex((r) => r.title === "种子甲")
-  ok(
-    "列表含当前未保存条目 + 三棵种子",
-    rows0.length >= 4 && iA > 0 && iB > 0 && iC > 0
-  )
-  ok(
-    "当前树置顶高亮并标注「未保存」",
-    rows0[0].cur && rows0[0].unsaved && rows0[0].title === "未命名对话"
-  )
-  ok("已保存树按 updated_at 降序(乙 → 丙 → 甲)", iB < iC && iC < iA)
-  ok("分支数徽标(种子甲 ⑂ 1)", rows0[iA]?.badge === "⑂ 1")
-  ok("相对时间显示", (rows0[iB]?.time ?? "").includes("小时前"))
-  await page.screenshot({ path: SHOT("tree-list-open") })
-
-  // 点当前树条目 = 仅关闭弹层,不跳转
-  await page.locator(".tlx-row").first().click()
-  await page.locator(".swx.tlx").waitFor({ state: "hidden", timeout: 3000 })
-  ok("点当前树条目仅关闭弹层(URL 不变)", page.url().endsWith(t0))
-
-  // ---- 2. 点击切换到种子甲:URL 跳转 + 数据恢复 ----
-  await page.keyboard.press("Meta+Shift+K")
-  await page.getByText("种子甲", { exact: true }).click()
-  await page.waitForURL(`**/thread-chat/${idA}`, { timeout: 8000 })
-  await page.waitForSelector(".tc .message.assistant", { timeout: 15000 })
-  const restored = await page.evaluate(() => ({
-    msgs: document.querySelector(".column")?.querySelectorAll(".message")
-      .length,
-    text: document.body.textContent.includes("种子甲的回答正文"),
-  }))
-  ok("点击切换:跳转到该树并恢复消息", restored.msgs === 2 && restored.text)
-
-  // ---- 3. 内联重命名:Esc 取消 → Enter 提交(乐观更新 + DB custom_title) ----
-  await page.keyboard.press("Meta+Shift+K")
-  await page.locator(".swx.tlx").waitFor({ state: "visible", timeout: 5000 })
-  const curRow = page.locator(".tlx-row.cur")
-  await curRow.hover()
-  await curRow.locator('.tlx-act[title="重命名"]').click()
-  await page.locator(".tlx-edit").fill("不该生效的名字")
-  await page.keyboard.press("Escape")
-  const afterEsc = await page.evaluate(() => ({
-    panelOpen: !!document.querySelector(".swx.tlx"),
-    title: document.querySelector(".tlx-row.cur .t")?.textContent ?? "",
-  }))
-  ok(
-    "重命名 Esc 取消:保留原名且弹层不被连带关闭",
-    afterEsc.panelOpen && afterEsc.title === "种子甲"
-  )
-  await curRow.hover()
-  await curRow.locator('.tlx-act[title="重命名"]').click()
-  await page.locator(".tlx-edit").fill("我的调研")
-  await page.keyboard.press("Enter")
-  ok(
-    "重命名 Enter 提交:条目就地更新(乐观)",
-    (await curRow.locator(".t").textContent()) === "我的调研"
-  )
-  await page.waitForTimeout(500)
-  const [renamed] =
-    await sql`SELECT title, custom_title FROM branch_trees WHERE id = ${idA}`
-  ok(
-    "DB:custom_title 写入且派生 title 未被触碰(双轨)",
-    renamed.custom_title === "我的调研" && renamed.title === "种子甲"
-  )
-  await page.keyboard.press("Escape") // 关闭弹层
-
-  // ---- 4. D1 关键路径:继续聊天触发防抖 PUT,名字不被派生标题覆盖 ----
-  await page
-    .locator(".column")
-    .first()
-    .locator("textarea")
-    .fill("请用一句话补充说明。")
-  await page.locator(".column").first().locator("textarea").press("Enter")
-  await page.waitForFunction(
-    () => {
-      const col = document.querySelector(".column")
-      const b = col?.querySelectorAll(".message.assistant .bubble")
-      const btn = col?.querySelector(".composer .send")
-      return (
-        b?.length >= 2 &&
-        b[b.length - 1].textContent.trim().length > 0 &&
-        btn &&
-        !btn.classList.contains("stop")
-      )
-    },
-    undefined,
-    { timeout: 120000 }
-  )
-  await page.waitForTimeout(2200) // 过 1.5s 防抖
-  ok(
-    "继续聊天后发生整树 PUT",
-    puts.some((u) => u.includes(idA))
-  )
-  const [afterPut] =
-    await sql`SELECT title, custom_title FROM branch_trees WHERE id = ${idA}`
-  ok(
-    "PUT 只更新派生 title(取主线首问前 20 字)",
-    afterPut.title === "种子甲主线首问的完整文本" && afterPut.title !== "种子甲"
-  )
-  ok(
-    "custom_title 不被防抖 PUT 覆盖(design D1)",
-    afterPut.custom_title === "我的调研"
-  )
-  await page.keyboard.press("Meta+Shift+K")
-  await page.locator(".swx.tlx").waitFor({ state: "visible", timeout: 5000 })
-  ok(
-    "列表展示仍为自定义名「我的调研」",
-    (await page.locator(".tlx-row.cur .t").textContent()) === "我的调研"
-  )
-
-  // ---- 5. 二段删除非当前树(种子乙):确认态复位 + 删除 + localStorage 善后 ----
-  await page.evaluate(
-    ([a, b]) => {
-      localStorage.setItem(`thread-chat:ui:${a}`, "{}")
-      localStorage.setItem(`thread-chat:ui:${b}`, "{}")
-    },
-    [idA, idB]
-  )
-  const rowB = page.locator(".tlx-row", { hasText: "种子乙" })
-  await rowB.hover()
-  await rowB.locator('.tlx-act[title="删除此对话"]').click()
-  await rowB
-    .locator(".tlx-act.confirm")
-    .waitFor({ state: "visible", timeout: 3000 })
-  ok("首次点删除进入「确认删除」态", true)
-  await page.keyboard.press("Escape")
-  const afterConfirmEsc = await page.evaluate(() => ({
-    panelOpen: !!document.querySelector(".swx.tlx"),
-    confirming: !!document.querySelector(".tlx-act.confirm"),
-  }))
-  ok(
-    "Esc 复位确认态(弹层不被连带关闭)",
-    afterConfirmEsc.panelOpen && !afterConfirmEsc.confirming
-  )
-  await rowB.hover()
-  await rowB.locator('.tlx-act[title="删除此对话"]').click()
-  await page.locator(".swx-title").click() // 点它处
-  ok("点它处复位确认态", !(await page.locator(".tlx-act.confirm").count()))
-  await rowB.hover()
-  await rowB.locator('.tlx-act[title="删除此对话"]').click()
-  await rowB.locator(".tlx-act.confirm").click()
-  await rowB.waitFor({ state: "detached", timeout: 5000 })
-  ok("确认删除后条目从列表消失", true)
-  const bRows = await sql`SELECT 1 FROM branch_trees WHERE id = ${idB}`
-  ok("DB:种子乙行已删除", bRows.length === 0)
-  const lsAfterB = await page.evaluate(
-    ([a, b]) => ({
-      uiB: localStorage.getItem(`thread-chat:ui:${b}`),
-      uiA: localStorage.getItem(`thread-chat:ui:${a}`),
-    }),
-    [idA, idB]
-  )
-  ok(
-    "localStorage:被删树的工作台记忆已清、当前树的保留",
-    lsAfterB.uiB === null && lsAfterB.uiA !== null
-  )
-  ok("当前树不受影响(弹层仍在当前树上)", page.url().endsWith(idA))
-
-  // ---- 6. 删除当前树(甲):跳转剩余最近一棵(丙)+ 指针善后 ----
-  const rowCur = page.locator(".tlx-row.cur")
-  await rowCur.hover()
-  await rowCur.locator('.tlx-act[title="删除此对话"]').click()
-  await rowCur.locator(".tlx-act.confirm").click()
-  // 共享开发库里可能存在比种子更新的真实树(用户在用/其它 e2e 留下),
-  // 「剩余最近一棵」不必然是种子丙——只断言跳转链本身:离开被删树、落到某棵合法存在的树上。
-  await page.waitForURL(
-    (u) =>
-      /\/thread-chat\/[0-9a-f-]{36}$/.test(u.toString()) &&
-      !u.toString().includes(idA),
-    { timeout: 8000 }
-  )
-  const jumpedTo = page.url().split("/").pop()
-  const jumpedRows =
-    await sql`SELECT 1 FROM branch_trees WHERE id = ${jumpedTo}`
-  ok("删除当前树:跳转到某棵仍存在的树(跳转链生效)", jumpedRows.length === 1)
-  const aRows = await sql`SELECT 1 FROM branch_trees WHERE id = ${idA}`
-  ok("DB:当前树行已删除", aRows.length === 0)
-  await page.waitForTimeout(2500) // 若有存盘尾巴此时也该到了
-  const aResurrect = await sql`SELECT 1 FROM branch_trees WHERE id = ${idA}`
-  ok("被删树未被卸载 flush 复活(抑制回写)", aResurrect.length === 0)
-  const lsAfterA = await page.evaluate(
-    ([a, c]) => ({
-      uiA: localStorage.getItem(`thread-chat:ui:${a}`),
-      last: localStorage.getItem("thread-chat:last-tree-id"),
-    }),
-    [idA, idC]
-  )
-  ok("localStorage:当前树的工作台记忆已清", lsAfterA.uiA === null)
-  ok(
-    "「最近一棵」指针不再指向被删树(已指向落地页)",
-    lsAfterA.last !== idA && lsAfterA.last === jumpedTo
-  )
-  await page.screenshot({ path: SHOT("tree-list-after-delete") })
-  await ctx.close()
-} finally {
-  // ---- 清理测试树行 ----
-  await sql`DELETE FROM branch_trees WHERE id = ANY(${testTreeIds})`
-  console.log(`已清理 ${testTreeIds.length} 行测试树(含已被用例删除的)`)
-  await sql.end()
-  await browser.close()
-}
diff --git a/e2e/thread-chat/web-research-placement.test.mjs b/e2e/thread-chat/web-research-placement.test.mjs
deleted file mode 100644
index acf20797..00000000
--- a/e2e/thread-chat/web-research-placement.test.mjs
+++ /dev/null
@@ -1,16 +0,0 @@
-import assert from "node:assert/strict"
-import { webResearchPlacement } from "../../app/thread-chat/branching/assistant/web-research-placement.ts"
-
-const empty = webResearchPlacement({ webResearchTextOffset: 12 })
-assert.deepEqual(empty, { activities: [], insertAt: undefined })
-
-const activity = { kind: "search", toolCallId: "tool-1" }
-assert.deepEqual(
-  webResearchPlacement({ webResearch: [activity], webResearchTextOffset: 12 }),
-  { activities: [activity], insertAt: 12 }
-)
-assert.equal(webResearchPlacement({ webResearch: [activity] }).insertAt, 0)
-
-console.log(
-  "PASS  web research placement waits for activity and preserves recorded/fallback offsets"
-)
diff --git a/e2e/thread-chat/web-research-sources.test.mjs b/e2e/thread-chat/web-research-sources.test.mjs
deleted file mode 100644
index 7ef38bf0..00000000
--- a/e2e/thread-chat/web-research-sources.test.mjs
+++ /dev/null
@@ -1,60 +0,0 @@
-import assert from "node:assert/strict"
-import {
-  createWebResearchActivityDispatcher,
-  webResearchSourcesFromOutput,
-} from "../../lib/chat/web-research-activity.ts"
-import { projectGenerationResult } from "../../lib/thread-chat/application/project-generation-result.ts"
-
-const output = {
-  results: [
-    { title: " Primary ", url: " https://example.com/a " },
-    { title: "Duplicate", url: "https://example.com/a" },
-    { title: "", url: "https://example.com/b" },
-    { title: "missing URL" },
-  ],
-}
-const canonicalSources = [
-  { title: "Primary", url: "https://example.com/a" },
-  { title: "https://example.com/b", url: "https://example.com/b" },
-]
-assert.deepEqual(webResearchSourcesFromOutput(output), canonicalSources)
-
-const activities = []
-const dispatch = createWebResearchActivityDispatcher((activity) =>
-  activities.push(activity)
-)
-dispatch({
-  type: "tool-input-available",
-  toolCallId: "search-1",
-  toolName: "webSearch",
-  input: { query: "source normalization" },
-})
-dispatch({
-  type: "tool-output-available",
-  toolCallId: "search-1",
-  output,
-})
-assert.deepEqual(activities.at(-1).sources, canonicalSources)
-
-const projected = projectGenerationResult({
-  generationId: "generation-1",
-  threadId: "main",
-  assistantMessageId: "assistant-1",
-  terminalStatus: "completed",
-  responseMessage: {
-    parts: [
-      {
-        type: "tool-webSearch",
-        toolCallId: "search-1",
-        state: "output-available",
-        input: { query: "source normalization" },
-        output,
-      },
-    ],
-  },
-})
-assert.deepEqual(projected.result.webResearch[0].sources, canonicalSources)
-
-console.log(
-  "PASS  live and persisted web research share one source normalization contract"
-)
diff --git a/e2e/thread-chat/workspace-composition-ownership.test.mjs b/e2e/thread-chat/workspace-composition-ownership.test.mjs
deleted file mode 100644
index 6f60de07..00000000
--- a/e2e/thread-chat/workspace-composition-ownership.test.mjs
+++ /dev/null
@@ -1,35 +0,0 @@
-import assert from "node:assert/strict"
-import { readFile } from "node:fs/promises"
-
-const shell = await readFile(
-  new URL("../../app/thread-chat/thread-chat-demo.tsx", import.meta.url),
-  "utf8"
-)
-const workspace = await readFile(
-  new URL(
-    "../../app/thread-chat/orchestration/workspace/use-thread-chat-workspace.ts",
-    import.meta.url
-  ),
-  "utf8"
-)
-
-assert.match(shell, /useThreadChatWorkspace\(/)
-for (const capability of [
-  "useColumnViewport",
-  "useColumnSlots",
-  "useUiStatePersistence",
-]) {
-  assert.doesNotMatch(shell, new RegExp(`${capability}\\(`))
-  assert.match(workspace, new RegExp(`${capability}\\(`))
-}
-assert.doesNotMatch(shell, /focusSeq/)
-assert.match(workspace, /focusCanvasNode/)
-assert.match(workspace, /CanvasChatActions/)
-assert.match(workspace, /CanvasViewState/)
-assert.match(workspace, /useMemo/)
-assert.match(workspace, /\[chat, messageCommands\]/)
-assert.doesNotMatch(workspace, /useState/)
-
-console.log(
-  "PASS  page shell consumes one composed column/canvas workspace capability"
-)
diff --git a/e2e/thread-chat/workspace-overlay.test.mjs b/e2e/thread-chat/workspace-overlay.test.mjs
deleted file mode 100644
index 3bd8b630..00000000
--- a/e2e/thread-chat/workspace-overlay.test.mjs
+++ /dev/null
@@ -1,64 +0,0 @@
-import assert from "node:assert/strict"
-import {
-  escapeOverlayTarget,
-  popupPosition,
-} from "../../app/thread-chat/orchestration/overlays/workspace-overlay-logic.ts"
-import { SWITCHER_DIMENSIONS } from "../../app/thread-chat/orchestration/navigation/switcher-dimensions.ts"
-
-assert.equal(
-  escapeOverlayTarget({
-    helpOpen: true,
-    treeListOpen: true,
-    selectionOpen: true,
-    switcherOpen: true,
-    drawerOpen: true,
-  }),
-  "help"
-)
-assert.equal(
-  escapeOverlayTarget({
-    helpOpen: false,
-    treeListOpen: false,
-    selectionOpen: true,
-    switcherOpen: true,
-    drawerOpen: true,
-  }),
-  "selection"
-)
-assert.equal(
-  escapeOverlayTarget({
-    helpOpen: false,
-    treeListOpen: false,
-    selectionOpen: false,
-    switcherOpen: false,
-    drawerOpen: false,
-  }),
-  null
-)
-
-assert.deepEqual(
-  popupPosition({
-    right: 900,
-    bottom: 700,
-    panelWidth: SWITCHER_DIMENSIONS.column.width,
-    panelHeight: SWITCHER_DIMENSIONS.column.height,
-    viewportWidth: 800,
-    viewportHeight: 600,
-  }),
-  { x: 462, y: 170 }
-)
-assert.deepEqual(
-  popupPosition({
-    right: 100,
-    bottom: 50,
-    panelWidth: SWITCHER_DIMENSIONS.column.width,
-    panelHeight: SWITCHER_DIMENSIONS.column.height,
-    viewportWidth: 800,
-    viewportHeight: 600,
-  }),
-  { x: 8, y: 56 }
-)
-
-console.log(
-  "PASS  workspace overlays preserve Escape priority and clamp popup positions"
-)
diff --git a/e2e/thread-chat/workspace-toast.test.mjs b/e2e/thread-chat/workspace-toast.test.mjs
deleted file mode 100644
index e694056b..00000000
--- a/e2e/thread-chat/workspace-toast.test.mjs
+++ /dev/null
@@ -1,7 +0,0 @@
-import assert from "node:assert/strict"
-import { workspaceToastDuration } from "../../app/thread-chat/orchestration/overlays/workspace-toast-logic.ts"
-
-assert.equal(workspaceToastDuration({ message: "saved" }), 2600)
-assert.equal(workspaceToastDuration({ message: "replaced", undo() {} }), 5200)
-
-console.log("PASS  workspace toast keeps normal and undo visibility windows")
diff --git a/lib/ai/model-call-logger.ts b/lib/ai/model-call-logger.ts
index c73cfbd0..a8c5d00c 100644
--- a/lib/ai/model-call-logger.ts
+++ b/lib/ai/model-call-logger.ts
@@ -10,7 +10,7 @@ import type { ModelCallPurpose } from "@/constants/model-call"
 
 export type ModelCallTrace = {
   requestId?: string
-  treeId?: string
+  projectId?: string
   threadId?: string
   generationId?: string
   assistantMessageId?: string
diff --git a/lib/chat/thread-chat-prompt.ts b/lib/chat/thread-chat-prompt.ts
deleted file mode 100644
index 4bbf2e29..00000000
--- a/lib/chat/thread-chat-prompt.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-// thread-chat 模式的服务端 system 提示构造(app/api/chat/route.ts 使用)。
-// system 归服务端所有:AI SDK v7 的 streamText 不允许 messages 里出现 system 角色
-// (安全默认值,防客户端注入任意 system),所以客户端只发 threadChat 标记与锚点原文,
-// 指令模板在这里拼装。
-
-import {
-  THREAD_CHAT_BRANCH_PREFIX,
-  THREAD_CHAT_BRANCH_SUFFIX,
-  THREAD_CHAT_MARKDOWN_ARTIFACT_SYSTEM,
-  THREAD_CHAT_SYSTEM,
-} from "@/constants/thread-chat"
-
-/**
- * 构造 thread-chat 模式的 system 提示:
- * 通用结构化风格段 +(anchorText 非空时)分支焦点段(锚点原文作为数据嵌入「」内)。
- */
-export function buildThreadChatSystem(
-  anchorText?: string | null,
-  options?: { enableMarkdownArtifact?: boolean }
-): string {
-  const anchor = anchorText?.trim()
-  return [
-    THREAD_CHAT_SYSTEM,
-    options?.enableMarkdownArtifact
-      ? THREAD_CHAT_MARKDOWN_ARTIFACT_SYSTEM
-      : null,
-    anchor
-      ? `${THREAD_CHAT_BRANCH_PREFIX}「${anchor}」。${THREAD_CHAT_BRANCH_SUFFIX}`
-      : null,
-  ]
-    .filter((part): part is string => part !== null)
-    .join("\n\n")
-}
diff --git a/lib/chat/tree-id.ts b/lib/chat/tree-id.ts
deleted file mode 100644
index aae865f7..00000000
--- a/lib/chat/tree-id.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * lib/chat/tree-id —— 分支树 treeId 的形状校验(UUID)。
- *
- * treeId 由客户端 crypto.randomUUID() 生成、URL 路径段承载(/thread-chat/{treeId})。
- * 路由([treeId]/page.tsx)与 API(/api/branch-trees/[treeId])共用同一校验作为安全阀,
- * 避免任意字符串打到 DB 主键——放 lib/ 供服务端与客户端两侧复用。
- */
-
-const UUID_RE =
-  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
-
-/** treeId 是否为 UUID 形状(大小写不敏感) */
-export function isValidTreeId(id: string): boolean {
-  return UUID_RE.test(id)
-}
diff --git a/lib/db/index.ts b/lib/db/index.ts
index 3547f0d6..78c7cfd1 100644
--- a/lib/db/index.ts
+++ b/lib/db/index.ts
@@ -22,4 +22,5 @@ const client =
   })
 if (process.env.NODE_ENV !== "production") globalThis.__dbClient = client
 
+export const dbClient = client
 export const db = drizzle(client, { schema })
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index bb6fb6a5..bc61a9af 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -5,21 +5,21 @@ import {
   index,
   uniqueIndex,
   integer,
-  boolean,
   vector,
-  primaryKey,
+  bigint,
+  check,
+  uuid,
+  type AnyPgColumn,
 } from "drizzle-orm/pg-core"
 import { sql } from "drizzle-orm"
+import type { UIMessage } from "ai"
 import { dbSchema } from "./pg-schema"
 import { EMBEDDING_DIMENSIONS } from "@/constants/rag"
 import { user } from "./auth-schema"
-import type {
-  GenerationBillingStatus,
-  GenerationResultV1,
-  GenerationStatus,
-  GenerationTurnSnapshot,
-} from "@/lib/thread-chat/domain/generation"
-import type { MessageFeedback } from "@/lib/thread-chat/domain/types"
+
+// drizzle-kit 必须能从 schema 入口发现自定义 namespace;只导出其中的表会把
+// thread_chat 误判为待删除 schema。
+export { dbSchema } from "./pg-schema"
 
 // 认证与计费表在独立文件中定义,这里统一 re-export,使 drizzle 客户端与迁移能感知它们。
 export * from "./auth-schema"
@@ -48,24 +48,28 @@ export const attachments = dbSchema.table("attachments", {
     .defaultNow(),
 })
 
-// 分支对话树(app/thread-chat)的整棵树持久化:一棵树一行,state 存完整
-// ThreadTreeState(JSON)。与上面 assistant-ui 线性模型的 threads/messages 表分开,
-// 互不复用——那两张表是线性会话,这张是树形分支态。treeId 由客户端生成
-// (crypto.randomUUID()),URL 路径段承载(/thread-chat/{treeId}),URL 即树身份。
-export const branchTrees = dbSchema.table(
-  "branch_trees",
+/** Project 是一整簇 Root/Branch Thread 的 owner scope 与永久删除边界。 */
+export const projects = dbSchema.table(
+  "projects",
   {
-    id: text("id").primaryKey(), // 客户端生成的 treeId(UUID,URL 路径段承载)
-    // 迁移期允许历史树无主;新写入必须由 API 绑定当前 session user。
-    userId: text("user_id").references(() => user.id, {
-      onDelete: "cascade",
-    }),
-    title: text("title"), // 可空:取 main 首条 user 文本前若干字,纯展示(机器派生轨)
-    // 双轨标题(design D1):用户重命名只写这列(PATCH),防抖整树 PUT 只写上面的派生
-    // title——两条写路径互不踩踏;对外展示一律 coalesce(custom_title, title)。
+    id: uuid("id").primaryKey().defaultRandom(),
+    ownerUserId: text("owner_user_id")
+      .notNull()
+      .references(() => user.id, { onDelete: "cascade" }),
+    autoTitle: text("auto_title"),
     customTitle: text("custom_title"),
-    state: jsonb("state").notNull(), // 完整 ThreadTreeState
-    revision: integer("revision").notNull().default(0),
+    target: jsonb("target").$type<{
+      ultimate: string | null
+      shortTerm: string[]
+      midTerm: string[]
+    }>(),
+    instruction: text("instruction"),
+    archivedAt: timestamp("archived_at", { withTimezone: true }),
+    artifactChangeSequence: bigint("artifact_change_sequence", {
+      mode: "number",
+    })
+      .notNull()
+      .default(0),
     createdAt: timestamp("created_at", { withTimezone: true })
       .notNull()
       .defaultNow(),
@@ -73,43 +77,148 @@ export const branchTrees = dbSchema.table(
       .notNull()
       .defaultNow(),
   },
-  (table) => [index("branch_trees_user_id_idx").on(table.userId)]
+  (table) => [
+    index("projects_owner_updated_idx").on(
+      table.ownerUserId,
+      table.updatedAt.desc(),
+      table.id.desc()
+    ),
+    check(
+      "projects_artifact_change_sequence_nonnegative_ck",
+      sql`${table.artifactChangeSequence} >= 0`
+    ),
+  ]
 )
 
-/**
- * 每次 thread-chat assistant attempt 的服务端权威 sidecar。终态不直接改整树 JSON,
- * 读取时再把 current generation result 合并进去,避免旧浏览器快照覆盖最终答案。
- */
-export const branchGenerations = dbSchema.table(
-  "branch_generations",
+/** Root/Branch 角色由 parent_thread_id 是否为空推导,不建立第二套实体类型。 */
+export const threads = dbSchema.table(
+  "threads",
   {
-    id: text("id").primaryKey(),
-    userId: text("user_id")
+    id: uuid("id").primaryKey().defaultRandom(),
+    projectId: uuid("project_id")
       .notNull()
-      .references(() => user.id, { onDelete: "cascade" }),
-    treeId: text("tree_id")
-      .notNull()
-      .references(() => branchTrees.id, { onDelete: "cascade" }),
-    threadId: text("thread_id").notNull(),
-    userMessageId: text("user_message_id").notNull(),
-    assistantMessageId: text("assistant_message_id").notNull(),
-    attempt: integer("attempt").notNull(),
-    isCurrent: boolean("is_current").notNull().default(true),
-    status: text("status").$type().notNull(),
-    modelId: text("model_id").notNull(),
-    assistantMessageIndex: integer("assistant_message_index").notNull(),
-    turnSnapshot: jsonb("turn_snapshot")
-      .$type()
-      .notNull(),
-    result: jsonb("result").$type(),
-    error: text("error"),
-    billingStatus: text("billing_status")
-      .$type()
-      .notNull()
-      .default("pending"),
-    heartbeatAt: timestamp("heartbeat_at", { withTimezone: true })
+      .references(() => projects.id, { onDelete: "cascade" }),
+    parentThreadId: uuid("parent_thread_id").references(
+      (): AnyPgColumn => threads.id,
+      { onDelete: "cascade" }
+    ),
+    // 同 Project、Parent/source 归属与无环关系无法由普通 CHECK 可靠表达,
+    // 必须由后续 Repository 在事务内锁定并校验。
+    sourceMessageId: uuid("source_message_id").references(
+      (): AnyPgColumn => messages.id
+    ),
+    forkSourceSnapshot: jsonb("fork_source_snapshot").$type<{
+      schemaVersion: 1
+      quote?: string
+      sourceRole: "user" | "assistant"
+      sourceSequence: number
+    }>(),
+    baseContext: jsonb("base_context").$type<{
+      schemaVersion: 1
+      messageIds: string[]
+    }>(),
+    autoTitle: text("auto_title"),
+    customTitle: text("custom_title"),
+    archivedAt: timestamp("archived_at", { withTimezone: true }),
+    createdAt: timestamp("created_at", { withTimezone: true })
       .notNull()
       .defaultNow(),
+    updatedAt: timestamp("updated_at", { withTimezone: true })
+      .notNull()
+      .defaultNow(),
+  },
+  (table) => [
+    uniqueIndex("threads_one_root_per_project_uq")
+      .on(table.projectId)
+      .where(sql`${table.parentThreadId} is null`),
+    index("threads_project_parent_idx").on(
+      table.projectId,
+      table.parentThreadId
+    ),
+    index("threads_source_message_idx").on(table.sourceMessageId),
+    check(
+      "threads_fork_facts_complete_ck",
+      sql`(
+        ${table.parentThreadId} is null
+        and ${table.sourceMessageId} is null
+        and ${table.forkSourceSnapshot} is null
+        and ${table.baseContext} is null
+      ) or (
+        ${table.parentThreadId} is not null
+        and ${table.sourceMessageId} is not null
+        and ${table.forkSourceSnapshot} is not null
+        and ${table.baseContext} is not null
+      )`
+    ),
+  ]
+)
+
+/** Thread 内的稳定线性时间线;finalized 内容只能通过 replacement 演进。 */
+export const messages = dbSchema.table(
+  "messages",
+  {
+    id: uuid("id").primaryKey().defaultRandom(),
+    threadId: uuid("thread_id")
+      .notNull()
+      .references(() => threads.id, { onDelete: "cascade" }),
+    sequence: bigint("sequence", { mode: "number" }).notNull(),
+    role: text("role").$type<"user" | "assistant">().notNull(),
+    parts: jsonb("parts").$type(),
+    // 同 Thread、同角色与来源状态属于跨行事务校验;数据库只保证来源存在且
+    // 每条旧 Message 最多有一个直接 replacement。
+    replacesMessageId: uuid("replaces_message_id").references(
+      (): AnyPgColumn => messages.id
+    ),
+    supersededAt: timestamp("superseded_at", { withTimezone: true }),
+    finalizedAt: timestamp("finalized_at", { withTimezone: true }),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .notNull()
+      .defaultNow(),
+  },
+  (table) => [
+    uniqueIndex("messages_thread_sequence_uq").on(
+      table.threadId,
+      table.sequence
+    ),
+    uniqueIndex("messages_single_replacement_uq")
+      .on(table.replacesMessageId)
+      .where(sql`${table.replacesMessageId} is not null`),
+    index("messages_thread_active_sequence_idx")
+      .on(table.threadId, table.sequence)
+      .where(sql`${table.supersededAt} is null`),
+    check("messages_sequence_positive_ck", sql`${table.sequence} > 0`),
+    check("messages_role_ck", sql`${table.role} in ('user', 'assistant')`),
+    check(
+      "messages_not_self_replacement_ck",
+      sql`${table.replacesMessageId} is null or ${table.replacesMessageId} <> ${table.id}`
+    ),
+  ]
+)
+
+/** 每条 assistant Message 恰有一条运行记录;user Message 的排除由 Repository 校验。 */
+export const messageRuns = dbSchema.table(
+  "message_runs",
+  {
+    id: uuid("id").primaryKey().defaultRandom(),
+    assistantMessageId: uuid("assistant_message_id")
+      .notNull()
+      .unique()
+      .references(() => messages.id, { onDelete: "cascade" }),
+    status: text("status")
+      .$type<"queued" | "running" | "completed" | "failed" | "stopped">()
+      .notNull()
+      .default("queued"),
+    modelId: text("model_id").notNull(),
+    eventSequence: bigint("event_sequence", { mode: "number" })
+      .notNull()
+      .default(0),
+    checkpointParts: jsonb("checkpoint_parts")
+      .$type()
+      .notNull()
+      .default(sql`'[]'::jsonb`),
+    errorCode: text("error_code"),
+    errorMessage: text("error_message"),
+    heartbeatAt: timestamp("heartbeat_at", { withTimezone: true }),
     stopRequestedAt: timestamp("stop_requested_at", { withTimezone: true }),
     finishedAt: timestamp("finished_at", { withTimezone: true }),
     createdAt: timestamp("created_at", { withTimezone: true })
@@ -120,41 +229,63 @@ export const branchGenerations = dbSchema.table(
       .defaultNow(),
   },
   (table) => [
-    uniqueIndex("branch_generations_current_assistant_uq")
-      .on(table.treeId, table.threadId, table.assistantMessageId)
-      .where(sql`${table.isCurrent} = true`),
-    uniqueIndex("branch_generations_assistant_attempt_uq").on(
-      table.treeId,
-      table.threadId,
-      table.assistantMessageId,
-      table.attempt
-    ),
-    index("branch_generations_user_id_idx").on(table.userId),
-    index("branch_generations_tree_current_idx").on(
-      table.treeId,
-      table.isCurrent
-    ),
-    index("branch_generations_user_status_idx").on(table.userId, table.status),
-    index("branch_generations_heartbeat_idx").on(
+    index("message_runs_status_heartbeat_idx").on(
       table.status,
       table.heartbeatAt
     ),
+    check(
+      "message_runs_status_ck",
+      sql`${table.status} in ('queued', 'running', 'completed', 'failed', 'stopped')`
+    ),
+    check(
+      "message_runs_event_sequence_nonnegative_ck",
+      sql`${table.eventSequence} >= 0`
+    ),
   ]
 )
 
-/** 产品层 assistant message 的当前互斥反馈;不与 generation 执行身份耦合。 */
-export const branchMessageFeedback = dbSchema.table(
-  "branch_message_feedback",
+/** Artifact 内容独立持久化,Project 决定 owner scope,Message 保留 provenance。 */
+export const artifacts = dbSchema.table(
+  "artifacts",
   {
-    userId: text("user_id")
+    id: uuid("id").primaryKey().defaultRandom(),
+    projectId: uuid("project_id")
       .notNull()
-      .references(() => user.id, { onDelete: "cascade" }),
-    treeId: text("tree_id")
+      .references(() => projects.id, { onDelete: "cascade" }),
+    // source Message 与 Artifact 的 Project 归属由 Repository 在同一事务复验。
+    sourceMessageId: uuid("source_message_id")
       .notNull()
-      .references(() => branchTrees.id, { onDelete: "cascade" }),
-    threadId: text("thread_id").notNull(),
-    messageId: text("message_id").notNull(),
-    feedback: text("feedback").$type().notNull(),
+      .references(() => messages.id),
+    changeSequence: bigint("change_sequence", { mode: "number" }).notNull(),
+    kind: text("kind").notNull(),
+    title: text("title").notNull(),
+    content: jsonb("content").$type().notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .notNull()
+      .defaultNow(),
+  },
+  (table) => [
+    uniqueIndex("artifacts_project_change_sequence_uq").on(
+      table.projectId,
+      table.changeSequence
+    ),
+    index("artifacts_project_kind_idx").on(table.projectId, table.kind),
+    index("artifacts_source_message_idx").on(table.sourceMessageId),
+    check(
+      "artifacts_change_sequence_positive_ck",
+      sql`${table.changeSequence} > 0`
+    ),
+  ]
+)
+
+/** assistant Message 的当前互斥反馈;null 由删除该行表达。 */
+export const messageFeedback = dbSchema.table(
+  "message_feedback",
+  {
+    assistantMessageId: uuid("assistant_message_id")
+      .primaryKey()
+      .references(() => messages.id, { onDelete: "cascade" }),
+    feedback: text("feedback").$type<"positive" | "negative">().notNull(),
     createdAt: timestamp("created_at", { withTimezone: true })
       .notNull()
       .defaultNow(),
@@ -163,11 +294,10 @@ export const branchMessageFeedback = dbSchema.table(
       .defaultNow(),
   },
   (table) => [
-    primaryKey({
-      name: "branch_message_feedback_pk",
-      columns: [table.userId, table.treeId, table.threadId, table.messageId],
-    }),
-    index("branch_message_feedback_tree_idx").on(table.userId, table.treeId),
+    check(
+      "message_feedback_value_ck",
+      sql`${table.feedback} in ('positive', 'negative')`
+    ),
   ]
 )
 
diff --git a/lib/thread-chat-generation/execution-state-repository.ts b/lib/thread-chat-generation/execution-state-repository.ts
deleted file mode 100644
index 2eb68da9..00000000
--- a/lib/thread-chat-generation/execution-state-repository.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { and, eq, inArray, sql } from "drizzle-orm"
-import { ACTIVE_GENERATION_STATUSES } from "@/constants/generation"
-import { db } from "@/lib/db"
-import { branchGenerations } from "@/lib/db/schema"
-import type { GenerationRow } from "@/lib/thread-chat-generation/query-repository"
-
-/** Owner-scoped running → stop_requested transition; terminal states remain idempotent. */
-export async function requestGenerationStop(
-  userId: string,
-  generationId: string
-): Promise {
-  return db.transaction(async (tx) => {
-    const locked = await tx.execute(sql`
-      select ${branchGenerations.id}
-      from ${branchGenerations}
-      where ${branchGenerations.id} = ${generationId}
-        and ${branchGenerations.userId} = ${userId}
-      for update
-    `)
-    if (locked.length === 0) return null
-
-    const now = new Date()
-    const [updated] = await tx
-      .update(branchGenerations)
-      .set({
-        status: "stop_requested",
-        stopRequestedAt: now,
-        updatedAt: now,
-      })
-      .where(
-        and(
-          eq(branchGenerations.id, generationId),
-          eq(branchGenerations.userId, userId),
-          eq(branchGenerations.status, "running")
-        )
-      )
-      .returning()
-    if (updated) return updated
-
-    const [current] = await tx
-      .select()
-      .from(branchGenerations)
-      .where(
-        and(
-          eq(branchGenerations.id, generationId),
-          eq(branchGenerations.userId, userId)
-        )
-      )
-    return current ?? null
-  })
-}
-
-/** 延长 active generation 的 lease;终态调用是无副作用的 no-op。 */
-export async function heartbeatGeneration(generationId: string) {
-  const now = new Date()
-  await db
-    .update(branchGenerations)
-    .set({ heartbeatAt: now, updatedAt: now })
-    .where(
-      and(
-        eq(branchGenerations.id, generationId),
-        inArray(branchGenerations.status, ACTIVE_GENERATION_STATUSES)
-      )
-    )
-}
diff --git a/lib/thread-chat-generation/execution.ts b/lib/thread-chat-generation/execution.ts
deleted file mode 100644
index 8763068c..00000000
--- a/lib/thread-chat-generation/execution.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
-  GENERATION_CANCEL_POLL_MS,
-  GENERATION_HEARTBEAT_MS,
-} from "@/constants/generation"
-import { heartbeatGeneration } from "@/lib/thread-chat-generation/execution-state-repository"
-import { getGenerationExecutionState } from "@/lib/thread-chat-generation/query-repository"
-
-declare global {
-  var __threadChatGenerationAbortControllers:
-    Map | undefined
-}
-
-const controllers =
-  globalThis.__threadChatGenerationAbortControllers ??
-  new Map()
-if (process.env.NODE_ENV !== "production") {
-  globalThis.__threadChatGenerationAbortControllers = controllers
-}
-
-export function registerGenerationController(
-  generationId: string,
-  controller: AbortController
-) {
-  controllers.set(generationId, controller)
-}
-
-export function unregisterGenerationController(
-  generationId: string,
-  controller: AbortController
-) {
-  if (controllers.get(generationId) === controller) {
-    controllers.delete(generationId)
-  }
-}
-
-export function abortGenerationLocally(generationId: string): boolean {
-  const controller = controllers.get(generationId)
-  if (!controller || controller.signal.aborted) return false
-  controller.abort(new DOMException("Generation stopped by user", "AbortError"))
-  return true
-}
-
-function delay(ms: number): Promise {
-  return new Promise((resolve) => setTimeout(resolve, ms))
-}
-
-type GenerationExecutionState = Awaited<
-  ReturnType
->
-
-/** DB 权威状态中只有 current running attempt 仍被允许继续消耗模型执行。 */
-export function shouldContinueGenerationExecution(
-  execution: GenerationExecutionState
-): boolean {
-  return execution?.status === "running" && execution.isCurrent
-}
-
-export function observeGenerationCancellation(
-  generationId: string,
-  controller: AbortController
-) {
-  let stopped = false
-  const done = (async () => {
-    let lastHeartbeat = Date.now()
-    while (!stopped && !controller.signal.aborted) {
-      await delay(GENERATION_CANCEL_POLL_MS)
-      if (stopped || controller.signal.aborted) break
-      try {
-        const execution = await getGenerationExecutionState(generationId)
-        if (!shouldContinueGenerationExecution(execution)) {
-          controller.abort(
-            new DOMException("Generation stopped by server state", "AbortError")
-          )
-          break
-        }
-        if (Date.now() - lastHeartbeat >= GENERATION_HEARTBEAT_MS) {
-          await heartbeatGeneration(generationId)
-          lastHeartbeat = Date.now()
-        }
-      } catch (error) {
-        console.error("[thread-chat-generation] 取消观察器查询失败", {
-          generationId,
-          error,
-        })
-      }
-    }
-  })()
-
-  return {
-    done,
-    stop() {
-      stopped = true
-    },
-  }
-}
diff --git a/lib/thread-chat-generation/finalize-with-retry.ts b/lib/thread-chat-generation/finalize-with-retry.ts
deleted file mode 100644
index e43cf18a..00000000
--- a/lib/thread-chat-generation/finalize-with-retry.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {
-  finalizeGeneration,
-  type FinalizeGenerationInput,
-} from "@/lib/thread-chat-generation/finalize"
-
-type FinalizeGeneration = typeof finalizeGeneration
-
-type FinalizeRetryDependencies = {
-  finalize: FinalizeGeneration
-  delay(ms: number): Promise
-}
-
-const defaultDependencies: FinalizeRetryDependencies = {
-  finalize: finalizeGeneration,
-  delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
-}
-
-/** generation 终态最多尝试三次;失败退避不改变输入或终态策略。 */
-export async function finalizeGenerationWithRetry(
-  input: FinalizeGenerationInput,
-  dependencies: FinalizeRetryDependencies = defaultDependencies
-) {
-  let lastError: unknown
-  for (let attempt = 1; attempt <= 3; attempt++) {
-    try {
-      return await dependencies.finalize(input)
-    } catch (error) {
-      lastError = error
-      console.error("[thread-chat-generation] finalize 失败", {
-        generationId: input.generationId,
-        attempt,
-        error,
-      })
-      if (attempt < 3) await dependencies.delay(attempt * 150)
-    }
-  }
-  throw lastError
-}
diff --git a/lib/thread-chat-generation/finalize.ts b/lib/thread-chat-generation/finalize.ts
deleted file mode 100644
index c8765920..00000000
--- a/lib/thread-chat-generation/finalize.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { and, eq, inArray, sql } from "drizzle-orm"
-import type {
-  GenerationBillingStatus,
-  GenerationResultV1,
-} from "@/lib/thread-chat/domain/generation"
-import { ACTIVE_GENERATION_STATUSES } from "@/constants/generation"
-import {
-  chargeUsageOnce,
-  type BillingTransaction,
-  type UsageCostEvidence,
-} from "@/lib/billing/credits"
-import { db } from "@/lib/db"
-import { branchGenerations } from "@/lib/db/schema"
-
-export type FinalizeGenerationUsage = {
-  inputTokens: number
-  outputTokens: number
-  costEvidence?: UsageCostEvidence
-}
-
-export type FinalizeGenerationInput = {
-  generationId: string
-  outcome: "completed" | "stopped" | "failed"
-  result: GenerationResultV1
-  error?: string
-  usage?: FinalizeGenerationUsage
-  usageUnavailable?: boolean
-}
-
-type GenerationBillingIdentity = {
-  id: string
-  userId: string
-  modelId: string
-  threadId: string
-  assistantMessageId: string
-}
-
-const BILLING_STATUS_PROGRESS: Record = {
-  pending: 0,
-  not_billable: 1,
-  usage_unavailable: 2,
-  settled: 3,
-}
-
-function advanceBillingStatus(
-  current: GenerationBillingStatus,
-  candidate: GenerationBillingStatus
-): GenerationBillingStatus {
-  return BILLING_STATUS_PROGRESS[candidate] > BILLING_STATUS_PROGRESS[current]
-    ? candidate
-    : current
-}
-
-async function settleGenerationUsage(
-  tx: BillingTransaction,
-  generation: GenerationBillingIdentity,
-  usage: FinalizeGenerationUsage
-) {
-  await chargeUsageOnce(tx, generation.id, {
-    userId: generation.userId,
-    model: generation.modelId,
-    inputTokens: usage.inputTokens,
-    outputTokens: usage.outputTokens,
-    threadId: generation.threadId,
-    messageId: generation.assistantMessageId,
-    costEvidence: usage.costEvidence,
-  })
-}
-
-export async function finalizeGeneration(input: FinalizeGenerationInput) {
-  return db.transaction(async (tx) => {
-    const locked = await tx.execute(sql`
-      select ${branchGenerations.id}
-      from ${branchGenerations}
-      where ${branchGenerations.id} = ${input.generationId}
-      for update
-    `)
-    if (locked.length === 0) return null
-
-    const [row] = await tx
-      .select()
-      .from(branchGenerations)
-      .where(eq(branchGenerations.id, input.generationId))
-    if (!row) return null
-
-    // 终态不可逆;迟到的权威 usage 仍需补记账,但不得改写用户已看到的结果。
-    if (["completed", "stopped", "failed"].includes(row.status)) {
-      if (!input.usage || row.billingStatus === "settled") return row
-      await settleGenerationUsage(tx, row, input.usage)
-      const [updated] = await tx
-        .update(branchGenerations)
-        .set({ billingStatus: "settled", updatedAt: new Date() })
-        .where(eq(branchGenerations.id, input.generationId))
-        .returning()
-      return updated ?? row
-    }
-
-    let terminalStatus = input.outcome
-    if (row.status === "superseded" || !row.isCurrent) {
-      terminalStatus = "failed"
-    } else if (row.status === "stop_requested") {
-      terminalStatus = "stopped"
-    }
-
-    let billingStatus: GenerationBillingStatus = "not_billable"
-    if (input.usage) {
-      await settleGenerationUsage(tx, row, input.usage)
-      billingStatus = "settled"
-    } else if (input.usageUnavailable) {
-      billingStatus = "usage_unavailable"
-    }
-
-    const now = new Date()
-    if (row.status === "superseded" || !row.isCurrent) {
-      const [updated] = await tx
-        .update(branchGenerations)
-        .set({
-          result: input.result,
-          error: input.error ?? null,
-          billingStatus: advanceBillingStatus(row.billingStatus, billingStatus),
-          finishedAt: row.finishedAt ?? now,
-          updatedAt: now,
-        })
-        .where(eq(branchGenerations.id, input.generationId))
-        .returning()
-      return updated ?? row
-    }
-
-    const [updated] = await tx
-      .update(branchGenerations)
-      .set({
-        status: terminalStatus,
-        result: input.result,
-        error: input.error ?? null,
-        billingStatus,
-        finishedAt: now,
-        updatedAt: now,
-      })
-      .where(
-        and(
-          eq(branchGenerations.id, input.generationId),
-          inArray(branchGenerations.status, ACTIVE_GENERATION_STATUSES)
-        )
-      )
-      .returning()
-    return updated ?? row
-  })
-}
diff --git a/lib/thread-chat-generation/message-feedback-repository.ts b/lib/thread-chat-generation/message-feedback-repository.ts
deleted file mode 100644
index 0a101577..00000000
--- a/lib/thread-chat-generation/message-feedback-repository.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { and, eq } from "drizzle-orm"
-import { parseThreadTreeState } from "@/lib/thread-chat/domain/message-graph"
-import type { ThreadTreeState } from "@/lib/thread-chat/domain/types"
-import type {
-  MessageFeedback,
-  MessageFeedbackSummary,
-  SetMessageFeedbackResult,
-} from "@/lib/thread-chat/contracts/message-feedback"
-import { db } from "@/lib/db"
-import {
-  branchGenerations,
-  branchMessageFeedback,
-  branchTrees,
-} from "@/lib/db/schema"
-
-function toSummary(
-  row: typeof branchMessageFeedback.$inferSelect
-): MessageFeedbackSummary {
-  return {
-    treeId: row.treeId,
-    threadId: row.threadId,
-    messageId: row.messageId,
-    feedback: row.feedback,
-    updatedAt: row.updatedAt.toISOString(),
-  }
-}
-
-export async function listMessageFeedbackForTree(
-  userId: string,
-  treeId: string
-): Promise {
-  const rows = await db
-    .select()
-    .from(branchMessageFeedback)
-    .where(
-      and(
-        eq(branchMessageFeedback.userId, userId),
-        eq(branchMessageFeedback.treeId, treeId)
-      )
-    )
-  return rows.map(toSummary)
-}
-
-/** Owner-scoped message feedback replacement; null deletes the current choice. */
-export async function setMessageFeedbackForOwner(input: {
-  userId: string
-  treeId: string
-  threadId: string
-  messageId: string
-  feedback: MessageFeedback | null
-}): Promise {
-  return db.transaction(async (tx) => {
-    const [tree] = await tx
-      .select({ state: branchTrees.state })
-      .from(branchTrees)
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId)
-        )
-      )
-    if (!tree) return { ok: false, reason: "not_found" }
-
-    let state: ThreadTreeState
-    try {
-      state = parseThreadTreeState(tree.state)
-    } catch {
-      return { ok: false, reason: "not_found" }
-    }
-    const message = state.threads[input.threadId]?.messages.find(
-      (candidate) => candidate.id === input.messageId
-    )
-    if (!message) return { ok: false, reason: "not_found" }
-    if (message.role !== "assistant" || message.status !== "done")
-      return { ok: false, reason: "not_completed" }
-
-    const [generation] = await tx
-      .select({ id: branchGenerations.id })
-      .from(branchGenerations)
-      .where(
-        and(
-          eq(branchGenerations.userId, input.userId),
-          eq(branchGenerations.treeId, input.treeId),
-          eq(branchGenerations.threadId, input.threadId),
-          eq(branchGenerations.assistantMessageId, input.messageId),
-          eq(branchGenerations.status, "completed")
-        )
-      )
-      .limit(1)
-    if (!generation) return { ok: false, reason: "missing_generation" }
-
-    const identity = and(
-      eq(branchMessageFeedback.userId, input.userId),
-      eq(branchMessageFeedback.treeId, input.treeId),
-      eq(branchMessageFeedback.threadId, input.threadId),
-      eq(branchMessageFeedback.messageId, input.messageId)
-    )
-    const [current] = await tx
-      .select()
-      .from(branchMessageFeedback)
-      .where(identity)
-    if (current?.feedback === input.feedback)
-      return { ok: true, feedback: toSummary(current) }
-    if (input.feedback === null) {
-      await tx.delete(branchMessageFeedback).where(identity)
-      return { ok: true, feedback: null }
-    }
-
-    const now = new Date()
-    const [saved] = await tx
-      .insert(branchMessageFeedback)
-      .values({
-        userId: input.userId,
-        treeId: input.treeId,
-        threadId: input.threadId,
-        messageId: input.messageId,
-        feedback: input.feedback,
-        updatedAt: now,
-      })
-      .onConflictDoUpdate({
-        target: [
-          branchMessageFeedback.userId,
-          branchMessageFeedback.treeId,
-          branchMessageFeedback.threadId,
-          branchMessageFeedback.messageId,
-        ],
-        set: { feedback: input.feedback, updatedAt: now },
-      })
-      .returning()
-    return { ok: true, feedback: toSummary(saved) }
-  })
-}
diff --git a/lib/thread-chat-generation/query-repository.ts b/lib/thread-chat-generation/query-repository.ts
deleted file mode 100644
index 5696d7b2..00000000
--- a/lib/thread-chat-generation/query-repository.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { and, desc, eq } from "drizzle-orm"
-import type { GenerationSummary } from "@/lib/thread-chat/domain/generation"
-import { db } from "@/lib/db"
-import { branchGenerations } from "@/lib/db/schema"
-
-export type GenerationRow = typeof branchGenerations.$inferSelect
-
-export async function getGenerationForOwner(
-  userId: string,
-  generationId: string
-): Promise {
-  const [row] = await db
-    .select()
-    .from(branchGenerations)
-    .where(
-      and(
-        eq(branchGenerations.id, generationId),
-        eq(branchGenerations.userId, userId)
-      )
-    )
-  return row ?? null
-}
-
-/** 内部执行观察器使用;不构成对用户暴露的数据接口。 */
-export async function getGenerationExecutionState(
-  generationId: string
-): Promise | null> {
-  const [row] = await db
-    .select({
-      status: branchGenerations.status,
-      isCurrent: branchGenerations.isCurrent,
-    })
-    .from(branchGenerations)
-    .where(eq(branchGenerations.id, generationId))
-  return row ?? null
-}
-
-export async function listCurrentGenerationsForTree(
-  userId: string,
-  treeId: string
-): Promise {
-  return db
-    .select()
-    .from(branchGenerations)
-    .where(
-      and(
-        eq(branchGenerations.userId, userId),
-        eq(branchGenerations.treeId, treeId),
-        eq(branchGenerations.isCurrent, true)
-      )
-    )
-    .orderBy(desc(branchGenerations.updatedAt))
-}
-
-export function toGenerationSummary(row: GenerationRow): GenerationSummary {
-  return {
-    id: row.id,
-    treeId: row.treeId,
-    threadId: row.threadId,
-    userMessageId: row.userMessageId,
-    assistantMessageId: row.assistantMessageId,
-    attempt: row.attempt,
-    isCurrent: row.isCurrent,
-    status: row.status,
-    updatedAt: row.updatedAt.toISOString(),
-    result: row.result,
-  }
-}
diff --git a/lib/thread-chat-generation/stale-generation-repository.ts b/lib/thread-chat-generation/stale-generation-repository.ts
deleted file mode 100644
index dcc590c1..00000000
--- a/lib/thread-chat-generation/stale-generation-repository.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { and, eq, inArray, lt } from "drizzle-orm"
-import type { GenerationResultV1 } from "@/lib/thread-chat/domain/generation"
-import { generationResultV1Schema } from "@/lib/thread-chat/contracts/generation-result"
-import {
-  ACTIVE_GENERATION_STATUSES,
-  GENERATION_ERRORS,
-  GENERATION_LEASE_MS,
-  GENERATION_RESULT_VERSION,
-} from "@/constants/generation"
-import { db } from "@/lib/db"
-import { branchGenerations } from "@/lib/db/schema"
-import {
-  getGenerationForOwner,
-  type GenerationRow,
-} from "@/lib/thread-chat-generation/query-repository"
-
-function staleFailureResult(row: GenerationRow): GenerationResultV1 {
-  const partial = row.turnSnapshot.assistantMessage
-  return generationResultV1Schema.parse({
-    version: GENERATION_RESULT_VERSION,
-    generationId: row.id,
-    text: partial.text,
-    status: "error",
-    error: GENERATION_ERRORS.backgroundInterrupted,
-    artifactIds: partial.artifactIds ?? [],
-    artifacts: {},
-    webResearch: partial.webResearch,
-    webResearchTextOffset: partial.webResearchTextOffset,
-    researchRoute: partial.researchRoute,
-    researchPlan: partial.researchPlan,
-  })
-}
-
-/** 将一棵 owner tree 内 lease 过期的活跃 generation 原子收敛为 failed。 */
-export async function failStaleGenerationsForTree(
-  userId: string,
-  treeId: string,
-  now = new Date()
-): Promise {
-  const staleBefore = new Date(now.getTime() - GENERATION_LEASE_MS)
-  const staleRows = await db
-    .select()
-    .from(branchGenerations)
-    .where(
-      and(
-        eq(branchGenerations.userId, userId),
-        eq(branchGenerations.treeId, treeId),
-        inArray(branchGenerations.status, ACTIVE_GENERATION_STATUSES),
-        lt(branchGenerations.heartbeatAt, staleBefore)
-      )
-    )
-
-  let changed = 0
-  for (const row of staleRows) {
-    const [updated] = await db
-      .update(branchGenerations)
-      .set({
-        status: "failed",
-        result: staleFailureResult(row),
-        error: GENERATION_ERRORS.backgroundInterrupted,
-        billingStatus: "usage_unavailable",
-        finishedAt: now,
-        updatedAt: now,
-      })
-      .where(
-        and(
-          eq(branchGenerations.id, row.id),
-          inArray(branchGenerations.status, ACTIVE_GENERATION_STATUSES),
-          lt(branchGenerations.heartbeatAt, staleBefore)
-        )
-      )
-      .returning({ id: branchGenerations.id })
-    if (updated) changed++
-  }
-  return changed
-}
-
-export async function failStaleGenerationForOwner(
-  userId: string,
-  generationId: string,
-  now = new Date()
-): Promise {
-  const row = await getGenerationForOwner(userId, generationId)
-  if (!row) return null
-  if (
-    ACTIVE_GENERATION_STATUSES.includes(
-      row.status as (typeof ACTIVE_GENERATION_STATUSES)[number]
-    ) &&
-    row.heartbeatAt.getTime() < now.getTime() - GENERATION_LEASE_MS
-  ) {
-    await failStaleGenerationsForTree(userId, row.treeId, now)
-    return getGenerationForOwner(userId, generationId)
-  }
-  return row
-}
diff --git a/lib/thread-chat-generation/start-generation-repository.ts b/lib/thread-chat-generation/start-generation-repository.ts
deleted file mode 100644
index 309a23d5..00000000
--- a/lib/thread-chat-generation/start-generation-repository.ts
+++ /dev/null
@@ -1,406 +0,0 @@
-import { and, eq, inArray, max, sql } from "drizzle-orm"
-import type { ThreadTreeState } from "@/lib/thread-chat/domain/types"
-import { parseThreadTreeState } from "@/lib/thread-chat/domain/message-graph"
-import {
-  prepareRegenerationPatch,
-  type PreparedTurnPatch,
-} from "@/lib/thread-chat/domain/regeneration"
-import type {
-  GenerationTurnIdentity,
-  GenerationTurnSnapshot,
-  ThreadChatGenerationIntent,
-} from "@/lib/thread-chat/domain/generation"
-import { ACTIVE_GENERATION_STATUSES } from "@/constants/generation"
-import { db } from "@/lib/db"
-import { branchGenerations, branchTrees } from "@/lib/db/schema"
-import type { GenerationRow } from "@/lib/thread-chat-generation/query-repository"
-
-export class GenerationRepositoryError extends Error {
-  constructor(
-    readonly code:
-      | "not_found"
-      | "generation_conflict"
-      | "model_mismatch"
-      | "invalid_turn"
-      | "not_latest_turn"
-      | "persistence_failed",
-    message: string
-  ) {
-    super(message)
-    this.name = "GenerationRepositoryError"
-  }
-}
-
-export type StartGenerationInput = GenerationTurnIdentity & {
-  userId: string
-  modelId: string
-  intent: ThreadChatGenerationIntent
-}
-
-export type StartGenerationResult = {
-  created: boolean
-  generation: GenerationRow
-}
-
-export type PrepareGenerationResult =
-  | {
-      created: true
-      generation: GenerationRow
-      state: ThreadTreeState
-      revision: number
-      turnSnapshot: GenerationTurnSnapshot
-      patch?: PreparedTurnPatch
-    }
-  | { created: false; generation: GenerationRow }
-
-function isThreadTreeState(value: unknown): value is ThreadTreeState {
-  if (typeof value !== "object" || value === null) return false
-  const candidate = value as Partial
-  return (
-    typeof candidate.threads === "object" &&
-    candidate.threads !== null &&
-    typeof candidate.artifacts === "object" &&
-    candidate.artifacts !== null &&
-    Array.isArray(candidate.artifactOrder)
-  )
-}
-
-function verifyTurn(
-  stateValue: unknown,
-  input: GenerationTurnIdentity,
-  intent: ThreadChatGenerationIntent
-): GenerationTurnSnapshot {
-  if (!isThreadTreeState(stateValue)) {
-    throw new GenerationRepositoryError(
-      "invalid_turn",
-      "已保存的分支树状态无效"
-    )
-  }
-
-  let state: ThreadTreeState
-  try {
-    state = parseThreadTreeState(stateValue)
-  } catch {
-    throw new GenerationRepositoryError(
-      "invalid_turn",
-      "已保存的分支树消息图无效"
-    )
-  }
-
-  const thread = state.threads[input.threadId]
-  if (!thread) {
-    throw new GenerationRepositoryError("invalid_turn", "目标会话不存在")
-  }
-
-  const assistantMessageIndex = thread.messages.findIndex(
-    (message) => message.id === input.assistantMessageId
-  )
-  const userMessageIndex = thread.messages.findIndex(
-    (message) => message.id === input.userMessageId
-  )
-  const assistantMessage = thread.messages[assistantMessageIndex]
-  const userMessage = thread.messages[userMessageIndex]
-
-  if (
-    assistantMessageIndex < 0 ||
-    userMessageIndex < 0 ||
-    userMessage?.role !== "user" ||
-    assistantMessage?.role !== "assistant" ||
-    assistantMessage.parentMessageId !== userMessage.id ||
-    thread.activeLeafMessageId !== assistantMessage.id ||
-    assistantMessage.generationId !== input.generationId
-  ) {
-    throw new GenerationRepositoryError(
-      "invalid_turn",
-      "生成目标与已持久化的消息占位不一致"
-    )
-  }
-
-  return {
-    intent: structuredClone(intent),
-    threadId: input.threadId,
-    assistantMessageIndex,
-    userMessage: structuredClone(userMessage),
-    assistantMessage: structuredClone(assistantMessage),
-    userParentMessageId: userMessage.parentMessageId,
-    assistantParentMessageId: userMessage.id,
-    activatesAssistantMessageId: assistantMessage.id,
-  }
-}
-
-function generationIntentMatches(
-  stored: ThreadChatGenerationIntent | undefined,
-  received: ThreadChatGenerationIntent
-): boolean {
-  if (!stored || stored.kind !== received.kind) return false
-  switch (received.kind) {
-    case "persisted-turn":
-    case "retry-orphan-user":
-      return true
-    case "regenerate-assistant":
-      return (
-        stored.kind === received.kind &&
-        stored.sourceAssistantMessageId === received.sourceAssistantMessageId
-      )
-    case "edit-last-user":
-      return (
-        stored.kind === received.kind &&
-        stored.sourceUserMessageId === received.sourceUserMessageId &&
-        stored.text === received.text
-      )
-  }
-}
-
-function assertReplayMatches(row: GenerationRow, input: StartGenerationInput) {
-  if (
-    row.userId !== input.userId ||
-    row.treeId !== input.treeId ||
-    row.threadId !== input.threadId ||
-    row.userMessageId !== input.userMessageId ||
-    row.assistantMessageId !== input.assistantMessageId ||
-    row.modelId !== input.modelId ||
-    !generationIntentMatches(row.turnSnapshot.intent, input.intent)
-  ) {
-    throw new GenerationRepositoryError(
-      "generation_conflict",
-      "generation id 已被其他生成请求使用"
-    )
-  }
-}
-
-/**
- * 严格 start transaction。只有它返回 created=true 时调用方才可以发起付费模型请求;
- * 同 generation id 重放返回既有记录,绝不会启动第二次调用。
- */
-export async function prepareGeneration(
-  input: StartGenerationInput
-): Promise {
-  return db.transaction(async (tx) => {
-    const locked = await tx.execute(sql`
-      select ${branchTrees.id}
-      from ${branchTrees}
-      where ${branchTrees.id} = ${input.treeId}
-        and ${branchTrees.userId} = ${input.userId}
-      for update
-    `)
-    if (locked.length === 0) {
-      throw new GenerationRepositoryError("not_found", "分支树不存在")
-    }
-
-    const [tree] = await tx
-      .select({ state: branchTrees.state, revision: branchTrees.revision })
-      .from(branchTrees)
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId)
-        )
-      )
-    if (!tree) {
-      throw new GenerationRepositoryError("not_found", "分支树不存在")
-    }
-
-    const [replayed] = await tx
-      .select()
-      .from(branchGenerations)
-      .where(eq(branchGenerations.id, input.generationId))
-    if (replayed) {
-      assertReplayMatches(replayed, input)
-      return { created: false, generation: replayed }
-    }
-
-    const intent = input.intent
-    let state: ThreadTreeState
-    try {
-      state = parseThreadTreeState(tree.state)
-    } catch {
-      throw new GenerationRepositoryError(
-        "invalid_turn",
-        "已保存的分支树消息图无效"
-      )
-    }
-    const targetThread = state.threads[input.threadId]
-    if (!targetThread)
-      throw new GenerationRepositoryError("invalid_turn", "目标会话不存在")
-    if (targetThread.modelId !== input.modelId)
-      throw new GenerationRepositoryError(
-        "model_mismatch",
-        "请求模型与目标会话不一致,请刷新后重试"
-      )
-
-    if (intent.kind === "persisted-turn") {
-      const [otherActiveGeneration] = await tx
-        .select({ id: branchGenerations.id })
-        .from(branchGenerations)
-        .where(
-          and(
-            eq(branchGenerations.treeId, input.treeId),
-            eq(branchGenerations.threadId, input.threadId),
-            eq(branchGenerations.isCurrent, true),
-            inArray(branchGenerations.status, ACTIVE_GENERATION_STATUSES),
-            sql`${branchGenerations.assistantMessageId} <> ${input.assistantMessageId}`
-          )
-        )
-        .limit(1)
-      if (otherActiveGeneration)
-        throw new GenerationRepositoryError(
-          "generation_conflict",
-          "目标会话已有正在执行的生成,请等待完成或明确停止后重试"
-        )
-    }
-
-    let patch: PreparedTurnPatch | undefined
-    let revision = tree.revision
-    if (intent.kind !== "persisted-turn") {
-      const targetIds = [input.assistantMessageId]
-      if (intent.kind === "edit-last-user") targetIds.push(input.userMessageId)
-      if (
-        Object.values(state.threads).some((thread) =>
-          thread.messages.some((message) => targetIds.includes(message.id))
-        )
-      )
-        throw new GenerationRepositoryError(
-          "generation_conflict",
-          "新消息 id 已被其他生成占用"
-        )
-      const sourceMessageId =
-        intent.kind === "regenerate-assistant"
-          ? intent.sourceAssistantMessageId
-          : intent.kind === "edit-last-user"
-            ? intent.sourceUserMessageId
-            : input.userMessageId
-      const sourceExists = state.threads[input.threadId]?.messages.some(
-        (message) => message.id === sourceMessageId
-      )
-      if (!sourceExists)
-        throw new GenerationRepositoryError(
-          "invalid_turn",
-          "生成来源消息不存在"
-        )
-      patch =
-        prepareRegenerationPatch(state, {
-          threadId: input.threadId,
-          userMessageId: input.userMessageId,
-          assistantMessageId: input.assistantMessageId,
-          generationId: input.generationId,
-          intent,
-        }) ?? undefined
-      if (!patch)
-        throw new GenerationRepositoryError(
-          "not_latest_turn",
-          "只能编辑或重新生成当前 active path 的最后一轮"
-        )
-      const thread = state.threads[input.threadId]
-      thread.messages.push(
-        ...patch.addedMessages.map((message) => structuredClone(message))
-      )
-      thread.activeLeafMessageId = patch.nextActiveLeafMessageId
-      const [updatedTree] = await tx
-        .update(branchTrees)
-        .set({
-          state,
-          revision: sql`${branchTrees.revision} + 1`,
-          updatedAt: new Date(),
-        })
-        .where(
-          and(
-            eq(branchTrees.id, input.treeId),
-            eq(branchTrees.userId, input.userId),
-            eq(branchTrees.revision, tree.revision)
-          )
-        )
-        .returning({ revision: branchTrees.revision })
-      if (!updatedTree)
-        throw new GenerationRepositoryError(
-          "generation_conflict",
-          "分支树修订号已变更,请刷新后重试"
-        )
-      revision = updatedTree.revision
-
-      const now = new Date()
-      await tx
-        .update(branchGenerations)
-        .set({
-          isCurrent: false,
-          status: "superseded",
-          finishedAt: now,
-          updatedAt: now,
-        })
-        .where(
-          and(
-            eq(branchGenerations.treeId, input.treeId),
-            eq(branchGenerations.threadId, input.threadId),
-            eq(branchGenerations.isCurrent, true),
-            inArray(branchGenerations.status, ACTIVE_GENERATION_STATUSES)
-          )
-        )
-    }
-
-    const turnSnapshot = verifyTurn(state, input, input.intent)
-    const [attemptRow] = await tx
-      .select({ value: max(branchGenerations.attempt) })
-      .from(branchGenerations)
-      .where(
-        and(
-          eq(branchGenerations.treeId, input.treeId),
-          eq(branchGenerations.threadId, input.threadId),
-          eq(branchGenerations.assistantMessageId, input.assistantMessageId)
-        )
-      )
-    const attempt = (attemptRow?.value ?? 0) + 1
-    const now = new Date()
-
-    if (intent.kind === "persisted-turn")
-      await tx
-        .update(branchGenerations)
-        .set({
-          isCurrent: false,
-          status: "superseded",
-          finishedAt: now,
-          updatedAt: now,
-        })
-        .where(
-          and(
-            eq(branchGenerations.treeId, input.treeId),
-            eq(branchGenerations.threadId, input.threadId),
-            eq(branchGenerations.assistantMessageId, input.assistantMessageId),
-            eq(branchGenerations.isCurrent, true)
-          )
-        )
-
-    const [created] = await tx
-      .insert(branchGenerations)
-      .values({
-        id: input.generationId,
-        userId: input.userId,
-        treeId: input.treeId,
-        threadId: input.threadId,
-        userMessageId: input.userMessageId,
-        assistantMessageId: input.assistantMessageId,
-        attempt,
-        isCurrent: true,
-        status: "running",
-        modelId: input.modelId,
-        assistantMessageIndex: turnSnapshot.assistantMessageIndex,
-        turnSnapshot,
-        heartbeatAt: now,
-        updatedAt: now,
-      })
-      .returning()
-
-    return {
-      created: true,
-      generation: created,
-      state,
-      revision,
-      turnSnapshot,
-      ...(patch ? { patch } : {}),
-    }
-  })
-}
-
-export async function startGeneration(
-  input: StartGenerationInput
-): Promise {
-  return prepareGeneration(input)
-}
diff --git a/lib/thread-chat-generation/tree-repository.ts b/lib/thread-chat-generation/tree-repository.ts
deleted file mode 100644
index 58e9f91c..00000000
--- a/lib/thread-chat-generation/tree-repository.ts
+++ /dev/null
@@ -1,307 +0,0 @@
-import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
-import { ACTIVE_GENERATION_STATUSES } from "@/constants/generation"
-import { TREE_TITLE_FALLBACK } from "@/constants/thread-chat"
-import {
-  activeLeafTurn,
-  assistantTurnAlternatives,
-  parseThreadTreeState,
-} from "@/lib/thread-chat/domain/message-graph"
-import type { ThreadTreeState } from "@/lib/thread-chat/domain/types"
-import type {
-  SwitchActiveLeafFailureReason,
-  SwitchActiveLeafRequest,
-  SwitchActiveLeafSuccessResponse,
-} from "@/lib/thread-chat/contracts/switch-active-leaf"
-import { db } from "@/lib/db"
-import { branchGenerations, branchTrees } from "@/lib/db/schema"
-
-export class TreeCommandError extends Error {
-  constructor(
-    readonly code: SwitchActiveLeafFailureReason,
-    message: string,
-    readonly currentRevision?: number
-  ) {
-    super(message)
-    this.name = "TreeCommandError"
-  }
-}
-
-export type DeleteOwnedTreeResult =
-  "deleted" | "not_found" | "generation_running"
-
-export type OwnedTreeSnapshot = Pick<
-  typeof branchTrees.$inferSelect,
-  "state" | "customTitle" | "revision"
->
-
-export type SaveOwnedTreeResult =
-  | { kind: "saved"; revision: number }
-  | { kind: "conflict"; revision: number }
-  | { kind: "not_found" }
-
-export interface OwnedTreeSummary {
-  id: string
-  title: string
-  updatedAt: Date
-  threadCount: number
-}
-
-/** 会话列表用的 owner-scoped 轻量投影;不把整棵 JSON state 带出仓储。 */
-export async function listOwnedTreeSummaries(
-  userId: string
-): Promise {
-  return db
-    .select({
-      id: branchTrees.id,
-      title: sql`coalesce(${branchTrees.customTitle}, ${branchTrees.title}, ${TREE_TITLE_FALLBACK})`,
-      updatedAt: branchTrees.updatedAt,
-      // 历史毒行的 threads 不是对象时返回 0,不能让一行数据打挂整个列表。
-      threadCount: sql`(case when jsonb_typeof(${branchTrees.state} -> 'threads') = 'object' then (select count(*) from jsonb_object_keys(${branchTrees.state} -> 'threads')) else 0 end)::int`,
-    })
-    .from(branchTrees)
-    .where(eq(branchTrees.userId, userId))
-    .orderBy(desc(branchTrees.updatedAt))
-    .limit(100)
-}
-
-/** GET 精确 URL 的迁移入口:普通 owner 读取,或原子认领一棵历史无主树。 */
-export async function loadOwnedOrClaimLegacyTree(input: {
-  userId: string
-  treeId: string
-}): Promise {
-  return db.transaction(async (tx) => {
-    const selection = {
-      state: branchTrees.state,
-      customTitle: branchTrees.customTitle,
-      revision: branchTrees.revision,
-    }
-    const [owned] = await tx
-      .select(selection)
-      .from(branchTrees)
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId)
-        )
-      )
-    if (owned) return owned
-
-    const [claimed] = await tx
-      .update(branchTrees)
-      .set({ userId: input.userId })
-      .where(and(eq(branchTrees.id, input.treeId), isNull(branchTrees.userId)))
-      .returning(selection)
-    return claimed ?? null
-  })
-}
-
-/** owner + revision CAS 的整树写入;新树只允许 baseRevision=0 时绑定当前用户。 */
-export async function saveOwnedTree(input: {
-  userId: string
-  treeId: string
-  state: ThreadTreeState
-  title: string | null
-  baseRevision: number
-}): Promise {
-  const now = new Date()
-  return db.transaction(async (tx) => {
-    const [updated] = await tx
-      .update(branchTrees)
-      .set({
-        state: input.state,
-        title: input.title,
-        revision: sql`${branchTrees.revision} + 1`,
-        updatedAt: now,
-      })
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId),
-          eq(branchTrees.revision, input.baseRevision)
-        )
-      )
-      .returning({ revision: branchTrees.revision })
-    if (updated) return { kind: "saved", revision: updated.revision }
-
-    const [existing] = await tx
-      .select({ revision: branchTrees.revision })
-      .from(branchTrees)
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId)
-        )
-      )
-    if (existing) return { kind: "conflict", revision: existing.revision }
-    if (input.baseRevision !== 0) return { kind: "not_found" }
-
-    const [inserted] = await tx
-      .insert(branchTrees)
-      .values({
-        id: input.treeId,
-        userId: input.userId,
-        state: input.state,
-        title: input.title,
-        revision: 1,
-        updatedAt: now,
-      })
-      .onConflictDoNothing({ target: branchTrees.id })
-      .returning({ revision: branchTrees.revision })
-    return inserted
-      ? { kind: "saved", revision: inserted.revision }
-      : { kind: "not_found" }
-  })
-}
-
-/** 已校验标题的 owner-scoped 用户命名写入;不触碰机器派生 title。 */
-export async function renameOwnedTree(input: {
-  userId: string
-  treeId: string
-  customTitle: string
-}): Promise {
-  const [updated] = await db
-    .update(branchTrees)
-    .set({ customTitle: input.customTitle })
-    .where(
-      and(
-        eq(branchTrees.id, input.treeId),
-        eq(branchTrees.userId, input.userId)
-      )
-    )
-    .returning({ id: branchTrees.id })
-  return Boolean(updated)
-}
-
-/**
- * 删除与 generation start 共用 branch_trees 行锁:两者并发时,只可能先删除并让
- * start 得到 not_found,或先创建 generation 并让删除得到 generation_running。
- */
-export async function deleteOwnedTreeIfIdle(input: {
-  userId: string
-  treeId: string
-}): Promise {
-  return db.transaction(async (tx) => {
-    const locked = await tx.execute(sql`
-      select ${branchTrees.id}
-      from ${branchTrees}
-      where ${branchTrees.id} = ${input.treeId}
-        and ${branchTrees.userId} = ${input.userId}
-      for update
-    `)
-    if (locked.length === 0) return "not_found"
-
-    const [activeGeneration] = await tx
-      .select({ id: branchGenerations.id })
-      .from(branchGenerations)
-      .where(
-        and(
-          eq(branchGenerations.userId, input.userId),
-          eq(branchGenerations.treeId, input.treeId),
-          inArray(branchGenerations.status, ACTIVE_GENERATION_STATUSES)
-        )
-      )
-      .limit(1)
-    if (activeGeneration) return "generation_running"
-
-    const [deleted] = await tx
-      .delete(branchTrees)
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId)
-        )
-      )
-      .returning({ id: branchTrees.id })
-    return deleted ? "deleted" : "not_found"
-  })
-}
-
-export async function switchActiveLeafForOwner(
-  input: SwitchActiveLeafRequest & {
-    userId: string
-    treeId: string
-  }
-): Promise {
-  return db.transaction(async (tx) => {
-    const locked = await tx.execute(sql`
-      select ${branchTrees.id}
-      from ${branchTrees}
-      where ${branchTrees.id} = ${input.treeId}
-        and ${branchTrees.userId} = ${input.userId}
-      for update
-    `)
-    if (locked.length === 0)
-      throw new TreeCommandError("not_found", "分支树不存在")
-
-    const [row] = await tx
-      .select({ state: branchTrees.state, revision: branchTrees.revision })
-      .from(branchTrees)
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId)
-        )
-      )
-    if (!row) throw new TreeCommandError("not_found", "分支树不存在")
-    if (row.revision !== input.baseRevision)
-      throw new TreeCommandError(
-        "tree_revision_conflict",
-        "该对话已在其他页面更新",
-        row.revision
-      )
-
-    let state: ThreadTreeState
-    try {
-      state = parseThreadTreeState(row.state)
-    } catch {
-      throw new TreeCommandError("invalid_turn", "分支树消息结构无效")
-    }
-    const thread = state.threads[input.threadId]
-    const currentTurn = thread ? activeLeafTurn(thread) : null
-    if (!thread || !currentTurn?.assistantMessage)
-      throw new TreeCommandError("invalid_turn", "当前会话没有可切换的最新回复")
-
-    const alternatives = assistantTurnAlternatives(
-      thread,
-      currentTurn.assistantMessage.id
-    )
-    const target = alternatives.find(
-      (message) => message.id === input.assistantMessageId
-    )
-    const targetHasMessageChildren = thread.messages.some(
-      (message) => message.parentMessageId === target?.id
-    )
-    if (!target || targetHasMessageChildren)
-      throw new TreeCommandError(
-        "invalid_turn",
-        "目标不是最新一轮的可切换回复版本"
-      )
-
-    thread.activeLeafMessageId = target.id
-    const [updated] = await tx
-      .update(branchTrees)
-      .set({
-        state,
-        revision: sql`${branchTrees.revision} + 1`,
-        updatedAt: new Date(),
-      })
-      .where(
-        and(
-          eq(branchTrees.id, input.treeId),
-          eq(branchTrees.userId, input.userId),
-          eq(branchTrees.revision, input.baseRevision)
-        )
-      )
-      .returning({ revision: branchTrees.revision })
-    if (!updated)
-      throw new TreeCommandError(
-        "tree_revision_conflict",
-        "该对话已在其他页面更新"
-      )
-
-    return {
-      revision: updated.revision,
-      thread: { id: thread.id, activeLeafMessageId: target.id },
-    }
-  })
-}
diff --git a/lib/thread-chat/api/capabilities.ts b/lib/thread-chat/api/capabilities.ts
new file mode 100644
index 00000000..cb8942f9
--- /dev/null
+++ b/lib/thread-chat/api/capabilities.ts
@@ -0,0 +1,95 @@
+import type { z } from "zod"
+import type {
+  artifactSchema,
+  assistantMessageEventSchema,
+  assistantRunStateSchema,
+  creationBundleSchema,
+  feedbackSchema,
+  forkThreadRequestSchema,
+  listProjectsResultSchema,
+  messageCreationBundleSchema,
+  projectBootstrapSchema,
+  projectSchema,
+  projectTargetSchema,
+  replacementBundleSchema,
+  threadMessageBundleSchema,
+  threadSchema,
+  UserMessageParts,
+} from "./contracts"
+
+export interface ThreadChatApiCapabilities {
+  listProjects(input?: {
+    status?: "active" | "archived" | "all"
+    limit?: number
+    cursor?: string
+    signal?: AbortSignal
+  }): Promise>
+  createProject(input: {
+    parts: UserMessageParts
+    requestedModelId?: string
+    signal?: AbortSignal
+  }): Promise>
+  bootstrapProject(
+    projectId: string,
+    signal?: AbortSignal
+  ): Promise>
+  patchProject(input: {
+    projectId: string
+    customTitle?: string | null
+    target?: z.infer | null
+    instruction?: string | null
+  }): Promise>
+  setProjectArchived(
+    projectId: string,
+    archived: boolean
+  ): Promise>
+  deleteProject(projectId: string): Promise
+  loadThreadMessages(input: {
+    threadId: string
+    limit?: number
+    beforeSequence?: number
+    signal?: AbortSignal
+  }): Promise>
+  patchThread(
+    threadId: string,
+    customTitle: string | null
+  ): Promise>
+  setThreadArchived(
+    threadId: string,
+    archived: boolean
+  ): Promise>
+  sendMessage(input: {
+    threadId: string
+    parts: UserMessageParts
+    requestedModelId?: string
+  }): Promise>
+  forkThread(
+    threadId: string,
+    input: z.infer
+  ): Promise<{ thread: z.infer }>
+  editMessage(input: {
+    messageId: string
+    parts: UserMessageParts
+    requestedModelId?: string
+  }): Promise>
+  regenerateMessage(input: {
+    messageId: string
+    requestedModelId?: string
+  }): Promise>
+  setFeedback(
+    messageId: string,
+    value: "positive" | "negative" | null
+  ): Promise>
+  loadArtifact(
+    artifactId: string,
+    signal?: AbortSignal
+  ): Promise>
+  subscribeAssistantEvents(input: {
+    assistantMessageId: string
+    afterEventSequence?: number
+    signal?: AbortSignal
+  }): AsyncIterable>
+  stopAssistant(
+    assistantMessageId: string
+  ): Promise>
+}
diff --git a/lib/thread-chat/api/client-error.ts b/lib/thread-chat/api/client-error.ts
new file mode 100644
index 00000000..52e74657
--- /dev/null
+++ b/lib/thread-chat/api/client-error.ts
@@ -0,0 +1,13 @@
+import type { ApiErrorCode } from "./contracts"
+
+export class ThreadChatClientError extends Error {
+  constructor(
+    readonly code: ApiErrorCode,
+    message: string,
+    readonly status: number,
+    readonly details?: unknown
+  ) {
+    super(message)
+    this.name = "ThreadChatClientError"
+  }
+}
diff --git a/lib/thread-chat/api/contracts.ts b/lib/thread-chat/api/contracts.ts
new file mode 100644
index 00000000..3bdbfc08
--- /dev/null
+++ b/lib/thread-chat/api/contracts.ts
@@ -0,0 +1,401 @@
+import { z } from "zod"
+
+export const idSchema = z.uuid()
+export const dateTimeSchema = z.iso.datetime({ offset: true })
+export const jsonValueSchema = z.json()
+
+const textPartSchema = z.strictObject({
+  type: z.literal("text"),
+  text: z.string(),
+})
+const filePartSchema = z.strictObject({
+  type: z.literal("file"),
+  mediaType: z.string().min(1),
+  filename: z.string().optional(),
+  url: z.string().min(1),
+})
+
+export const userMessagePartsSchema = z
+  .array(z.discriminatedUnion("type", [textPartSchema, filePartSchema]))
+  .min(1)
+  .refine(
+    (parts) =>
+      parts.some(
+        (part) =>
+          (part.type === "text" && part.text.trim().length > 0) ||
+          part.type === "file"
+      ),
+    "Message parts must contain meaningful content."
+  )
+
+export const messagePartsSchema = z.array(
+  z.looseObject({ type: z.string().min(1) })
+)
+export const markdownArtifactToolOutputSchema = z.strictObject({
+  artifactId: idSchema,
+})
+
+export const projectTargetSchema = z.strictObject({
+  ultimate: z.string().max(4_000).nullable(),
+  shortTerm: z.array(z.string().trim().min(1).max(500)).max(50),
+  midTerm: z.array(z.string().trim().min(1).max(500)).max(50),
+})
+
+export const projectSchema = z.strictObject({
+  id: idSchema,
+  ownerUserId: z.string().min(1),
+  autoTitle: z.string().nullable(),
+  customTitle: z.string().nullable(),
+  target: projectTargetSchema.nullable(),
+  instruction: z.string().nullable(),
+  archivedAt: dateTimeSchema.nullable(),
+  createdAt: dateTimeSchema,
+  updatedAt: dateTimeSchema,
+})
+
+export const projectSummarySchema = z.strictObject({
+  id: idSchema,
+  displayTitle: z.string(),
+  archivedAt: dateTimeSchema.nullable(),
+  updatedAt: dateTimeSchema,
+  threadCount: z.int().nonnegative(),
+  messageCount: z.int().nonnegative(),
+})
+
+export const forkSourceSnapshotSchema = z.strictObject({
+  schemaVersion: z.literal(1),
+  quote: z.string().optional(),
+  sourceRole: z.enum(["user", "assistant"]),
+  sourceSequence: z.int().positive(),
+})
+
+export const threadSchema = z
+  .strictObject({
+    id: idSchema,
+    projectId: idSchema,
+    parentThreadId: idSchema.nullable(),
+    sourceMessageId: idSchema.nullable(),
+    forkSourceSnapshot: forkSourceSnapshotSchema.nullable(),
+    autoTitle: z.string().nullable(),
+    customTitle: z.string().nullable(),
+    archivedAt: dateTimeSchema.nullable(),
+    createdAt: dateTimeSchema,
+    updatedAt: dateTimeSchema,
+  })
+  .superRefine((thread, context) => {
+    const rootFactsValid =
+      thread.parentThreadId === null &&
+      thread.sourceMessageId === null &&
+      thread.forkSourceSnapshot === null
+    const branchFactsValid =
+      thread.parentThreadId !== null &&
+      thread.sourceMessageId !== null &&
+      thread.forkSourceSnapshot !== null
+    if (!rootFactsValid && !branchFactsValid)
+      context.addIssue({
+        code: "custom",
+        message: "Thread ForkFacts are inconsistent.",
+      })
+  })
+
+export const messageSchema = z.strictObject({
+  id: idSchema,
+  threadId: idSchema,
+  sequence: z.int().positive(),
+  role: z.enum(["user", "assistant"]),
+  parts: messagePartsSchema.nullable(),
+  replacesMessageId: idSchema.nullable(),
+  supersededAt: dateTimeSchema.nullable(),
+  finalizedAt: dateTimeSchema.nullable(),
+  createdAt: dateTimeSchema,
+})
+
+export const assistantRunStateSchema = z.strictObject({
+  assistantMessageId: idSchema,
+  status: z.enum(["queued", "running", "completed", "failed", "stopped"]),
+  modelId: z.string().min(1),
+  checkpointParts: messagePartsSchema,
+  eventSequence: z.int().nonnegative(),
+  error: z.strictObject({ code: z.string(), message: z.string() }).nullable(),
+  stopRequestedAt: dateTimeSchema.nullable(),
+  finishedAt: dateTimeSchema.nullable(),
+})
+
+export const artifactSchema = z.strictObject({
+  id: idSchema,
+  projectId: idSchema,
+  sourceMessageId: idSchema,
+  kind: z.string(),
+  title: z.string(),
+  content: jsonValueSchema,
+  createdAt: dateTimeSchema,
+})
+
+export const artifactSummarySchema = z
+  .strictObject({
+    changeSequence: z.int().nonnegative(),
+    total: z.int().nonnegative(),
+    byKind: z.record(z.string(), z.int().nonnegative()),
+  })
+  .refine(
+    (summary) =>
+      Object.values(summary.byKind).reduce((sum, count) => sum + count, 0) ===
+      summary.total,
+    "Artifact summary total must equal byKind counts."
+  )
+
+export const feedbackSchema = z.strictObject({
+  messageId: idSchema,
+  value: z.enum(["positive", "negative"]).nullable(),
+  updatedAt: dateTimeSchema,
+})
+
+export const threadMessageBundleSchema = z
+  .strictObject({
+    threadId: idSchema,
+    messages: z.array(messageSchema),
+    assistantRuns: z.array(assistantRunStateSchema),
+    hasOlderMessages: z.boolean(),
+    oldestReturnedSequence: z.int().positive().nullable(),
+    newestReturnedSequence: z.int().positive().nullable(),
+  })
+  .superRefine((bundle, context) => {
+    if (bundle.messages.some((message) => message.threadId !== bundle.threadId))
+      context.addIssue({
+        code: "custom",
+        message: "Message belongs to another Thread.",
+      })
+    const assistantIds = bundle.messages
+      .filter((message) => message.role === "assistant")
+      .map((message) => message.id)
+      .toSorted()
+    const runIds = bundle.assistantRuns
+      .map((run) => run.assistantMessageId)
+      .toSorted()
+    if (JSON.stringify(assistantIds) !== JSON.stringify(runIds))
+      context.addIssue({
+        code: "custom",
+        message: "Assistant Run coverage is invalid.",
+      })
+  })
+
+export const projectBootstrapSchema = z
+  .strictObject({
+    project: projectSchema,
+    threadTopology: z.array(threadSchema),
+    artifactSummary: artifactSummarySchema,
+    initialThread: threadMessageBundleSchema,
+  })
+  .superRefine((bootstrap, context) => {
+    const roots = bootstrap.threadTopology.filter(
+      (thread) => thread.parentThreadId === null
+    )
+    if (
+      roots.length !== 1 ||
+      roots[0].id !== bootstrap.initialThread.threadId ||
+      bootstrap.threadTopology.some(
+        (thread) => thread.projectId !== bootstrap.project.id
+      )
+    )
+      context.addIssue({
+        code: "custom",
+        message: "Project Bootstrap ownership is invalid.",
+      })
+  })
+
+export const creationBundleSchema = z
+  .strictObject({
+    project: projectSchema,
+    rootThread: threadSchema,
+    artifactSummary: artifactSummarySchema,
+    userMessage: messageSchema,
+    assistantMessage: messageSchema,
+    assistantRun: assistantRunStateSchema,
+  })
+  .superRefine((bundle, context) => {
+    if (
+      bundle.rootThread.projectId !== bundle.project.id ||
+      bundle.rootThread.parentThreadId !== null ||
+      bundle.userMessage.threadId !== bundle.rootThread.id ||
+      bundle.assistantMessage.threadId !== bundle.rootThread.id ||
+      bundle.assistantRun.assistantMessageId !== bundle.assistantMessage.id
+    )
+      context.addIssue({
+        code: "custom",
+        message: "Creation Bundle ownership is invalid.",
+      })
+  })
+
+export const messageCreationBundleSchema = z
+  .strictObject({
+    userMessage: messageSchema,
+    assistantMessage: messageSchema,
+    assistantRun: assistantRunStateSchema,
+  })
+  .superRefine((bundle, context) => {
+    if (
+      bundle.userMessage.threadId !== bundle.assistantMessage.threadId ||
+      bundle.assistantRun.assistantMessageId !== bundle.assistantMessage.id
+    )
+      context.addIssue({
+        code: "custom",
+        message: "Message Bundle ownership is invalid.",
+      })
+  })
+
+export const replacementBundleSchema = z.strictObject({
+  supersededMessageIds: z.array(idSchema),
+  createdMessages: z.array(messageSchema),
+  assistantRun: assistantRunStateSchema,
+})
+
+export const listProjectsResultSchema = z.strictObject({
+  items: z.array(projectSummarySchema),
+  nextCursor: z.string().nullable(),
+})
+
+export const listProjectsQuerySchema = z.strictObject({
+  status: z.enum(["active", "archived", "all"]).default("active"),
+  limit: z.coerce.number().int().min(1).max(100).default(50),
+  cursor: z.string().min(1).optional(),
+})
+export const threadMessagesQuerySchema = z.strictObject({
+  limit: z.coerce.number().int().min(1).max(200).default(200),
+  beforeSequence: z.coerce.number().int().positive().optional(),
+})
+export const assistantEventsQuerySchema = z.strictObject({
+  afterEventSequence: z.coerce.number().int().nonnegative().default(0),
+})
+
+export const createProjectRequestSchema = z.strictObject({
+  initialMessage: z.strictObject({ parts: userMessagePartsSchema }),
+  requestedModelId: z.string().min(1).optional(),
+})
+export const sendMessageRequestSchema = z.strictObject({
+  parts: userMessagePartsSchema,
+  requestedModelId: z.string().min(1).optional(),
+})
+export const patchProjectRequestSchema = z
+  .strictObject({
+    customTitle: z.string().trim().min(1).max(120).nullable().optional(),
+    target: projectTargetSchema.nullable().optional(),
+    instruction: z.string().max(20_000).nullable().optional(),
+  })
+  .refine(
+    (value) => Object.keys(value).length > 0,
+    "At least one field is required."
+  )
+export const patchThreadRequestSchema = z.strictObject({
+  customTitle: z.string().trim().min(1).max(120).nullable(),
+})
+export const forkThreadRequestSchema = z.strictObject({
+  sourceMessageId: idSchema,
+  anchor: z
+    .strictObject({
+      exactQuote: z.string().min(1),
+      textPosition: z
+        .strictObject({
+          start: z.int().nonnegative(),
+          end: z.int().positive(),
+        })
+        .refine((position) => position.end > position.start)
+        .optional(),
+    })
+    .optional(),
+})
+export const editMessageRequestSchema = sendMessageRequestSchema
+export const regenerateMessageRequestSchema = z.strictObject({
+  requestedModelId: z.string().min(1).optional(),
+})
+export const putFeedbackRequestSchema = z.strictObject({
+  value: z.enum(["positive", "negative"]).nullable(),
+})
+
+export const apiErrorCodeSchema = z.enum([
+  "validation_error",
+  "invalid_query",
+  "invalid_cursor",
+  "invalid_event_cursor",
+  "unauthorized",
+  "forbidden",
+  "project_not_found",
+  "thread_not_found",
+  "message_not_found",
+  "assistant_message_not_found",
+  "message_run_not_found",
+  "artifact_not_found",
+  "model_not_available",
+  "thread_archived",
+  "thread_generation_in_progress",
+  "root_thread_title_owned_by_project",
+  "root_thread_archive_owned_by_project",
+  "source_message_not_found",
+  "fork_source_thread_mismatch",
+  "fork_source_not_finalized",
+  "fork_source_superseded",
+  "fork_anchor_mismatch",
+  "message_not_editable",
+  "message_not_regeneratable",
+  "message_not_feedback_eligible",
+  "fork_required",
+  "project_delete_conflict",
+  "internal_error",
+])
+
+export const apiErrorResponseSchema = z.strictObject({
+  error: z.strictObject({
+    code: apiErrorCodeSchema,
+    message: z.string(),
+    details: jsonValueSchema.optional(),
+  }),
+})
+
+const uiMessageChunkSchema = z.looseObject({ type: z.string().min(1) })
+export const runSnapshotEventSchema = z.strictObject({
+  type: z.literal("run.snapshot"),
+  cursor: z.int().nonnegative(),
+  run: assistantRunStateSchema,
+  message: messageSchema,
+  artifactSummary: artifactSummarySchema,
+})
+export const runDeltaEventSchema = z.strictObject({
+  type: z.literal("run.delta"),
+  eventSequence: z.int().nonnegative(),
+  chunk: uiMessageChunkSchema,
+})
+export const runCompletedEventSchema = z.strictObject({
+  type: z.literal("run.completed"),
+  eventSequence: z.int().nonnegative(),
+  run: assistantRunStateSchema,
+  message: messageSchema,
+  artifactSummary: artifactSummarySchema,
+})
+export const runFailedEventSchema = z.strictObject({
+  type: z.literal("run.failed"),
+  eventSequence: z.int().nonnegative(),
+  run: assistantRunStateSchema,
+})
+export const runStoppedEventSchema = z.strictObject({
+  type: z.literal("run.stopped"),
+  eventSequence: z.int().nonnegative(),
+  run: assistantRunStateSchema,
+  message: messageSchema,
+})
+export const assistantMessageEventSchema = z.discriminatedUnion("type", [
+  runSnapshotEventSchema,
+  runDeltaEventSchema,
+  runCompletedEventSchema,
+  runFailedEventSchema,
+  runStoppedEventSchema,
+])
+
+export type ProjectDTO = z.infer
+export type ThreadDTO = z.infer
+export type MessageDTO = z.infer
+export type AssistantRunStateDTO = z.infer
+export type AssistantMessageEvent = z.infer
+export type ApiErrorCode = z.infer
+export type UserMessageParts = z.infer
+export function apiResponseSchema(data: T) {
+  return z.strictObject({ data })
+}
diff --git a/lib/thread-chat/api/json-transport.ts b/lib/thread-chat/api/json-transport.ts
new file mode 100644
index 00000000..5814bdae
--- /dev/null
+++ b/lib/thread-chat/api/json-transport.ts
@@ -0,0 +1,269 @@
+import { z } from "zod"
+import { ThreadChatClientError } from "./client-error"
+import type { ThreadChatApiCapabilities } from "./capabilities"
+import {
+  apiErrorResponseSchema,
+  apiResponseSchema,
+  artifactSchema,
+  assistantMessageEventSchema,
+  assistantRunStateSchema,
+  creationBundleSchema,
+  feedbackSchema,
+  listProjectsResultSchema,
+  messageCreationBundleSchema,
+  projectBootstrapSchema,
+  projectSchema,
+  replacementBundleSchema,
+  threadMessageBundleSchema,
+  threadSchema,
+} from "./contracts"
+import { threadChatApiRoutes } from "./routes"
+
+type RequestOptions = {
+  method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"
+  body?: unknown
+  signal?: AbortSignal
+  schema: z.ZodType
+}
+
+export class JsonThreadChatTransport implements ThreadChatApiCapabilities {
+  constructor(
+    private readonly fetcher: typeof fetch = globalThis.fetch.bind(globalThis),
+    private readonly baseUrl = ""
+  ) {}
+
+  listProjects(input: Parameters[0] = {}) {
+    const query = new URLSearchParams()
+    if (input.status) query.set("status", input.status)
+    if (input.limit !== undefined) query.set("limit", String(input.limit))
+    if (input.cursor) query.set("cursor", input.cursor)
+    return this.request(
+      `${threadChatApiRoutes.projects()}${query.size ? `?${query}` : ""}`,
+      { schema: listProjectsResultSchema, signal: input.signal }
+    )
+  }
+
+  createProject(input: Parameters[0]) {
+    return this.request(threadChatApiRoutes.projects(), {
+      method: "POST",
+      body: {
+        initialMessage: { parts: input.parts },
+        ...(input.requestedModelId
+          ? { requestedModelId: input.requestedModelId }
+          : {}),
+      },
+      schema: creationBundleSchema,
+      signal: input.signal,
+    })
+  }
+
+  bootstrapProject(projectId: string, signal?: AbortSignal) {
+    return this.request(threadChatApiRoutes.projectBootstrap(projectId), {
+      schema: projectBootstrapSchema,
+      signal,
+    })
+  }
+
+  patchProject(input: Parameters[0]) {
+    const { projectId, ...body } = input
+    return this.request(threadChatApiRoutes.project(projectId), {
+      method: "PATCH",
+      body,
+      schema: projectSchema,
+    })
+  }
+
+  setProjectArchived(projectId: string, archived: boolean) {
+    return this.request(threadChatApiRoutes.projectArchive(projectId, archived), {
+      method: "POST",
+      schema: projectSchema,
+    })
+  }
+
+  async deleteProject(projectId: string): Promise {
+    await this.requestEmpty(threadChatApiRoutes.project(projectId), "DELETE")
+  }
+
+  loadThreadMessages(input: Parameters[0]) {
+    const query = new URLSearchParams()
+    if (input.limit !== undefined) query.set("limit", String(input.limit))
+    if (input.beforeSequence !== undefined)
+      query.set("beforeSequence", String(input.beforeSequence))
+    return this.request(
+      `${threadChatApiRoutes.threadMessages(input.threadId)}${query.size ? `?${query}` : ""}`,
+      { schema: threadMessageBundleSchema, signal: input.signal }
+    )
+  }
+
+  patchThread(threadId: string, customTitle: string | null) {
+    return this.request(threadChatApiRoutes.thread(threadId), {
+      method: "PATCH",
+      body: { customTitle },
+      schema: threadSchema,
+    })
+  }
+
+  setThreadArchived(threadId: string, archived: boolean) {
+    return this.request(threadChatApiRoutes.threadArchive(threadId, archived), {
+      method: "POST",
+      schema: threadSchema,
+    })
+  }
+
+  sendMessage(input: Parameters[0]) {
+    return this.request(threadChatApiRoutes.threadMessages(input.threadId), {
+      method: "POST",
+      body: {
+        parts: input.parts,
+        ...(input.requestedModelId
+          ? { requestedModelId: input.requestedModelId }
+          : {}),
+      },
+      schema: messageCreationBundleSchema,
+    })
+  }
+
+  forkThread(
+    threadId: string,
+    input: Parameters[1]
+  ) {
+    return this.request(threadChatApiRoutes.threadForks(threadId), {
+      method: "POST",
+      body: input,
+      schema: z.strictObject({ thread: threadSchema }),
+    })
+  }
+
+  editMessage(input: Parameters[0]) {
+    return this.request(threadChatApiRoutes.messageEdits(input.messageId), {
+      method: "POST",
+      body: {
+        parts: input.parts,
+        ...(input.requestedModelId
+          ? { requestedModelId: input.requestedModelId }
+          : {}),
+      },
+      schema: replacementBundleSchema,
+    })
+  }
+
+  regenerateMessage(
+    input: Parameters[0]
+  ) {
+    return this.request(
+      threadChatApiRoutes.messageRegenerations(input.messageId),
+      {
+        method: "POST",
+        body: input.requestedModelId
+          ? { requestedModelId: input.requestedModelId }
+          : {},
+        schema: replacementBundleSchema,
+      }
+    )
+  }
+
+  setFeedback(
+    messageId: string,
+    value: "positive" | "negative" | null
+  ) {
+    return this.request(threadChatApiRoutes.messageFeedback(messageId), {
+      method: "PUT",
+      body: { value },
+      schema: feedbackSchema,
+    })
+  }
+
+  loadArtifact(artifactId: string, signal?: AbortSignal) {
+    return this.request(threadChatApiRoutes.artifact(artifactId), {
+      schema: artifactSchema,
+      signal,
+    })
+  }
+
+  async *subscribeAssistantEvents(
+    input: Parameters[0]
+  ) {
+    const query = new URLSearchParams()
+    if (input.afterEventSequence !== undefined)
+      query.set("afterEventSequence", String(input.afterEventSequence))
+    const response = await this.fetcher(
+      this.url(
+        `${threadChatApiRoutes.assistantEvents(input.assistantMessageId)}${query.size ? `?${query}` : ""}`
+      ),
+      {
+        headers: { Accept: "text/event-stream" },
+        credentials: "same-origin",
+        signal: input.signal,
+      }
+    )
+    await this.assertSuccess(response)
+    if (!response.body) throw new Error("SSE response body is missing.")
+    const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
+    let buffer = ""
+    try {
+      while (true) {
+        const { value, done } = await reader.read()
+        if (done) break
+        buffer += value
+        let boundary = buffer.indexOf("\n\n")
+        while (boundary >= 0) {
+          const block = buffer.slice(0, boundary)
+          buffer = buffer.slice(boundary + 2)
+          const data = block
+            .split("\n")
+            .filter((line) => line.startsWith("data:"))
+            .map((line) => line.slice(5).trimStart())
+            .join("\n")
+          if (data) yield assistantMessageEventSchema.parse(JSON.parse(data))
+          boundary = buffer.indexOf("\n\n")
+        }
+      }
+    } finally {
+      reader.releaseLock()
+    }
+  }
+
+  stopAssistant(assistantMessageId: string) {
+    return this.request(threadChatApiRoutes.assistantStop(assistantMessageId), {
+      method: "POST",
+      schema: assistantRunStateSchema,
+    })
+  }
+
+  private async request(path: string, options: RequestOptions): Promise {
+    const response = await this.fetcher(this.url(path), {
+      method: options.method ?? "GET",
+      headers: options.body === undefined ? undefined : { "Content-Type": "application/json" },
+      body: options.body === undefined ? undefined : JSON.stringify(options.body),
+      credentials: "same-origin",
+      signal: options.signal,
+    })
+    await this.assertSuccess(response)
+    const envelope = apiResponseSchema(options.schema).parse(await response.json())
+    return envelope.data
+  }
+
+  private async requestEmpty(path: string, method: "DELETE"): Promise {
+    const response = await this.fetcher(this.url(path), {
+      method,
+      credentials: "same-origin",
+    })
+    await this.assertSuccess(response)
+  }
+
+  private async assertSuccess(response: Response): Promise {
+    if (response.ok) return
+    const parsed = apiErrorResponseSchema.safeParse(await response.json())
+    if (!parsed.success) throw new Error(`Invalid API error response (${response.status}).`)
+    throw new ThreadChatClientError(
+      parsed.data.error.code,
+      parsed.data.error.message,
+      response.status,
+      parsed.data.error.details
+    )
+  }
+
+  private url(path: string): string {
+    return `${this.baseUrl}${path}`
+  }
+}
diff --git a/lib/thread-chat/api/routes.ts b/lib/thread-chat/api/routes.ts
new file mode 100644
index 00000000..ebb8301c
--- /dev/null
+++ b/lib/thread-chat/api/routes.ts
@@ -0,0 +1,34 @@
+export const threadChatRoutes = {
+  newProject: () => "/thread-chat/new",
+  project: (projectId: string) => `/thread-chat/${encodeURIComponent(projectId)}`,
+} as const
+
+export const threadChatApiRoutes = {
+  projects: () => "/api/v1/projects",
+  project: (projectId: string) =>
+    `/api/v1/projects/${encodeURIComponent(projectId)}`,
+  projectBootstrap: (projectId: string) =>
+    `/api/v1/projects/${encodeURIComponent(projectId)}/bootstrap`,
+  projectArchive: (projectId: string, archived: boolean) =>
+    `/api/v1/projects/${encodeURIComponent(projectId)}/${archived ? "archive" : "unarchive"}`,
+  thread: (threadId: string) =>
+    `/api/v1/threads/${encodeURIComponent(threadId)}`,
+  threadMessages: (threadId: string) =>
+    `/api/v1/threads/${encodeURIComponent(threadId)}/messages`,
+  threadArchive: (threadId: string, archived: boolean) =>
+    `/api/v1/threads/${encodeURIComponent(threadId)}/${archived ? "archive" : "unarchive"}`,
+  threadForks: (threadId: string) =>
+    `/api/v1/threads/${encodeURIComponent(threadId)}/forks`,
+  messageEdits: (messageId: string) =>
+    `/api/v1/messages/${encodeURIComponent(messageId)}/edits`,
+  messageRegenerations: (messageId: string) =>
+    `/api/v1/messages/${encodeURIComponent(messageId)}/regenerations`,
+  messageFeedback: (messageId: string) =>
+    `/api/v1/messages/${encodeURIComponent(messageId)}/feedback`,
+  assistantEvents: (messageId: string) =>
+    `/api/v1/assistant-messages/${encodeURIComponent(messageId)}/events`,
+  assistantStop: (messageId: string) =>
+    `/api/v1/assistant-messages/${encodeURIComponent(messageId)}/stop`,
+  artifact: (artifactId: string) =>
+    `/api/v1/artifacts/${encodeURIComponent(artifactId)}`,
+} as const
diff --git a/lib/thread-chat/api/server/cursor.ts b/lib/thread-chat/api/server/cursor.ts
new file mode 100644
index 00000000..cee044de
--- /dev/null
+++ b/lib/thread-chat/api/server/cursor.ts
@@ -0,0 +1,45 @@
+import { createHmac, timingSafeEqual } from "node:crypto"
+import { z } from "zod"
+import { ThreadChatApiError } from "./errors"
+
+const cursorPayloadSchema = z.strictObject({
+  actorId: z.string(),
+  status: z.enum(["active", "archived", "all"]),
+  updatedAt: z.iso.datetime({ offset: true }),
+  id: z.uuid(),
+})
+
+function secret(): string {
+  return process.env.BETTER_AUTH_SECRET ?? "thread-chat-local-cursor-secret"
+}
+
+function signature(payload: string): Buffer {
+  return createHmac("sha256", secret()).update(payload).digest()
+}
+
+export function encodeProjectCursor(input: z.infer) {
+  const payload = Buffer.from(JSON.stringify(input)).toString("base64url")
+  return `${payload}.${signature(payload).toString("base64url")}`
+}
+
+export function decodeProjectCursor(
+  cursor: string,
+  binding: { actorId: string; status: "active" | "archived" | "all" }
+) {
+  try {
+    const [payload, encodedSignature, extra] = cursor.split(".")
+    if (!payload || !encodedSignature || extra) throw new Error("shape")
+    const actual = Buffer.from(encodedSignature, "base64url")
+    const expected = signature(payload)
+    if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
+      throw new Error("signature")
+    const decoded = cursorPayloadSchema.parse(
+      JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
+    )
+    if (decoded.actorId !== binding.actorId || decoded.status !== binding.status)
+      throw new Error("binding")
+    return { updatedAt: new Date(decoded.updatedAt), id: decoded.id }
+  } catch {
+    throw new ThreadChatApiError("invalid_cursor", 400, "Invalid cursor.")
+  }
+}
diff --git a/lib/thread-chat/api/server/errors.ts b/lib/thread-chat/api/server/errors.ts
new file mode 100644
index 00000000..2ee542f8
--- /dev/null
+++ b/lib/thread-chat/api/server/errors.ts
@@ -0,0 +1,119 @@
+import { ZodError } from "zod"
+import { ThreadChatDomainError } from "../../domain/domain-error"
+import type { ApiErrorCode } from "../contracts"
+
+export class ThreadChatApiError extends Error {
+  constructor(
+    readonly code: ApiErrorCode,
+    readonly status: number,
+    message: string,
+    readonly details?: unknown
+  ) {
+    super(message)
+    this.name = "ThreadChatApiError"
+  }
+}
+
+const domainErrorMap: Partial<
+  Record
+> = {
+  project_owner_mismatch: { code: "forbidden", status: 403 },
+  thread_archived: { code: "thread_archived", status: 409 },
+  thread_generation_in_progress: {
+    code: "thread_generation_in_progress",
+    status: 409,
+  },
+  root_thread_title_owned_by_project: {
+    code: "root_thread_title_owned_by_project",
+    status: 422,
+  },
+  root_thread_archive_owned_by_project: {
+    code: "root_thread_archive_owned_by_project",
+    status: 422,
+  },
+  message_not_editable: { code: "message_not_editable", status: 422 },
+  message_not_regeneratable: {
+    code: "message_not_regeneratable",
+    status: 422,
+  },
+  feedback_not_eligible: {
+    code: "message_not_feedback_eligible",
+    status: 422,
+  },
+  fork_required: { code: "fork_required", status: 422 },
+  fork_anchor_mismatch: { code: "fork_anchor_mismatch", status: 422 },
+  message_not_fork_eligible: {
+    code: "fork_source_not_finalized",
+    status: 422,
+  },
+  message_not_finalized: {
+    code: "fork_source_not_finalized",
+    status: 422,
+  },
+  message_superseded: { code: "fork_source_superseded", status: 422 },
+  thread_source_invalid: {
+    code: "fork_source_thread_mismatch",
+    status: 422,
+  },
+  thread_not_found: { code: "thread_not_found", status: 404 },
+  message_not_found: { code: "message_not_found", status: 404 },
+  source_message_not_found: {
+    code: "source_message_not_found",
+    status: 404,
+  },
+  assistant_message_not_found: {
+    code: "assistant_message_not_found",
+    status: 404,
+  },
+  message_run_not_found: { code: "message_run_not_found", status: 404 },
+}
+
+export function errorResponse(
+  error: unknown,
+  fallbackNotFound: ApiErrorCode = "internal_error"
+): Response {
+  if (error instanceof ThreadChatApiError) {
+    return Response.json(
+      {
+        error: {
+          code: error.code,
+          message: error.message,
+          ...(error.details === undefined ? {} : { details: error.details }),
+        },
+      },
+      { status: error.status }
+    )
+  }
+  if (error instanceof ZodError) {
+    return Response.json(
+      {
+        error: {
+          code: "validation_error",
+          message: "Request validation failed.",
+          details: error.issues,
+        },
+      },
+      { status: 400 }
+    )
+  }
+  if (error instanceof ThreadChatDomainError) {
+    if (error.code === "entity_not_found") {
+      return Response.json(
+        { error: { code: fallbackNotFound, message: error.message } },
+        { status: 404 }
+      )
+    }
+    const mapped = domainErrorMap[error.code]
+    if (mapped) {
+      return Response.json(
+        { error: { code: mapped.code, message: error.message } },
+        { status: mapped.status }
+      )
+    }
+  }
+  console.error("[thread-chat-api] unhandled error", error)
+  return Response.json(
+    { error: { code: "internal_error", message: "Internal server error." } },
+    { status: 500 }
+  )
+}
diff --git a/lib/thread-chat/api/server/handlers.ts b/lib/thread-chat/api/server/handlers.ts
new file mode 100644
index 00000000..0be2df57
--- /dev/null
+++ b/lib/thread-chat/api/server/handlers.ts
@@ -0,0 +1,435 @@
+import { z } from "zod"
+import type { UIMessage } from "ai"
+import {
+  assistantEventsQuerySchema,
+  createProjectRequestSchema,
+  editMessageRequestSchema,
+  forkThreadRequestSchema,
+  idSchema,
+  listProjectsQuerySchema,
+  patchProjectRequestSchema,
+  patchThreadRequestSchema,
+  putFeedbackRequestSchema,
+  regenerateMessageRequestSchema,
+  sendMessageRequestSchema,
+  threadMessagesQuerySchema,
+} from "../contracts"
+import { decodeProjectCursor, encodeProjectCursor } from "./cursor"
+import { ThreadChatApiError } from "./errors"
+import { jsonData, readJson } from "./http"
+import {
+  toArtifactDTO,
+  toAssistantRunStateDTO,
+  toMessageDTO,
+  toProjectDTO,
+  toProjectSummaryDTO,
+  toThreadDTO,
+  toThreadMessageBundleDTO,
+} from "./mappers"
+import { threadChatServer } from "./runtime"
+
+function queryObject(request: Request): Record {
+  const entries = [...new URL(request.url).searchParams.entries()]
+  if (new Set(entries.map(([key]) => key)).size !== entries.length)
+    throw new ThreadChatApiError("invalid_query", 400, "Duplicate query parameter.")
+  return Object.fromEntries(entries)
+}
+
+function validatePathIds(...ids: string[]): void {
+  for (const id of ids) idSchema.parse(id)
+}
+
+export async function listProjects(actorId: string, request: Request) {
+  let query: z.infer
+  try {
+    query = listProjectsQuerySchema.parse(queryObject(request))
+  } catch (error) {
+    if (error instanceof ThreadChatApiError) throw error
+    throw new ThreadChatApiError("invalid_query", 400, "Invalid project query.")
+  }
+  const before = query.cursor
+    ? decodeProjectCursor(query.cursor, {
+        actorId,
+        status: query.status,
+      })
+    : undefined
+  const projects = await threadChatServer.queries.listProjects({
+    actorId,
+    status: query.status,
+    limit: query.limit + 1,
+    before,
+  })
+  const hasNext = projects.length > query.limit
+  const items = projects.slice(0, query.limit)
+  const last = items.at(-1)
+  return jsonData({
+    items: items.map(toProjectSummaryDTO),
+    nextCursor:
+      hasNext && last
+        ? encodeProjectCursor({
+            actorId,
+            status: query.status,
+            updatedAt: last.updatedAt.toISOString(),
+            id: last.id,
+          })
+        : null,
+  })
+}
+
+export async function createProject(actorId: string, request: Request) {
+  const body = createProjectRequestSchema.parse(await readJson(request))
+  const result = await threadChatServer.commands().createProject({
+    actorId,
+    parts: body.initialMessage.parts as UIMessage["parts"],
+    requestedModelId: body.requestedModelId,
+  })
+  return jsonData(
+    {
+      project: toProjectDTO(result.project),
+      rootThread: toThreadDTO(result.rootThread),
+      artifactSummary: result.artifactSummary,
+      userMessage: toMessageDTO(result.userMessage),
+      assistantMessage: toMessageDTO(result.assistantMessage),
+      assistantRun: toAssistantRunStateDTO(result.assistantRun),
+    },
+    201
+  )
+}
+
+export async function bootstrapProject(actorId: string, projectId: string) {
+  validatePathIds(projectId)
+  const result = await threadChatServer.queries.projectBootstrap({
+    actorId,
+    projectId,
+  })
+  return jsonData({
+    project: toProjectDTO(result.project),
+    threadTopology: result.threadTopology.map(toThreadDTO),
+    artifactSummary: result.artifactSummary,
+    initialThread: toThreadMessageBundleDTO(result.initialThread),
+  })
+}
+
+export async function patchProject(
+  actorId: string,
+  projectId: string,
+  request: Request
+) {
+  validatePathIds(projectId)
+  const patch = patchProjectRequestSchema.parse(await readJson(request))
+  const project = await threadChatServer.commands().patchProject({
+    actorId,
+    projectId,
+    patch,
+  })
+  return jsonData(toProjectDTO(project))
+}
+
+export async function setProjectArchived(
+  actorId: string,
+  projectId: string,
+  archived: boolean
+) {
+  validatePathIds(projectId)
+  const project = await threadChatServer.commands().setProjectArchived({
+    actorId,
+    projectId,
+    archived,
+  })
+  return jsonData(toProjectDTO(project))
+}
+
+export async function deleteProject(actorId: string, projectId: string) {
+  validatePathIds(projectId)
+  await threadChatServer.commands().deleteProject({ actorId, projectId })
+  return new Response(null, { status: 204 })
+}
+
+export async function loadThreadMessages(
+  actorId: string,
+  threadId: string,
+  request: Request
+) {
+  validatePathIds(threadId)
+  let query: z.infer
+  try {
+    query = threadMessagesQuerySchema.parse(queryObject(request))
+  } catch {
+    throw new ThreadChatApiError("invalid_query", 400, "Invalid message query.")
+  }
+  const bundle = await threadChatServer.queries.threadMessages({
+    actorId,
+    threadId,
+    ...query,
+  })
+  return jsonData(toThreadMessageBundleDTO(bundle))
+}
+
+export async function sendMessage(
+  actorId: string,
+  threadId: string,
+  request: Request
+) {
+  validatePathIds(threadId)
+  const body = sendMessageRequestSchema.parse(await readJson(request))
+  const result = await threadChatServer.commands().sendMessage({
+    actorId,
+    threadId,
+    parts: body.parts as UIMessage["parts"],
+    requestedModelId: body.requestedModelId,
+  })
+  return jsonData(
+    {
+      userMessage: toMessageDTO(result.userMessage),
+      assistantMessage: toMessageDTO(result.assistantMessage),
+      assistantRun: toAssistantRunStateDTO(result.assistantRun),
+    },
+    201
+  )
+}
+
+export async function patchThread(
+  actorId: string,
+  threadId: string,
+  request: Request
+) {
+  validatePathIds(threadId)
+  const body = patchThreadRequestSchema.parse(await readJson(request))
+  const thread = await threadChatServer.commands().patchBranch({
+    actorId,
+    threadId,
+    customTitle: body.customTitle,
+  })
+  return jsonData(toThreadDTO(thread))
+}
+
+export async function setThreadArchived(
+  actorId: string,
+  threadId: string,
+  archived: boolean
+) {
+  validatePathIds(threadId)
+  const thread = await threadChatServer.commands().patchBranch({
+    actorId,
+    threadId,
+    archived,
+  })
+  return jsonData(toThreadDTO(thread))
+}
+
+export async function forkThread(
+  actorId: string,
+  threadId: string,
+  request: Request
+) {
+  validatePathIds(threadId)
+  const body = forkThreadRequestSchema.parse(await readJson(request))
+  const thread = await threadChatServer.commands().forkThread({
+    actorId,
+    sourceThreadId: threadId,
+    sourceMessageId: body.sourceMessageId,
+    anchor: body.anchor,
+  })
+  return jsonData({ thread: toThreadDTO(thread) }, 201)
+}
+
+export async function editMessage(
+  actorId: string,
+  messageId: string,
+  request: Request
+) {
+  validatePathIds(messageId)
+  const body = editMessageRequestSchema.parse(await readJson(request))
+  const result = await threadChatServer.commands().editLastUser({
+    actorId,
+    sourceUserMessageId: messageId,
+    parts: body.parts as UIMessage["parts"],
+    requestedModelId: body.requestedModelId,
+  })
+  return jsonData(
+    {
+      supersededMessageIds: result.supersededMessageIds,
+      createdMessages: result.createdMessages.map(toMessageDTO),
+      assistantRun: toAssistantRunStateDTO(result.assistantRun),
+    },
+    201
+  )
+}
+
+export async function regenerateMessage(
+  actorId: string,
+  messageId: string,
+  request: Request
+) {
+  validatePathIds(messageId)
+  const body = regenerateMessageRequestSchema.parse(await readJson(request))
+  const result = await threadChatServer.commands().regenerate({
+    actorId,
+    sourceAssistantMessageId: messageId,
+    requestedModelId: body.requestedModelId,
+  })
+  return jsonData(
+    {
+      supersededMessageIds: result.supersededMessageIds,
+      createdMessages: result.createdMessages.map(toMessageDTO),
+      assistantRun: toAssistantRunStateDTO(result.assistantRun),
+    },
+    201
+  )
+}
+
+export async function setFeedback(
+  actorId: string,
+  messageId: string,
+  request: Request
+) {
+  validatePathIds(messageId)
+  const body = putFeedbackRequestSchema.parse(await readJson(request))
+  const feedback = await threadChatServer.commands().setFeedback({
+    actorId,
+    assistantMessageId: messageId,
+    feedback: body.value,
+  })
+  return jsonData({
+    messageId: feedback.messageId,
+    value: feedback.value,
+    updatedAt: feedback.updatedAt.toISOString(),
+  })
+}
+
+export async function loadArtifact(actorId: string, artifactId: string) {
+  validatePathIds(artifactId)
+  const artifact = await threadChatServer.queries.artifactById({
+    actorId,
+    artifactId,
+  })
+  return jsonData(toArtifactDTO(artifact))
+}
+
+export async function stopAssistant(actorId: string, assistantMessageId: string) {
+  validatePathIds(assistantMessageId)
+  const run = await threadChatServer.runner.requestStop({
+    actorId,
+    assistantMessageId,
+  })
+  return jsonData(toAssistantRunStateDTO(run))
+}
+
+export async function assistantEvents(
+  actorId: string,
+  assistantMessageId: string,
+  request: Request
+) {
+  validatePathIds(assistantMessageId)
+  let query: z.infer
+  try {
+    query = assistantEventsQuerySchema.parse(queryObject(request))
+  } catch {
+    throw new ThreadChatApiError(
+      "invalid_query",
+      400,
+      "Invalid event query."
+    )
+  }
+  const initial = await threadChatServer.queries.assistantSnapshot({
+    actorId,
+    assistantMessageId,
+  })
+  if (query.afterEventSequence > initial.run.eventSequence)
+    throw new ThreadChatApiError(
+      "invalid_event_cursor",
+      409,
+      "Event cursor is ahead of the server cursor."
+    )
+
+  const encoder = new TextEncoder()
+  let cancelled = false
+  const encodeEvent = (event: unknown) =>
+    encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
+  const stream = new ReadableStream({
+    async start(controller) {
+      let snapshot = initial
+      let cursor = snapshot.run.eventSequence
+      controller.enqueue(
+        encodeEvent({
+          type: "run.snapshot",
+          cursor,
+          run: toAssistantRunStateDTO(snapshot.run),
+          message: toMessageDTO(snapshot.message),
+          artifactSummary: snapshot.artifactSummary,
+        })
+      )
+      if (["completed", "failed", "stopped"].includes(snapshot.run.status)) {
+        controller.close()
+        return
+      }
+      while (!cancelled && !request.signal.aborted) {
+        await new Promise((resolve) => setTimeout(resolve, 100))
+        snapshot = await threadChatServer.queries.assistantSnapshot({
+          actorId,
+          assistantMessageId,
+        })
+        if (snapshot.run.eventSequence <= cursor) continue
+        cursor = snapshot.run.eventSequence
+        if (snapshot.run.status === "completed") {
+          controller.enqueue(
+            encodeEvent({
+              type: "run.completed",
+              eventSequence: cursor,
+              run: toAssistantRunStateDTO(snapshot.run),
+              message: toMessageDTO(snapshot.message),
+              artifactSummary: snapshot.artifactSummary,
+            })
+          )
+          controller.close()
+          return
+        }
+        if (snapshot.run.status === "failed") {
+          controller.enqueue(
+            encodeEvent({
+              type: "run.failed",
+              eventSequence: cursor,
+              run: toAssistantRunStateDTO(snapshot.run),
+            })
+          )
+          controller.close()
+          return
+        }
+        if (snapshot.run.status === "stopped") {
+          controller.enqueue(
+            encodeEvent({
+              type: "run.stopped",
+              eventSequence: cursor,
+              run: toAssistantRunStateDTO(snapshot.run),
+              message: toMessageDTO(snapshot.message),
+            })
+          )
+          controller.close()
+          return
+        }
+        controller.enqueue(
+          encodeEvent({
+            type: "run.delta",
+            eventSequence: cursor,
+            chunk: {
+              type: "data-run-checkpoint",
+              id: assistantMessageId,
+              data: { checkpointParts: snapshot.run.checkpointParts },
+            },
+          })
+        )
+      }
+      if (!cancelled) controller.close()
+    },
+    cancel() {
+      cancelled = true
+    },
+  })
+  return new Response(stream, {
+    headers: {
+      "Cache-Control": "no-cache, no-transform",
+      "Content-Type": "text/event-stream; charset=utf-8",
+      Connection: "keep-alive",
+    },
+  })
+}
diff --git a/lib/thread-chat/api/server/http.ts b/lib/thread-chat/api/server/http.ts
new file mode 100644
index 00000000..2801a31c
--- /dev/null
+++ b/lib/thread-chat/api/server/http.ts
@@ -0,0 +1,37 @@
+import { getCurrentUserId } from "@/lib/auth/server"
+import type { ApiErrorCode } from "../contracts"
+import { errorResponse, ThreadChatApiError } from "./errors"
+
+export const jsonData = (data: unknown, status = 200) =>
+  Response.json({ data }, { status })
+
+export async function readJson(request: Request): Promise {
+  try {
+    return await request.json()
+  } catch {
+    throw new ThreadChatApiError(
+      "validation_error",
+      400,
+      "Request body must be valid JSON."
+    )
+  }
+}
+
+export async function withActor(
+  action: (actorId: string) => Promise,
+  fallbackNotFound: ApiErrorCode = "internal_error",
+  resolveActor: () => Promise = getCurrentUserId
+): Promise {
+  try {
+    const actorId = await resolveActor()
+    if (!actorId)
+      throw new ThreadChatApiError(
+        "unauthorized",
+        401,
+        "Authentication required."
+      )
+    return await action(actorId)
+  } catch (error) {
+    return errorResponse(error, fallbackNotFound)
+  }
+}
diff --git a/lib/thread-chat/api/server/mappers.ts b/lib/thread-chat/api/server/mappers.ts
new file mode 100644
index 00000000..541f8ad4
--- /dev/null
+++ b/lib/thread-chat/api/server/mappers.ts
@@ -0,0 +1,105 @@
+import type { Artifact } from "../../domain/artifact"
+import type { Message } from "../../domain/message"
+import type { MessageRun } from "../../domain/message-run"
+import type { Project } from "../../domain/project"
+import type { Thread } from "../../domain/thread"
+import type { ProjectSummary } from "../../application/application-types"
+
+const iso = (value: Date | null) => value?.toISOString() ?? null
+
+export function toProjectDTO(project: Project) {
+  return {
+    id: project.id,
+    ownerUserId: project.ownerUserId,
+    autoTitle: project.autoTitle,
+    customTitle: project.customTitle,
+    target: project.target,
+    instruction: project.instruction,
+    archivedAt: iso(project.archivedAt),
+    createdAt: project.createdAt.toISOString(),
+    updatedAt: project.updatedAt.toISOString(),
+  }
+}
+
+export function toProjectSummaryDTO(project: ProjectSummary) {
+  return {
+    id: project.id,
+    displayTitle: project.displayTitle,
+    archivedAt: iso(project.archivedAt),
+    updatedAt: project.updatedAt.toISOString(),
+    threadCount: project.threadCount,
+    messageCount: project.messageCount,
+  }
+}
+
+export function toThreadDTO(thread: Thread) {
+  return {
+    id: thread.id,
+    projectId: thread.projectId,
+    parentThreadId: thread.parentThreadId,
+    sourceMessageId: thread.sourceMessageId,
+    forkSourceSnapshot: thread.forkSourceSnapshot,
+    autoTitle: thread.autoTitle,
+    customTitle: thread.customTitle,
+    archivedAt: iso(thread.archivedAt),
+    createdAt: thread.createdAt.toISOString(),
+    updatedAt: thread.updatedAt.toISOString(),
+  }
+}
+
+export function toMessageDTO(message: Message) {
+  return {
+    id: message.id,
+    threadId: message.threadId,
+    sequence: message.sequence,
+    role: message.role,
+    parts: message.parts,
+    replacesMessageId: message.replacesMessageId,
+    supersededAt: iso(message.supersededAt),
+    finalizedAt: iso(message.finalizedAt),
+    createdAt: message.createdAt.toISOString(),
+  }
+}
+
+export function toAssistantRunStateDTO(run: MessageRun) {
+  return {
+    assistantMessageId: run.assistantMessageId,
+    status: run.status,
+    modelId: run.modelId,
+    checkpointParts: run.checkpointParts,
+    eventSequence: run.eventSequence,
+    error:
+      run.errorCode && run.errorMessage
+        ? { code: run.errorCode, message: run.errorMessage }
+        : null,
+    stopRequestedAt: iso(run.stopRequestedAt),
+    finishedAt: iso(run.finishedAt),
+  }
+}
+
+export function toArtifactDTO(artifact: Artifact) {
+  return {
+    id: artifact.id,
+    projectId: artifact.projectId,
+    sourceMessageId: artifact.sourceMessageId,
+    kind: artifact.kind,
+    title: artifact.title,
+    content: artifact.content,
+    createdAt: artifact.createdAt.toISOString(),
+  }
+}
+
+export function toThreadMessageBundleDTO(bundle: {
+  threadId: string
+  messages: Message[]
+  assistantRuns: MessageRun[]
+  hasOlderMessages: boolean
+  oldestReturnedSequence: number | null
+  newestReturnedSequence: number | null
+}) {
+  return {
+    ...bundle,
+    messages: bundle.messages.map(toMessageDTO),
+    assistantRuns: bundle.assistantRuns.map(toAssistantRunStateDTO),
+  }
+}
diff --git a/lib/thread-chat/api/server/runtime.ts b/lib/thread-chat/api/server/runtime.ts
new file mode 100644
index 00000000..05c60602
--- /dev/null
+++ b/lib/thread-chat/api/server/runtime.ts
@@ -0,0 +1,55 @@
+import { after } from "next/server"
+import { randomUUID } from "node:crypto"
+import {
+  DEFAULT_THREAD_CHAT_MODEL_ID,
+  isThreadChatModelId,
+} from "@/constants/model"
+import { dbClient } from "@/lib/db"
+import { MessageRunner } from "../../application/message-runner"
+import { ThreadChatCommands } from "../../application/thread-chat-commands"
+import { ThreadChatQueries } from "../../application/thread-chat-queries"
+import { AiSdkRuntime } from "../../infrastructure/ai-sdk-runtime"
+import {
+  IsolatedTestAiRuntime,
+  usesIsolatedTestAiRuntime,
+} from "../../infrastructure/isolated-test-ai-runtime"
+import { ThreadChatUnitOfWork } from "../../infrastructure/repositories"
+import { ThreadChatApiError } from "./errors"
+
+const unitOfWork = new ThreadChatUnitOfWork(dbClient)
+const aiRuntime = usesIsolatedTestAiRuntime({
+  databaseUrl: process.env.DATABASE_URL,
+  nodeEnv: process.env.NODE_ENV,
+})
+  ? new IsolatedTestAiRuntime()
+  : new AiSdkRuntime()
+const runner = new MessageRunner(dbClient, unitOfWork, aiRuntime, {
+  generateId: randomUUID,
+  now: () => new Date(),
+})
+
+export const threadChatServer = {
+  queries: new ThreadChatQueries(dbClient),
+  runner,
+  commands() {
+    return new ThreadChatCommands(unitOfWork, {
+      generateId: randomUUID,
+      now: () => new Date(),
+      resolveModelId(requestedModelId) {
+        if (requestedModelId === undefined) return DEFAULT_THREAD_CHAT_MODEL_ID
+        if (isThreadChatModelId(requestedModelId)) return requestedModelId
+        throw new ThreadChatApiError(
+          "model_not_available",
+          422,
+          "Requested model is not available for ThreadChat."
+        )
+      },
+      wakeRunAfterCommit(messageRunId) {
+        after(() => runner.execute(messageRunId))
+      },
+      onWakeError(error) {
+        console.error("[thread-chat-api] run wake failed", error)
+      },
+    })
+  },
+}
diff --git a/lib/thread-chat/application/application-types.ts b/lib/thread-chat/application/application-types.ts
new file mode 100644
index 00000000..a640a41c
--- /dev/null
+++ b/lib/thread-chat/application/application-types.ts
@@ -0,0 +1,74 @@
+import type { UIMessage } from "ai"
+import type { Artifact } from "../domain/artifact"
+import type { Message } from "../domain/message"
+import type { MessageRun } from "../domain/message-run"
+import type { Project, ProjectTarget } from "../domain/project"
+import type { Thread } from "../domain/thread"
+
+export type IdGenerator = () => string
+
+export type ThreadChatApplicationDependencies = {
+  generateId: IdGenerator
+  now: () => Date
+  resolveModelId: (requestedModelId?: string) => string
+  wakeRunAfterCommit?: (messageRunId: string) => void | Promise
+  onWakeError?: (error: unknown) => void
+}
+
+export type ProjectArtifactSummary = {
+  changeSequence: number
+  total: number
+  byKind: Record
+}
+
+export type ThreadMessageBundle = {
+  threadId: string
+  messages: Message[]
+  assistantRuns: MessageRun[]
+  hasOlderMessages: boolean
+  oldestReturnedSequence: number | null
+  newestReturnedSequence: number | null
+}
+
+export type CreationBundle = {
+  project: Project
+  rootThread: Thread
+  artifactSummary: ProjectArtifactSummary
+  userMessage: Message
+  assistantMessage: Message
+  assistantRun: MessageRun
+}
+
+export type MessageCreationBundle = Pick<
+  CreationBundle,
+  "userMessage" | "assistantMessage" | "assistantRun"
+>
+
+export type ReplacementBundle = {
+  supersededMessageIds: string[]
+  createdMessages: Message[]
+  assistantRun: MessageRun
+}
+
+export type ProjectPatch = {
+  customTitle?: string | null
+  target?: ProjectTarget | null
+  instruction?: string | null
+}
+
+export type UserMessageInput = UIMessage["parts"]
+
+export type ProjectBootstrap = {
+  project: Project
+  threadTopology: Thread[]
+  artifactSummary: ProjectArtifactSummary
+  initialThread: ThreadMessageBundle
+}
+
+export type ProjectSummary = Project & {
+  displayTitle: string
+  threadCount: number
+  messageCount: number
+}
+
+export type ArtifactResult = Artifact
diff --git a/lib/thread-chat/application/compile-thread-chat-messages.ts b/lib/thread-chat/application/compile-thread-chat-messages.ts
deleted file mode 100644
index 417d4e3b..00000000
--- a/lib/thread-chat/application/compile-thread-chat-messages.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { activeMessagePath } from "@/lib/thread-chat/domain/message-graph"
-import { collectInherited } from "@/lib/thread-chat/domain/selectors"
-import type { Message, ThreadTreeState } from "@/lib/thread-chat/domain/types"
-import { INHERITED_CHAR_BUDGET } from "@/constants/thread-chat"
-import { serializeMessageForModel } from "@/lib/thread-chat/application/serialize-message-for-model"
-import {
-  applyInheritedBudget,
-  omittedNoticeText,
-} from "@/lib/thread-chat/application/prompt-policy"
-
-/** 发给 /api/chat 的最小消息形状(结构匹配 AI SDK UIMessage)。 */
-export interface UIMessageLike {
-  id: string
-  role: "user" | "assistant"
-  parts: { type: "text"; text: string }[]
-}
-
-function includable(message: Message, serialized: string | null): boolean {
-  if (message.status === "error") return false
-  return message.role === "user" || serialized !== null
-}
-
-/**
- * 从已提交的服务端树编译模型上下文:当前 Thread 跟 active path,
- * 祖先 Thread 跟 child.forkFromMsgId 的精确不可变来源路径。
- */
-export function compileThreadChatMessages(input: {
-  state: ThreadTreeState
-  threadId: string
-  excludeAssistantMessageId: string
-}): UIMessageLike[] {
-  const thread = input.state.threads[input.threadId]
-  if (!thread) return []
-  const messages: UIMessageLike[] = []
-  const inherited: UIMessageLike[] = []
-
-  for (const message of collectInherited(input.state, thread)) {
-    const text = serializeMessageForModel(input.state, message)
-    if (!includable(message, text) || text === null) continue
-    inherited.push({
-      id: `inh-${message.id}`,
-      role: message.role,
-      parts: [{ type: "text", text }],
-    })
-  }
-  const { kept, omitted } = applyInheritedBudget(
-    inherited,
-    (message) => message.parts[0].text,
-    INHERITED_CHAR_BUDGET
-  )
-  if (omitted > 0)
-    messages.push({
-      id: "inh-omitted",
-      role: "user",
-      parts: [{ type: "text", text: omittedNoticeText(omitted) }],
-    })
-  messages.push(...kept)
-
-  for (const message of activeMessagePath(thread)) {
-    if (message.id === input.excludeAssistantMessageId) continue
-    const text = serializeMessageForModel(input.state, message)
-    if (!includable(message, text) || text === null) continue
-    messages.push({
-      id: message.id,
-      role: message.role,
-      parts: [{ type: "text", text }],
-    })
-  }
-  return messages
-}
diff --git a/lib/thread-chat/application/merge-generation-result.ts b/lib/thread-chat/application/merge-generation-result.ts
deleted file mode 100644
index c187c6f1..00000000
--- a/lib/thread-chat/application/merge-generation-result.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import type {
-  Artifact,
-  Message,
-  ThreadTreeState,
-} from "@/lib/thread-chat/domain/types"
-import type {
-  GenerationResultV1,
-  GenerationTurnSnapshot,
-} from "@/lib/thread-chat/domain/generation"
-
-export type MergeGenerationResultInput = {
-  threadId: string
-  assistantMessageId: string
-  generationId: string
-  turnSnapshot?: GenerationTurnSnapshot
-  result: GenerationResultV1
-}
-
-function messageReferenceCounts(state: ThreadTreeState): Map {
-  const counts = new Map()
-  for (const thread of Object.values(state.threads)) {
-    for (const message of thread.messages) {
-      for (const artifactId of message.artifactIds ?? []) {
-        counts.set(artifactId, (counts.get(artifactId) ?? 0) + 1)
-      }
-    }
-  }
-  return counts
-}
-
-export function restoreTurnSnapshot(
-  thread: ThreadTreeState["threads"][string],
-  snapshot: GenerationTurnSnapshot
-): { userMessage: Message; assistantMessage: Message } | null {
-  let userMessage = thread.messages.find(
-    (message) => message.id === snapshot.userMessage.id
-  )
-  if (!userMessage) {
-    userMessage = {
-      ...structuredClone(snapshot.userMessage),
-      parentMessageId: snapshot.userParentMessageId,
-    }
-    thread.messages.push(userMessage)
-  }
-  if (userMessage.role !== "user") return null
-
-  let assistantMessage = thread.messages.find(
-    (message) => message.id === snapshot.assistantMessage.id
-  )
-  if (!assistantMessage) {
-    assistantMessage = {
-      ...structuredClone(snapshot.assistantMessage),
-      parentMessageId: snapshot.assistantParentMessageId,
-    }
-    thread.messages.push(assistantMessage)
-  }
-  if (assistantMessage.role !== "assistant") return null
-  return { userMessage, assistantMessage }
-}
-
-/**
- * current generation patch 的幂等合并。只覆盖 generation-owned 字段,目标消息的
- * forks/quote 等并发用户编辑保持不变;不同 generationId 的晚到 patch 被 CAS 丢弃。
- */
-export function mergeGenerationResult(
-  state: ThreadTreeState,
-  input: MergeGenerationResultInput
-): ThreadTreeState {
-  if (input.result.generationId !== input.generationId) return state
-  const sourceThread = state.threads[input.threadId]
-  if (!sourceThread) return state
-
-  const next = structuredClone(state)
-  const thread = next.threads[input.threadId]
-  let messageIndex = thread.messages.findIndex(
-    (message) => message.id === input.assistantMessageId
-  )
-  if (messageIndex === -1) {
-    if (!input.turnSnapshot) return state
-    const repaired = restoreTurnSnapshot(thread, input.turnSnapshot)
-    if (!repaired || repaired.assistantMessage.id !== input.assistantMessageId)
-      return state
-    messageIndex = thread.messages.findIndex(
-      (message) => message.id === input.assistantMessageId
-    )
-  }
-  const message = thread.messages[messageIndex]
-  if (!message || message.role !== "assistant") return state
-  if (message.generationId && message.generationId !== input.generationId)
-    return state
-
-  const oldArtifactIds = message.artifactIds ?? []
-  const referenceCounts = messageReferenceCounts(next)
-  const newArtifactIds = [...new Set(input.result.artifactIds)]
-  const newArtifacts: Record = {}
-  for (const id of newArtifactIds) {
-    const artifact = input.result.artifacts[id]
-    if (artifact)
-      newArtifacts[id] = {
-        ...structuredClone(artifact),
-        sourceThreadId: input.threadId,
-        sourceMessageId: input.assistantMessageId,
-      }
-  }
-
-  for (const oldId of oldArtifactIds) {
-    if (!newArtifacts[oldId] && (referenceCounts.get(oldId) ?? 0) <= 1) {
-      delete next.artifacts[oldId]
-    }
-  }
-  Object.assign(next.artifacts, newArtifacts)
-  next.artifactOrder = next.artifactOrder.filter(
-    (id, index, all) =>
-      next.artifacts[id] !== undefined && all.indexOf(id) === index
-  )
-  for (const id of newArtifactIds) {
-    if (newArtifacts[id] && !next.artifactOrder.includes(id)) {
-      next.artifactOrder.push(id)
-    }
-  }
-
-  message.text = input.result.text
-  message.status = input.result.status
-  message.error = input.result.error
-  message.generationId = input.generationId
-  message.backgroundGeneration = undefined
-  message.artifactIds = newArtifactIds.length ? newArtifactIds : undefined
-  message.markdownGeneration = undefined
-  message.webResearch = input.result.webResearch
-  message.webResearchTextOffset = input.result.webResearchTextOffset
-  message.researchRoute = input.result.researchRoute
-  message.researchPlan = input.result.researchPlan
-  return next
-}
diff --git a/lib/thread-chat/application/message-runner.ts b/lib/thread-chat/application/message-runner.ts
new file mode 100644
index 00000000..f3ca2315
--- /dev/null
+++ b/lib/thread-chat/application/message-runner.ts
@@ -0,0 +1,286 @@
+import type { UIMessage } from "ai"
+import { toMarkdownArtifactToolOutput } from "../domain/artifact"
+import { invariant } from "../domain/domain-error"
+import type { MessageRun } from "../domain/message-run"
+import {
+  createThreadChatRepositories,
+  type ThreadChatSql,
+  type ThreadChatUnitOfWork,
+} from "../infrastructure/repositories"
+import { loadPromptHistory } from "./prompt-history"
+import type { AiRuntime } from "./ports/ai-runtime"
+
+export type MessageRunExecutionResult =
+  | { outcome: "not_claimed" }
+  | { outcome: "completed" | "failed" | "stopped"; run: MessageRun }
+
+export class MessageRunner {
+  private readonly controllers = new Map()
+
+  constructor(
+    private readonly sql: ThreadChatSql,
+    private readonly unitOfWork: ThreadChatUnitOfWork,
+    private readonly runtime: AiRuntime,
+    private readonly dependencies: {
+      generateId: () => string
+      now: () => Date
+      heartbeatIntervalMs?: number
+    }
+  ) {}
+
+  async execute(messageRunId: string): Promise {
+    const repositories = createThreadChatRepositories(this.sql)
+    const context = await repositories.messageRuns.findExecutionContext(
+      messageRunId
+    )
+    if (!context) return { outcome: "not_claimed" }
+    const claimed = await repositories.messageRuns.transition({
+      actorId: context.actorId,
+      messageRunId,
+      expectedStatus: "queued",
+      nextStatus: "running",
+    })
+    if (!claimed) return { outcome: "not_claimed" }
+
+    const controller = new AbortController()
+    this.controllers.set(messageRunId, controller)
+    const heartbeatTimer = setInterval(() => {
+      void repositories.messageRuns
+        .heartbeat({
+          actorId: context.actorId,
+          messageRunId,
+          heartbeatAt: this.dependencies.now(),
+        })
+        .catch(() => undefined)
+    }, this.dependencies.heartbeatIntervalMs ?? 15_000)
+    heartbeatTimer.unref()
+
+    try {
+      const promptMessages = await loadPromptHistory(this.sql, {
+        actorId: context.actorId,
+        threadId: context.threadId,
+      })
+      let checkpointParts = claimed.checkpointParts
+      let eventSequence = claimed.eventSequence
+      const artifactParts: UIMessage["parts"] = []
+      const events = this.runtime.execute(
+        {
+          messageRunId,
+          assistantMessageId: claimed.assistantMessageId,
+          modelId: claimed.modelId,
+          prompt: promptMessages.map((message) => ({
+            id: message.id,
+            role: message.role,
+            parts: message.parts ?? [],
+          })),
+        },
+        { signal: controller.signal }
+      )
+
+      for await (const event of events) {
+        if (await this.stopWasRequested(context.actorId, messageRunId)) {
+          controller.abort()
+          return this.finish(context.actorId, claimed, "stopped")
+        }
+
+        if (event.type === "delta") {
+          checkpointParts = [...checkpointParts, ...event.partsDelta]
+          const checkpointed = await repositories.messageRuns.checkpoint({
+            actorId: context.actorId,
+            messageRunId,
+            expectedEventSequence: eventSequence,
+            checkpointParts,
+            heartbeatAt: this.dependencies.now(),
+          })
+          if (!checkpointed) return { outcome: "not_claimed" }
+          eventSequence = checkpointed.eventSequence
+          continue
+        }
+
+        if (event.type === "artifact") {
+          const artifact = await this.unitOfWork.transaction(async (tx) => {
+            const created = await tx.artifacts.insert({
+              actorId: context.actorId,
+              id: this.dependencies.generateId(),
+              projectId: context.projectId,
+              sourceMessageId: claimed.assistantMessageId,
+              kind: event.output.kind,
+              title: event.output.title,
+              content: event.output.content,
+            })
+            const toolPart = {
+              type: "dynamic-tool" as const,
+              toolName: "createMarkdownArtifact",
+              toolCallId: event.output.toolCallId ?? created.id,
+              state: "output-available" as const,
+              input: { title: event.output.title },
+              output: toMarkdownArtifactToolOutput(created),
+            }
+            artifactParts.push(toolPart)
+            checkpointParts = [...checkpointParts, toolPart]
+            const checkpointed = await tx.messageRuns.checkpoint({
+              actorId: context.actorId,
+              messageRunId,
+              expectedEventSequence: eventSequence,
+              checkpointParts,
+              heartbeatAt: this.dependencies.now(),
+            })
+            invariant(
+              checkpointed,
+              "message_run_transition_invalid",
+              "Artifact checkpoint 写入时 MessageRun 已改变。"
+            )
+            return { artifact: created, checkpointed }
+          })
+          eventSequence = artifact.checkpointed.eventSequence
+          continue
+        }
+
+        if (event.type === "completed") {
+          return this.complete(
+            context.actorId,
+            claimed,
+            [...artifactParts, ...event.parts]
+          )
+        }
+        if (event.type === "failed") {
+          return this.finish(context.actorId, claimed, "failed", event.error)
+        }
+        return this.finish(context.actorId, claimed, "stopped")
+      }
+
+      return this.finish(context.actorId, claimed, "failed", {
+        code: "runtime_ended_without_terminal_event",
+        message: "AI Runtime 未产生终态事件。",
+      })
+    } catch (error) {
+      if (
+        controller.signal.aborted ||
+        (await this.stopWasRequested(context.actorId, messageRunId))
+      ) {
+        return this.finish(context.actorId, claimed, "stopped")
+      }
+      return this.finish(context.actorId, claimed, "failed", {
+        code: "runtime_execution_failed",
+        message: error instanceof Error ? error.message : "AI Runtime 执行失败。",
+      })
+    } finally {
+      clearInterval(heartbeatTimer)
+      this.controllers.delete(messageRunId)
+    }
+  }
+
+  async scanQueued(limit = 20): Promise[]> {
+    const ids = await createThreadChatRepositories(
+      this.sql
+    ).messageRuns.listQueuedIds(limit)
+    return Promise.allSettled(ids.map((id) => this.execute(id)))
+  }
+
+  async requestStop(input: {
+    actorId: string
+    assistantMessageId: string
+  }): Promise {
+    const run = await this.unitOfWork.transaction(async (repositories) => {
+      const requested = await repositories.messageRuns.requestStop(
+        input.actorId,
+        input.assistantMessageId,
+        this.dependencies.now()
+      )
+      if (!requested) {
+        const current =
+          await repositories.messageRuns.findOwnedByAssistantMessageId(
+            input.actorId,
+            input.assistantMessageId
+          )
+        invariant(current, "entity_not_found", "MessageRun 不存在。")
+        return current
+      }
+      if (requested.status !== "queued") return requested
+      const stopped = await repositories.messageRuns.transition({
+        actorId: input.actorId,
+        messageRunId: requested.id,
+        expectedStatus: "queued",
+        nextStatus: "stopped",
+        finishedAt: this.dependencies.now(),
+        incrementEventSequence: true,
+      })
+      invariant(
+        stopped,
+        "message_run_transition_invalid",
+        "queued MessageRun Stop 转换失败。"
+      )
+      return stopped
+    })
+    this.controllers.get(run.id)?.abort()
+    return run
+  }
+
+  private async stopWasRequested(
+    actorId: string,
+    messageRunId: string
+  ): Promise {
+    const context = await createThreadChatRepositories(
+      this.sql
+    ).messageRuns.findExecutionContext(messageRunId)
+    return context?.actorId === actorId && context.run.stopRequestedAt !== null
+  }
+
+  private complete(
+    actorId: string,
+    claimed: MessageRun,
+    finalParts: UIMessage["parts"]
+  ): Promise {
+    return this.unitOfWork.transaction(async (repositories) => {
+      const message = await repositories.messages.finalizeAssistantOnce({
+        actorId,
+        messageId: claimed.assistantMessageId,
+        parts: finalParts,
+        finalizedAt: this.dependencies.now(),
+      })
+      invariant(
+        message,
+        "message_run_transition_invalid",
+        "assistant Message 无法封存。"
+      )
+      const run = await repositories.messageRuns.transition({
+        actorId,
+        messageRunId: claimed.id,
+        expectedStatus: "running",
+        nextStatus: "completed",
+        finishedAt: this.dependencies.now(),
+        incrementEventSequence: true,
+      })
+      invariant(
+        run,
+        "message_run_transition_invalid",
+        "MessageRun completed 条件更新失败。"
+      )
+      return { outcome: "completed", run }
+    })
+  }
+
+  private async finish(
+    actorId: string,
+    claimed: MessageRun,
+    outcome: "failed" | "stopped",
+    error?: { code: string; message: string }
+  ): Promise {
+    const repositories = createThreadChatRepositories(this.sql)
+    const run = await repositories.messageRuns.transition({
+      actorId,
+      messageRunId: claimed.id,
+      expectedStatus: "running",
+      nextStatus: outcome,
+      finishedAt: this.dependencies.now(),
+      error: outcome === "failed" ? error : null,
+      incrementEventSequence: true,
+    })
+    if (run) return { outcome, run }
+    const current = await repositories.messageRuns.findExecutionContext(
+      claimed.id
+    )
+    invariant(current, "entity_not_found", "MessageRun 不存在。")
+    return { outcome: current.run.status as "failed" | "stopped", run: current.run }
+  }
+}
diff --git a/lib/thread-chat/application/ports/ai-runtime.ts b/lib/thread-chat/application/ports/ai-runtime.ts
new file mode 100644
index 00000000..0d59adfb
--- /dev/null
+++ b/lib/thread-chat/application/ports/ai-runtime.ts
@@ -0,0 +1,35 @@
+import type { UIMessage } from "ai"
+
+/** Markdown Artifact 由运行器持久化;AI Runtime 只返回待投影的工具结果。 */
+export type MarkdownArtifactRuntimeOutput = {
+  kind: "markdown"
+  title: string
+  content: string
+  toolCallId?: string
+}
+
+/** AI Runtime 的供应商无关输出;持久化游标由 MessageRun runner 分配。 */
+export type AiRuntimeEvent =
+  | { type: "delta"; partsDelta: UIMessage["parts"] }
+  | { type: "artifact"; output: MarkdownArtifactRuntimeOutput }
+  | { type: "completed"; parts: UIMessage["parts"] }
+  | { type: "failed"; error: { code: string; message: string } }
+  | { type: "stopped" }
+
+export type AiRuntimeRequest = {
+  messageRunId: string
+  assistantMessageId: string
+  modelId: string
+  prompt: UIMessage[]
+}
+
+/**
+ * MessageRun 执行器只依赖此 capability。真实供应商与测试 Fake 都必须实现它,
+ * 浏览器订阅生命周期不得直接控制这里的 AbortSignal。
+ */
+export interface AiRuntime {
+  execute(
+    request: AiRuntimeRequest,
+    options?: { signal?: AbortSignal }
+  ): AsyncIterable
+}
diff --git a/lib/thread-chat/application/project-generation-result.ts b/lib/thread-chat/application/project-generation-result.ts
deleted file mode 100644
index f04e62e1..00000000
--- a/lib/thread-chat/application/project-generation-result.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import type { UIMessage } from "ai"
-import type { Artifact } from "@/lib/thread-chat/domain/types"
-import type {
-  GenerationResultV1,
-  GenerationUsageMetadata,
-} from "@/lib/thread-chat/domain/generation"
-import { generationResultV1Schema } from "@/lib/thread-chat/contracts/generation-result"
-import {
-  GENERATION_ERRORS,
-  GENERATION_RESULT_VERSION,
-} from "@/constants/generation"
-import {
-  MARKDOWN_ARTIFACT_TOOL_NAME,
-  markdownArtifactInputSchema,
-} from "@/lib/chat/markdown-artifact"
-import type { ResearchPlan, ResearchRoute } from "@/lib/chat/research-router"
-import {
-  webResearchSourcesFromOutput,
-  type WebResearchActivity,
-} from "@/lib/chat/web-research-activity"
-
-type ProjectTerminalStatus = "completed" | "stopped" | "failed"
-
-export type ProjectGenerationResultInput = {
-  generationId: string
-  threadId: string
-  assistantMessageId: string
-  responseMessage: Pick
-  terminalStatus: ProjectTerminalStatus
-  error?: string
-  researchRoute?: ResearchRoute
-  researchPlan?: ResearchPlan
-  usage?: GenerationUsageMetadata
-}
-
-export type ProjectGenerationResultOutput = {
-  result: GenerationResultV1
-  hasDisplayableOutput: boolean
-}
-
-function isRecord(value: unknown): value is Record {
-  return typeof value === "object" && value !== null
-}
-
-function stableHash(value: string): string {
-  let hash = 0x811c9dc5
-  for (let i = 0; i < value.length; i++) {
-    hash ^= value.charCodeAt(i)
-    hash = Math.imul(hash, 0x01000193)
-  }
-  return (hash >>> 0).toString(36)
-}
-
-export function generationArtifactId(
-  generationId: string,
-  toolCallId: string
-): string {
-  return `ga_${generationId}_${stableHash(toolCallId)}`
-}
-
-function toolName(part: Record): string | null {
-  if (part.type === "dynamic-tool") {
-    return typeof part.toolName === "string" ? part.toolName : null
-  }
-  if (typeof part.type !== "string" || !part.type.startsWith("tool-"))
-    return null
-  return part.type.slice("tool-".length)
-}
-
-function optionalString(value: unknown): string | undefined {
-  return typeof value === "string" && value.trim() ? value.trim() : undefined
-}
-
-export function projectGenerationResult({
-  generationId,
-  threadId,
-  assistantMessageId,
-  responseMessage,
-  terminalStatus,
-  error,
-  researchRoute: knownRoute,
-  researchPlan: knownPlan,
-  usage,
-}: ProjectGenerationResultInput): ProjectGenerationResultOutput {
-  const textParts: string[] = []
-  const artifacts: Record = {}
-  const artifactIds: string[] = []
-  const researchByCall = new Map()
-  let webResearchTextOffset: number | undefined
-  let receivedTextLength = 0
-  let researchRoute = knownRoute
-  let researchPlan = knownPlan
-
-  for (const rawPart of responseMessage.parts) {
-    if (!isRecord(rawPart) || typeof rawPart.type !== "string") continue
-    const part: Record = rawPart
-    if (part.type === "text" && typeof part.text === "string") {
-      textParts.push(part.text)
-      receivedTextLength += part.text.length
-      continue
-    }
-    if (part.type === "data-research-route" && isRecord(part.data)) {
-      researchRoute = part.data as ResearchRoute
-      continue
-    }
-    if (part.type === "data-research-plan" && isRecord(part.data)) {
-      researchPlan = part.data as unknown as ResearchPlan
-      continue
-    }
-
-    const name = toolName(part)
-    const toolCallId = optionalString(part.toolCallId)
-    if (!name || !toolCallId) continue
-    const input = part.input
-
-    if (name === MARKDOWN_ARTIFACT_TOOL_NAME) {
-      const parsed = markdownArtifactInputSchema.safeParse(input)
-      if (!parsed.success) continue
-      const id = generationArtifactId(generationId, toolCallId)
-      artifacts[id] = {
-        id,
-        sourceThreadId: threadId,
-        sourceMessageId: assistantMessageId,
-        kind: "markdown",
-        title: parsed.data.title,
-        content: parsed.data.content,
-      }
-      if (!artifactIds.includes(id)) artifactIds.push(id)
-      continue
-    }
-
-    if (name === "webSearch" || name === "readUrl") {
-      webResearchTextOffset ??= receivedTextLength
-      const inputRecord = isRecord(input) ? input : {}
-      const output = part.output
-      researchByCall.set(toolCallId, {
-        toolCallId,
-        kind: name === "webSearch" ? "search" : "read",
-        status: "complete",
-        query:
-          name === "webSearch" ? optionalString(inputRecord.query) : undefined,
-        url: name === "readUrl" ? optionalString(inputRecord.url) : undefined,
-        sources:
-          name === "webSearch"
-            ? webResearchSourcesFromOutput(output)
-            : [],
-      })
-    }
-  }
-
-  const text = textParts.join("")
-  const webResearch = [...researchByCall.values()]
-  const hasDisplayableOutput =
-    text.trim().length > 0 ||
-    artifactIds.length > 0 ||
-    webResearch.length > 0 ||
-    researchPlan !== undefined
-
-  let status: GenerationResultV1["status"] = "done"
-  let resultError: string | undefined
-  if (terminalStatus === "failed") {
-    status = "error"
-    resultError = error || GENERATION_ERRORS.streamFailed
-  } else if (terminalStatus === "stopped") {
-    status = "error"
-    resultError = GENERATION_ERRORS.stopped
-  } else if (terminalStatus === "completed" && !hasDisplayableOutput) {
-    status = "error"
-    resultError = GENERATION_ERRORS.emptyResponse
-  }
-
-  return {
-    hasDisplayableOutput,
-    result: generationResultV1Schema.parse({
-      version: GENERATION_RESULT_VERSION,
-      generationId,
-      text,
-      status,
-      ...(resultError ? { error: resultError } : {}),
-      artifactIds,
-      artifacts,
-      ...(webResearch.length > 0 ? { webResearch } : {}),
-      ...(webResearchTextOffset !== undefined ? { webResearchTextOffset } : {}),
-      ...(researchRoute ? { researchRoute } : {}),
-      ...(researchPlan ? { researchPlan } : {}),
-      ...(usage ? { usage } : {}),
-    }),
-  }
-}
diff --git a/lib/thread-chat/application/prompt-history.ts b/lib/thread-chat/application/prompt-history.ts
new file mode 100644
index 00000000..7647e479
--- /dev/null
+++ b/lib/thread-chat/application/prompt-history.ts
@@ -0,0 +1,37 @@
+import { invariant } from "../domain/domain-error"
+import type { Message } from "../domain/message"
+import { buildPromptHistory } from "../domain/prompt-history"
+import {
+  createThreadChatRepositories,
+  type ThreadChatSql,
+} from "../infrastructure/repositories"
+
+export async function loadPromptHistory(
+  sql: ThreadChatSql,
+  input: { actorId: string; threadId: string }
+): Promise {
+  const repositories = createThreadChatRepositories(sql)
+  const thread = await repositories.threads.findOwnedById(
+    input.actorId,
+    input.threadId
+  )
+  invariant(thread, "entity_not_found", "Thread 不存在。")
+  const baseIds = thread.baseContext?.messageIds ?? []
+  const [baseMessages, currentMessages] = await Promise.all([
+    repositories.messages.listByIdsOwned(input.actorId, baseIds),
+    repositories.messages.listEffectiveOwned(input.actorId, thread.id, 100_000),
+  ])
+  const assistantIds = [...baseMessages, ...currentMessages]
+    .filter((message) => message.role === "assistant")
+    .map((message) => message.id)
+  const runs = await repositories.messageRuns.findOwnedByAssistantMessageIds(
+    input.actorId,
+    assistantIds
+  )
+  return buildPromptHistory({
+    baseMessageIds: baseIds,
+    baseMessages,
+    currentMessages,
+    assistantRuns: runs,
+  })
+}
diff --git a/lib/thread-chat/application/reconcile-turns.ts b/lib/thread-chat/application/reconcile-turns.ts
deleted file mode 100644
index c4df9018..00000000
--- a/lib/thread-chat/application/reconcile-turns.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { parseThreadTreeState } from "@/lib/thread-chat/domain/message-graph"
-import type { Message, ThreadTreeState } from "@/lib/thread-chat/domain/types"
-import { GENERATION_ERRORS } from "@/constants/generation"
-import {
-  isActiveGenerationStatus,
-  type GenerationForReconcile,
-  type GenerationSummary,
-  type ReconciledThreadChatTree,
-  type RecoverableTurn,
-} from "@/lib/thread-chat/domain/generation"
-import {
-  mergeGenerationResult,
-  restoreTurnSnapshot,
-} from "@/lib/thread-chat/application/merge-generation-result"
-
-const recoverableKey = (threadId: string, userMessageId: string) =>
-  `${threadId}:${userMessageId}`
-
-function parentUser(
-  messages: readonly Message[],
-  assistant: Message
-): Message | undefined {
-  return messages.find(
-    (message) =>
-      message.id === assistant.parentMessageId && message.role === "user"
-  )
-}
-
-export class InvalidCompletedMessageGenerationLinkError extends Error {
-  constructor(readonly messageId: string) {
-    super(`Completed assistant message ${messageId} has no generation link`)
-    this.name = "InvalidCompletedMessageGenerationLinkError"
-  }
-}
-
-/** 完成消息可以不冗余 generationId,但服务端必须能反查其完成执行。 */
-export function assertCompletedMessageGenerationLinks(
-  state: ThreadTreeState,
-  generations: readonly GenerationSummary[]
-): void {
-  const completedByMessage = new Set()
-  const completedByGeneration = new Set()
-  for (const generation of generations) {
-    if (
-      !generation.isCurrent ||
-      generation.status !== "completed" ||
-      generation.result?.status !== "done"
-    )
-      continue
-    const messageKey = `${generation.threadId}:${generation.assistantMessageId}`
-    completedByMessage.add(messageKey)
-    completedByGeneration.add(`${messageKey}:${generation.id}`)
-  }
-
-  for (const thread of Object.values(state.threads)) {
-    for (const message of thread.messages) {
-      if (message.role !== "assistant" || message.status !== "done") continue
-      const messageKey = `${thread.id}:${message.id}`
-      const linked = message.generationId
-        ? completedByGeneration.has(`${messageKey}:${message.generationId}`)
-        : completedByMessage.has(messageKey)
-      if (!linked)
-        throw new InvalidCompletedMessageGenerationLinkError(message.id)
-    }
-  }
-}
-
-/**
- * 用 tree + current generation sidecar 得到唯一的加载投影。
- * 函数不修改输入,也不把协调结果反写 DB。
- */
-export function reconcileThreadChatTurns(input: {
-  state: ThreadTreeState
-  generations: readonly GenerationForReconcile[]
-}): ReconciledThreadChatTree {
-  let state = parseThreadTreeState(input.state)
-  const currentGenerations = input.generations.filter(
-    (generation) => generation.isCurrent
-  )
-
-  for (const generation of currentGenerations) {
-    if (generation.result) {
-      state = mergeGenerationResult(state, {
-        threadId: generation.threadId,
-        assistantMessageId: generation.assistantMessageId,
-        generationId: generation.id,
-        turnSnapshot: generation.turnSnapshot,
-        result: generation.result,
-      })
-      continue
-    }
-
-    if (!isActiveGenerationStatus(generation.status)) continue
-    const thread = state.threads[generation.threadId]
-    if (!thread) continue
-    let assistant = thread.messages.find(
-      (message) => message.id === generation.assistantMessageId
-    )
-    if (!assistant) {
-      const restored = restoreTurnSnapshot(thread, generation.turnSnapshot)
-      assistant = restored?.assistantMessage
-      if (
-        restored &&
-        (thread.activeLeafMessageId === null ||
-          thread.activeLeafMessageId === restored.userMessage.id)
-      )
-        thread.activeLeafMessageId = restored.assistantMessage.id
-    }
-    if (assistant?.role === "assistant") {
-      assistant.generationId = generation.id
-      assistant.backgroundGeneration = true
-      if (assistant.status !== "streaming") assistant.status = "pending"
-      assistant.error = undefined
-    }
-  }
-
-  const activeByAssistant = new Map(
-    currentGenerations
-      .filter((generation) => isActiveGenerationStatus(generation.status))
-      .map((generation) => [
-        `${generation.threadId}:${generation.assistantMessageId}`,
-        generation,
-      ])
-  )
-  const currentByUser = new Map(
-    currentGenerations.map((generation) => [
-      `${generation.threadId}:${generation.userMessageId}`,
-      generation,
-    ])
-  )
-  const recoverableByTurn = new Map()
-
-  for (const thread of Object.values(state.threads)) {
-    for (const message of thread.messages) {
-      if (
-        message.role !== "assistant" ||
-        (message.status !== "pending" && message.status !== "streaming")
-      )
-        continue
-
-      if (activeByAssistant.has(`${thread.id}:${message.id}`)) continue
-      const user = parentUser(thread.messages, message)
-      if (!user) continue
-      const generation = currentByUser.get(`${thread.id}:${user.id}`)
-      message.status = "error"
-      message.error = GENERATION_ERRORS.backgroundInterrupted
-      message.backgroundGeneration = undefined
-      recoverableByTurn.set(recoverableKey(thread.id, user.id), {
-        threadId: thread.id,
-        userMessageId: user.id,
-        assistantMessageId: message.id,
-        reason: generation ? "interrupted_generation" : "missing_generation",
-      })
-    }
-
-    const activeLeaf = thread.messages.find(
-      (message) => message.id === thread.activeLeafMessageId
-    )
-    if (activeLeaf?.role !== "user") continue
-    const hasAssistantChild = thread.messages.some(
-      (message) =>
-        message.role === "assistant" &&
-        message.parentMessageId === activeLeaf.id
-    )
-    if (
-      !hasAssistantChild &&
-      !currentByUser.has(`${thread.id}:${activeLeaf.id}`)
-    )
-      recoverableByTurn.set(recoverableKey(thread.id, activeLeaf.id), {
-        threadId: thread.id,
-        userMessageId: activeLeaf.id,
-        reason: "missing_assistant",
-      })
-  }
-
-  return {
-    state,
-    recoverableTurns: [...recoverableByTurn.values()],
-  }
-}
diff --git a/lib/thread-chat/application/serialize-message-for-model.ts b/lib/thread-chat/application/serialize-message-for-model.ts
deleted file mode 100644
index 37f6e1c2..00000000
--- a/lib/thread-chat/application/serialize-message-for-model.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { Message, ThreadTreeState } from "@/lib/thread-chat/domain/types"
-
-/**
- * 把领域消息编译为模型可见文本。Artifact 不保存 AI SDK tool parts,因此用明确边界
- * 回放标题与原始内容,让“修改刚才的 Markdown”等追问仍有完整 grounding。
- */
-export function serializeMessageForModel(
-  state: ThreadTreeState,
-  message: Message
-): string | null {
-  const sections: string[] = []
-  const body = message.quote?.text
-    ? `就我划选的这段话:「${message.quote.text}」——${message.text}`
-    : message.text
-  if (body.trim()) sections.push(body)
-
-  for (const artifactId of message.artifactIds ?? []) {
-    const artifact = state.artifacts[artifactId]
-    if (!artifact) continue
-    if (artifact.kind !== "markdown") continue
-    sections.push(
-      `[Markdown Artifact: ${artifact.title}]\n${artifact.content}\n[/Markdown Artifact]`
-    )
-  }
-
-  const serialized = sections.join("\n\n").trim()
-  return serialized || null
-}
diff --git a/lib/thread-chat/application/thread-chat-commands.ts b/lib/thread-chat/application/thread-chat-commands.ts
new file mode 100644
index 00000000..130565b9
--- /dev/null
+++ b/lib/thread-chat/application/thread-chat-commands.ts
@@ -0,0 +1,460 @@
+import { invariant } from "../domain/domain-error"
+import type { UserId } from "../domain/ids"
+import { assertMessageForkEligible } from "../domain/message"
+import type {
+  ThreadChatRepositories,
+  ThreadChatUnitOfWork,
+} from "../infrastructure/repositories"
+import type {
+  CreationBundle,
+  MessageCreationBundle,
+  ProjectPatch,
+  ReplacementBundle,
+  ThreadChatApplicationDependencies,
+  UserMessageInput,
+} from "./application-types"
+
+export class ThreadChatCommands {
+  constructor(
+    private readonly unitOfWork: ThreadChatUnitOfWork,
+    private readonly dependencies: ThreadChatApplicationDependencies
+  ) {}
+
+  async createProject(input: {
+    actorId: UserId
+    parts: UserMessageInput
+    requestedModelId?: string
+  }): Promise {
+    const modelId = this.dependencies.resolveModelId(input.requestedModelId)
+    const result = await this.unitOfWork.transaction(async (repositories) => {
+      const project = await repositories.projects.insert({
+        id: this.dependencies.generateId(),
+        ownerUserId: input.actorId,
+      })
+      const rootThread = await repositories.threads.insertRoot({
+        actorId: input.actorId,
+        id: this.dependencies.generateId(),
+        projectId: project.id,
+      })
+      const created = await this.appendTurn(repositories, {
+        actorId: input.actorId,
+        threadId: rootThread.id,
+        parts: input.parts,
+        modelId,
+        threadAlreadyLocked: true,
+      })
+      return {
+        project,
+        rootThread,
+        artifactSummary: { changeSequence: 0, total: 0, byKind: {} },
+        ...created,
+      }
+    })
+    await this.wakeAfterCommit(result.assistantRun.id)
+    return result
+  }
+
+  async sendMessage(input: {
+    actorId: UserId
+    threadId: string
+    parts: UserMessageInput
+    requestedModelId?: string
+  }): Promise {
+    const modelId = this.dependencies.resolveModelId(input.requestedModelId)
+    const result = await this.unitOfWork.transaction((repositories) =>
+      this.appendTurn(repositories, { ...input, modelId })
+    )
+    await this.wakeAfterCommit(result.assistantRun.id)
+    return result
+  }
+
+  async forkThread(input: {
+    actorId: UserId
+    sourceThreadId: string
+    sourceMessageId: string
+    anchor?: {
+      exactQuote: string
+      textPosition?: { start: number; end: number }
+    }
+  }) {
+    return this.unitOfWork.transaction(async (repositories) => {
+      const parent = await repositories.threads.findOwnedByIdForUpdate(
+        input.actorId,
+        input.sourceThreadId
+      )
+      invariant(parent, "thread_not_found", "Parent Thread 不存在。")
+      const source = await repositories.messages.findOwnedByIdForUpdate(
+        input.actorId,
+        input.sourceMessageId
+      )
+      invariant(
+        source,
+        "source_message_not_found",
+        "Fork source Message 不存在。"
+      )
+      invariant(
+        source.threadId === parent.id,
+        "thread_source_invalid",
+        "Fork source 不属于指定 Parent Thread。"
+      )
+      const sourceRun =
+        source.role === "assistant"
+          ? await repositories.messageRuns.findOwnedByAssistantMessageIdForUpdate(
+              input.actorId,
+              source.id
+            )
+          : null
+      invariant(
+        source.finalizedAt !== null,
+        "message_not_finalized",
+        "Fork source 尚未 finalized。"
+      )
+      invariant(
+        source.supersededAt === null,
+        "message_superseded",
+        "Fork source 已 superseded。"
+      )
+      assertMessageForkEligible(source, sourceRun)
+      if (input.anchor?.textPosition) {
+        const sourceText = (source.parts ?? [])
+          .filter(
+            (part): part is Extract =>
+              part.type === "text"
+          )
+          .map((part) => part.text)
+          .join("\n")
+        const { start, end } = input.anchor.textPosition
+        invariant(
+          start >= 0 &&
+            end > start &&
+            sourceText.slice(start, end) === input.anchor.exactQuote,
+          "fork_anchor_mismatch",
+          "Fork anchor 与来源 Message 的规范文本投影不一致。"
+        )
+      }
+
+      const inheritedIds = parent.baseContext?.messageIds ?? []
+      const current = await repositories.messages.listEffectiveOwned(
+        input.actorId,
+        parent.id,
+        100_000
+      )
+      const currentAssistantIds = current
+        .filter((message) => message.role === "assistant")
+        .map((message) => message.id)
+      const currentRuns =
+        await repositories.messageRuns.findOwnedByAssistantMessageIds(
+          input.actorId,
+          currentAssistantIds
+        )
+      const completedAssistantIds = new Set(
+        currentRuns
+          .filter((run) => run.status === "completed")
+          .map((run) => run.assistantMessageId)
+      )
+      const eligibleIds = current
+        .filter(
+          (message) =>
+            message.sequence <= source.sequence &&
+            message.finalizedAt !== null &&
+            (message.role === "user" ||
+              completedAssistantIds.has(message.id))
+        )
+        .map((message) => message.id)
+      const baseContext = {
+        schemaVersion: 1 as const,
+        messageIds: [...new Set([...inheritedIds, ...eligibleIds])],
+      }
+      return repositories.threads.insertBranch({
+        actorId: input.actorId,
+        id: this.dependencies.generateId(),
+        projectId: parent.projectId,
+        parentThreadId: parent.id,
+        sourceMessageId: source.id,
+        forkSourceSnapshot: {
+          schemaVersion: 1,
+          ...(input.anchor === undefined
+            ? {}
+            : { quote: input.anchor.exactQuote }),
+          sourceRole: source.role,
+          sourceSequence: source.sequence,
+        },
+        baseContext,
+      })
+    })
+  }
+
+  async regenerate(input: {
+    actorId: UserId
+    sourceAssistantMessageId: string
+    requestedModelId?: string
+  }): Promise {
+    const modelId = this.dependencies.resolveModelId(input.requestedModelId)
+    const result = await this.unitOfWork.transaction(async (repositories) => {
+      const found = await repositories.messages.findOwnedById(
+        input.actorId,
+        input.sourceAssistantMessageId
+      )
+      invariant(found, "message_not_found", "Message 不存在。")
+      await repositories.threads.findOwnedByIdForUpdate(
+        input.actorId,
+        found.threadId
+      )
+      const source = await repositories.messages.findOwnedByIdForUpdate(
+        input.actorId,
+        found.id
+      )
+      invariant(source, "message_not_found", "Message 不存在。")
+      invariant(
+        source.role === "assistant" &&
+          source.finalizedAt !== null &&
+          source.supersededAt === null,
+        "message_not_regeneratable",
+        "Message 不满足 Regenerate 资格。"
+      )
+      invariant(
+        await repositories.messages.isLastEffective(source),
+        "fork_required",
+        "历史位置需要通过 Fork 保留另一条路线。"
+      )
+      const run =
+        await repositories.messageRuns.findOwnedByAssistantMessageIdForUpdate(
+          input.actorId,
+          source.id
+        )
+      invariant(
+        run?.status === "completed",
+        "message_not_regeneratable",
+        "只有 completed assistant Message 可以 Regenerate。"
+      )
+      await repositories.messageRuns.assertNoActiveForThread(
+        input.actorId,
+        source.threadId
+      )
+      const replacement = await repositories.messages.append({
+        actorId: input.actorId,
+        id: this.dependencies.generateId(),
+        threadId: source.threadId,
+        role: "assistant",
+        parts: null,
+        finalizedAt: null,
+        replacesMessageId: source.id,
+      })
+      const assistantRun = await repositories.messageRuns.insertQueued({
+        actorId: input.actorId,
+        id: this.dependencies.generateId(),
+        assistantMessageId: replacement.id,
+        modelId,
+      })
+      return {
+        supersededMessageIds: [source.id],
+        createdMessages: [replacement],
+        assistantRun,
+      }
+    })
+    await this.wakeAfterCommit(result.assistantRun.id)
+    return result
+  }
+
+  async editLastUser(input: {
+    actorId: UserId
+    sourceUserMessageId: string
+    parts: UserMessageInput
+    requestedModelId?: string
+  }): Promise {
+    const modelId = this.dependencies.resolveModelId(input.requestedModelId)
+    const result = await this.unitOfWork.transaction(async (repositories) => {
+      const found = await repositories.messages.findOwnedById(
+        input.actorId,
+        input.sourceUserMessageId
+      )
+      invariant(found, "message_not_found", "Message 不存在。")
+      await repositories.threads.findOwnedByIdForUpdate(
+        input.actorId,
+        found.threadId
+      )
+      const source = await repositories.messages.findOwnedByIdForUpdate(
+        input.actorId,
+        found.id
+      )
+      invariant(source, "message_not_found", "Message 不存在。")
+      invariant(
+        source.role === "user" &&
+          source.finalizedAt !== null &&
+          source.supersededAt === null,
+        "message_not_editable",
+        "Message 不满足 Edit 资格。"
+      )
+      invariant(
+        await repositories.messages.isLastEffectiveUser(source),
+        "fork_required",
+        "只有最后一条有效 user Message 可以 Edit。"
+      )
+      await repositories.messageRuns.assertNoActiveForThread(
+        input.actorId,
+        source.threadId
+      )
+      const replacementUser = await repositories.messages.append({
+        actorId: input.actorId,
+        id: this.dependencies.generateId(),
+        threadId: source.threadId,
+        role: "user",
+        parts: input.parts,
+        finalizedAt: this.dependencies.now(),
+        replacesMessageId: source.id,
+      })
+      const suffixIds = await repositories.messages.supersedeEffectiveRange({
+        actorId: input.actorId,
+        threadId: source.threadId,
+        afterSequence: source.sequence,
+        beforeSequence: replacementUser.sequence,
+        supersededAt: this.dependencies.now(),
+      })
+      const replacementAssistant = await repositories.messages.append({
+        actorId: input.actorId,
+        id: this.dependencies.generateId(),
+        threadId: source.threadId,
+        role: "assistant",
+        parts: null,
+        finalizedAt: null,
+      })
+      const assistantRun = await repositories.messageRuns.insertQueued({
+        actorId: input.actorId,
+        id: this.dependencies.generateId(),
+        assistantMessageId: replacementAssistant.id,
+        modelId,
+      })
+      return {
+        supersededMessageIds: [source.id, ...suffixIds],
+        createdMessages: [replacementUser, replacementAssistant],
+        assistantRun,
+      }
+    })
+    await this.wakeAfterCommit(result.assistantRun.id)
+    return result
+  }
+
+  patchProject(input: {
+    actorId: UserId
+    projectId: string
+    patch: ProjectPatch
+  }) {
+    return this.unitOfWork.transaction(async (repositories) => {
+      const project = await repositories.projects.updateMetadata({
+        actorId: input.actorId,
+        projectId: input.projectId,
+        ...input.patch,
+      })
+      invariant(project, "entity_not_found", "Project 不存在。")
+      return project
+    })
+  }
+
+  setProjectArchived(input: {
+    actorId: UserId
+    projectId: string
+    archived: boolean
+  }) {
+    return this.unitOfWork.transaction(async (repositories) => {
+      const project = await repositories.projects.setArchived({
+        ...input,
+        now: this.dependencies.now(),
+      })
+      invariant(project, "entity_not_found", "Project 不存在。")
+      return project
+    })
+  }
+
+  patchBranch(input: {
+    actorId: UserId
+    threadId: string
+    customTitle?: string | null
+    archived?: boolean
+  }) {
+    return this.unitOfWork.transaction(async (repositories) => {
+      const thread = await repositories.threads.updateBranchMetadata({
+        ...input,
+        now: this.dependencies.now(),
+      })
+      invariant(thread, "entity_not_found", "Thread 不存在。")
+      return thread
+    })
+  }
+
+  setFeedback(input: {
+    actorId: UserId
+    assistantMessageId: string
+    feedback: "positive" | "negative" | null
+  }) {
+    return this.unitOfWork.transaction(async (repositories) => ({
+      messageId: input.assistantMessageId,
+      value: await repositories.feedback.set(input),
+      updatedAt: this.dependencies.now(),
+    }))
+  }
+
+  deleteProject(input: { actorId: UserId; projectId: string }): Promise {
+    return this.unitOfWork.transaction(async (repositories) => {
+      const deleted = await repositories.projects.deleteOwned(
+        input.actorId,
+        input.projectId
+      )
+      invariant(deleted, "entity_not_found", "Project 不存在。")
+    })
+  }
+
+  private async appendTurn(
+    repositories: ThreadChatRepositories,
+    input: {
+      actorId: UserId
+      threadId: string
+      parts: UserMessageInput
+      modelId: string
+      threadAlreadyLocked?: boolean
+    }
+  ): Promise {
+    const thread = input.threadAlreadyLocked
+      ? await repositories.threads.findOwnedById(input.actorId, input.threadId)
+      : await repositories.threads.findOwnedByIdForUpdate(
+          input.actorId,
+          input.threadId
+        )
+    invariant(thread, "thread_not_found", "Thread 不存在。")
+    invariant(!thread.archivedAt, "thread_archived", "已归档 Thread 不能发送消息。")
+    await repositories.messageRuns.assertNoActiveForThread(
+      input.actorId,
+      input.threadId
+    )
+    const userMessage = await repositories.messages.append({
+      actorId: input.actorId,
+      id: this.dependencies.generateId(),
+      threadId: input.threadId,
+      role: "user",
+      parts: input.parts,
+      finalizedAt: this.dependencies.now(),
+    })
+    const assistantMessage = await repositories.messages.append({
+      actorId: input.actorId,
+      id: this.dependencies.generateId(),
+      threadId: input.threadId,
+      role: "assistant",
+      parts: null,
+      finalizedAt: null,
+    })
+    const assistantRun = await repositories.messageRuns.insertQueued({
+      actorId: input.actorId,
+      id: this.dependencies.generateId(),
+      assistantMessageId: assistantMessage.id,
+      modelId: input.modelId,
+    })
+    return { userMessage, assistantMessage, assistantRun }
+  }
+
+  private async wakeAfterCommit(messageRunId: string): Promise {
+    try {
+      await this.dependencies.wakeRunAfterCommit?.(messageRunId)
+    } catch (error) {
+      this.dependencies.onWakeError?.(error)
+    }
+  }
+}
diff --git a/lib/thread-chat/application/thread-chat-queries.ts b/lib/thread-chat/application/thread-chat-queries.ts
new file mode 100644
index 00000000..0386d3c6
--- /dev/null
+++ b/lib/thread-chat/application/thread-chat-queries.ts
@@ -0,0 +1,150 @@
+import { invariant } from "../domain/domain-error"
+import { getProjectDisplayTitle } from "../domain/project"
+import {
+  createThreadChatRepositories,
+  type ThreadChatSql,
+} from "../infrastructure/repositories"
+import type {
+  ProjectBootstrap,
+  ProjectSummary,
+  ThreadMessageBundle,
+} from "./application-types"
+
+export class ThreadChatQueries {
+  constructor(private readonly sql: ThreadChatSql) {}
+
+  async listProjects(input: {
+    actorId: string
+    status?: "active" | "archived" | "all"
+    limit?: number
+    before?: { updatedAt: Date; id: string }
+  }): Promise {
+    const repositories = createThreadChatRepositories(this.sql)
+    const projects = await repositories.projects.listOwned({
+      actorId: input.actorId,
+      status: input.status ?? "active",
+      limit: input.limit ?? 50,
+      before: input.before,
+    })
+    return projects.map((project) => ({
+      ...project,
+      displayTitle: getProjectDisplayTitle(project),
+    }))
+  }
+
+  async projectBootstrap(input: {
+    actorId: string
+    projectId: string
+  }): Promise {
+    const repositories = createThreadChatRepositories(this.sql)
+    const project = await repositories.projects.findOwnedById(
+      input.actorId,
+      input.projectId
+    )
+    invariant(project, "entity_not_found", "Project 不存在。")
+    const threadTopology = await repositories.threads.listOwnedTopology(
+      input.actorId,
+      input.projectId
+    )
+    const root = threadTopology.find((thread) => thread.parentThreadId === null)
+    invariant(root, "project_root_invalid", "Project 缺少唯一 Root Thread。")
+    const artifactSummary = await repositories.artifacts.summarizeOwnedProject(
+      input.actorId,
+      input.projectId
+    )
+    invariant(artifactSummary, "entity_not_found", "Project 不存在。")
+    return {
+      project,
+      threadTopology,
+      artifactSummary,
+      initialThread: await this.threadMessages({
+        actorId: input.actorId,
+        threadId: root.id,
+      }),
+    }
+  }
+
+  async threadMessages(input: {
+    actorId: string
+    threadId: string
+    limit?: number
+    beforeSequence?: number
+  }): Promise {
+    const repositories = createThreadChatRepositories(this.sql)
+    const thread = await repositories.threads.findOwnedById(
+      input.actorId,
+      input.threadId
+    )
+    invariant(thread, "entity_not_found", "Thread 不存在。")
+    const limit = input.limit ?? 200
+    const fetched = await repositories.messages.listEffectiveWindow({
+      actorId: input.actorId,
+      threadId: input.threadId,
+      beforeSequence: input.beforeSequence,
+      limit: limit + 1,
+    })
+    const hasOlderMessages = fetched.length > limit
+    const messages = hasOlderMessages ? fetched.slice(1) : fetched
+    const assistantMessageIds = messages
+      .filter((message) => message.role === "assistant")
+      .map((message) => message.id)
+    const assistantRuns =
+      await repositories.messageRuns.findOwnedByAssistantMessageIds(
+        input.actorId,
+        assistantMessageIds
+      )
+    invariant(
+      assistantRuns.length === assistantMessageIds.length,
+      "entity_not_found",
+      "assistant Message 缺少唯一 MessageRun。"
+    )
+    return {
+      threadId: input.threadId,
+      messages,
+      assistantRuns,
+      hasOlderMessages,
+      oldestReturnedSequence: messages[0]?.sequence ?? null,
+      newestReturnedSequence: messages.at(-1)?.sequence ?? null,
+    }
+  }
+
+  async artifactById(input: { actorId: string; artifactId: string }) {
+    const artifact = await createThreadChatRepositories(
+      this.sql
+    ).artifacts.findOwnedById(input.actorId, input.artifactId)
+    invariant(artifact, "entity_not_found", "Artifact 不存在。")
+    return artifact
+  }
+
+  async assistantSnapshot(input: {
+    actorId: string
+    assistantMessageId: string
+  }) {
+    const repositories = createThreadChatRepositories(this.sql)
+    const message = await repositories.messages.findOwnedById(
+      input.actorId,
+      input.assistantMessageId
+    )
+    invariant(
+      message?.role === "assistant",
+      "assistant_message_not_found",
+      "assistant Message 不存在。"
+    )
+    const thread = await repositories.threads.findOwnedById(
+      input.actorId,
+      message.threadId
+    )
+    invariant(thread, "entity_not_found", "Thread 不存在。")
+    const run = await repositories.messageRuns.findOwnedByAssistantMessageId(
+      input.actorId,
+      message.id
+    )
+    invariant(run, "message_run_not_found", "MessageRun 不存在。")
+    const artifactSummary = await repositories.artifacts.summarizeOwnedProject(
+      input.actorId,
+      thread.projectId
+    )
+    invariant(artifactSummary, "entity_not_found", "Project 不存在。")
+    return { message, run, artifactSummary }
+  }
+}
diff --git a/lib/thread-chat/client/app-store.ts b/lib/thread-chat/client/app-store.ts
new file mode 100644
index 00000000..892bf95d
--- /dev/null
+++ b/lib/thread-chat/client/app-store.ts
@@ -0,0 +1,121 @@
+import { createStore } from "zustand/vanilla"
+import type {
+  ListProjectsResult,
+  ProjectSummary,
+  ThreadChatAppState,
+  ThreadChatAppStore,
+} from "./types"
+
+const compareProjects = (left: ProjectSummary, right: ProjectSummary) =>
+  right.updatedAt.localeCompare(left.updatedAt) ||
+  right.id.localeCompare(left.id)
+
+function createInitialState(
+  initialCatalog?: ListProjectsResult
+): ThreadChatAppState {
+  const items = initialCatalog?.items ?? []
+  return {
+    catalog: {
+      projectsById: Object.fromEntries(items.map((item) => [item.id, item])),
+      orderedProjectIds: items.toSorted(compareProjects).map((item) => item.id),
+      loadState: initialCatalog ? { status: "ready" } : { status: "idle" },
+      activeFilter: "active",
+      nextCursor: initialCatalog?.nextCursor ?? null,
+    },
+    shellUi: {
+      sidebarOpen: true,
+      sidebarWidth: 280,
+      projectSearchQuery: "",
+      pendingProjectId: null,
+    },
+  }
+}
+
+export function createThreadChatAppStore(initialCatalog?: ListProjectsResult) {
+  return createStore()((set) => ({
+    ...createInitialState(initialCatalog),
+    mergeProjectPage(result, reset = false) {
+      set((state) => {
+        const projectsById = reset ? {} : { ...state.catalog.projectsById }
+        for (const item of result.items) projectsById[item.id] = item
+        return {
+          catalog: {
+            ...state.catalog,
+            projectsById,
+            orderedProjectIds: Object.values(projectsById)
+              .toSorted(compareProjects)
+              .map((item) => item.id),
+            loadState: { status: "ready" },
+            nextCursor: result.nextCursor,
+          },
+        }
+      })
+    },
+    upsertProjectSummary(summary) {
+      set((state) => {
+        const projectsById = {
+          ...state.catalog.projectsById,
+          [summary.id]: summary,
+        }
+        return {
+          catalog: {
+            ...state.catalog,
+            projectsById,
+            orderedProjectIds: Object.values(projectsById)
+              .toSorted(compareProjects)
+              .map((item) => item.id),
+          },
+        }
+      })
+    },
+    removeProjectSummary(projectId) {
+      set((state) => {
+        const projectsById = { ...state.catalog.projectsById }
+        delete projectsById[projectId]
+        return {
+          catalog: {
+            ...state.catalog,
+            projectsById,
+            orderedProjectIds: state.catalog.orderedProjectIds.filter(
+              (id) => id !== projectId
+            ),
+          },
+        }
+      })
+    },
+    setCatalogLoadState(loadState) {
+      set((state) => ({ catalog: { ...state.catalog, loadState } }))
+    },
+    setCatalogFilter(activeFilter) {
+      set((state) => ({
+        catalog: {
+          ...state.catalog,
+          activeFilter,
+          projectsById: {},
+          orderedProjectIds: [],
+          loadState: { status: "idle" },
+          nextCursor: null,
+        },
+      }))
+    },
+    setProjectRoutePending(pendingProjectId) {
+      set((state) => ({ shellUi: { ...state.shellUi, pendingProjectId } }))
+    },
+    setSidebarOpen(sidebarOpen) {
+      set((state) => ({ shellUi: { ...state.shellUi, sidebarOpen } }))
+    },
+    setSidebarWidth(sidebarWidth) {
+      set((state) => ({
+        shellUi: {
+          ...state.shellUi,
+          sidebarWidth: Math.max(200, Math.min(520, sidebarWidth)),
+        },
+      }))
+    },
+    setProjectSearchQuery(projectSearchQuery) {
+      set((state) => ({
+        shellUi: { ...state.shellUi, projectSearchQuery },
+      }))
+    },
+  }))
+}
diff --git a/lib/thread-chat/client/commands.ts b/lib/thread-chat/client/commands.ts
new file mode 100644
index 00000000..a867aa92
--- /dev/null
+++ b/lib/thread-chat/client/commands.ts
@@ -0,0 +1,418 @@
+import type { StoreApi } from "zustand/vanilla"
+import type { ThreadChatApiCapabilities } from "../api/capabilities"
+import { ThreadChatClientError } from "../api/client-error"
+import {
+  assistantRunStateSchema,
+  creationBundleSchema,
+  feedbackSchema,
+  listProjectsResultSchema,
+  messageCreationBundleSchema,
+  projectBootstrapSchema,
+  projectSchema,
+  replacementBundleSchema,
+  threadSchema,
+} from "../api/contracts"
+import { threadChatRoutes } from "../api/routes"
+import { clientInvariant, isAbortError, normalizeClientError } from "./errors"
+import { selectForkAvailability } from "./selectors"
+import type {
+  GenerationCoordinator,
+  NavigationCapability,
+  NewProjectDraftStore,
+  ProjectRuntimeRegistry,
+  ThreadChatAppCommands,
+  ThreadChatAppStore,
+  ThreadChatProjectCommands,
+  ThreadChatProjectStore,
+  ThreadMessageLoader,
+  ArtifactLoader,
+} from "./types"
+
+async function runProjectCommand(input: {
+  store: StoreApi
+  scope: string
+  execute(): Promise
+}): Promise {
+  if (
+    input.store.getState().requests.commandByScope[input.scope]?.status ===
+    "submitting"
+  )
+    return
+  input.store.getState().setCommandState(input.scope, { status: "submitting" })
+  try {
+    await input.execute()
+    input.store.getState().setCommandState(input.scope, null)
+  } catch (error) {
+    input.store.getState().setCommandState(input.scope, {
+      status: "error",
+      error: normalizeClientError(error),
+    })
+  }
+}
+
+export function createThreadChatProjectCommands(input: {
+  projectId: string
+  api: ThreadChatApiCapabilities
+  store: StoreApi
+  messageLoader: ThreadMessageLoader
+  artifactLoader: ArtifactLoader
+  generationCoordinator: GenerationCoordinator
+}): { commands: ThreadChatProjectCommands; destroy(): void } {
+  let bootstrapPromise: Promise | null = null
+  let bootstrapController: AbortController | null = null
+  let disposed = false
+
+  const commands: ThreadChatProjectCommands = {
+    loadProjectBootstrap() {
+      const state = input.store.getState()
+      if (state.requests.bootstrap.status === "ready") {
+        input.generationCoordinator.resumeLoadedRuns()
+        return Promise.resolve()
+      }
+      if (bootstrapPromise) return bootstrapPromise
+      state.setBootstrapLoadState({ status: "loading" })
+      bootstrapController = new AbortController()
+      bootstrapPromise = input.api
+        .bootstrapProject(input.projectId, bootstrapController.signal)
+        .then((rawBootstrap) => {
+          if (disposed) return
+          const bootstrap = projectBootstrapSchema.parse(rawBootstrap)
+          input.store.getState().mergeBootstrap(bootstrap)
+          input.generationCoordinator.resumeLoadedRuns()
+        })
+        .catch((error: unknown) => {
+          if (disposed || isAbortError(error)) return
+          input.store.getState().setBootstrapLoadState({
+            status: "error",
+            error: normalizeClientError(error),
+          })
+        })
+        .finally(() => {
+          bootstrapPromise = null
+          bootstrapController = null
+        })
+      return bootstrapPromise
+    },
+    ensureThreadMessages: input.messageLoader.ensure,
+    ensureArtifact: input.artifactLoader.ensure,
+    updateProject(patch) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `project:update:${input.projectId}`,
+        execute: async () => {
+          input.store.getState().applyProject(
+            projectSchema.parse(
+              await input.api.patchProject({
+                projectId: input.projectId,
+                ...patch,
+              })
+            )
+          )
+        },
+      })
+    },
+    updateThread(threadId, customTitle) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `thread:update:${threadId}`,
+        execute: async () => {
+          input.store
+            .getState()
+            .applyThread(
+              threadSchema.parse(
+                await input.api.patchThread(threadId, customTitle)
+              )
+            )
+        },
+      })
+    },
+    setProjectArchived(archived) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `project:archive:${input.projectId}`,
+        execute: async () => {
+          input.store
+            .getState()
+            .applyProject(
+              projectSchema.parse(
+                await input.api.setProjectArchived(input.projectId, archived)
+              )
+            )
+        },
+      })
+    },
+    setThreadArchived(threadId, archived) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `thread:archive:${threadId}`,
+        execute: async () => {
+          input.store
+            .getState()
+            .applyThread(
+              threadSchema.parse(
+                await input.api.setThreadArchived(threadId, archived)
+              )
+            )
+        },
+      })
+    },
+    deleteProject() {
+      return runProjectCommand({
+        store: input.store,
+        scope: `project:delete:${input.projectId}`,
+        execute: () => input.api.deleteProject(input.projectId),
+      })
+    },
+    sendMessage(threadId, parts, requestedModelId) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `send:${threadId}`,
+        execute: async () => {
+          const bundle = messageCreationBundleSchema.parse(
+            await input.api.sendMessage({
+              threadId,
+              parts,
+              requestedModelId,
+            })
+          )
+          input.store.getState().applyMessageCreationBundle(bundle)
+          input.generationCoordinator.subscribeAssistant(
+            bundle.assistantRun.assistantMessageId
+          )
+        },
+      })
+    },
+    forkThread(command) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `fork:${command.sourceMessageId}`,
+        execute: async () => {
+          const availability = selectForkAvailability(
+            input.store.getState(),
+            command.sourceMessageId
+          )
+          if (!availability.allowed) clientInvariant(false, availability.reason)
+          const rawResult = await input.api.forkThread(command.sourceThreadId, {
+            sourceMessageId: command.sourceMessageId,
+            anchor: command.anchor,
+          })
+          const result = { thread: threadSchema.parse(rawResult.thread) }
+          input.store.getState().applyThreadCreated(result.thread)
+          input.store
+            .getState()
+            .openThread(
+              result.thread.id,
+              command.sourceSlotId,
+              command.placement
+            )
+        },
+      })
+    },
+    editMessage(messageId, parts, requestedModelId) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `edit:${messageId}`,
+        execute: async () => {
+          const bundle = replacementBundleSchema.parse(
+            await input.api.editMessage({
+              messageId,
+              parts,
+              requestedModelId,
+            })
+          )
+          input.store.getState().applyReplacementBundle(bundle)
+          input.generationCoordinator.subscribeAssistant(
+            bundle.assistantRun.assistantMessageId
+          )
+        },
+      })
+    },
+    regenerateMessage(messageId, requestedModelId) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `regenerate:${messageId}`,
+        execute: async () => {
+          const bundle = replacementBundleSchema.parse(
+            await input.api.regenerateMessage({
+              messageId,
+              requestedModelId,
+            })
+          )
+          input.store.getState().applyReplacementBundle(bundle)
+          input.generationCoordinator.subscribeAssistant(
+            bundle.assistantRun.assistantMessageId
+          )
+        },
+      })
+    },
+    setFeedback(messageId, value) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `feedback:${messageId}`,
+        execute: async () => {
+          input.store
+            .getState()
+            .applyFeedback(
+              feedbackSchema.parse(
+                await input.api.setFeedback(messageId, value)
+              )
+            )
+        },
+      })
+    },
+    stopAssistant(assistantMessageId) {
+      return runProjectCommand({
+        store: input.store,
+        scope: `stop:${assistantMessageId}`,
+        execute: async () => {
+          input.store
+            .getState()
+            .applyAssistantRun(
+              assistantRunStateSchema.parse(
+                await input.api.stopAssistant(assistantMessageId)
+              )
+            )
+        },
+      })
+    },
+  }
+
+  return {
+    commands,
+    destroy() {
+      disposed = true
+      bootstrapController?.abort()
+      bootstrapController = null
+      bootstrapPromise = null
+    },
+  }
+}
+
+export function createThreadChatAppCommands(input: {
+  api: ThreadChatApiCapabilities
+  store: StoreApi
+  navigation: NavigationCapability
+}): { commands: ThreadChatAppCommands; destroy(): void } {
+  let catalogPromise: Promise | null = null
+  let controller: AbortController | null = null
+  let disposed = false
+
+  return {
+    commands: {
+      loadProjectCatalog({ reset = false } = {}) {
+        if (catalogPromise) return catalogPromise
+        const state = input.store.getState()
+        if (
+          !reset &&
+          state.catalog.loadState.status === "ready" &&
+          !state.catalog.nextCursor
+        )
+          return Promise.resolve()
+        state.setCatalogLoadState({ status: "loading" })
+        controller = new AbortController()
+        catalogPromise = input.api
+          .listProjects({
+            status: state.catalog.activeFilter,
+            cursor: reset ? undefined : (state.catalog.nextCursor ?? undefined),
+            signal: controller.signal,
+          })
+          .then((rawResult) => {
+            if (!disposed)
+              input.store
+                .getState()
+                .mergeProjectPage(
+                  listProjectsResultSchema.parse(rawResult),
+                  reset
+                )
+          })
+          .catch((error: unknown) => {
+            if (disposed || isAbortError(error)) return
+            input.store.getState().setCatalogLoadState({
+              status: "error",
+              error: normalizeClientError(error),
+            })
+          })
+          .finally(() => {
+            catalogPromise = null
+            controller = null
+          })
+        return catalogPromise
+      },
+      async setProjectArchived(projectId, archived) {
+        const project = projectSchema.parse(
+          await input.api.setProjectArchived(projectId, archived)
+        )
+        const current = input.store.getState().catalog.projectsById[projectId]
+        if (!current) return
+        input.store.getState().upsertProjectSummary({
+          ...current,
+          displayTitle: project.customTitle ?? project.autoTitle ?? "",
+          archivedAt: project.archivedAt,
+          updatedAt: project.updatedAt,
+        })
+      },
+      async deleteProject(projectId) {
+        await input.api.deleteProject(projectId)
+        input.store.getState().removeProjectSummary(projectId)
+        if (input.navigation.currentProjectId?.() === projectId)
+          input.navigation.replace(threadChatRoutes.newProject())
+      },
+    },
+    destroy() {
+      disposed = true
+      controller?.abort()
+      controller = null
+      catalogPromise = null
+    },
+  }
+}
+
+export async function submitNewProjectDraft(input: {
+  api: ThreadChatApiCapabilities
+  appStore: StoreApi
+  registry: ProjectRuntimeRegistry
+  navigation: NavigationCapability
+  draftStore: StoreApi
+}): Promise {
+  const draft = input.draftStore.getState()
+  if (draft.status === "submitting") return
+  const parts = structuredClone(draft.draftParts)
+  draft.markSubmitting()
+  try {
+    const bundle = creationBundleSchema.parse(
+      await input.api.createProject({
+        parts,
+        requestedModelId: draft.requestedModelId,
+      })
+    )
+    const runtime = input.registry.seedFromCreation(bundle)
+    input.appStore.getState().upsertProjectSummary({
+      id: bundle.project.id,
+      displayTitle:
+        bundle.project.customTitle ?? bundle.project.autoTitle ?? "New project",
+      archivedAt: bundle.project.archivedAt,
+      updatedAt: bundle.project.updatedAt,
+      threadCount: 1,
+      messageCount: 2,
+    })
+    input.appStore.getState().setProjectRoutePending(bundle.project.id)
+    runtime.generationCoordinator.subscribeAssistant(
+      bundle.assistantRun.assistantMessageId
+    )
+    input.navigation.replace(threadChatRoutes.project(bundle.project.id))
+  } catch (error) {
+    const normalized = normalizeClientError(error)
+    input.draftStore
+      .getState()
+      .markError(
+        normalized.status === 0
+          ? new ThreadChatClientError(
+              normalized.code,
+              "Project creation result is unknown. Check the project list before retrying.",
+              normalized.status,
+              normalized.details
+            )
+          : normalized
+      )
+  }
+}
diff --git a/lib/thread-chat/client/errors.ts b/lib/thread-chat/client/errors.ts
new file mode 100644
index 00000000..4c4d6c71
--- /dev/null
+++ b/lib/thread-chat/client/errors.ts
@@ -0,0 +1,28 @@
+import { ThreadChatClientError } from "../api/client-error"
+
+export function normalizeClientError(error: unknown): ThreadChatClientError {
+  if (error instanceof ThreadChatClientError) return error
+  if (error instanceof Error && error.name === "AbortError")
+    return new ThreadChatClientError(
+      "internal_error",
+      "Request was aborted.",
+      0
+    )
+  return new ThreadChatClientError(
+    "internal_error",
+    error instanceof Error ? error.message : "Unexpected client error.",
+    0
+  )
+}
+
+export function clientInvariant(
+  condition: unknown,
+  message: string
+): asserts condition {
+  if (!condition)
+    throw new ThreadChatClientError("validation_error", message, 0)
+}
+
+export function isAbortError(error: unknown): boolean {
+  return error instanceof Error && error.name === "AbortError"
+}
diff --git a/lib/thread-chat/client/generation-coordinator.ts b/lib/thread-chat/client/generation-coordinator.ts
new file mode 100644
index 00000000..1bef33a2
--- /dev/null
+++ b/lib/thread-chat/client/generation-coordinator.ts
@@ -0,0 +1,137 @@
+import type { StoreApi } from "zustand/vanilla"
+import type { ThreadChatApiCapabilities } from "../api/capabilities"
+import { assistantMessageEventSchema } from "../api/contracts"
+import { isAbortError } from "./errors"
+import type {
+  AssistantMessageEvent,
+  GenerationCoordinator,
+  ThreadChatProjectStore,
+} from "./types"
+
+function defaultScheduleFlush(callback: () => void): () => void {
+  if (typeof requestAnimationFrame === "function") {
+    const id = requestAnimationFrame(callback)
+    return () => cancelAnimationFrame(id)
+  }
+  let cancelled = false
+  queueMicrotask(() => {
+    if (!cancelled) callback()
+  })
+  return () => {
+    cancelled = true
+  }
+}
+
+function defaultReconnectWait(signal: AbortSignal): Promise {
+  return new Promise((resolve) => {
+    const timeout = setTimeout(resolve, 250)
+    signal.addEventListener(
+      "abort",
+      () => {
+        clearTimeout(timeout)
+        resolve()
+      },
+      { once: true }
+    )
+  })
+}
+
+function isTerminal(event: AssistantMessageEvent): boolean {
+  return (
+    event.type === "run.completed" ||
+    event.type === "run.failed" ||
+    event.type === "run.stopped" ||
+    (event.type === "run.snapshot" &&
+      ["completed", "failed", "stopped"].includes(event.run.status))
+  )
+}
+
+export function createGenerationCoordinator(input: {
+  api: ThreadChatApiCapabilities
+  store: StoreApi
+  scheduleFlush?: (callback: () => void) => () => void
+  waitForReconnect?: (signal: AbortSignal) => Promise
+}): GenerationCoordinator {
+  const controllers = new Map()
+  const cancelFlushByMessageId = new Map void>()
+  const scheduleFlush = input.scheduleFlush ?? defaultScheduleFlush
+  const waitForReconnect = input.waitForReconnect ?? defaultReconnectWait
+  let disposed = false
+
+  const scheduleMessageFlush = (assistantMessageId: string) => {
+    if (cancelFlushByMessageId.has(assistantMessageId)) return
+    cancelFlushByMessageId.set(
+      assistantMessageId,
+      scheduleFlush(() => {
+        cancelFlushByMessageId.delete(assistantMessageId)
+        if (!disposed) input.store.getState().flushRunBuffer(assistantMessageId)
+      })
+    )
+  }
+
+  const consume = async (
+    assistantMessageId: string,
+    controller: AbortController
+  ) => {
+    while (!disposed && !controller.signal.aborted) {
+      const run =
+        input.store.getState().runs.byAssistantMessageId[assistantMessageId]
+      if (!run || ["completed", "failed", "stopped"].includes(run.status))
+        return
+      try {
+        for await (const rawEvent of input.api.subscribeAssistantEvents({
+          assistantMessageId,
+          afterEventSequence: run.eventSequence,
+          signal: controller.signal,
+        })) {
+          if (disposed || controller.signal.aborted) return
+          const event = assistantMessageEventSchema.parse(rawEvent)
+          input.store.getState().applyRunEvent(event, assistantMessageId)
+          if (event.type === "run.delta")
+            scheduleMessageFlush(assistantMessageId)
+          if (isTerminal(event)) return
+        }
+      } catch (error) {
+        if (disposed || controller.signal.aborted || isAbortError(error)) return
+      }
+      await waitForReconnect(controller.signal)
+    }
+  }
+
+  const coordinator: GenerationCoordinator = {
+    resumeLoadedRuns() {
+      for (const run of Object.values(
+        input.store.getState().runs.byAssistantMessageId
+      ))
+        if (run.status === "queued" || run.status === "running")
+          coordinator.subscribeAssistant(run.assistantMessageId)
+    },
+    subscribeAssistant(assistantMessageId) {
+      if (disposed || controllers.has(assistantMessageId)) return
+      const run =
+        input.store.getState().runs.byAssistantMessageId[assistantMessageId]
+      if (!run || ["completed", "failed", "stopped"].includes(run.status))
+        return
+      const controller = new AbortController()
+      controllers.set(assistantMessageId, controller)
+      void consume(assistantMessageId, controller).finally(() => {
+        if (controllers.get(assistantMessageId) === controller)
+          controllers.delete(assistantMessageId)
+      })
+    },
+    unsubscribeAssistant(assistantMessageId) {
+      controllers.get(assistantMessageId)?.abort()
+      controllers.delete(assistantMessageId)
+      cancelFlushByMessageId.get(assistantMessageId)?.()
+      cancelFlushByMessageId.delete(assistantMessageId)
+    },
+    destroy() {
+      disposed = true
+      for (const controller of controllers.values()) controller.abort()
+      controllers.clear()
+      for (const cancel of cancelFlushByMessageId.values()) cancel()
+      cancelFlushByMessageId.clear()
+    },
+  }
+  return coordinator
+}
diff --git a/lib/thread-chat/client/hooks.ts b/lib/thread-chat/client/hooks.ts
new file mode 100644
index 00000000..7b67572c
--- /dev/null
+++ b/lib/thread-chat/client/hooks.ts
@@ -0,0 +1,182 @@
+"use client"
+
+import { useCallback, useEffect, useMemo } from "react"
+import { useStore } from "zustand"
+import { submitNewProjectDraft } from "./commands"
+import {
+  selectAppShellUi,
+  selectArtifact,
+  selectAssistantRun,
+  selectFocusedColumnId,
+  selectFocusedThreadId,
+  selectForkAvailability,
+  selectProject,
+  selectProjectCatalog,
+  selectProjectHeaderView,
+  selectProjectTarget,
+  selectProjectTreeRows,
+  selectThread,
+  selectThreadColumnHeaderView,
+  selectThreadColumnView,
+  selectThreadMessages,
+  selectVisibleThreadColumns,
+} from "./selectors"
+import {
+  useNewProjectDraftStoreApi,
+  useThreadChatAppRuntime,
+  useThreadChatProjectRuntime,
+} from "./providers"
+import type {
+  NewProjectDraftStore,
+  ThreadChatAppStore,
+  ThreadChatProjectStore,
+} from "./types"
+
+export function useThreadChatAppStore(
+  selector: (state: ThreadChatAppStore) => T
+): T {
+  return useStore(useThreadChatAppRuntime().appStore, selector)
+}
+
+export function useThreadChatStore(
+  selector: (state: ThreadChatProjectStore) => T
+): T {
+  return useStore(useThreadChatProjectRuntime().store, selector)
+}
+
+export function useNewProjectDraftStore(
+  selector: (state: NewProjectDraftStore) => T
+): T {
+  return useStore(useNewProjectDraftStoreApi(), selector)
+}
+
+export const useProjectCatalog = () =>
+  useThreadChatAppStore(selectProjectCatalog)
+export const useAppShellUi = () => useThreadChatAppStore(selectAppShellUi)
+export const useProject = () => useThreadChatStore(selectProject)
+export const useProjectTarget = () => useThreadChatStore(selectProjectTarget)
+export const useThread = (threadId: string) =>
+  useThreadChatStore((state) => selectThread(state, threadId))
+export const useThreadMessages = (threadId: string) =>
+  useThreadChatStore((state) => selectThreadMessages(state, threadId))
+export const useThreadColumnView = (slotId: "root" | string) =>
+  useThreadChatStore((state) => selectThreadColumnView(state, slotId))
+export const useThreadColumnHeaderView = (slotId: "root" | string) =>
+  useThreadChatStore((state) => selectThreadColumnHeaderView(state, slotId))
+export const useProjectTreeRows = () =>
+  useThreadChatStore(selectProjectTreeRows)
+export const useAssistantRun = (assistantMessageId: string) =>
+  useThreadChatStore((state) => selectAssistantRun(state, assistantMessageId))
+export const useArtifact = (artifactId: string) =>
+  useThreadChatStore((state) => selectArtifact(state, artifactId))
+export const useForkAvailability = (messageId: string) =>
+  useThreadChatStore((state) => selectForkAvailability(state, messageId))
+export const useVisibleThreadColumns = () =>
+  useThreadChatStore(selectVisibleThreadColumns)
+export const useProjectHeaderView = () =>
+  useThreadChatStore(selectProjectHeaderView)
+export const useFocusedColumnId = () =>
+  useThreadChatStore(selectFocusedColumnId)
+export const useFocusedThreadId = () =>
+  useThreadChatStore(selectFocusedThreadId)
+
+export function useAppShellCommands() {
+  const runtime = useThreadChatAppRuntime()
+  return useMemo(
+    () => ({
+      setSidebarOpen: runtime.appStore.getState().setSidebarOpen,
+      setSidebarWidth: runtime.appStore.getState().setSidebarWidth,
+      setProjectSearchQuery: runtime.appStore.getState().setProjectSearchQuery,
+      setCatalogFilter: runtime.appStore.getState().setCatalogFilter,
+      setProjectRoutePending:
+        runtime.appStore.getState().setProjectRoutePending,
+      loadProjectCatalog: runtime.commands.loadProjectCatalog,
+    }),
+    [runtime]
+  )
+}
+
+export function useProjectCommands() {
+  const runtime = useThreadChatProjectRuntime()
+  return runtime.commands
+}
+
+export function useThreadCommands(threadId: string) {
+  const runtime = useThreadChatProjectRuntime()
+  return useMemo(
+    () => ({
+      send: runtime.commands.sendMessage.bind(null, threadId),
+      updateTitle: runtime.commands.updateThread.bind(null, threadId),
+      setArchived: runtime.commands.setThreadArchived.bind(null, threadId),
+      ensureLoaded: runtime.commands.ensureThreadMessages.bind(null, threadId),
+      fork: (
+        sourceSlotId: "root" | string,
+        sourceMessageId: string,
+        anchor?: {
+          exactQuote: string
+          textPosition?: { start: number; end: number }
+        }
+      ) =>
+        runtime.commands.forkThread({
+          sourceSlotId,
+          sourceThreadId: threadId,
+          sourceMessageId,
+          anchor,
+        }),
+    }),
+    [runtime, threadId]
+  )
+}
+
+export function useMessageCommands(messageId: string) {
+  const runtime = useThreadChatProjectRuntime()
+  return useMemo(
+    () => ({
+      edit: runtime.commands.editMessage.bind(null, messageId),
+      regenerate: runtime.commands.regenerateMessage.bind(null, messageId),
+      feedback: runtime.commands.setFeedback.bind(null, messageId),
+      stop: () => runtime.commands.stopAssistant(messageId),
+    }),
+    [messageId, runtime]
+  )
+}
+
+export function useSubmitNewProjectDraft() {
+  const appRuntime = useThreadChatAppRuntime()
+  const draftStore = useNewProjectDraftStoreApi()
+  return useCallback(
+    () =>
+      submitNewProjectDraft({
+        api: appRuntime.api,
+        appStore: appRuntime.appStore,
+        registry: appRuntime.projectRuntimeRegistry,
+        navigation: appRuntime.navigation,
+        draftStore,
+      }),
+    [appRuntime, draftStore]
+  )
+}
+
+export function useEnsureThreadMessagesLoaded(threadId: string | null) {
+  const runtime = useThreadChatProjectRuntime()
+  useEffect(() => {
+    if (threadId) void runtime.commands.ensureThreadMessages(threadId)
+  }, [runtime, threadId])
+}
+
+export function useEnsureArtifactLoaded(
+  artifactId: string | null,
+  enabled = true
+) {
+  const runtime = useThreadChatProjectRuntime()
+  useEffect(() => {
+    if (enabled && artifactId) void runtime.commands.ensureArtifact(artifactId)
+  }, [artifactId, enabled, runtime])
+}
+
+export function useActiveGenerationSubscriptions() {
+  const runtime = useThreadChatProjectRuntime()
+  useEffect(() => {
+    runtime.generationCoordinator.resumeLoadedRuns()
+  }, [runtime])
+}
diff --git a/lib/thread-chat/client/index.ts b/lib/thread-chat/client/index.ts
new file mode 100644
index 00000000..df8251fc
--- /dev/null
+++ b/lib/thread-chat/client/index.ts
@@ -0,0 +1,10 @@
+export * from "./app-store"
+export * from "./commands"
+export * from "./errors"
+export * from "./generation-coordinator"
+export * from "./loaders"
+export * from "./normalizer"
+export * from "./project-store"
+export * from "./runtime"
+export * from "./selectors"
+export type * from "./types"
diff --git a/lib/thread-chat/client/loaders.ts b/lib/thread-chat/client/loaders.ts
new file mode 100644
index 00000000..ec34fd72
--- /dev/null
+++ b/lib/thread-chat/client/loaders.ts
@@ -0,0 +1,126 @@
+import type { StoreApi } from "zustand/vanilla"
+import type { ThreadChatApiCapabilities } from "../api/capabilities"
+import { artifactSchema, threadMessageBundleSchema } from "../api/contracts"
+import { isAbortError, normalizeClientError, clientInvariant } from "./errors"
+import type {
+  ArtifactLoader,
+  GenerationCoordinator,
+  ThreadChatProjectStore,
+  ThreadMessageLoader,
+} from "./types"
+
+export function createThreadMessageLoader(input: {
+  projectId: string
+  api: ThreadChatApiCapabilities
+  store: StoreApi
+  generationCoordinator: GenerationCoordinator
+}): ThreadMessageLoader {
+  const inFlight = new Map>()
+  const controllers = new Map()
+  let disposed = false
+
+  return {
+    ensure(threadId) {
+      const state = input.store.getState()
+      clientInvariant(
+        state.entities.threadsById[threadId]?.projectId === input.projectId,
+        "Thread loader target belongs to another Project."
+      )
+      if (
+        state.requests.threadMessagesById[threadId]?.loadState.status ===
+        "ready"
+      )
+        return Promise.resolve()
+      const existing = inFlight.get(threadId)
+      if (existing) return existing
+
+      state.setThreadMessageLoadState(threadId, { status: "loading" })
+      const controller = new AbortController()
+      controllers.set(threadId, controller)
+      const request = input.api
+        .loadThreadMessages({ threadId, signal: controller.signal })
+        .then((rawBundle) => {
+          if (disposed) return
+          const bundle = threadMessageBundleSchema.parse(rawBundle)
+          clientInvariant(
+            bundle.threadId === threadId,
+            "Thread loader response identity mismatch."
+          )
+          input.store.getState().applyMessageBundle(bundle)
+          input.generationCoordinator.resumeLoadedRuns()
+        })
+        .catch((error: unknown) => {
+          if (disposed || isAbortError(error)) return
+          input.store.getState().setThreadMessageLoadState(threadId, {
+            status: "error",
+            error: normalizeClientError(error),
+          })
+        })
+        .finally(() => {
+          inFlight.delete(threadId)
+          controllers.delete(threadId)
+        })
+      inFlight.set(threadId, request)
+      return request
+    },
+    destroy() {
+      disposed = true
+      for (const controller of controllers.values()) controller.abort()
+      controllers.clear()
+      inFlight.clear()
+    },
+  }
+}
+
+export function createArtifactLoader(input: {
+  projectId: string
+  api: ThreadChatApiCapabilities
+  store: StoreApi
+}): ArtifactLoader {
+  const inFlight = new Map>()
+  const controllers = new Map()
+  let disposed = false
+
+  return {
+    ensure(artifactId) {
+      const state = input.store.getState()
+      if (state.entities.artifactsById[artifactId]) return Promise.resolve()
+      const existing = inFlight.get(artifactId)
+      if (existing) return existing
+      state.setArtifactLoadState(artifactId, { status: "loading" })
+      const controller = new AbortController()
+      controllers.set(artifactId, controller)
+      const request = input.api
+        .loadArtifact(artifactId, controller.signal)
+        .then((rawArtifact) => {
+          if (disposed) return
+          const artifact = artifactSchema.parse(rawArtifact)
+          clientInvariant(
+            artifact.id === artifactId &&
+              artifact.projectId === input.projectId,
+            "Artifact loader response identity mismatch."
+          )
+          input.store.getState().applyArtifact(artifact)
+        })
+        .catch((error: unknown) => {
+          if (disposed || isAbortError(error)) return
+          input.store.getState().setArtifactLoadState(artifactId, {
+            status: "error",
+            error: normalizeClientError(error),
+          })
+        })
+        .finally(() => {
+          inFlight.delete(artifactId)
+          controllers.delete(artifactId)
+        })
+      inFlight.set(artifactId, request)
+      return request
+    },
+    destroy() {
+      disposed = true
+      for (const controller of controllers.values()) controller.abort()
+      controllers.clear()
+      inFlight.clear()
+    },
+  }
+}
diff --git a/lib/thread-chat/client/normalizer.ts b/lib/thread-chat/client/normalizer.ts
new file mode 100644
index 00000000..7bf27823
--- /dev/null
+++ b/lib/thread-chat/client/normalizer.ts
@@ -0,0 +1,174 @@
+import type {
+  AssistantRunState,
+  MessageEntity,
+  ProjectArtifactSummary,
+  ThreadEntity,
+  ThreadId,
+} from "./types"
+import { clientInvariant } from "./errors"
+
+function sameValue(left: unknown, right: unknown): boolean {
+  return JSON.stringify(left) === JSON.stringify(right)
+}
+
+export function normalizeThreads(input: {
+  projectId: string
+  current: Record
+  incoming: readonly ThreadEntity[]
+}): Record {
+  const threads = { ...input.current }
+  for (const thread of input.incoming) {
+    clientInvariant(
+      thread.projectId === input.projectId,
+      "Thread belongs to another Project."
+    )
+    const existing = threads[thread.id]
+    clientInvariant(
+      !existing || sameValue(existing, thread),
+      "Confirmed Thread identity changed."
+    )
+    threads[thread.id] = thread
+  }
+  for (const thread of Object.values(threads)) {
+    if (thread.projectId !== input.projectId) continue
+    if (thread.parentThreadId)
+      clientInvariant(
+        threads[thread.parentThreadId]?.projectId === input.projectId,
+        "Thread parent is missing from Project topology."
+      )
+    const visited = new Set()
+    let cursor: ThreadEntity | undefined = thread
+    while (cursor?.parentThreadId) {
+      clientInvariant(
+        !visited.has(cursor.id),
+        "Thread topology contains a cycle."
+      )
+      visited.add(cursor.id)
+      cursor = threads[cursor.parentThreadId]
+    }
+  }
+  return threads
+}
+
+export function normalizeMessages(input: {
+  threadId: ThreadId
+  currentById: Record
+  currentIdsByThread: Record
+  incoming: readonly MessageEntity[]
+}): {
+  messagesById: Record
+  messageIdsByThreadId: Record
+} {
+  const messagesById = { ...input.currentById }
+  const ids = new Set(input.currentIdsByThread[input.threadId] ?? [])
+  for (const message of input.incoming) {
+    clientInvariant(
+      message.threadId === input.threadId,
+      "Message belongs to another Thread."
+    )
+    const existing = messagesById[message.id]
+    if (existing) {
+      clientInvariant(
+        existing.threadId === message.threadId &&
+          existing.sequence === message.sequence &&
+          existing.role === message.role &&
+          existing.replacesMessageId === message.replacesMessageId &&
+          existing.createdAt === message.createdAt,
+        "Confirmed Message identity changed."
+      )
+      if (existing.finalizedAt)
+        clientInvariant(
+          existing.finalizedAt === message.finalizedAt &&
+            sameValue(existing.parts, message.parts),
+          "Finalized Message content changed."
+        )
+      if (existing.supersededAt)
+        clientInvariant(
+          existing.supersededAt === message.supersededAt,
+          "Message supersededAt changed."
+        )
+    }
+    messagesById[message.id] = message
+    ids.add(message.id)
+  }
+  const sortedIds = [...ids].toSorted((leftId, rightId) => {
+    const left = messagesById[leftId]
+    const right = messagesById[rightId]
+    return left.sequence - right.sequence || left.id.localeCompare(right.id)
+  })
+  for (let index = 1; index < sortedIds.length; index++)
+    clientInvariant(
+      messagesById[sortedIds[index - 1]].sequence !==
+        messagesById[sortedIds[index]].sequence,
+      "Thread contains duplicate Message sequence."
+    )
+  return {
+    messagesById,
+    messageIdsByThreadId: {
+      ...input.currentIdsByThread,
+      [input.threadId]: sortedIds,
+    },
+  }
+}
+
+export function normalizeRuns(input: {
+  current: Record
+  incoming: readonly AssistantRunState[]
+  messagesById: Record
+}): Record {
+  const runs = { ...input.current }
+  for (const run of input.incoming) {
+    const message = input.messagesById[run.assistantMessageId]
+    clientInvariant(
+      message?.role === "assistant",
+      "Assistant Run does not reference an assistant Message."
+    )
+    const existing = runs[run.assistantMessageId]
+    if (existing) {
+      clientInvariant(
+        run.eventSequence >= existing.eventSequence,
+        "Assistant Run eventSequence moved backwards."
+      )
+      clientInvariant(
+        run.modelId === existing.modelId,
+        "Assistant Run model identity changed."
+      )
+      const existingTerminal = ["completed", "failed", "stopped"].includes(
+        existing.status
+      )
+      if (existingTerminal)
+        clientInvariant(
+          sameValue(existing, run),
+          "Terminal Assistant Run changed."
+        )
+      if (run.eventSequence === existing.eventSequence) {
+        clientInvariant(
+          existing.status === run.status ||
+            (existing.status === "queued" && run.status === "running"),
+          "Assistant Run status changed without a valid transition."
+        )
+        clientInvariant(
+          existing.stopRequestedAt === null ||
+            existing.stopRequestedAt === run.stopRequestedAt,
+          "Assistant Run stop request moved backwards."
+        )
+      }
+    }
+    runs[run.assistantMessageId] = run
+  }
+  return runs
+}
+
+export function mergeArtifactSummary(
+  current: ProjectArtifactSummary | null,
+  incoming: ProjectArtifactSummary
+): ProjectArtifactSummary {
+  if (!current || incoming.changeSequence > current.changeSequence)
+    return incoming
+  if (incoming.changeSequence < current.changeSequence) return current
+  clientInvariant(
+    sameValue(current, incoming),
+    "Artifact Summary changed without advancing changeSequence."
+  )
+  return current
+}
diff --git a/lib/thread-chat/client/project-store.ts b/lib/thread-chat/client/project-store.ts
new file mode 100644
index 00000000..105f1ab8
--- /dev/null
+++ b/lib/thread-chat/client/project-store.ts
@@ -0,0 +1,1093 @@
+import { createStore } from "zustand/vanilla"
+import { clientInvariant } from "./errors"
+import {
+  mergeArtifactSummary,
+  normalizeMessages,
+  normalizeRuns,
+  normalizeThreads,
+} from "./normalizer"
+import type {
+  AssistantMessageEvent,
+  AssistantRunState,
+  MessageEntity,
+  ThreadChatProjectState,
+  ThreadChatProjectStore,
+  ThreadColumnSlot,
+  ThreadId,
+  ThreadWorkbenchSnapshotV1,
+} from "./types"
+
+function initialState(): ThreadChatProjectState {
+  return {
+    entities: {
+      project: null,
+      threadsById: {},
+      messagesById: {},
+      messageIdsByThreadId: {},
+      artifactsById: {},
+      feedbackByMessageId: {},
+    },
+    runs: {
+      byAssistantMessageId: {},
+      streamBuffersByAssistantMessageId: {},
+      resumedAssistantMessageIds: {},
+    },
+    requests: {
+      bootstrap: { status: "idle" },
+      threadMessagesById: {},
+      artifactById: {},
+      commandByScope: {},
+    },
+    readModels: {
+      artifactSummary: null,
+      replacementSupersededMessageIds: {},
+    },
+    ui: {
+      columnSlots: [],
+      focusedSlotId: null,
+      rootColumnWidthPx: null,
+      forceColumnCount: null,
+      placementMode: "replace",
+      viewMode: "columns",
+      canvasPins: {},
+      composerDraftByThreadId: {},
+      selectedArtifactId: null,
+      activationClock: 0,
+      lastActivatedOrderBySlotId: {},
+      overlays: {
+        selection: null,
+        threadSwitcherScope: null,
+        treeListOpen: false,
+        helpPanelOpen: false,
+        artifactDrawerOpen: false,
+      },
+    },
+  }
+}
+
+function withMessages(
+  state: ThreadChatProjectState,
+  threadId: ThreadId,
+  messages: readonly MessageEntity[],
+  runs: readonly AssistantRunState[]
+) {
+  const normalized = normalizeMessages({
+    threadId,
+    currentById: state.entities.messagesById,
+    currentIdsByThread: state.entities.messageIdsByThreadId,
+    incoming: messages,
+  })
+  return {
+    entities: {
+      ...state.entities,
+      ...normalized,
+    },
+    runs: {
+      ...state.runs,
+      byAssistantMessageId: normalizeRuns({
+        current: state.runs.byAssistantMessageId,
+        incoming: runs,
+        messagesById: normalized.messagesById,
+      }),
+    },
+  }
+}
+
+function markResumedRuns(
+  resumedAssistantMessageIds: Record,
+  runs: readonly AssistantRunState[]
+): Record {
+  return {
+    ...resumedAssistantMessageIds,
+    ...Object.fromEntries(
+      runs
+        .filter((run) => run.status === "queued" || run.status === "running")
+        .map((run) => [run.assistantMessageId, true as const])
+    ),
+  }
+}
+
+function clearResumedRun(
+  resumedAssistantMessageIds: Record,
+  run: AssistantRunState
+): Record {
+  if (run.status === "queued" || run.status === "running")
+    return resumedAssistantMessageIds
+  if (!resumedAssistantMessageIds[run.assistantMessageId])
+    return resumedAssistantMessageIds
+  const next = { ...resumedAssistantMessageIds }
+  delete next[run.assistantMessageId]
+  return next
+}
+
+function isValidWidth(width: number | null): boolean {
+  return (
+    width === null || (Number.isFinite(width) && width >= 120 && width <= 2_000)
+  )
+}
+
+function sanitizeSnapshot(
+  snapshot: ThreadWorkbenchSnapshotV1,
+  state: ThreadChatProjectState,
+  projectId: string
+): ThreadWorkbenchSnapshotV1 {
+  clientInvariant(
+    snapshot.schemaVersion === 1,
+    "Unsupported workbench snapshot."
+  )
+  const seenSlots = new Set()
+  const seenThreads = new Set()
+  const columnSlots = snapshot.columnSlots.filter((slot) => {
+    const thread = state.entities.threadsById[slot.threadId]
+    const valid =
+      slot.slotId.length > 0 &&
+      !seenSlots.has(slot.slotId) &&
+      !seenThreads.has(slot.threadId) &&
+      thread?.projectId === projectId &&
+      thread.parentThreadId !== null &&
+      isValidWidth(slot.widthPx)
+    if (valid) {
+      seenSlots.add(slot.slotId)
+      seenThreads.add(slot.threadId)
+    }
+    return valid
+  })
+  const expandedSlotIds = new Set(
+    columnSlots.filter((slot) => !slot.folded).map((slot) => slot.slotId)
+  )
+  const focusedSlotId =
+    snapshot.focusedSlotId === "root" ||
+    expandedSlotIds.has(snapshot.focusedSlotId)
+      ? snapshot.focusedSlotId
+      : (columnSlots.find((slot) => !slot.folded)?.slotId ?? "root")
+  const canvasPins = Object.fromEntries(
+    Object.entries(snapshot.canvasPins).filter(
+      ([threadId, point]) =>
+        state.entities.threadsById[threadId]?.projectId === projectId &&
+        Number.isFinite(point.x) &&
+        Number.isFinite(point.y)
+    )
+  )
+  return {
+    ...snapshot,
+    columnSlots,
+    focusedSlotId,
+    rootColumnWidthPx: isValidWidth(snapshot.rootColumnWidthPx)
+      ? snapshot.rootColumnWidthPx
+      : null,
+    forceColumnCount:
+      snapshot.forceColumnCount === null ||
+      (Number.isInteger(snapshot.forceColumnCount) &&
+        snapshot.forceColumnCount > 0 &&
+        snapshot.forceColumnCount <= 12)
+        ? snapshot.forceColumnCount
+        : null,
+    canvasPins,
+  }
+}
+
+function findRootThreadId(state: ThreadChatProjectState): string | null {
+  return (
+    Object.values(state.entities.threadsById).find(
+      (thread) => thread.parentThreadId === null
+    )?.id ?? null
+  )
+}
+
+function nextFocusedSlot(
+  slots: readonly ThreadColumnSlot[],
+  removedIndex: number
+): "root" | string {
+  for (let distance = 0; distance < slots.length; distance++) {
+    const right = slots[removedIndex + distance]
+    if (right && !right.folded) return right.slotId
+    const left = slots[removedIndex - distance - 1]
+    if (left && !left.folded) return left.slotId
+  }
+  return "root"
+}
+
+function checkpointFromEvent(event: AssistantMessageEvent) {
+  if (event.type !== "run.delta") return null
+  const chunk = event.chunk as {
+    type?: string
+    data?: { checkpointParts?: AssistantRunState["checkpointParts"] }
+  }
+  return chunk.type === "data-run-checkpoint" &&
+    Array.isArray(chunk.data?.checkpointParts)
+    ? chunk.data.checkpointParts
+    : null
+}
+
+export function createThreadChatProjectStore(input: {
+  projectId: string
+  generateSlotId?: () => string
+}) {
+  const generateSlotId =
+    input.generateSlotId ?? (() => globalThis.crypto.randomUUID())
+
+  return createStore()((set) => ({
+    ...initialState(),
+    mergeCreationBundle(bundle) {
+      set((state) => {
+        clientInvariant(
+          bundle.project.id === input.projectId,
+          "Creation Bundle belongs to another Project."
+        )
+        const threadsById = normalizeThreads({
+          projectId: input.projectId,
+          current: state.entities.threadsById,
+          incoming: [bundle.rootThread],
+        })
+        const merged = withMessages(
+          { ...state, entities: { ...state.entities, threadsById } },
+          bundle.rootThread.id,
+          [bundle.userMessage, bundle.assistantMessage],
+          [bundle.assistantRun]
+        )
+        return {
+          entities: {
+            ...merged.entities,
+            project: bundle.project,
+          },
+          runs: merged.runs,
+          requests: {
+            ...state.requests,
+            bootstrap: { status: "ready" },
+            threadMessagesById: {
+              ...state.requests.threadMessagesById,
+              [bundle.rootThread.id]: {
+                loadState: { status: "ready" },
+                hasOlderMessages: false,
+                oldestReturnedSequence: bundle.userMessage.sequence,
+                newestReturnedSequence: bundle.assistantMessage.sequence,
+              },
+            },
+          },
+          readModels: {
+            ...state.readModels,
+            artifactSummary: mergeArtifactSummary(
+              state.readModels.artifactSummary,
+              bundle.artifactSummary
+            ),
+          },
+          ui: {
+            ...state.ui,
+            focusedSlotId: "root",
+            activationClock: state.ui.activationClock + 1,
+            lastActivatedOrderBySlotId: {
+              ...state.ui.lastActivatedOrderBySlotId,
+              root: state.ui.activationClock + 1,
+            },
+          },
+        }
+      })
+    },
+    mergeBootstrap(bootstrap) {
+      set((state) => {
+        clientInvariant(
+          bootstrap.project.id === input.projectId,
+          "Bootstrap belongs to another Project."
+        )
+        const threadsById = normalizeThreads({
+          projectId: input.projectId,
+          current: state.entities.threadsById,
+          incoming: bootstrap.threadTopology,
+        })
+        const merged = withMessages(
+          { ...state, entities: { ...state.entities, threadsById } },
+          bootstrap.initialThread.threadId,
+          bootstrap.initialThread.messages,
+          bootstrap.initialThread.assistantRuns
+        )
+        return {
+          entities: {
+            ...merged.entities,
+            project: bootstrap.project,
+          },
+          runs: {
+            ...merged.runs,
+            resumedAssistantMessageIds: markResumedRuns(
+              merged.runs.resumedAssistantMessageIds,
+              bootstrap.initialThread.assistantRuns
+            ),
+          },
+          requests: {
+            ...state.requests,
+            bootstrap: { status: "ready" },
+            threadMessagesById: {
+              ...state.requests.threadMessagesById,
+              [bootstrap.initialThread.threadId]: {
+                loadState: { status: "ready" },
+                hasOlderMessages: bootstrap.initialThread.hasOlderMessages,
+                oldestReturnedSequence:
+                  bootstrap.initialThread.oldestReturnedSequence,
+                newestReturnedSequence:
+                  bootstrap.initialThread.newestReturnedSequence,
+              },
+            },
+          },
+          readModels: {
+            ...state.readModels,
+            artifactSummary: mergeArtifactSummary(
+              state.readModels.artifactSummary,
+              bootstrap.artifactSummary
+            ),
+          },
+          ui: {
+            ...state.ui,
+            focusedSlotId: state.ui.focusedSlotId ?? "root",
+          },
+        }
+      })
+    },
+    applyMessageBundle(bundle) {
+      set((state) => {
+        const thread = state.entities.threadsById[bundle.threadId]
+        clientInvariant(
+          thread?.projectId === input.projectId,
+          "Message Bundle Thread belongs to another Project."
+        )
+        const merged = withMessages(
+          state,
+          bundle.threadId,
+          bundle.messages,
+          bundle.assistantRuns
+        )
+        return {
+          entities: merged.entities,
+          runs: {
+            ...merged.runs,
+            resumedAssistantMessageIds: markResumedRuns(
+              merged.runs.resumedAssistantMessageIds,
+              bundle.assistantRuns
+            ),
+          },
+          requests: {
+            ...state.requests,
+            threadMessagesById: {
+              ...state.requests.threadMessagesById,
+              [bundle.threadId]: {
+                loadState: { status: "ready" },
+                hasOlderMessages: bundle.hasOlderMessages,
+                oldestReturnedSequence: bundle.oldestReturnedSequence,
+                newestReturnedSequence: bundle.newestReturnedSequence,
+              },
+            },
+          },
+        }
+      })
+    },
+    applyMessageCreationBundle(bundle) {
+      set((state) => {
+        clientInvariant(
+          bundle.userMessage.threadId === bundle.assistantMessage.threadId &&
+            state.entities.threadsById[bundle.userMessage.threadId]
+              ?.projectId === input.projectId,
+          "Message Creation Bundle belongs to another Project."
+        )
+        return withMessages(
+          state,
+          bundle.userMessage.threadId,
+          [bundle.userMessage, bundle.assistantMessage],
+          [bundle.assistantRun]
+        )
+      })
+    },
+    applyThreadCreated(thread) {
+      set((state) => ({
+        entities: {
+          ...state.entities,
+          threadsById: normalizeThreads({
+            projectId: input.projectId,
+            current: state.entities.threadsById,
+            incoming: [thread],
+          }),
+        },
+      }))
+    },
+    applyReplacementBundle(bundle) {
+      set((state) => {
+        clientInvariant(
+          bundle.createdMessages.length > 0,
+          "Replacement Bundle contains no Message."
+        )
+        const threadId = bundle.createdMessages[0].threadId
+        clientInvariant(
+          bundle.createdMessages.every(
+            (message) => message.threadId === threadId
+          ) &&
+            state.entities.threadsById[threadId]?.projectId ===
+              input.projectId &&
+            bundle.supersededMessageIds.every(
+              (messageId) => state.entities.messagesById[messageId]
+            ),
+          "Replacement Bundle relations are invalid."
+        )
+        const merged = withMessages(state, threadId, bundle.createdMessages, [
+          bundle.assistantRun,
+        ])
+        return {
+          ...merged,
+          readModels: {
+            ...state.readModels,
+            replacementSupersededMessageIds: {
+              ...state.readModels.replacementSupersededMessageIds,
+              ...Object.fromEntries(
+                bundle.supersededMessageIds.map((messageId) => [
+                  messageId,
+                  true,
+                ])
+              ),
+            },
+          },
+        }
+      })
+    },
+    applyRunEvent(event, scopedAssistantMessageId) {
+      set((state) => {
+        if (event.type === "run.delta") {
+          const assistantMessageId = scopedAssistantMessageId
+          const run = assistantMessageId
+            ? state.runs.byAssistantMessageId[assistantMessageId]
+            : undefined
+          clientInvariant(
+            assistantMessageId && run,
+            "Run delta cannot be associated with a loaded assistant Message."
+          )
+          if (event.eventSequence <= run.eventSequence) return state
+          const currentBuffer = state.runs.streamBuffersByAssistantMessageId[
+            assistantMessageId
+          ] ?? {
+            pendingChunks: [],
+            lastReceivedEventSequence: run.eventSequence,
+            flushScheduled: false,
+          }
+          if (event.eventSequence <= currentBuffer.lastReceivedEventSequence)
+            return state
+          clientInvariant(
+            event.eventSequence === currentBuffer.lastReceivedEventSequence + 1,
+            "Run delta eventSequence contains a gap."
+          )
+          return {
+            runs: {
+              ...state.runs,
+              byAssistantMessageId: {
+                ...state.runs.byAssistantMessageId,
+                [assistantMessageId]: {
+                  ...run,
+                  status: "running",
+                  eventSequence: event.eventSequence,
+                },
+              },
+              streamBuffersByAssistantMessageId: {
+                ...state.runs.streamBuffersByAssistantMessageId,
+                [assistantMessageId]: {
+                  pendingChunks: [...currentBuffer.pendingChunks, event.chunk],
+                  lastReceivedEventSequence: event.eventSequence,
+                  flushScheduled: true,
+                },
+              },
+            },
+          }
+        }
+
+        const run = event.run
+        const existing = state.runs.byAssistantMessageId[run.assistantMessageId]
+        if (existing && run.eventSequence < existing.eventSequence) return state
+        let entities = state.entities
+        if (
+          event.type === "run.snapshot" ||
+          event.type === "run.completed" ||
+          event.type === "run.stopped"
+        ) {
+          const normalized = normalizeMessages({
+            threadId: event.message.threadId,
+            currentById: entities.messagesById,
+            currentIdsByThread: entities.messageIdsByThreadId,
+            incoming: [event.message],
+          })
+          entities = { ...entities, ...normalized }
+        }
+        return {
+          entities,
+          runs: {
+            ...state.runs,
+            byAssistantMessageId: normalizeRuns({
+              current: state.runs.byAssistantMessageId,
+              incoming: [run],
+              messagesById: entities.messagesById,
+            }),
+            streamBuffersByAssistantMessageId: Object.fromEntries(
+              Object.entries(
+                state.runs.streamBuffersByAssistantMessageId
+              ).filter(
+                ([assistantMessageId]) =>
+                  assistantMessageId !== run.assistantMessageId
+              )
+            ),
+            resumedAssistantMessageIds: clearResumedRun(
+              state.runs.resumedAssistantMessageIds,
+              run
+            ),
+          },
+          readModels:
+            event.type === "run.snapshot" || event.type === "run.completed"
+              ? {
+                  ...state.readModels,
+                  artifactSummary: mergeArtifactSummary(
+                    state.readModels.artifactSummary,
+                    event.artifactSummary
+                  ),
+                }
+              : state.readModels,
+        }
+      })
+    },
+    applyAssistantRun(run) {
+      set((state) => ({
+        runs: {
+          ...state.runs,
+          byAssistantMessageId: normalizeRuns({
+            current: state.runs.byAssistantMessageId,
+            incoming: [run],
+            messagesById: state.entities.messagesById,
+          }),
+          resumedAssistantMessageIds: clearResumedRun(
+            state.runs.resumedAssistantMessageIds,
+            run
+          ),
+        },
+      }))
+    },
+    flushRunBuffer(assistantMessageId) {
+      set((state) => {
+        const buffer =
+          state.runs.streamBuffersByAssistantMessageId[assistantMessageId]
+        const run = state.runs.byAssistantMessageId[assistantMessageId]
+        if (!buffer || !run) return state
+        let checkpointParts = run.checkpointParts
+        for (const chunk of buffer.pendingChunks) {
+          const candidate = checkpointFromEvent({
+            type: "run.delta",
+            eventSequence: buffer.lastReceivedEventSequence,
+            chunk,
+          })
+          if (candidate) checkpointParts = candidate
+        }
+        return {
+          runs: {
+            ...state.runs,
+            byAssistantMessageId: {
+              ...state.runs.byAssistantMessageId,
+              [assistantMessageId]: { ...run, checkpointParts },
+            },
+            streamBuffersByAssistantMessageId: {
+              ...state.runs.streamBuffersByAssistantMessageId,
+              [assistantMessageId]: {
+                ...buffer,
+                pendingChunks: [],
+                flushScheduled: false,
+              },
+            },
+          },
+        }
+      })
+    },
+    applyArtifact(artifact) {
+      set((state) => {
+        clientInvariant(
+          artifact.projectId === input.projectId,
+          "Artifact belongs to another Project."
+        )
+        return {
+          entities: {
+            ...state.entities,
+            artifactsById: {
+              ...state.entities.artifactsById,
+              [artifact.id]: artifact,
+            },
+          },
+          requests: {
+            ...state.requests,
+            artifactById: {
+              ...state.requests.artifactById,
+              [artifact.id]: { status: "ready" },
+            },
+          },
+        }
+      })
+    },
+    applyProject(project) {
+      clientInvariant(
+        project.id === input.projectId,
+        "Project response belongs to another Runtime."
+      )
+      set((state) => ({ entities: { ...state.entities, project } }))
+    },
+    applyThread(thread) {
+      set((state) => ({
+        entities: {
+          ...state.entities,
+          threadsById: {
+            ...state.entities.threadsById,
+            ...normalizeThreads({
+              projectId: input.projectId,
+              current: state.entities.threadsById,
+              incoming: [thread],
+            }),
+          },
+        },
+      }))
+    },
+    applyFeedback(feedback) {
+      set((state) => {
+        clientInvariant(
+          state.entities.messagesById[feedback.messageId]?.role === "assistant",
+          "Feedback response does not reference a loaded assistant Message."
+        )
+        return {
+          entities: {
+            ...state.entities,
+            feedbackByMessageId: {
+              ...state.entities.feedbackByMessageId,
+              [feedback.messageId]: feedback,
+            },
+          },
+        }
+      })
+    },
+    setBootstrapLoadState(bootstrap) {
+      set((state) => ({ requests: { ...state.requests, bootstrap } }))
+    },
+    setCommandState(scope, commandState) {
+      set((state) => {
+        const commandByScope = { ...state.requests.commandByScope }
+        if (commandState) commandByScope[scope] = commandState
+        else delete commandByScope[scope]
+        return {
+          requests: { ...state.requests, commandByScope },
+        }
+      })
+    },
+    setThreadMessageLoadState(threadId, loadState) {
+      set((state) => ({
+        requests: {
+          ...state.requests,
+          threadMessagesById: {
+            ...state.requests.threadMessagesById,
+            [threadId]: {
+              ...(state.requests.threadMessagesById[threadId] ?? {
+                hasOlderMessages: false,
+                oldestReturnedSequence: null,
+                newestReturnedSequence: null,
+              }),
+              loadState,
+            },
+          },
+        },
+      }))
+    },
+    setArtifactLoadState(artifactId, loadState) {
+      set((state) => ({
+        requests: {
+          ...state.requests,
+          artifactById: {
+            ...state.requests.artifactById,
+            [artifactId]: loadState,
+          },
+        },
+      }))
+    },
+    restoreWorkbenchSnapshot(snapshot) {
+      set((state) => {
+        clientInvariant(
+          state.requests.bootstrap.status === "ready",
+          "Workbench can only be restored after Bootstrap."
+        )
+        const sanitized = sanitizeSnapshot(snapshot, state, input.projectId)
+        return {
+          ui: {
+            ...state.ui,
+            columnSlots: sanitized.columnSlots,
+            focusedSlotId: sanitized.focusedSlotId,
+            rootColumnWidthPx: sanitized.rootColumnWidthPx,
+            forceColumnCount: sanitized.forceColumnCount,
+            placementMode: sanitized.placementMode,
+            viewMode: sanitized.viewMode,
+            canvasPins: sanitized.canvasPins,
+          },
+        }
+      })
+    },
+    resetWorkbenchToDefault() {
+      set((state) => ({
+        ui: {
+          ...state.ui,
+          columnSlots: [],
+          focusedSlotId: findRootThreadId(state) ? "root" : null,
+          rootColumnWidthPx: null,
+          forceColumnCount: null,
+          placementMode: "replace",
+          viewMode: "columns",
+          canvasPins: {},
+        },
+      }))
+    },
+    openThread(threadId, sourceSlotId, placement) {
+      set((state) => {
+        const thread = state.entities.threadsById[threadId]
+        clientInvariant(
+          thread?.projectId === input.projectId &&
+            thread.parentThreadId !== null,
+          "Only a Branch in this Project can open in a column."
+        )
+        const existingIndex = state.ui.columnSlots.findIndex(
+          (slot) => slot.threadId === threadId
+        )
+        const maxExpanded = Math.max(
+          0,
+          placement?.maxExpanded ??
+            (state.ui.forceColumnCount === null
+              ? Number.POSITIVE_INFINITY
+              : state.ui.forceColumnCount - 1)
+        )
+        const clock = state.ui.activationClock + 1
+        if (existingIndex >= 0) {
+          let slots = state.ui.columnSlots.map((slot, index) =>
+            index === existingIndex ? { ...slot, folded: false } : slot
+          )
+          const slotId = slots[existingIndex].slotId
+          if (
+            state.ui.placementMode === "fold" &&
+            slots.filter((slot) => !slot.folded).length > maxExpanded
+          ) {
+            const candidates = slots
+              .filter(
+                (slot) =>
+                  !slot.folded &&
+                  slot.slotId !== slotId &&
+                  slot.slotId !== sourceSlotId
+              )
+              .toSorted(
+                (left, right) =>
+                  (state.ui.lastActivatedOrderBySlotId[left.slotId] ?? 0) -
+                  (state.ui.lastActivatedOrderBySlotId[right.slotId] ?? 0)
+              )
+            const fallback = slots.find(
+              (slot) => !slot.folded && slot.slotId !== slotId
+            )
+            const foldTarget = candidates[0] ?? fallback
+            if (foldTarget)
+              slots = slots.map((slot) =>
+                slot.slotId === foldTarget.slotId
+                  ? { ...slot, folded: true }
+                  : slot
+              )
+          }
+          return {
+            ui: {
+              ...state.ui,
+              columnSlots: slots,
+              focusedSlotId: slotId,
+              activationClock: clock,
+              lastActivatedOrderBySlotId: {
+                ...state.ui.lastActivatedOrderBySlotId,
+                [slotId]: clock,
+              },
+            },
+          }
+        }
+
+        let slots = [...state.ui.columnSlots]
+        const sourceIndex =
+          sourceSlotId === "root"
+            ? -1
+            : slots.findIndex((slot) => slot.slotId === sourceSlotId)
+        const expandedSlots = () => slots.filter((slot) => !slot.folded)
+        const lru = (pool: readonly ThreadColumnSlot[]) =>
+          pool.toSorted(
+            (left, right) =>
+              (state.ui.lastActivatedOrderBySlotId[left.slotId] ?? 0) -
+              (state.ui.lastActivatedOrderBySlotId[right.slotId] ?? 0)
+          )[0]
+        const finish = (slotId: string) => ({
+          ui: {
+            ...state.ui,
+            columnSlots: slots,
+            focusedSlotId: slotId,
+            activationClock: clock,
+            lastActivatedOrderBySlotId: {
+              ...state.ui.lastActivatedOrderBySlotId,
+              [slotId]: clock,
+            },
+          },
+        })
+        const replaceSlot = (slotId: string) => {
+          slots = slots.map((slot) =>
+            slot.slotId === slotId
+              ? { ...slot, threadId, folded: false }
+              : slot
+          )
+          return finish(slotId)
+        }
+
+        const target = placement?.targetSlotId
+          ? slots.find((slot) => slot.slotId === placement.targetSlotId)
+          : undefined
+        if (target && state.ui.placementMode === "replace")
+          return replaceSlot(target.slotId)
+
+        if (
+          state.ui.placementMode === "replace" &&
+          expandedSlots().length >= maxExpanded
+        ) {
+          let candidate = target
+          if (!candidate && placement?.keepSource) {
+            candidate = slots[sourceIndex + 1]
+            if (!candidate)
+              candidate = lru(
+                expandedSlots().filter(
+                  (slot) => slot.slotId !== sourceSlotId
+                )
+              )
+          }
+          if (!candidate && sourceSlotId !== "root")
+            candidate = slots.find((slot) => slot.slotId === sourceSlotId)
+          candidate ??= lru(expandedSlots())
+          if (candidate) return replaceSlot(candidate.slotId)
+        }
+
+        const slotId = generateSlotId()
+        const insertAt = placement?.keepSource ? sourceIndex + 1 : slots.length
+        slots.splice(insertAt, 0, {
+          slotId,
+          threadId,
+          folded: false,
+          widthPx: null,
+        })
+
+        if (state.ui.placementMode === "fold") {
+          let foldTarget = target
+          if (!foldTarget && expandedSlots().length > maxExpanded) {
+            const preferred = expandedSlots().filter(
+              (slot) =>
+                slot.slotId !== slotId && slot.slotId !== sourceSlotId
+            )
+            foldTarget = lru(
+              preferred.length
+                ? preferred
+                : expandedSlots().filter((slot) => slot.slotId !== slotId)
+            )
+          }
+          if (foldTarget && foldTarget.slotId !== slotId)
+            slots = slots.map((slot) =>
+              slot.slotId === foldTarget.slotId
+                ? { ...slot, folded: true }
+                : slot
+            )
+        }
+        return {
+          ui: {
+            ...state.ui,
+            columnSlots: slots,
+            focusedSlotId: slotId,
+            activationClock: clock,
+            lastActivatedOrderBySlotId: {
+              ...state.ui.lastActivatedOrderBySlotId,
+              [slotId]: clock,
+            },
+          },
+        }
+      })
+    },
+    switchColumnThread(slotId, threadId) {
+      set((state) => {
+        const thread = state.entities.threadsById[threadId]
+        clientInvariant(
+          thread?.projectId === input.projectId &&
+            thread.parentThreadId !== null,
+          "Column target must be a Branch in this Project."
+        )
+        const source = state.ui.columnSlots.find(
+          (slot) => slot.slotId === slotId
+        )
+        clientInvariant(source, "Column Slot does not exist.")
+        const duplicate = state.ui.columnSlots.find(
+          (slot) => slot.slotId !== slotId && slot.threadId === threadId
+        )
+        const clock = state.ui.activationClock + 1
+        return {
+          ui: {
+            ...state.ui,
+            columnSlots: state.ui.columnSlots.map((slot) => {
+              if (slot.slotId === slotId)
+                return { ...slot, threadId, folded: false }
+              if (duplicate?.slotId === slot.slotId)
+                return { ...slot, threadId: source.threadId }
+              return slot
+            }),
+            focusedSlotId: slotId,
+            activationClock: clock,
+            lastActivatedOrderBySlotId: {
+              ...state.ui.lastActivatedOrderBySlotId,
+              [slotId]: clock,
+            },
+          },
+        }
+      })
+    },
+    closeColumn(slotId) {
+      set((state) => {
+        const index = state.ui.columnSlots.findIndex(
+          (slot) => slot.slotId === slotId
+        )
+        if (index < 0) return state
+        const columnSlots = state.ui.columnSlots.filter(
+          (slot) => slot.slotId !== slotId
+        )
+        const focusedSlotId =
+          state.ui.focusedSlotId === slotId
+            ? nextFocusedSlot(columnSlots, index)
+            : state.ui.focusedSlotId
+        const lastActivatedOrderBySlotId = {
+          ...state.ui.lastActivatedOrderBySlotId,
+        }
+        delete lastActivatedOrderBySlotId[slotId]
+        return {
+          ui: {
+            ...state.ui,
+            columnSlots,
+            focusedSlotId,
+            lastActivatedOrderBySlotId,
+          },
+        }
+      })
+    },
+    setColumnFolded(slotId, folded) {
+      set((state) => {
+        const index = state.ui.columnSlots.findIndex(
+          (slot) => slot.slotId === slotId
+        )
+        if (index < 0) return state
+        const columnSlots = state.ui.columnSlots.map((slot) =>
+          slot.slotId === slotId ? { ...slot, folded } : slot
+        )
+        return {
+          ui: {
+            ...state.ui,
+            columnSlots,
+            focusedSlotId:
+              folded && state.ui.focusedSlotId === slotId
+                ? nextFocusedSlot(columnSlots, index)
+                : folded
+                  ? state.ui.focusedSlotId
+                  : slotId,
+          },
+        }
+      })
+    },
+    focusColumn(slotId) {
+      set((state) => {
+        clientInvariant(
+          slotId === "root" ||
+            state.ui.columnSlots.some(
+              (slot) => slot.slotId === slotId && !slot.folded
+            ),
+          "Focused column must exist and be expanded."
+        )
+        const clock = state.ui.activationClock + 1
+        return {
+          ui: {
+            ...state.ui,
+            focusedSlotId: slotId,
+            activationClock: clock,
+            lastActivatedOrderBySlotId: {
+              ...state.ui.lastActivatedOrderBySlotId,
+              [slotId]: clock,
+            },
+          },
+        }
+      })
+    },
+    commitColumnWidths(widths) {
+      set((state) => {
+        for (const width of Object.values(widths))
+          clientInvariant(
+            width === undefined || isValidWidth(width),
+            "Column width is invalid."
+          )
+        return {
+          ui: {
+            ...state.ui,
+            rootColumnWidthPx:
+              widths.root === undefined
+                ? state.ui.rootColumnWidthPx
+                : widths.root,
+            columnSlots: state.ui.columnSlots.map((slot) => {
+              const width = widths[slot.slotId]
+              return {
+                ...slot,
+                widthPx: width === undefined ? slot.widthPx : width,
+              }
+            }),
+          },
+        }
+      })
+    },
+    setForceColumnCount(forceColumnCount) {
+      clientInvariant(
+        forceColumnCount === null ||
+          (Number.isInteger(forceColumnCount) && forceColumnCount > 0),
+        "Forced column count is invalid."
+      )
+      set((state) => ({ ui: { ...state.ui, forceColumnCount } }))
+    },
+    setPlacementMode(placementMode) {
+      set((state) => ({ ui: { ...state.ui, placementMode } }))
+    },
+    setViewMode(viewMode) {
+      set((state) => ({ ui: { ...state.ui, viewMode } }))
+    },
+    setCanvasPin(threadId, point) {
+      set((state) => {
+        clientInvariant(
+          state.entities.threadsById[threadId]?.projectId === input.projectId,
+          "Canvas pin Thread belongs to another Project."
+        )
+        const canvasPins = { ...state.ui.canvasPins }
+        if (point) canvasPins[threadId] = point
+        else delete canvasPins[threadId]
+        return { ui: { ...state.ui, canvasPins } }
+      })
+    },
+    setComposerDraft(threadId, parts) {
+      set((state) => {
+        clientInvariant(
+          state.entities.threadsById[threadId]?.projectId === input.projectId,
+          "Composer draft Thread belongs to another Project."
+        )
+        return {
+          ui: {
+            ...state.ui,
+            composerDraftByThreadId: {
+              ...state.ui.composerDraftByThreadId,
+              [threadId]: parts,
+            },
+          },
+        }
+      })
+    },
+    setSelectedArtifact(selectedArtifactId) {
+      set((state) => ({ ui: { ...state.ui, selectedArtifactId } }))
+    },
+    setOverlays(patch) {
+      set((state) => ({
+        ui: {
+          ...state.ui,
+          overlays: { ...state.ui.overlays, ...patch },
+        },
+      }))
+    },
+  }))
+}
diff --git a/lib/thread-chat/client/providers.tsx b/lib/thread-chat/client/providers.tsx
new file mode 100644
index 00000000..e37d0d25
--- /dev/null
+++ b/lib/thread-chat/client/providers.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import {
+  createContext,
+  useContext,
+  useEffect,
+  useState,
+  type ReactNode,
+} from "react"
+import { useRouter } from "next/navigation"
+import type { ThreadChatApiCapabilities } from "../api/capabilities"
+import {
+  createNewProjectDraftStore,
+  createThreadChatAppRuntime,
+} from "./runtime"
+import type {
+  ListProjectsResult,
+  NavigationCapability,
+  NewProjectDraftStore,
+  ThreadChatAppRuntime,
+  ThreadChatProjectRuntime,
+} from "./types"
+import type { StoreApi } from "zustand/vanilla"
+
+const ThreadChatAppRuntimeContext = createContext(
+  null
+)
+const ThreadChatProjectRuntimeContext =
+  createContext(null)
+const NewProjectDraftStoreContext =
+  createContext | null>(null)
+const appRuntimeLifecycleVersions = new WeakMap()
+
+function projectIdFromPathname(pathname: string): string | null {
+  const match = pathname.match(/^\/thread-chat\/([^/]+)$/)
+  if (!match || match[1] === "new") return null
+  return decodeURIComponent(match[1])
+}
+
+export function ThreadChatAppProvider({
+  children,
+  initialCatalog,
+  api,
+  navigation,
+}: {
+  children: ReactNode
+  initialCatalog?: ListProjectsResult
+  api?: ThreadChatApiCapabilities
+  navigation?: NavigationCapability
+}) {
+  const router = useRouter()
+  const [runtime] = useState(() =>
+    createThreadChatAppRuntime({
+      api,
+      initialCatalog,
+      navigation: navigation ?? {
+        replace: (path) => router.replace(path),
+        currentProjectId: () =>
+          typeof window === "undefined"
+            ? null
+            : projectIdFromPathname(window.location.pathname),
+      },
+    })
+  )
+  useEffect(() => {
+    const version = (appRuntimeLifecycleVersions.get(runtime) ?? 0) + 1
+    appRuntimeLifecycleVersions.set(runtime, version)
+    return () => {
+      queueMicrotask(() => {
+        if (appRuntimeLifecycleVersions.get(runtime) === version) {
+          appRuntimeLifecycleVersions.delete(runtime)
+          runtime.destroy()
+        }
+      })
+    }
+  }, [runtime])
+  return (
+    
+      {children}
+    
+  )
+}
+
+export function ThreadChatProjectProvider({
+  children,
+  projectId,
+}: {
+  children: ReactNode
+  projectId: string
+}) {
+  const appRuntime = useThreadChatAppRuntime()
+  const [runtime] = useState(() =>
+    appRuntime.projectRuntimeRegistry.acquire(projectId)
+  )
+
+  useEffect(() => {
+    const bootstrap = runtime.store.getState().requests.bootstrap
+    const ready =
+      bootstrap.status === "ready"
+        ? Promise.resolve()
+        : runtime.commands.loadProjectBootstrap()
+    runtime.generationCoordinator.resumeLoadedRuns()
+    void ready.finally(() => {
+      if (appRuntime.appStore.getState().shellUi.pendingProjectId === projectId)
+        appRuntime.appStore.getState().setProjectRoutePending(null)
+    })
+  }, [appRuntime, projectId, runtime])
+
+  useEffect(() => {
+    const leased = appRuntime.projectRuntimeRegistry.acquire(projectId)
+    if (leased !== runtime)
+      throw new Error("Project Runtime identity changed during Provider lease.")
+    return () => appRuntime.projectRuntimeRegistry.release(projectId)
+  }, [appRuntime, projectId, runtime])
+
+  return (
+    
+      {children}
+    
+  )
+}
+
+export function NewProjectDraftProvider({
+  children,
+  initialRequestedModelId,
+}: {
+  children: ReactNode
+  initialRequestedModelId?: string
+}) {
+  const [store] = useState(() =>
+    createNewProjectDraftStore(initialRequestedModelId)
+  )
+  return (
+    
+      {children}
+    
+  )
+}
+
+export function useThreadChatAppRuntime(): ThreadChatAppRuntime {
+  const runtime = useContext(ThreadChatAppRuntimeContext)
+  if (!runtime)
+    throw new Error("ThreadChatAppProvider is required for this hook.")
+  return runtime
+}
+
+export function useThreadChatProjectRuntime(): ThreadChatProjectRuntime {
+  const runtime = useContext(ThreadChatProjectRuntimeContext)
+  if (!runtime)
+    throw new Error("ThreadChatProjectProvider is required for this hook.")
+  return runtime
+}
+
+export function useNewProjectDraftStoreApi(): StoreApi {
+  const store = useContext(NewProjectDraftStoreContext)
+  if (!store)
+    throw new Error("NewProjectDraftProvider is required for this hook.")
+  return store
+}
diff --git a/lib/thread-chat/client/runtime.ts b/lib/thread-chat/client/runtime.ts
new file mode 100644
index 00000000..90464373
--- /dev/null
+++ b/lib/thread-chat/client/runtime.ts
@@ -0,0 +1,209 @@
+import { createStore } from "zustand/vanilla"
+import { JsonThreadChatTransport } from "../api/json-transport"
+import { createThreadChatAppStore } from "./app-store"
+import {
+  createThreadChatAppCommands,
+  createThreadChatProjectCommands,
+} from "./commands"
+import { clientInvariant } from "./errors"
+import { createGenerationCoordinator } from "./generation-coordinator"
+import { createArtifactLoader, createThreadMessageLoader } from "./loaders"
+import { createThreadChatProjectStore } from "./project-store"
+import type {
+  CreationBundle,
+  ListProjectsResult,
+  NavigationCapability,
+  NewProjectDraftStore,
+  ProjectRuntimeRegistry,
+  ThreadChatAppRuntime,
+  ThreadChatProjectRuntime,
+} from "./types"
+import type { ThreadChatApiCapabilities } from "../api/capabilities"
+
+export function createNewProjectDraftStore(initialRequestedModelId?: string) {
+  return createStore()((set) => ({
+    draftParts: [],
+    requestedModelId: initialRequestedModelId,
+    status: "idle",
+    error: null,
+    setDraftParts(draftParts) {
+      set({ draftParts })
+    },
+    setRequestedModelId(requestedModelId) {
+      set({ requestedModelId })
+    },
+    markSubmitting() {
+      set({ status: "submitting", error: null })
+    },
+    markError(error) {
+      set({ status: "error", error })
+    },
+    markIdle() {
+      set({ status: "idle", error: null })
+    },
+  }))
+}
+
+export function createThreadChatProjectRuntime(input: {
+  projectId: string
+  api: ThreadChatApiCapabilities
+  generateSlotId?: () => string
+  scheduleFlush?: (callback: () => void) => () => void
+  waitForReconnect?: (signal: AbortSignal) => Promise
+}): ThreadChatProjectRuntime {
+  const store = createThreadChatProjectStore({
+    projectId: input.projectId,
+    generateSlotId: input.generateSlotId,
+  })
+  const generationCoordinator = createGenerationCoordinator({
+    api: input.api,
+    store,
+    scheduleFlush: input.scheduleFlush,
+    waitForReconnect: input.waitForReconnect,
+  })
+  const messageLoader = createThreadMessageLoader({
+    projectId: input.projectId,
+    api: input.api,
+    store,
+    generationCoordinator,
+  })
+  const artifactLoader = createArtifactLoader({
+    projectId: input.projectId,
+    api: input.api,
+    store,
+  })
+  const projectCommands = createThreadChatProjectCommands({
+    projectId: input.projectId,
+    api: input.api,
+    store,
+    messageLoader,
+    artifactLoader,
+    generationCoordinator,
+  })
+  let disposed = false
+  return {
+    projectId: input.projectId,
+    store,
+    commands: projectCommands.commands,
+    messageLoader,
+    artifactLoader,
+    generationCoordinator,
+    destroy() {
+      if (disposed) return
+      disposed = true
+      projectCommands.destroy()
+      messageLoader.destroy()
+      artifactLoader.destroy()
+      generationCoordinator.destroy()
+    },
+  }
+}
+
+export function createProjectRuntimeRegistry(input: {
+  createRuntime(projectId: string): ThreadChatProjectRuntime
+}): ProjectRuntimeRegistry {
+  const entries = new Map<
+    string,
+    {
+      runtime: ThreadChatProjectRuntime
+      leased: boolean
+      handoff: boolean
+    }
+  >()
+
+  return {
+    seedFromCreation(bundle: CreationBundle) {
+      let entry = entries.get(bundle.project.id)
+      if (!entry) {
+        entry = {
+          runtime: input.createRuntime(bundle.project.id),
+          leased: false,
+          handoff: true,
+        }
+        entries.set(bundle.project.id, entry)
+      } else {
+        clientInvariant(
+          entry.runtime.store.getState().entities.project === null,
+          "Cannot seed an initialized Project Runtime."
+        )
+        entry.handoff = true
+      }
+      entry.runtime.store.getState().mergeCreationBundle(bundle)
+      return entry.runtime
+    },
+    acquire(projectId) {
+      let entry = entries.get(projectId)
+      if (!entry) {
+        entry = {
+          runtime: input.createRuntime(projectId),
+          leased: true,
+          handoff: false,
+        }
+        entries.set(projectId, entry)
+      } else {
+        entry.leased = true
+        entry.handoff = false
+      }
+      return entry.runtime
+    },
+    release(projectId) {
+      const entry = entries.get(projectId)
+      if (!entry) return
+      entry.leased = false
+      queueMicrotask(() => {
+        if (
+          entries.get(projectId) === entry &&
+          !entry.leased &&
+          !entry.handoff
+        ) {
+          entry.runtime.destroy()
+          entries.delete(projectId)
+        }
+      })
+    },
+    peek(projectId) {
+      return entries.get(projectId)?.runtime ?? null
+    },
+    destroy() {
+      for (const entry of entries.values()) entry.runtime.destroy()
+      entries.clear()
+    },
+  }
+}
+
+export function createThreadChatAppRuntime(input: {
+  api?: ThreadChatApiCapabilities
+  navigation: NavigationCapability
+  initialCatalog?: ListProjectsResult
+  createProjectRuntime?: (
+    projectId: string,
+    api: ThreadChatApiCapabilities
+  ) => ThreadChatProjectRuntime
+}): ThreadChatAppRuntime {
+  const api = input.api ?? new JsonThreadChatTransport()
+  const appStore = createThreadChatAppStore(input.initialCatalog)
+  const registry = createProjectRuntimeRegistry({
+    createRuntime: (projectId) =>
+      input.createProjectRuntime?.(projectId, api) ??
+      createThreadChatProjectRuntime({ projectId, api }),
+  })
+  const appCommands = createThreadChatAppCommands({
+    api,
+    store: appStore,
+    navigation: input.navigation,
+  })
+  let disposed = false
+  return {
+    appStore,
+    projectRuntimeRegistry: registry,
+    api,
+    commands: appCommands.commands,
+    navigation: input.navigation,
+    destroy() {
+      if (disposed) return
+      disposed = true
+      appCommands.destroy()
+      registry.destroy()
+    },
+  }
+}
diff --git a/lib/thread-chat/client/selectors.ts b/lib/thread-chat/client/selectors.ts
new file mode 100644
index 00000000..73696234
--- /dev/null
+++ b/lib/thread-chat/client/selectors.ts
@@ -0,0 +1,255 @@
+import type {
+  ArtifactEntity,
+  MessageEntity,
+  ProjectId,
+  ThreadChatAppStore,
+  ThreadChatProjectStore,
+  ThreadColumnSlot,
+} from "./types"
+
+export const selectProjectCatalog = (state: ThreadChatAppStore) => state.catalog
+
+export const selectAppShellUi = (state: ThreadChatAppStore) => state.shellUi
+
+export function selectFilteredProjectIds(
+  state: ThreadChatAppStore
+): ProjectId[] {
+  const query = state.shellUi.projectSearchQuery.trim().toLocaleLowerCase()
+  if (!query) return state.catalog.orderedProjectIds
+  return state.catalog.orderedProjectIds.filter((projectId) =>
+    state.catalog.projectsById[projectId]?.displayTitle
+      .toLocaleLowerCase()
+      .includes(query)
+  )
+}
+
+export const selectProject = (state: ThreadChatProjectStore) =>
+  state.entities.project
+
+export const selectProjectTarget = (state: ThreadChatProjectStore) =>
+  state.entities.project?.target ?? null
+
+export const selectThread = (state: ThreadChatProjectStore, threadId: string) =>
+  state.entities.threadsById[threadId] ?? null
+
+export const selectRootThread = (state: ThreadChatProjectStore) =>
+  Object.values(state.entities.threadsById).find(
+    (thread) => thread.parentThreadId === null
+  ) ?? null
+
+export function selectThreadMessages(
+  state: ThreadChatProjectStore,
+  threadId: string
+): MessageEntity[] {
+  return (state.entities.messageIdsByThreadId[threadId] ?? [])
+    .map((messageId) => state.entities.messagesById[messageId])
+    .filter(
+      (message): message is MessageEntity =>
+        Boolean(message) &&
+        message.supersededAt === null &&
+        !state.readModels.replacementSupersededMessageIds[message.id]
+    )
+}
+
+export const selectAssistantRun = (
+  state: ThreadChatProjectStore,
+  assistantMessageId: string
+) => state.runs.byAssistantMessageId[assistantMessageId] ?? null
+
+export const selectArtifact = (
+  state: ThreadChatProjectStore,
+  artifactId: string
+): ArtifactEntity | null => state.entities.artifactsById[artifactId] ?? null
+
+export function selectSlotThreadId(
+  state: ThreadChatProjectStore,
+  slotId: "root" | string
+): string | null {
+  if (slotId === "root") return selectRootThread(state)?.id ?? null
+  return (
+    state.ui.columnSlots.find((slot) => slot.slotId === slotId)?.threadId ??
+    null
+  )
+}
+
+export function selectVisibleThreadColumns(state: ThreadChatProjectStore) {
+  const root = selectRootThread(state)
+  const rootColumn = root
+    ? [{ slotId: "root" as const, threadId: root.id, folded: false }]
+    : []
+  return [
+    ...rootColumn,
+    ...state.ui.columnSlots.map((slot) => ({
+      slotId: slot.slotId,
+      threadId: slot.threadId,
+      folded: slot.folded,
+    })),
+  ]
+}
+
+export function selectFocusedThreadId(
+  state: ThreadChatProjectStore
+): string | null {
+  return state.ui.focusedSlotId
+    ? selectSlotThreadId(state, state.ui.focusedSlotId)
+    : null
+}
+
+export const selectFocusedColumnId = (state: ThreadChatProjectStore) =>
+  state.ui.focusedSlotId
+
+export function selectForkAvailability(
+  state: ThreadChatProjectStore,
+  messageId: string
+): { allowed: true } | { allowed: false; reason: string } {
+  const message = state.entities.messagesById[messageId]
+  if (!message) return { allowed: false, reason: "message_not_loaded" }
+  if (
+    message.finalizedAt === null ||
+    message.supersededAt !== null ||
+    state.readModels.replacementSupersededMessageIds[messageId]
+  )
+    return { allowed: false, reason: "message_not_finalized" }
+  if (message.role === "assistant") {
+    const run = state.runs.byAssistantMessageId[messageId]
+    if (run?.status !== "completed")
+      return { allowed: false, reason: "assistant_run_not_completed" }
+  }
+  return { allowed: true }
+}
+
+export function selectArtifactIdsFromMessage(message: MessageEntity): string[] {
+  if (!message.parts) return []
+  const ids = new Set()
+  for (const part of message.parts) {
+    const candidate = part as {
+      type?: string
+      output?: { artifactId?: unknown }
+    }
+    if (
+      candidate.type === "dynamic-tool" &&
+      typeof candidate.output?.artifactId === "string"
+    )
+      ids.add(candidate.output.artifactId)
+  }
+  return [...ids]
+}
+
+export function selectThreadColumnView(
+  state: ThreadChatProjectStore,
+  slotId: "root" | string
+) {
+  const threadId = selectSlotThreadId(state, slotId)
+  if (!threadId) return { status: "loading" as const, slotId, threadId: null }
+  const window = state.requests.threadMessagesById[threadId]
+  if (
+    !window ||
+    window.loadState.status === "idle" ||
+    window.loadState.status === "loading"
+  )
+    return { status: "loading" as const, slotId, threadId }
+  if (window.loadState.status === "error")
+    return {
+      status: "error" as const,
+      slotId,
+      threadId,
+      error: window.loadState.error,
+      canRetry: true as const,
+    }
+  const messages = selectThreadMessages(state, threadId)
+  return {
+    status: "ready" as const,
+    slotId,
+    threadId,
+    thread: state.entities.threadsById[threadId],
+    messages,
+    runs: Object.fromEntries(
+      messages
+        .filter((message) => message.role === "assistant")
+        .map((message) => [
+          message.id,
+          state.runs.byAssistantMessageId[message.id] ?? null,
+        ])
+    ),
+    artifactIds: [...new Set(messages.flatMap(selectArtifactIdsFromMessage))],
+    hasOlderMessages: window.hasOlderMessages,
+  }
+}
+
+export function selectThreadColumnHeaderView(
+  state: ThreadChatProjectStore,
+  slotId: "root" | string
+) {
+  const threadId = selectSlotThreadId(state, slotId)
+  const thread = threadId ? state.entities.threadsById[threadId] : null
+  const slot: ThreadColumnSlot | undefined =
+    slotId === "root"
+      ? undefined
+      : state.ui.columnSlots.find((candidate) => candidate.slotId === slotId)
+  const childCount = threadId
+    ? Object.values(state.entities.threadsById).filter(
+        (candidate) => candidate.parentThreadId === threadId
+      ).length
+    : 0
+  return {
+    slotId,
+    threadId,
+    title:
+      slotId === "root"
+        ? (state.entities.project?.customTitle ??
+          state.entities.project?.autoTitle ??
+          "")
+        : (thread?.customTitle ?? thread?.autoTitle ?? ""),
+    folded: slot?.folded ?? false,
+    childCount,
+    focused: state.ui.focusedSlotId === slotId,
+  }
+}
+
+export function selectProjectTreeRows(state: ThreadChatProjectStore) {
+  const threads = Object.values(state.entities.threadsById)
+  const childrenByParent = new Map()
+  for (const thread of threads) {
+    const siblings = childrenByParent.get(thread.parentThreadId) ?? []
+    siblings.push(thread)
+    childrenByParent.set(thread.parentThreadId, siblings)
+  }
+  const rows: Array<{
+    threadId: string
+    parentThreadId: string | null
+    depth: number
+    title: string
+    archived: boolean
+  }> = []
+  const visit = (parentThreadId: string | null, depth: number) => {
+    for (const thread of (childrenByParent.get(parentThreadId) ?? []).toSorted(
+      (left, right) => left.createdAt.localeCompare(right.createdAt)
+    )) {
+      rows.push({
+        threadId: thread.id,
+        parentThreadId: thread.parentThreadId,
+        depth,
+        title:
+          thread.parentThreadId === null
+            ? (state.entities.project?.customTitle ??
+              state.entities.project?.autoTitle ??
+              "")
+            : (thread.customTitle ?? thread.autoTitle ?? ""),
+        archived: thread.archivedAt !== null,
+      })
+      visit(thread.id, depth + 1)
+    }
+  }
+  visit(null, 0)
+  return rows
+}
+
+export function selectProjectHeaderView(state: ThreadChatProjectStore) {
+  const project = state.entities.project
+  return {
+    title: project?.customTitle ?? project?.autoTitle ?? "",
+    archived: project?.archivedAt !== null && project !== null,
+    artifactSummary: state.readModels.artifactSummary,
+    focusedThreadId: selectFocusedThreadId(state),
+  }
+}
diff --git a/lib/thread-chat/client/types.ts b/lib/thread-chat/client/types.ts
new file mode 100644
index 00000000..3e68444b
--- /dev/null
+++ b/lib/thread-chat/client/types.ts
@@ -0,0 +1,366 @@
+import type { z } from "zod"
+import type { StoreApi } from "zustand/vanilla"
+import type { ThreadChatClientError } from "../api/client-error"
+import type { ThreadChatApiCapabilities } from "../api/capabilities"
+import type {
+  artifactSchema,
+  assistantMessageEventSchema,
+  assistantRunStateSchema,
+  creationBundleSchema,
+  feedbackSchema,
+  listProjectsResultSchema,
+  messageCreationBundleSchema,
+  messageSchema,
+  projectBootstrapSchema,
+  projectSchema,
+  projectSummarySchema,
+  replacementBundleSchema,
+  threadMessageBundleSchema,
+  threadSchema,
+  UserMessageParts,
+} from "../api/contracts"
+
+export type ProjectEntity = z.infer
+export type ProjectSummary = z.infer
+export type ThreadEntity = z.infer
+export type MessageEntity = z.infer
+export type AssistantRunState = z.infer
+export type ArtifactEntity = z.infer
+export type Feedback = z.infer
+export type ListProjectsResult = z.infer
+export type CreationBundle = z.infer
+export type ProjectBootstrap = z.infer
+export type ThreadMessageBundle = z.infer
+export type MessageCreationBundle = z.infer
+export type ReplacementBundle = z.infer
+export type AssistantMessageEvent = z.infer
+
+export type ProjectId = string
+export type ThreadId = string
+export type MessageId = string
+export type ArtifactId = string
+export type ColumnSlotId = string
+
+export type LoadState =
+  | { status: "idle" }
+  | { status: "loading" }
+  | { status: "ready" }
+  | { status: "error"; error: ThreadChatClientError }
+
+export type CommandState =
+  { status: "submitting" } | { status: "error"; error: ThreadChatClientError }
+
+export interface ThreadMessageWindowState {
+  loadState: LoadState
+  hasOlderMessages: boolean
+  oldestReturnedSequence: number | null
+  newestReturnedSequence: number | null
+}
+
+export interface ProjectCatalogState {
+  projectsById: Record
+  orderedProjectIds: ProjectId[]
+  loadState: LoadState
+  activeFilter: "active" | "archived"
+  nextCursor: string | null
+}
+
+export interface AppShellUiState {
+  sidebarOpen: boolean
+  sidebarWidth: number
+  projectSearchQuery: string
+  pendingProjectId: ProjectId | null
+}
+
+export interface ThreadChatAppState {
+  catalog: ProjectCatalogState
+  shellUi: AppShellUiState
+}
+
+export interface ThreadChatAppActions {
+  mergeProjectPage(result: ListProjectsResult, reset?: boolean): void
+  upsertProjectSummary(summary: ProjectSummary): void
+  removeProjectSummary(projectId: ProjectId): void
+  setCatalogLoadState(state: LoadState): void
+  setCatalogFilter(filter: ProjectCatalogState["activeFilter"]): void
+  setProjectRoutePending(projectId: ProjectId | null): void
+  setSidebarOpen(open: boolean): void
+  setSidebarWidth(width: number): void
+  setProjectSearchQuery(query: string): void
+}
+
+export type ThreadChatAppStore = ThreadChatAppState & ThreadChatAppActions
+
+export interface ThreadChatEntitiesState {
+  project: ProjectEntity | null
+  threadsById: Record
+  messagesById: Record
+  messageIdsByThreadId: Record
+  artifactsById: Record
+  feedbackByMessageId: Record
+}
+
+export interface StreamBuffer {
+  pendingChunks: Extract<
+    AssistantMessageEvent,
+    { type: "run.delta" }
+  >["chunk"][]
+  lastReceivedEventSequence: number
+  flushScheduled: boolean
+}
+
+export interface ThreadChatRunsState {
+  byAssistantMessageId: Record
+  streamBuffersByAssistantMessageId: Record
+  resumedAssistantMessageIds: Record
+}
+
+export interface ThreadChatRequestsState {
+  bootstrap: LoadState
+  threadMessagesById: Record
+  artifactById: Record
+  commandByScope: Record
+}
+
+export interface ProjectArtifactSummary {
+  changeSequence: number
+  total: number
+  byKind: Record
+}
+
+export interface ThreadColumnSlot {
+  slotId: ColumnSlotId
+  threadId: ThreadId
+  folded: boolean
+  widthPx: number | null
+}
+
+export interface Point {
+  x: number
+  y: number
+}
+
+export interface TextSelectionState {
+  messageId: MessageId
+  exactQuote: string
+  textPosition?: { start: number; end: number }
+}
+
+export interface OverlayState {
+  selection: TextSelectionState | null
+  threadSwitcherScope:
+    | { kind: "global" }
+    | { kind: "column"; slotId: ColumnSlotId }
+    | { kind: "subtree"; rootThreadId: ThreadId }
+    | null
+  treeListOpen: boolean
+  helpPanelOpen: boolean
+  artifactDrawerOpen: boolean
+}
+
+export interface ThreadChatUiState {
+  columnSlots: ThreadColumnSlot[]
+  focusedSlotId: "root" | ColumnSlotId | null
+  rootColumnWidthPx: number | null
+  forceColumnCount: number | null
+  placementMode: "replace" | "fold"
+  viewMode: "columns" | "canvas"
+  canvasPins: Record
+  composerDraftByThreadId: Record
+  selectedArtifactId: ArtifactId | null
+  activationClock: number
+  lastActivatedOrderBySlotId: Record
+  overlays: OverlayState
+}
+
+export interface ThreadChatProjectState {
+  entities: ThreadChatEntitiesState
+  runs: ThreadChatRunsState
+  requests: ThreadChatRequestsState
+  readModels: {
+    artifactSummary: ProjectArtifactSummary | null
+    replacementSupersededMessageIds: Record
+  }
+  ui: ThreadChatUiState
+}
+
+export interface ThreadWorkbenchSnapshotV1 {
+  schemaVersion: 1
+  columnSlots: ThreadColumnSlot[]
+  focusedSlotId: "root" | ColumnSlotId
+  rootColumnWidthPx: number | null
+  forceColumnCount: number | null
+  placementMode: "replace" | "fold"
+  viewMode: "columns" | "canvas"
+  canvasPins: Record
+}
+
+export interface ThreadPlacementOptions {
+  maxExpanded?: number
+  keepSource?: boolean
+  targetSlotId?: ColumnSlotId
+}
+
+export interface ThreadChatProjectActions {
+  mergeCreationBundle(bundle: CreationBundle): void
+  mergeBootstrap(bootstrap: ProjectBootstrap): void
+  applyMessageBundle(bundle: ThreadMessageBundle): void
+  applyMessageCreationBundle(bundle: MessageCreationBundle): void
+  applyThreadCreated(thread: ThreadEntity): void
+  applyReplacementBundle(bundle: ReplacementBundle): void
+  applyRunEvent(
+    event: AssistantMessageEvent,
+    assistantMessageId?: MessageId
+  ): void
+  applyAssistantRun(run: AssistantRunState): void
+  flushRunBuffer(assistantMessageId: MessageId): void
+  applyArtifact(artifact: ArtifactEntity): void
+  applyProject(project: ProjectEntity): void
+  applyThread(thread: ThreadEntity): void
+  applyFeedback(feedback: Feedback): void
+  setBootstrapLoadState(state: LoadState): void
+  setCommandState(scope: string, state: CommandState | null): void
+  setThreadMessageLoadState(threadId: ThreadId, state: LoadState): void
+  setArtifactLoadState(artifactId: ArtifactId, state: LoadState): void
+  restoreWorkbenchSnapshot(snapshot: ThreadWorkbenchSnapshotV1): void
+  resetWorkbenchToDefault(): void
+  openThread(
+    threadId: ThreadId,
+    sourceSlotId: "root" | ColumnSlotId,
+    placement?: ThreadPlacementOptions
+  ): void
+  switchColumnThread(slotId: ColumnSlotId, threadId: ThreadId): void
+  closeColumn(slotId: ColumnSlotId): void
+  setColumnFolded(slotId: ColumnSlotId, folded: boolean): void
+  focusColumn(slotId: "root" | ColumnSlotId): void
+  commitColumnWidths(
+    widths: Partial>
+  ): void
+  setForceColumnCount(count: number | null): void
+  setPlacementMode(mode: ThreadChatUiState["placementMode"]): void
+  setViewMode(mode: ThreadChatUiState["viewMode"]): void
+  setCanvasPin(threadId: ThreadId, point: Point | null): void
+  setComposerDraft(threadId: ThreadId, parts: UserMessageParts): void
+  setSelectedArtifact(artifactId: ArtifactId | null): void
+  setOverlays(patch: Partial): void
+}
+
+export type ThreadChatProjectStore = ThreadChatProjectState &
+  ThreadChatProjectActions
+
+export interface GenerationCoordinator {
+  resumeLoadedRuns(): void
+  subscribeAssistant(assistantMessageId: MessageId): void
+  unsubscribeAssistant(assistantMessageId: MessageId): void
+  destroy(): void
+}
+
+export interface ThreadMessageLoader {
+  ensure(threadId: ThreadId): Promise
+  destroy(): void
+}
+
+export interface ArtifactLoader {
+  ensure(artifactId: ArtifactId): Promise
+  destroy(): void
+}
+
+export interface ThreadChatProjectCommands {
+  loadProjectBootstrap(): Promise
+  ensureThreadMessages(threadId: ThreadId): Promise
+  ensureArtifact(artifactId: ArtifactId): Promise
+  updateProject(
+    patch: Omit<
+      Parameters[0],
+      "projectId"
+    >
+  ): Promise
+  updateThread(threadId: ThreadId, customTitle: string | null): Promise
+  setProjectArchived(archived: boolean): Promise
+  setThreadArchived(threadId: ThreadId, archived: boolean): Promise
+  deleteProject(): Promise
+  sendMessage(
+    threadId: ThreadId,
+    parts: UserMessageParts,
+    requestedModelId?: string
+  ): Promise
+  forkThread(input: {
+    sourceSlotId: "root" | ColumnSlotId
+    placement?: ThreadPlacementOptions
+    sourceThreadId: ThreadId
+    sourceMessageId: MessageId
+    anchor?: {
+      exactQuote: string
+      textPosition?: { start: number; end: number }
+    }
+  }): Promise
+  editMessage(
+    messageId: MessageId,
+    parts: UserMessageParts,
+    requestedModelId?: string
+  ): Promise
+  regenerateMessage(
+    messageId: MessageId,
+    requestedModelId?: string
+  ): Promise
+  setFeedback(
+    messageId: MessageId,
+    value: "positive" | "negative" | null
+  ): Promise
+  stopAssistant(assistantMessageId: MessageId): Promise
+}
+
+export interface ThreadChatProjectRuntime {
+  projectId: ProjectId
+  store: StoreApi
+  commands: ThreadChatProjectCommands
+  messageLoader: ThreadMessageLoader
+  artifactLoader: ArtifactLoader
+  generationCoordinator: GenerationCoordinator
+  destroy(): void
+}
+
+export interface ProjectRuntimeRegistry {
+  seedFromCreation(bundle: CreationBundle): ThreadChatProjectRuntime
+  acquire(projectId: ProjectId): ThreadChatProjectRuntime
+  release(projectId: ProjectId): void
+  peek(projectId: ProjectId): ThreadChatProjectRuntime | null
+  destroy(): void
+}
+
+export interface ThreadChatAppCommands {
+  loadProjectCatalog(input?: { reset?: boolean }): Promise
+  setProjectArchived(projectId: ProjectId, archived: boolean): Promise
+  deleteProject(projectId: ProjectId): Promise
+}
+
+export interface NavigationCapability {
+  replace(path: string): void
+  currentProjectId?(): ProjectId | null
+}
+
+export interface ThreadChatAppRuntime {
+  appStore: StoreApi
+  projectRuntimeRegistry: ProjectRuntimeRegistry
+  api: ThreadChatApiCapabilities
+  commands: ThreadChatAppCommands
+  navigation: NavigationCapability
+  destroy(): void
+}
+
+export interface NewProjectDraftState {
+  draftParts: UserMessageParts
+  requestedModelId?: string
+  status: "idle" | "submitting" | "error"
+  error: ThreadChatClientError | null
+}
+
+export interface NewProjectDraftActions {
+  setDraftParts(parts: UserMessageParts): void
+  setRequestedModelId(modelId?: string): void
+  markSubmitting(): void
+  markError(error: ThreadChatClientError): void
+  markIdle(): void
+}
+
+export type NewProjectDraftStore = NewProjectDraftState & NewProjectDraftActions
diff --git a/lib/thread-chat/client/workbench-persistence.ts b/lib/thread-chat/client/workbench-persistence.ts
new file mode 100644
index 00000000..677e246f
--- /dev/null
+++ b/lib/thread-chat/client/workbench-persistence.ts
@@ -0,0 +1,50 @@
+import type {
+  ThreadChatProjectStore,
+  ThreadWorkbenchSnapshotV1,
+} from "./types"
+
+const WORKBENCH_STORAGE_PREFIX = "thread-chat:project-workbench:v1:"
+
+export function projectWorkbenchStorageKey(projectId: string) {
+  return `${WORKBENCH_STORAGE_PREFIX}${projectId}`
+}
+
+export function createWorkbenchSnapshot(
+  state: ThreadChatProjectStore
+): ThreadWorkbenchSnapshotV1 {
+  return {
+    schemaVersion: 1,
+    columnSlots: structuredClone(state.ui.columnSlots),
+    focusedSlotId: state.ui.focusedSlotId ?? "root",
+    rootColumnWidthPx: state.ui.rootColumnWidthPx,
+    forceColumnCount: state.ui.forceColumnCount,
+    placementMode: state.ui.placementMode,
+    viewMode: state.ui.viewMode,
+    canvasPins: structuredClone(state.ui.canvasPins),
+  }
+}
+
+export function parseWorkbenchSnapshot(
+  value: string | null
+): ThreadWorkbenchSnapshotV1 | null {
+  if (!value) return null
+  try {
+    const parsed = JSON.parse(value) as Partial
+    if (
+      parsed.schemaVersion !== 1 ||
+      !Array.isArray(parsed.columnSlots) ||
+      (parsed.focusedSlotId !== "root" &&
+        typeof parsed.focusedSlotId !== "string") ||
+      !Object.prototype.hasOwnProperty.call(parsed, "rootColumnWidthPx") ||
+      !Object.prototype.hasOwnProperty.call(parsed, "forceColumnCount") ||
+      (parsed.placementMode !== "replace" && parsed.placementMode !== "fold") ||
+      (parsed.viewMode !== "columns" && parsed.viewMode !== "canvas") ||
+      typeof parsed.canvasPins !== "object" ||
+      parsed.canvasPins === null
+    )
+      return null
+    return parsed as ThreadWorkbenchSnapshotV1
+  } catch {
+    return null
+  }
+}
diff --git a/lib/thread-chat/contracts/generation-identity.ts b/lib/thread-chat/contracts/generation-identity.ts
deleted file mode 100644
index 18a6277f..00000000
--- a/lib/thread-chat/contracts/generation-identity.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { z } from "zod"
-import { threadChatGenerationIntentSchema } from "@/lib/thread-chat/contracts/generation-intent"
-
-/** Thread Chat 生成请求定位持久化 turn 的唯一运行时契约。 */
-export const threadChatGenerationIdentitySchema = z.object({
-  anchorText: z.string().nullable().optional(),
-  treeId: z.string().uuid(),
-  threadId: z.string().min(1),
-  userMessageId: z.string().min(1),
-  assistantMessageId: z.string().min(1),
-  generationId: z.string().uuid(),
-  intent: threadChatGenerationIntentSchema,
-})
-
-export type ThreadChatGenerationIdentity = z.infer<
-  typeof threadChatGenerationIdentitySchema
->
diff --git a/lib/thread-chat/contracts/generation-intent.ts b/lib/thread-chat/contracts/generation-intent.ts
deleted file mode 100644
index 594ca514..00000000
--- a/lib/thread-chat/contracts/generation-intent.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { z } from "zod"
-
-/**
- * Thread Chat generation 命令的唯一运行时契约。
- * TypeScript 类型必须由该 schema 推导,避免客户端类型与 API 校验漂移。
- */
-export const threadChatGenerationIntentSchema = z.discriminatedUnion("kind", [
-  z.object({ kind: z.literal("persisted-turn") }),
-  z.object({
-    kind: z.literal("regenerate-assistant"),
-    sourceAssistantMessageId: z.string().min(1),
-  }),
-  z.object({ kind: z.literal("retry-orphan-user") }),
-  z.object({
-    kind: z.literal("edit-last-user"),
-    sourceUserMessageId: z.string().min(1),
-    text: z.string().trim().min(1),
-  }),
-])
-
-export type ThreadChatGenerationIntent = z.infer<
-  typeof threadChatGenerationIntentSchema
->
diff --git a/lib/thread-chat/contracts/generation-result.ts b/lib/thread-chat/contracts/generation-result.ts
deleted file mode 100644
index fd6c3eb2..00000000
--- a/lib/thread-chat/contracts/generation-result.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { z } from "zod"
-import { GENERATION_RESULT_VERSION } from "@/constants/generation"
-import {
-  researchPlanSchema,
-  researchRouteSchema,
-} from "@/lib/chat/research-contract"
-
-const artifactSchema = z.object({
-  id: z.string(),
-  title: z.string(),
-  kind: z.enum(["code", "note", "markdown"]),
-  lang: z.string().optional(),
-  content: z.string(),
-  sourceThreadId: z.string(),
-  sourceMessageId: z.string(),
-})
-
-const webResearchSourceSchema = z.object({
-  title: z.string(),
-  url: z.string(),
-})
-
-const webResearchActivitySchema = z.object({
-  toolCallId: z.string(),
-  kind: z.enum(["search", "read"]),
-  status: z.enum(["running", "complete"]),
-  query: z.string().optional(),
-  url: z.string().optional(),
-  sources: z.array(webResearchSourceSchema),
-})
-
-const generationUsageMetadataSchema = z.object({
-  inputTokens: z.number(),
-  outputTokens: z.number(),
-  totalTokens: z.number().optional(),
-})
-
-/** generation result V1 的唯一运行时与 TypeScript 契约。 */
-export const generationResultV1Schema = z.object({
-  version: z.literal(GENERATION_RESULT_VERSION),
-  generationId: z.string(),
-  text: z.string(),
-  status: z.enum(["pending", "streaming", "done", "error"]),
-  error: z.string().optional(),
-  artifactIds: z.array(z.string()),
-  artifacts: z.record(z.string(), artifactSchema),
-  webResearch: z.array(webResearchActivitySchema).optional(),
-  webResearchTextOffset: z.number().optional(),
-  researchRoute: researchRouteSchema.optional(),
-  researchPlan: researchPlanSchema.optional(),
-  usage: generationUsageMetadataSchema.optional(),
-})
-
-export type GenerationResultV1 = z.infer
diff --git a/lib/thread-chat/contracts/message-action-failure.ts b/lib/thread-chat/contracts/message-action-failure.ts
deleted file mode 100644
index 9c419f2d..00000000
--- a/lib/thread-chat/contracts/message-action-failure.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { z } from "zod"
-
-/** Thread Chat 消息命令可返回的稳定失败码。 */
-export const messageActionFailureCodeSchema = z.enum([
-  "not_found",
-  "invalid_id",
-  "invalid_request",
-  "invalid_generation_identity",
-  "invalid_thread_model",
-  "invalid_turn",
-  "not_latest_turn",
-  "generation_conflict",
-  "model_mismatch",
-  "tree_revision_conflict",
-  "revision_required",
-  "persistence_failed",
-  "unauthorized",
-  "network_error",
-])
-
-export const messageActionFailureResponseSchema = z.object({
-  error: z.object({
-    code: messageActionFailureCodeSchema,
-    message: z.string().min(1),
-  }),
-})
-
-export type MessageActionFailureCode = z.infer<
-  typeof messageActionFailureCodeSchema
->
-export type MessageActionFailureResponse = z.infer<
-  typeof messageActionFailureResponseSchema
->
diff --git a/lib/thread-chat/contracts/message-feedback.ts b/lib/thread-chat/contracts/message-feedback.ts
deleted file mode 100644
index 2576f1cf..00000000
--- a/lib/thread-chat/contracts/message-feedback.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { z } from "zod"
-
-/**
- * 消息反馈值与写入请求的唯一运行时契约。
- * TypeScript 类型必须由 schema 推导,避免领域类型与 API 校验漂移。
- */
-export const messageFeedbackSchema = z.enum(["positive", "negative"])
-
-export const setMessageFeedbackRequestSchema = z.object({
-  threadId: z.string().min(1),
-  feedback: messageFeedbackSchema.nullable(),
-})
-
-export const messageFeedbackSummarySchema = z.object({
-  treeId: z.string().min(1),
-  threadId: z.string().min(1),
-  messageId: z.string().min(1),
-  feedback: messageFeedbackSchema,
-  updatedAt: z.string().min(1),
-})
-
-export const setMessageFeedbackFailureReasonSchema = z.enum([
-  "not_found",
-  "not_completed",
-  "missing_generation",
-])
-
-export const messageFeedbackErrorCodeSchema = z.enum([
-  "unauthorized",
-  "invalid_id",
-  "invalid_feedback",
-  "not_found",
-  "message_not_completed",
-  "missing_generation_link",
-])
-
-export const setMessageFeedbackSuccessResponseSchema = z.object({
-  feedback: messageFeedbackSummarySchema.nullable(),
-})
-
-export const setMessageFeedbackErrorResponseSchema = z.object({
-  error: z.object({
-    code: messageFeedbackErrorCodeSchema,
-    message: z.string().min(1),
-  }),
-})
-
-export type MessageFeedback = z.infer
-export type SetMessageFeedbackRequest = z.infer<
-  typeof setMessageFeedbackRequestSchema
->
-export type MessageFeedbackSummary = z.infer<
-  typeof messageFeedbackSummarySchema
->
-export type SetMessageFeedbackFailureReason = z.infer<
-  typeof setMessageFeedbackFailureReasonSchema
->
-export type MessageFeedbackErrorCode = z.infer<
-  typeof messageFeedbackErrorCodeSchema
->
-export type SetMessageFeedbackSuccessResponse = z.infer<
-  typeof setMessageFeedbackSuccessResponseSchema
->
-export type SetMessageFeedbackResult =
-  | { ok: true; feedback: MessageFeedbackSummary | null }
-  | { ok: false; reason: SetMessageFeedbackFailureReason }
-
-type MessageFeedbackHttpError = {
-  status: number
-  error: {
-    code: MessageFeedbackErrorCode
-    message: string
-  }
-}
-
-/** 路由阶段与仓储失败原因到公开 HTTP 错误的唯一映射。 */
-export const MESSAGE_FEEDBACK_HTTP_ERRORS = {
-  unauthorized: {
-    status: 401,
-    error: { code: "unauthorized", message: "请先登录" },
-  },
-  invalid_id: {
-    status: 400,
-    error: { code: "invalid_id", message: "消息身份无效" },
-  },
-  invalid_feedback: {
-    status: 400,
-    error: {
-      code: "invalid_feedback",
-      message: "threadId 与 feedback 必须有效",
-    },
-  },
-  not_found: {
-    status: 404,
-    error: { code: "not_found", message: "消息不存在" },
-  },
-  not_completed: {
-    status: 409,
-    error: {
-      code: "message_not_completed",
-      message: "只有已完成的 AI 回复可以评价",
-    },
-  },
-  missing_generation: {
-    status: 409,
-    error: {
-      code: "missing_generation_link",
-      message: "已完成回复缺少生成记录",
-    },
-  },
-} as const satisfies Record<
-  | "unauthorized"
-  | "invalid_id"
-  | "invalid_feedback"
-  | SetMessageFeedbackFailureReason,
-  MessageFeedbackHttpError
->
diff --git a/lib/thread-chat/contracts/save-tree.ts b/lib/thread-chat/contracts/save-tree.ts
deleted file mode 100644
index 2afcacbf..00000000
--- a/lib/thread-chat/contracts/save-tree.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { z } from "zod"
-import { treeRevisionSchema } from "@/lib/thread-chat/contracts/tree-revision"
-
-/**
- * 整树 PUT 的命令信封。state 的完整消息图约束由领域 parser 负责;
- * 本契约只组合 HTTP 信封与 CAS revision,避免复制领域 schema。
- */
-export const saveTreeRequestSchema = z.object({
-  state: z.unknown(),
-  title: z.unknown().optional(),
-  baseRevision: treeRevisionSchema,
-})
-
-export const treeWriteRevisionErrorCodeSchema = z.enum([
-  "tree_revision_conflict",
-  "revision_required",
-])
-
-export const saveTreeErrorCodeSchema = z.enum([
-  "invalid_tree_state",
-  ...treeWriteRevisionErrorCodeSchema.options,
-])
-
-export const saveTreeSuccessResponseSchema = z.object({
-  ok: z.literal(true),
-  revision: treeRevisionSchema,
-})
-
-export const saveTreeErrorResponseSchema = z.object({
-  error: z.object({
-    code: saveTreeErrorCodeSchema,
-    message: z.string().min(1),
-    currentRevision: treeRevisionSchema.optional(),
-  }),
-})
-
-export type SaveTreeRequest = z.infer
-export type SaveTreeErrorCode = z.infer
-export type TreeWriteRevisionErrorCode = z.infer<
-  typeof treeWriteRevisionErrorCodeSchema
->
-
-export const SAVE_TREE_ERROR_STATUS = {
-  invalid_tree_state: 400,
-  tree_revision_conflict: 409,
-  revision_required: 428,
-} as const satisfies Record
-
-export const SAVE_TREE_REVISION_ERRORS = {
-  tree_revision_conflict: {
-    code: "tree_revision_conflict",
-    message: "该对话已在其他页面更新",
-  },
-  revision_required: {
-    code: "revision_required",
-    message: "消息图存盘必须携带 baseRevision",
-  },
-} as const satisfies Record<
-  TreeWriteRevisionErrorCode,
-  { code: TreeWriteRevisionErrorCode; message: string }
->
diff --git a/lib/thread-chat/contracts/switch-active-leaf.ts b/lib/thread-chat/contracts/switch-active-leaf.ts
deleted file mode 100644
index be28f697..00000000
--- a/lib/thread-chat/contracts/switch-active-leaf.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { z } from "zod"
-import { treeRevisionSchema } from "@/lib/thread-chat/contracts/tree-revision"
-
-export const switchActiveLeafRequestSchema = z.object({
-  threadId: z.string().trim().min(1),
-  assistantMessageId: z.string().trim().min(1),
-  baseRevision: treeRevisionSchema,
-})
-
-export const switchActiveLeafFailureReasonSchema = z.enum([
-  "not_found",
-  "tree_revision_conflict",
-  "invalid_turn",
-])
-
-export const switchActiveLeafErrorCodeSchema = z.enum([
-  "unauthorized",
-  "invalid_id",
-  "invalid_request",
-  ...switchActiveLeafFailureReasonSchema.options,
-])
-
-export const switchActiveLeafSuccessResponseSchema = z.object({
-  revision: treeRevisionSchema,
-  thread: z.object({
-    id: z.string().min(1),
-    activeLeafMessageId: z.string().min(1),
-  }),
-})
-
-export const switchActiveLeafErrorResponseSchema = z.object({
-  error: z.object({
-    code: switchActiveLeafErrorCodeSchema,
-    message: z.string().min(1),
-    currentRevision: treeRevisionSchema.optional(),
-  }),
-})
-
-export type SwitchActiveLeafRequest = z.infer<
-  typeof switchActiveLeafRequestSchema
->
-export type SwitchActiveLeafFailureReason = z.infer<
-  typeof switchActiveLeafFailureReasonSchema
->
-export type SwitchActiveLeafErrorCode = z.infer<
-  typeof switchActiveLeafErrorCodeSchema
->
-export type SwitchActiveLeafSuccessResponse = z.infer<
-  typeof switchActiveLeafSuccessResponseSchema
->
-
-export const SWITCH_ACTIVE_LEAF_ERROR_STATUS = {
-  unauthorized: 401,
-  invalid_id: 400,
-  invalid_request: 400,
-  not_found: 404,
-  tree_revision_conflict: 409,
-  invalid_turn: 400,
-} as const satisfies Record
-
-export const SWITCH_ACTIVE_LEAF_ROUTE_ERRORS = {
-  unauthorized: { code: "unauthorized", message: "请先登录" },
-  invalid_id: { code: "invalid_id", message: "treeId 必须是 UUID" },
-  invalid_request: { code: "invalid_request", message: "版本切换参数无效" },
-} as const satisfies Record<
-  "unauthorized" | "invalid_id" | "invalid_request",
-  { code: SwitchActiveLeafErrorCode; message: string }
->
diff --git a/lib/thread-chat/contracts/title-request.ts b/lib/thread-chat/contracts/title-request.ts
deleted file mode 100644
index 8648963e..00000000
--- a/lib/thread-chat/contracts/title-request.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/** 主线与分支共用的标题生成请求契约。 */
-export type ThreadTitleInput =
-  | {
-      kind: "main"
-      question: string
-    }
-  | {
-      kind: "branch"
-      anchorText: string
-      question: string
-      answer: string
-    }
-
-/**
- * 只接受带显式 kind 的统一标题请求;旧分支请求体不会被隐式兼容。
- */
-export function parseThreadTitleInput(input: unknown): ThreadTitleInput | null {
-  if (!input || typeof input !== "object") return null
-  const record = input as Record
-
-  if (record.kind === "main" && typeof record.question === "string") {
-    return record.question.trim()
-      ? { kind: "main", question: record.question }
-      : null
-  }
-
-  if (
-    record.kind === "branch" &&
-    typeof record.anchorText === "string" &&
-    typeof record.question === "string" &&
-    typeof record.answer === "string" &&
-    record.anchorText.trim() &&
-    record.question.trim()
-  ) {
-    return {
-      kind: "branch",
-      anchorText: record.anchorText,
-      question: record.question,
-      answer: record.answer,
-    }
-  }
-
-  return null
-}
diff --git a/lib/thread-chat/contracts/tree-revision.ts b/lib/thread-chat/contracts/tree-revision.ts
deleted file mode 100644
index 2b3cf8d3..00000000
--- a/lib/thread-chat/contracts/tree-revision.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { z } from "zod"
-
-/** 树写命令使用的非负 CAS 修订号。 */
-export const treeRevisionSchema = z.number().int().nonnegative()
-
-export type TreeRevision = z.infer
diff --git a/lib/thread-chat/domain/artifact.ts b/lib/thread-chat/domain/artifact.ts
new file mode 100644
index 00000000..20a1d89a
--- /dev/null
+++ b/lib/thread-chat/domain/artifact.ts
@@ -0,0 +1,37 @@
+import { invariant } from "./domain-error"
+import type { ArtifactId, MessageId, ProjectId } from "./ids"
+import type { Message } from "./message"
+import type { Thread } from "./thread"
+
+export type Artifact = {
+  id: ArtifactId
+  projectId: ProjectId
+  sourceMessageId: MessageId
+  changeSequence: number
+  kind: string
+  title: string
+  content: unknown
+  createdAt: Date
+}
+
+export type MarkdownArtifactToolOutput = { artifactId: ArtifactId }
+
+export function assertArtifactProvenance(
+  artifact: Pick,
+  sourceMessage: Message,
+  sourceThread: Thread
+): void {
+  invariant(
+    sourceMessage.id === artifact.sourceMessageId &&
+      sourceMessage.threadId === sourceThread.id &&
+      sourceThread.projectId === artifact.projectId,
+    "artifact_provenance_invalid",
+    "Artifact source Message 必须属于同一 Project。"
+  )
+}
+
+export function toMarkdownArtifactToolOutput(
+  artifact: Pick
+): MarkdownArtifactToolOutput {
+  return { artifactId: artifact.id }
+}
diff --git a/lib/thread-chat/domain/base-context.ts b/lib/thread-chat/domain/base-context.ts
new file mode 100644
index 00000000..e1b5d54e
--- /dev/null
+++ b/lib/thread-chat/domain/base-context.ts
@@ -0,0 +1,51 @@
+import { invariant } from "./domain-error"
+import type { MessageId } from "./ids"
+import type { Message } from "./message"
+
+export type BaseContextV1 = {
+  schemaVersion: 1
+  messageIds: MessageId[]
+}
+
+export function validateBaseContext(value: unknown): BaseContextV1 {
+  invariant(
+    typeof value === "object" && value !== null,
+    "base_context_invalid",
+    "BaseContext 必须是对象。"
+  )
+  const candidate = value as { schemaVersion?: unknown; messageIds?: unknown }
+  invariant(
+    candidate.schemaVersion === 1 && Array.isArray(candidate.messageIds),
+    "base_context_invalid",
+    "BaseContext 必须使用 schemaVersion=1 与 messageIds。"
+  )
+  invariant(
+    candidate.messageIds.every(
+      (messageId): messageId is string =>
+        typeof messageId === "string" && messageId.length > 0
+    ),
+    "base_context_invalid",
+    "BaseContext.messageIds 必须是非空字符串数组。"
+  )
+  invariant(
+    new Set(candidate.messageIds).size === candidate.messageIds.length,
+    "base_context_invalid",
+    "BaseContext.messageIds 不得重复。"
+  )
+  return { schemaVersion: 1, messageIds: [...candidate.messageIds] }
+}
+
+export function resolveBaseContextMessages(
+  context: BaseContextV1,
+  messagesById: ReadonlyMap
+): Message[] {
+  return context.messageIds.map((messageId) => {
+    const message = messagesById.get(messageId)
+    invariant(
+      message,
+      "base_context_message_missing",
+      `BaseContext 引用的 Message ${messageId} 不存在。`
+    )
+    return message
+  })
+}
diff --git a/lib/thread-chat/domain/domain-error.ts b/lib/thread-chat/domain/domain-error.ts
new file mode 100644
index 00000000..b3277da3
--- /dev/null
+++ b/lib/thread-chat/domain/domain-error.ts
@@ -0,0 +1,48 @@
+export type ThreadChatDomainErrorCode =
+  | "project_owner_mismatch"
+  | "project_root_invalid"
+  | "thread_fork_facts_invalid"
+  | "thread_parent_invalid"
+  | "thread_source_invalid"
+  | "thread_cycle"
+  | "message_not_finalized"
+  | "message_superseded"
+  | "message_replacement_invalid"
+  | "message_not_fork_eligible"
+  | "base_context_invalid"
+  | "base_context_message_missing"
+  | "message_run_transition_invalid"
+  | "artifact_provenance_invalid"
+  | "feedback_not_eligible"
+  | "thread_archived"
+  | "thread_generation_in_progress"
+  | "root_thread_title_owned_by_project"
+  | "root_thread_archive_owned_by_project"
+  | "message_not_editable"
+  | "message_not_regeneratable"
+  | "fork_required"
+  | "fork_anchor_mismatch"
+  | "thread_not_found"
+  | "message_not_found"
+  | "source_message_not_found"
+  | "assistant_message_not_found"
+  | "message_run_not_found"
+  | "entity_not_found"
+
+export class ThreadChatDomainError extends Error {
+  constructor(
+    readonly code: ThreadChatDomainErrorCode,
+    message: string
+  ) {
+    super(message)
+    this.name = "ThreadChatDomainError"
+  }
+}
+
+export function invariant(
+  condition: unknown,
+  code: ThreadChatDomainErrorCode,
+  message: string
+): asserts condition {
+  if (!condition) throw new ThreadChatDomainError(code, message)
+}
diff --git a/lib/thread-chat/domain/generation.ts b/lib/thread-chat/domain/generation.ts
deleted file mode 100644
index 1df6e95b..00000000
--- a/lib/thread-chat/domain/generation.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { Message, ThreadTreeState } from "@/lib/thread-chat/domain/types"
-import type {
-  GENERATION_BILLING_STATUSES,
-  GENERATION_STATUSES,
-} from "@/constants/generation"
-import { ACTIVE_GENERATION_STATUSES } from "@/constants/generation"
-import type { GenerationResultV1 } from "@/lib/thread-chat/contracts/generation-result"
-import type { ThreadChatGenerationIntent } from "@/lib/thread-chat/contracts/generation-intent"
-
-export type GenerationStatus = (typeof GENERATION_STATUSES)[number]
-export type GenerationBillingStatus =
-  (typeof GENERATION_BILLING_STATUSES)[number]
-
-export type RecoverableTurnReason =
-  "missing_assistant" | "missing_generation" | "interrupted_generation"
-
-export interface RecoverableTurn {
-  threadId: string
-  userMessageId: string
-  assistantMessageId?: string
-  reason: RecoverableTurnReason
-}
-
-export type { ThreadChatGenerationIntent } from "@/lib/thread-chat/contracts/generation-intent"
-
-export type GenerationTurnIdentity = {
-  treeId: string
-  threadId: string
-  userMessageId: string
-  assistantMessageId: string
-  generationId: string
-}
-
-/** 服务端从严格 schema-v2 树中验证后的最小 turn 快照,用于消息被并发快照删掉时读修复。 */
-export type GenerationTurnSnapshot = {
-  intent: ThreadChatGenerationIntent
-  threadId: string
-  assistantMessageIndex: number
-  userMessage: Message
-  assistantMessage: Message
-  userParentMessageId: string | null
-  assistantParentMessageId: string
-  activatesAssistantMessageId: string
-}
-
-export type { GenerationResultV1 } from "@/lib/thread-chat/contracts/generation-result"
-
-export type GenerationUsageMetadata = NonNullable<
-  import("@/lib/thread-chat/contracts/generation-result").GenerationResultV1["usage"]
->
-
-export type GenerationSummary = {
-  id: string
-  treeId: string
-  threadId: string
-  userMessageId: string
-  assistantMessageId: string
-  attempt: number
-  isCurrent: boolean
-  status: GenerationStatus
-  updatedAt: string
-  result?: GenerationResultV1 | null
-}
-
-export interface GenerationForReconcile extends GenerationSummary {
-  turnSnapshot: GenerationTurnSnapshot
-}
-
-export interface ReconciledThreadChatTree {
-  state: ThreadTreeState
-  recoverableTurns: RecoverableTurn[]
-}
-
-export function isActiveGenerationStatus(
-  status: GenerationStatus
-): status is "running" | "stop_requested" {
-  return ACTIVE_GENERATION_STATUSES.includes(
-    status as (typeof ACTIVE_GENERATION_STATUSES)[number]
-  )
-}
diff --git a/lib/thread-chat/domain/ids.ts b/lib/thread-chat/domain/ids.ts
new file mode 100644
index 00000000..847c7b5c
--- /dev/null
+++ b/lib/thread-chat/domain/ids.ts
@@ -0,0 +1,6 @@
+export type UserId = string
+export type ProjectId = string
+export type ThreadId = string
+export type MessageId = string
+export type MessageRunId = string
+export type ArtifactId = string
diff --git a/lib/thread-chat/domain/message-run.ts b/lib/thread-chat/domain/message-run.ts
new file mode 100644
index 00000000..c1245773
--- /dev/null
+++ b/lib/thread-chat/domain/message-run.ts
@@ -0,0 +1,56 @@
+import type { UIMessage } from "ai"
+import { invariant } from "./domain-error"
+import type { MessageId, MessageRunId } from "./ids"
+
+export type MessageRunStatus =
+  "queued" | "running" | "completed" | "failed" | "stopped"
+
+export type MessageRun = {
+  id: MessageRunId
+  assistantMessageId: MessageId
+  status: MessageRunStatus
+  modelId: string
+  eventSequence: number
+  checkpointParts: UIMessage["parts"]
+  errorCode: string | null
+  errorMessage: string | null
+  heartbeatAt: Date | null
+  stopRequestedAt: Date | null
+  finishedAt: Date | null
+  createdAt: Date
+  updatedAt: Date
+}
+
+const ALLOWED_TRANSITIONS: Readonly<
+  Record
+> = {
+  queued: ["running", "failed", "stopped"],
+  running: ["completed", "failed", "stopped"],
+  completed: [],
+  failed: [],
+  stopped: [],
+}
+
+export function isTerminalMessageRunStatus(status: MessageRunStatus): boolean {
+  return status === "completed" || status === "failed" || status === "stopped"
+}
+
+export function assertMessageRunTransition(
+  current: MessageRunStatus,
+  next: MessageRunStatus
+): void {
+  invariant(
+    ALLOWED_TRANSITIONS[current].includes(next),
+    "message_run_transition_invalid",
+    `MessageRun 不允许从 ${current} 转为 ${next}。`
+  )
+}
+
+export function nextEventSequence(current: number): number {
+  invariant(
+    Number.isSafeInteger(current) && current >= 0,
+    "message_run_transition_invalid",
+    "eventSequence 必须是非负安全整数。"
+  )
+  return current + 1
+}
diff --git a/lib/thread-chat/domain/message.ts b/lib/thread-chat/domain/message.ts
new file mode 100644
index 00000000..a587b0ba
--- /dev/null
+++ b/lib/thread-chat/domain/message.ts
@@ -0,0 +1,67 @@
+import type { UIMessage } from "ai"
+import { invariant } from "./domain-error"
+import type { MessageId, ThreadId } from "./ids"
+import type { MessageRun } from "./message-run"
+
+export type MessageRole = "user" | "assistant"
+
+export type Message = {
+  id: MessageId
+  threadId: ThreadId
+  sequence: number
+  role: MessageRole
+  parts: UIMessage["parts"] | null
+  replacesMessageId: MessageId | null
+  supersededAt: Date | null
+  finalizedAt: Date | null
+  createdAt: Date
+}
+
+export function selectEffectiveMessages(
+  messages: readonly Message[]
+): Message[] {
+  return messages
+    .filter((message) => message.supersededAt === null)
+    .toSorted((left, right) => left.sequence - right.sequence)
+}
+
+export function assertMessageCanBeReplaced(
+  source: Message,
+  replacement: Pick
+): void {
+  invariant(
+    source.finalizedAt !== null,
+    "message_not_finalized",
+    "只有 finalized Message 可以被 replacement。"
+  )
+  invariant(
+    source.supersededAt === null,
+    "message_superseded",
+    "已 superseded Message 不能再次创建 replacement。"
+  )
+  invariant(
+    replacement.replacesMessageId === source.id &&
+      replacement.threadId === source.threadId &&
+      replacement.role === source.role,
+    "message_replacement_invalid",
+    "replacement 必须指向同 Thread、同角色的来源 Message。"
+  )
+}
+
+export function assertMessageForkEligible(
+  message: Message,
+  run: MessageRun | null
+): void {
+  invariant(
+    message.finalizedAt !== null && message.supersededAt === null,
+    "message_not_fork_eligible",
+    "Fork source 必须 finalized 且仍在有效时间线。"
+  )
+  if (message.role === "assistant") {
+    invariant(
+      run?.status === "completed",
+      "message_not_fork_eligible",
+      "只有 completed assistant Message 可以作为 Fork source。"
+    )
+  }
+}
diff --git a/lib/thread-chat/domain/project.ts b/lib/thread-chat/domain/project.ts
new file mode 100644
index 00000000..e52658ce
--- /dev/null
+++ b/lib/thread-chat/domain/project.ts
@@ -0,0 +1,24 @@
+import type { ProjectId, UserId } from "./ids"
+
+export type ProjectTarget = {
+  ultimate: string | null
+  shortTerm: string[]
+  midTerm: string[]
+}
+
+export type Project = {
+  id: ProjectId
+  ownerUserId: UserId
+  autoTitle: string | null
+  customTitle: string | null
+  target: ProjectTarget | null
+  instruction: string | null
+  archivedAt: Date | null
+  artifactChangeSequence: number
+  createdAt: Date
+  updatedAt: Date
+}
+
+export function getProjectDisplayTitle(project: Project): string {
+  return project.customTitle ?? project.autoTitle ?? "新对话"
+}
diff --git a/lib/thread-chat/domain/prompt-history.ts b/lib/thread-chat/domain/prompt-history.ts
new file mode 100644
index 00000000..d9b50115
--- /dev/null
+++ b/lib/thread-chat/domain/prompt-history.ts
@@ -0,0 +1,36 @@
+import { invariant } from "./domain-error"
+import type { MessageId } from "./ids"
+import type { Message } from "./message"
+import type { MessageRun } from "./message-run"
+
+export function buildPromptHistory(input: {
+  baseMessageIds: readonly MessageId[]
+  baseMessages: readonly Message[]
+  currentMessages: readonly Message[]
+  assistantRuns: readonly MessageRun[]
+}): Message[] {
+  const baseById = new Map(
+    input.baseMessages.map((message) => [message.id, message])
+  )
+  const orderedBase = input.baseMessageIds.map((messageId) => {
+    const message = baseById.get(messageId)
+    invariant(
+      message,
+      "base_context_message_missing",
+      `BaseContext 引用的 Message ${messageId} 不存在。`
+    )
+    return message
+  })
+  const completedAssistantIds = new Set(
+    input.assistantRuns
+      .filter((run) => run.status === "completed")
+      .map((run) => run.assistantMessageId)
+  )
+  const seen = new Set()
+  return [...orderedBase, ...input.currentMessages].filter((message) => {
+    if (seen.has(message.id)) return false
+    seen.add(message.id)
+    if (message.finalizedAt === null) return false
+    return message.role === "user" || completedAssistantIds.has(message.id)
+  })
+}
diff --git a/lib/thread-chat/domain/regeneration.ts b/lib/thread-chat/domain/regeneration.ts
deleted file mode 100644
index 1d4facf4..00000000
--- a/lib/thread-chat/domain/regeneration.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { activeLeafTurn } from "./message-graph"
-import type { Message, ThreadTreeState } from "./types"
-import type { ThreadChatGenerationIntent } from "./generation"
-
-export interface PrepareRegenerationInput {
-  threadId: string
-  userMessageId: string
-  assistantMessageId: string
-  generationId: string
-  intent: Exclude
-}
-
-export interface PreparedTurnPatch {
-  threadId: string
-  addedMessages: readonly Message[]
-  nextActiveLeafMessageId: string
-  supersededGenerationId?: string
-}
-
-function messageIdExists(state: ThreadTreeState, messageId: string): boolean {
-  return Object.values(state.threads).some((thread) =>
-    thread.messages.some((message) => message.id === messageId)
-  )
-}
-
-function pendingAssistant(input: {
-  id: string
-  parentMessageId: string
-  generationId: string
-}): Message {
-  return {
-    id: input.id,
-    parentMessageId: input.parentMessageId,
-    role: "assistant",
-    text: "",
-    forks: [],
-    generationId: input.generationId,
-    status: "pending",
-  }
-}
-
-/**
- * 为最新一轮构造只追加的变体 patch。返回 null 表示来源不是最新轮、
- * ID 冲突或结构不合法;函数不修改 state/source node/Artifact。
- */
-export function prepareRegenerationPatch(
-  state: ThreadTreeState,
-  input: PrepareRegenerationInput
-): PreparedTurnPatch | null {
-  const thread = state.threads[input.threadId]
-  if (!thread) return null
-  const latest = activeLeafTurn(thread)
-  if (!latest) return null
-
-  if (
-    messageIdExists(state, input.assistantMessageId) ||
-    (input.intent.kind === "edit-last-user" &&
-      messageIdExists(state, input.userMessageId))
-  )
-    return null
-
-  if (input.intent.kind === "regenerate-assistant") {
-    if (
-      latest.assistantMessage?.id !== input.intent.sourceAssistantMessageId ||
-      latest.userMessage.id !== input.userMessageId
-    )
-      return null
-    return {
-      threadId: input.threadId,
-      addedMessages: [
-        pendingAssistant({
-          id: input.assistantMessageId,
-          parentMessageId: latest.userMessage.id,
-          generationId: input.generationId,
-        }),
-      ],
-      nextActiveLeafMessageId: input.assistantMessageId,
-      ...(latest.assistantMessage.generationId &&
-      (latest.assistantMessage.status === "pending" ||
-        latest.assistantMessage.status === "streaming")
-        ? { supersededGenerationId: latest.assistantMessage.generationId }
-        : {}),
-    }
-  }
-
-  if (input.intent.kind === "retry-orphan-user") {
-    if (latest.userMessage.id !== input.userMessageId) return null
-    if (
-      latest.assistantMessage &&
-      (latest.assistantMessage.text.trim() !== "" ||
-        (latest.assistantMessage.artifactIds?.length ?? 0) > 0 ||
-        latest.assistantMessage.status !== "error")
-    )
-      return null
-    return {
-      threadId: input.threadId,
-      addedMessages: [
-        pendingAssistant({
-          id: input.assistantMessageId,
-          parentMessageId: latest.userMessage.id,
-          generationId: input.generationId,
-        }),
-      ],
-      nextActiveLeafMessageId: input.assistantMessageId,
-    }
-  }
-
-  if (
-    latest.userMessage.id !== input.intent.sourceUserMessageId ||
-    input.intent.text.trim() === ""
-  )
-    return null
-  const editedUser: Message = {
-    id: input.userMessageId,
-    parentMessageId: latest.userMessage.parentMessageId,
-    role: "user",
-    text: input.intent.text.trim(),
-    forks: [],
-    ...(latest.userMessage.quote
-      ? { quote: structuredClone(latest.userMessage.quote) }
-      : {}),
-  }
-  return {
-    threadId: input.threadId,
-    addedMessages: [
-      editedUser,
-      pendingAssistant({
-        id: input.assistantMessageId,
-        parentMessageId: editedUser.id,
-        generationId: input.generationId,
-      }),
-    ],
-    nextActiveLeafMessageId: input.assistantMessageId,
-    ...(latest.assistantMessage?.generationId &&
-    (latest.assistantMessage.status === "pending" ||
-      latest.assistantMessage.status === "streaming")
-      ? { supersededGenerationId: latest.assistantMessage.generationId }
-      : {}),
-  }
-}
diff --git a/lib/thread-chat/domain/selectors.ts b/lib/thread-chat/domain/selectors.ts
index 7c2c61ee..e57655f4 100644
--- a/lib/thread-chat/domain/selectors.ts
+++ b/lib/thread-chat/domain/selectors.ts
@@ -111,7 +111,7 @@ function rowOf(t: Thread, relDepth: number): TreeRow {
     id: t.id,
     depth: t.depth,
     relDepth,
-    isMain: t.id === "main",
+    isMain: t.parentId === null,
     title: t.title,
     footnote: t.footnote,
     anchor: t.anchorText,
@@ -120,7 +120,10 @@ function rowOf(t: Thread, relDepth: number): TreeRow {
 
 /** 整棵树的先序遍历 rows(⌘K / 每列 ⇄ 切换器用) */
 export function allTreeRows(state: ThreadTreeState): TreeRow[] {
-  return subtreeRowsInner(state, "main", 0, true)
+  const root = Object.values(state.threads).find(
+    (thread) => thread.parentId === null
+  )
+  return root ? subtreeRowsInner(state, root.id, 0, true) : []
 }
 
 /** 以 rootId 为根的整棵子树 rows(不含根自身,relDepth 从 0 起),子树弹层用 */
diff --git a/lib/thread-chat/domain/thread.ts b/lib/thread-chat/domain/thread.ts
new file mode 100644
index 00000000..86754133
--- /dev/null
+++ b/lib/thread-chat/domain/thread.ts
@@ -0,0 +1,95 @@
+import type { BaseContextV1 } from "./base-context"
+import { validateBaseContext } from "./base-context"
+import { invariant } from "./domain-error"
+import type { MessageId, ProjectId, ThreadId } from "./ids"
+import type { Message } from "./message"
+
+export type ForkSourceSnapshot = {
+  schemaVersion: 1
+  quote?: string
+  sourceRole: "user" | "assistant"
+  sourceSequence: number
+}
+
+export type Thread = {
+  id: ThreadId
+  projectId: ProjectId
+  parentThreadId: ThreadId | null
+  sourceMessageId: MessageId | null
+  forkSourceSnapshot: ForkSourceSnapshot | null
+  baseContext: BaseContextV1 | null
+  autoTitle: string | null
+  customTitle: string | null
+  archivedAt: Date | null
+  createdAt: Date
+  updatedAt: Date
+}
+
+export function isRootThread(thread: Thread): boolean {
+  return thread.parentThreadId === null
+}
+
+export function validateThreadTopology(
+  projectId: ProjectId,
+  projectThreads: readonly Thread[],
+  messagesById: ReadonlyMap
+): void {
+  const byId = new Map(projectThreads.map((thread) => [thread.id, thread]))
+  const roots = projectThreads.filter(isRootThread)
+  invariant(
+    roots.length === 1,
+    "project_root_invalid",
+    "Project 必须恰有一个 Root Thread。"
+  )
+
+  for (const thread of projectThreads) {
+    invariant(
+      thread.projectId === projectId,
+      "thread_parent_invalid",
+      "Thread 必须属于当前 Project。"
+    )
+    if (isRootThread(thread)) {
+      invariant(
+        thread.sourceMessageId === null &&
+          thread.forkSourceSnapshot === null &&
+          thread.baseContext === null,
+        "thread_fork_facts_invalid",
+        "Root Thread 不得包含 ForkFacts。"
+      )
+      continue
+    }
+
+    invariant(
+      thread.sourceMessageId && thread.forkSourceSnapshot && thread.baseContext,
+      "thread_fork_facts_invalid",
+      "Branch Thread 必须包含完整 ForkFacts。"
+    )
+    const parent = byId.get(thread.parentThreadId!)
+    invariant(
+      parent?.projectId === projectId,
+      "thread_parent_invalid",
+      "Branch Parent 必须属于同一 Project。"
+    )
+    const source = messagesById.get(thread.sourceMessageId)
+    invariant(
+      source?.threadId === parent.id,
+      "thread_source_invalid",
+      "Fork source Message 必须属于 Parent Thread。"
+    )
+    validateBaseContext(thread.baseContext)
+  }
+
+  for (const thread of projectThreads) {
+    const visited = new Set()
+    let cursor: Thread | undefined = thread
+    while (cursor?.parentThreadId) {
+      invariant(
+        !visited.has(cursor.id),
+        "thread_cycle",
+        "Thread topology 不得形成环。"
+      )
+      visited.add(cursor.id)
+      cursor = byId.get(cursor.parentThreadId)
+    }
+  }
+}
diff --git a/lib/thread-chat/domain/types.ts b/lib/thread-chat/domain/types.ts
index 51b6baef..f028e6e3 100644
--- a/lib/thread-chat/domain/types.ts
+++ b/lib/thread-chat/domain/types.ts
@@ -10,10 +10,13 @@ import type { TextAnchor } from "@/lib/thread-chat/domain/text-anchor"
 import type { WebResearchActivity } from "@/lib/chat/web-research-activity"
 import type { ResearchPlan, ResearchRoute } from "@/lib/chat/research-router"
 import type { THREAD_TREE_SCHEMA_VERSION } from "@/constants/thread-chat"
-export type {
-  MessageFeedback,
-  MessageFeedbackSummary,
-} from "@/lib/thread-chat/contracts/message-feedback"
+export type MessageFeedback = "positive" | "negative"
+
+export interface MessageFeedbackSummary {
+  messageId: string
+  feedback: MessageFeedback
+  updatedAt: string
+}
 
 export type Role = "user" | "assistant"
 export type ArtifactKind = "code" | "note" | "markdown"
diff --git a/lib/thread-chat/infrastructure/ai-sdk-runtime.ts b/lib/thread-chat/infrastructure/ai-sdk-runtime.ts
new file mode 100644
index 00000000..22b20306
--- /dev/null
+++ b/lib/thread-chat/infrastructure/ai-sdk-runtime.ts
@@ -0,0 +1,111 @@
+import {
+  convertToModelMessages,
+  isStepCount,
+  streamText,
+  tool,
+  type LanguageModel,
+} from "ai"
+import {
+  isExplicitMarkdownArtifactRequest,
+  MARKDOWN_ARTIFACT_TOOL_DESCRIPTION,
+  MARKDOWN_ARTIFACT_TOOL_NAME,
+  markdownArtifactInputSchema,
+} from "@/lib/chat/markdown-artifact"
+import { resolveChatModel } from "@/lib/ai/provider"
+import type {
+  AiRuntime,
+  AiRuntimeEvent,
+  AiRuntimeRequest,
+} from "../application/ports/ai-runtime"
+
+const markdownArtifactTool = tool({
+  description: MARKDOWN_ARTIFACT_TOOL_DESCRIPTION,
+  inputSchema: markdownArtifactInputSchema,
+  execute: async () => ({ created: true as const }),
+})
+
+export class AiSdkRuntime implements AiRuntime {
+  constructor(
+    private readonly resolveModel: (modelId: string) => LanguageModel =
+      resolveChatModel
+  ) {}
+
+  async *execute(
+    request: AiRuntimeRequest,
+    options: { signal?: AbortSignal } = {}
+  ): AsyncIterable {
+    const lastUserText = request.prompt
+      .findLast((message) => message.role === "user")
+      ?.parts.filter((part) => part.type === "text")
+      .map((part) => part.text)
+      .join("\n")
+    const tools = { [MARKDOWN_ARTIFACT_TOOL_NAME]: markdownArtifactTool }
+    const markdownArtifactEnabled = isExplicitMarkdownArtifactRequest(
+      lastUserText ?? ""
+    )
+    const result = streamText({
+      model: this.resolveModel(request.modelId),
+      messages: await convertToModelMessages(request.prompt, { tools }),
+      tools,
+      activeTools: markdownArtifactEnabled
+        ? [MARKDOWN_ARTIFACT_TOOL_NAME]
+        : [],
+      abortSignal: options.signal,
+      stopWhen: isStepCount(5),
+    })
+    let text = ""
+
+    for await (const part of result.fullStream) {
+      if (part.type === "text-delta") {
+        text += part.text
+        yield {
+          type: "delta",
+          partsDelta: [{ type: "text", text: part.text }],
+        }
+        continue
+      }
+      if (
+        part.type === "tool-result" &&
+        part.toolName === MARKDOWN_ARTIFACT_TOOL_NAME
+      ) {
+        const input = markdownArtifactInputSchema.parse(part.input)
+        yield {
+          type: "artifact",
+          output: {
+            kind: "markdown",
+            title: input.title,
+            content: input.content,
+            toolCallId: part.toolCallId,
+          },
+        }
+        continue
+      }
+      if (part.type === "abort") {
+        yield { type: "stopped" }
+        return
+      }
+      if (part.type === "error") {
+        if (options.signal?.aborted) {
+          yield { type: "stopped" }
+          return
+        }
+        yield {
+          type: "failed",
+          error: {
+            code: "ai_sdk_stream_error",
+            message:
+              part.error instanceof Error
+                ? part.error.message
+                : "AI SDK stream failed.",
+          },
+        }
+        return
+      }
+    }
+
+    yield {
+      type: "completed",
+      parts: text ? [{ type: "text", text }] : [],
+    }
+  }
+}
diff --git a/lib/thread-chat/infrastructure/isolated-test-ai-runtime.ts b/lib/thread-chat/infrastructure/isolated-test-ai-runtime.ts
new file mode 100644
index 00000000..3bbc3084
--- /dev/null
+++ b/lib/thread-chat/infrastructure/isolated-test-ai-runtime.ts
@@ -0,0 +1,108 @@
+import { isExplicitMarkdownArtifactRequest } from "@/lib/chat/markdown-artifact"
+import type {
+  AiRuntime,
+  AiRuntimeEvent,
+  AiRuntimeRequest,
+} from "../application/ports/ai-runtime"
+
+const ISOLATED_TEST_DATABASE_NAME = "thread-chat-test"
+
+export function usesIsolatedTestAiRuntime(input: {
+  databaseUrl: string | undefined
+  nodeEnv: string | undefined
+}): boolean {
+  if (input.nodeEnv === "production" || !input.databaseUrl) return false
+  try {
+    const url = new URL(input.databaseUrl)
+    return (
+      (url.protocol === "postgres:" || url.protocol === "postgresql:") &&
+      decodeURIComponent(url.pathname.slice(1)) === ISOLATED_TEST_DATABASE_NAME
+    )
+  } catch {
+    return false
+  }
+}
+
+function waitForDelayOrAbort(
+  delayMs: number,
+  signal: AbortSignal | undefined
+): Promise<"elapsed" | "aborted"> {
+  if (signal?.aborted) return Promise.resolve("aborted")
+  return new Promise((resolve) => {
+    const timer = setTimeout(() => {
+      signal?.removeEventListener("abort", onAbort)
+      resolve("elapsed")
+    }, delayMs)
+    const onAbort = () => {
+      clearTimeout(timer)
+      signal?.removeEventListener("abort", onAbort)
+      resolve("aborted")
+    }
+    signal?.addEventListener("abort", onAbort, { once: true })
+  })
+}
+
+/**
+ * 只在 allowlisted `thread-chat-test` 数据库 + 非 production 进程启用。
+ * 它不是产品 feature flag:无法通过任意环境变量切换,也不会在正式数据库上运行。
+ */
+export class IsolatedTestAiRuntime implements AiRuntime {
+  constructor(
+    private readonly delays: {
+      normalMs: number
+      slowMs: number
+      stopTimeoutMs: number
+    } = { normalMs: 120, slowMs: 2_500, stopTimeoutMs: 30_000 }
+  ) {}
+
+  async *execute(
+    request: AiRuntimeRequest,
+    options: { signal?: AbortSignal } = {}
+  ): AsyncIterable {
+    const prompt = request.prompt
+      .findLast((message) => message.role === "user")
+      ?.parts.filter((part) => part.type === "text")
+      .map((part) => part.text)
+      .join("\n")
+      .trim() ?? ""
+    const response = `测试回复 ${request.assistantMessageId.slice(-6)}:已收到「${prompt}」。`
+
+    yield {
+      type: "delta",
+      partsDelta: [{ type: "text", text: "测试回复生成中…" }],
+    }
+
+    const stopScenario = prompt.includes("直到我停止")
+    const slowScenario = prompt.includes("刷新恢复")
+    const waitResult = await waitForDelayOrAbort(
+      stopScenario
+        ? this.delays.stopTimeoutMs
+        : slowScenario
+          ? this.delays.slowMs
+          : this.delays.normalMs,
+      options.signal
+    )
+    if (waitResult === "aborted") {
+      yield { type: "stopped" }
+      return
+    }
+
+    if (isExplicitMarkdownArtifactRequest(prompt)) {
+      yield {
+        type: "artifact",
+        output: {
+          kind: "markdown",
+          title: "E2E Markdown",
+          content:
+            "# E2E Markdown\n\n- Artifact 按 ID 加载\n- 来源 Message 可定位\n- 刷新后可恢复",
+          toolCallId: `e2e-${request.messageRunId}`,
+        },
+      }
+    }
+
+    yield {
+      type: "completed",
+      parts: [{ type: "text", text: response }],
+    }
+  }
+}
diff --git a/lib/thread-chat/infrastructure/repositories/artifact-repository.ts b/lib/thread-chat/infrastructure/repositories/artifact-repository.ts
new file mode 100644
index 00000000..e5bcac1b
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/artifact-repository.ts
@@ -0,0 +1,169 @@
+import type { Artifact } from "../../domain/artifact"
+import { assertArtifactProvenance } from "../../domain/artifact"
+import { invariant } from "../../domain/domain-error"
+import type { ArtifactId, MessageId, ProjectId, UserId } from "../../domain/ids"
+import {
+  mapArtifact,
+  mapMessage,
+  mapThread,
+  toSqlJsonText,
+  type ThreadChatSql,
+} from "./database"
+
+export class ArtifactRepository {
+  constructor(private readonly sql: ThreadChatSql) {}
+
+  async insert(input: {
+    actorId: UserId
+    id: ArtifactId
+    projectId: ProjectId
+    sourceMessageId: MessageId
+    kind: string
+    title: string
+    content: unknown
+  }): Promise {
+    const [sourceRow] = await this.sql[]>`
+      select
+        m.id,
+        m.thread_id as "threadId",
+        m.sequence,
+        m.role,
+        m.parts,
+        m.replaces_message_id as "replacesMessageId",
+        m.superseded_at as "supersededAt",
+        m.finalized_at as "finalizedAt",
+        m.created_at as "createdAt",
+        t.id as "sourceThreadId",
+        t.project_id as "sourceProjectId",
+        t.parent_thread_id as "parentThreadId",
+        t.source_message_id as "threadSourceMessageId",
+        t.fork_source_snapshot as "forkSourceSnapshot",
+        t.base_context as "baseContext",
+        t.auto_title as "threadAutoTitle",
+        t.custom_title as "threadCustomTitle",
+        t.archived_at as "threadArchivedAt",
+        t.created_at as "threadCreatedAt",
+        t.updated_at as "threadUpdatedAt"
+      from thread_chat.messages m
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where m.id = ${input.sourceMessageId}
+        and t.project_id = ${input.projectId}
+        and p.owner_user_id = ${input.actorId}
+      for update of p, m
+    `
+    invariant(
+      sourceRow,
+      "artifact_provenance_invalid",
+      "Artifact source 不属于 actor 的目标 Project。"
+    )
+    const sourceMessage = mapMessage(sourceRow)
+    const sourceThread = mapThread({
+      id: sourceRow.sourceThreadId,
+      projectId: sourceRow.sourceProjectId,
+      parentThreadId: sourceRow.parentThreadId,
+      sourceMessageId: sourceRow.threadSourceMessageId,
+      forkSourceSnapshot: sourceRow.forkSourceSnapshot,
+      baseContext: sourceRow.baseContext,
+      autoTitle: sourceRow.threadAutoTitle,
+      customTitle: sourceRow.threadCustomTitle,
+      archivedAt: sourceRow.threadArchivedAt,
+      createdAt: sourceRow.threadCreatedAt,
+      updatedAt: sourceRow.threadUpdatedAt,
+    })
+    assertArtifactProvenance(input, sourceMessage, sourceThread)
+
+    const [counter] = await this.sql<{ changeSequence: number }[]>`
+      update thread_chat.projects
+      set artifact_change_sequence = artifact_change_sequence + 1,
+          updated_at = now()
+      where id = ${input.projectId} and owner_user_id = ${input.actorId}
+      returning artifact_change_sequence as "changeSequence"
+    `
+    invariant(counter, "project_owner_mismatch", "Project 不属于当前 actor。")
+
+    const [row] = await this.sql[]>`
+      insert into thread_chat.artifacts (
+        id,
+        project_id,
+        source_message_id,
+        change_sequence,
+        kind,
+        title,
+        content
+      ) values (
+        ${input.id},
+        ${input.projectId},
+        ${input.sourceMessageId},
+        ${counter.changeSequence},
+        ${input.kind},
+        ${input.title},
+        ${toSqlJsonText(input.content)}::jsonb
+      )
+      returning
+        id,
+        project_id as "projectId",
+        source_message_id as "sourceMessageId",
+        change_sequence as "changeSequence",
+        kind,
+        title,
+        content,
+        created_at as "createdAt"
+    `
+    return mapArtifact(row)
+  }
+
+  async findOwnedById(
+    actorId: UserId,
+    artifactId: ArtifactId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        a.id,
+        a.project_id as "projectId",
+        a.source_message_id as "sourceMessageId",
+        a.change_sequence as "changeSequence",
+        a.kind,
+        a.title,
+        a.content,
+        a.created_at as "createdAt"
+      from thread_chat.artifacts a
+      join thread_chat.projects p on p.id = a.project_id
+      where a.id = ${artifactId} and p.owner_user_id = ${actorId}
+    `
+    return row ? mapArtifact(row) : null
+  }
+
+  async summarizeOwnedProject(
+    actorId: UserId,
+    projectId: ProjectId
+  ): Promise<{
+    changeSequence: number
+    total: number
+    byKind: Record
+  } | null> {
+    const rows = await this.sql<
+      { changeSequence: number; kind: string | null; count: number }[]
+    >`
+      select
+        p.artifact_change_sequence::integer as "changeSequence",
+        a.kind,
+        count(a.id)::integer as count
+      from thread_chat.projects p
+      left join thread_chat.artifacts a on a.project_id = p.id
+      where p.id = ${projectId} and p.owner_user_id = ${actorId}
+      group by p.id, p.artifact_change_sequence, a.kind
+    `
+    if (rows.length === 0) return null
+    const byKind = Object.fromEntries(
+      rows
+        .filter((row) => row.kind !== null)
+        .map((row) => [row.kind!, Number(row.count)])
+    )
+    return {
+      changeSequence: Number(rows[0].changeSequence),
+      total: Object.values(byKind).reduce((sum, count) => sum + count, 0),
+      byKind,
+    }
+  }
+}
diff --git a/lib/thread-chat/infrastructure/repositories/database.ts b/lib/thread-chat/infrastructure/repositories/database.ts
new file mode 100644
index 00000000..5d89ab8a
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/database.ts
@@ -0,0 +1,114 @@
+import type postgres from "postgres"
+import type { Artifact } from "../../domain/artifact"
+import type { Message } from "../../domain/message"
+import type { MessageRun } from "../../domain/message-run"
+import type { Project } from "../../domain/project"
+import type { Thread } from "../../domain/thread"
+
+export type ThreadChatSql = postgres.Sql | postgres.TransactionSql
+
+export function toSqlJsonText(value: unknown): string {
+  return JSON.stringify(value)
+}
+
+export function toSqlTimestamp(value: Date | null | undefined): string | null {
+  return value?.toISOString() ?? null
+}
+
+function toDate(value: unknown): Date {
+  return value instanceof Date ? value : new Date(String(value))
+}
+
+function toNullableDate(value: unknown): Date | null {
+  return value === null || value === undefined ? null : toDate(value)
+}
+
+function fromSqlJson(value: unknown): unknown {
+  if (typeof value !== "string") return value
+  try {
+    return JSON.parse(value)
+  } catch {
+    return value
+  }
+}
+
+export function mapProject(row: Record): Project {
+  return {
+    id: String(row.id),
+    ownerUserId: String(row.ownerUserId),
+    autoTitle: (row.autoTitle as string | null) ?? null,
+    customTitle: (row.customTitle as string | null) ?? null,
+    target: (row.target as Project["target"]) ?? null,
+    instruction: (row.instruction as string | null) ?? null,
+    archivedAt: toNullableDate(row.archivedAt),
+    artifactChangeSequence: Number(row.artifactChangeSequence),
+    createdAt: toDate(row.createdAt),
+    updatedAt: toDate(row.updatedAt),
+  }
+}
+
+export function mapThread(row: Record): Thread {
+  return {
+    id: String(row.id),
+    projectId: String(row.projectId),
+    parentThreadId: (row.parentThreadId as string | null) ?? null,
+    sourceMessageId: (row.sourceMessageId as string | null) ?? null,
+    forkSourceSnapshot:
+      (fromSqlJson(row.forkSourceSnapshot) as Thread["forkSourceSnapshot"]) ??
+      null,
+    baseContext:
+      (fromSqlJson(row.baseContext) as Thread["baseContext"]) ?? null,
+    autoTitle: (row.autoTitle as string | null) ?? null,
+    customTitle: (row.customTitle as string | null) ?? null,
+    archivedAt: toNullableDate(row.archivedAt),
+    createdAt: toDate(row.createdAt),
+    updatedAt: toDate(row.updatedAt),
+  }
+}
+
+export function mapMessage(row: Record): Message {
+  return {
+    id: String(row.id),
+    threadId: String(row.threadId),
+    sequence: Number(row.sequence),
+    role: row.role as Message["role"],
+    parts: (fromSqlJson(row.parts) as Message["parts"]) ?? null,
+    replacesMessageId: (row.replacesMessageId as string | null) ?? null,
+    supersededAt: toNullableDate(row.supersededAt),
+    finalizedAt: toNullableDate(row.finalizedAt),
+    createdAt: toDate(row.createdAt),
+  }
+}
+
+export function mapMessageRun(row: Record): MessageRun {
+  return {
+    id: String(row.id),
+    assistantMessageId: String(row.assistantMessageId),
+    status: row.status as MessageRun["status"],
+    modelId: String(row.modelId),
+    eventSequence: Number(row.eventSequence),
+    checkpointParts: fromSqlJson(
+      row.checkpointParts
+    ) as MessageRun["checkpointParts"],
+    errorCode: (row.errorCode as string | null) ?? null,
+    errorMessage: (row.errorMessage as string | null) ?? null,
+    heartbeatAt: toNullableDate(row.heartbeatAt),
+    stopRequestedAt: toNullableDate(row.stopRequestedAt),
+    finishedAt: toNullableDate(row.finishedAt),
+    createdAt: toDate(row.createdAt),
+    updatedAt: toDate(row.updatedAt),
+  }
+}
+
+export function mapArtifact(row: Record): Artifact {
+  return {
+    id: String(row.id),
+    projectId: String(row.projectId),
+    sourceMessageId: String(row.sourceMessageId),
+    changeSequence: Number(row.changeSequence),
+    kind: String(row.kind),
+    title: String(row.title),
+    content: fromSqlJson(row.content),
+    createdAt: toDate(row.createdAt),
+  }
+}
diff --git a/lib/thread-chat/infrastructure/repositories/feedback-repository.ts b/lib/thread-chat/infrastructure/repositories/feedback-repository.ts
new file mode 100644
index 00000000..0054d684
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/feedback-repository.ts
@@ -0,0 +1,55 @@
+import { invariant } from "../../domain/domain-error"
+import type { MessageId, UserId } from "../../domain/ids"
+import type { ThreadChatSql } from "./database"
+
+export type MessageFeedbackValue = "positive" | "negative"
+
+export class FeedbackRepository {
+  constructor(private readonly sql: ThreadChatSql) {}
+
+  async set(input: {
+    actorId: UserId
+    assistantMessageId: MessageId
+    feedback: MessageFeedbackValue | null
+  }): Promise {
+    const [eligible] = await this.sql`
+      select m.id
+      from thread_chat.messages m
+      join thread_chat.message_runs r on r.assistant_message_id = m.id
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where m.id = ${input.assistantMessageId}
+        and m.role = 'assistant'
+        and m.finalized_at is not null
+        and m.superseded_at is null
+        and r.status = 'completed'
+        and p.owner_user_id = ${input.actorId}
+      for update of m
+    `
+    invariant(
+      eligible,
+      "feedback_not_eligible",
+      "Message 不属于 actor 或不满足 feedback 资格。"
+    )
+
+    if (input.feedback === null) {
+      await this.sql`
+        delete from thread_chat.message_feedback
+        where assistant_message_id = ${input.assistantMessageId}
+      `
+      return null
+    }
+
+    const [row] = await this.sql<{ feedback: MessageFeedbackValue }[]>`
+      insert into thread_chat.message_feedback (
+        assistant_message_id, feedback
+      ) values (
+        ${input.assistantMessageId}, ${input.feedback}
+      )
+      on conflict (assistant_message_id) do update
+      set feedback = excluded.feedback, updated_at = now()
+      returning feedback
+    `
+    return row.feedback
+  }
+}
diff --git a/lib/thread-chat/infrastructure/repositories/index.ts b/lib/thread-chat/infrastructure/repositories/index.ts
new file mode 100644
index 00000000..6fec6db5
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/index.ts
@@ -0,0 +1,43 @@
+import type postgres from "postgres"
+import { ArtifactRepository } from "./artifact-repository"
+import { FeedbackRepository } from "./feedback-repository"
+import { MessageRepository } from "./message-repository"
+import { MessageRunRepository } from "./message-run-repository"
+import { ProjectRepository } from "./project-repository"
+import { ThreadRepository } from "./thread-repository"
+import type { ThreadChatSql } from "./database"
+
+export type ThreadChatRepositories = ReturnType<
+  typeof createThreadChatRepositories
+>
+
+export function createThreadChatRepositories(sql: ThreadChatSql) {
+  return {
+    projects: new ProjectRepository(sql),
+    threads: new ThreadRepository(sql),
+    messages: new MessageRepository(sql),
+    messageRuns: new MessageRunRepository(sql),
+    artifacts: new ArtifactRepository(sql),
+    feedback: new FeedbackRepository(sql),
+  }
+}
+
+export class ThreadChatUnitOfWork {
+  constructor(private readonly sql: postgres.Sql) {}
+
+  transaction(
+    callback: (repositories: ThreadChatRepositories) => Promise
+  ): Promise {
+    return this.sql.begin((transactionSql) =>
+      callback(createThreadChatRepositories(transactionSql))
+    ) as Promise
+  }
+}
+
+export { ArtifactRepository } from "./artifact-repository"
+export { FeedbackRepository } from "./feedback-repository"
+export { MessageRepository } from "./message-repository"
+export { MessageRunRepository } from "./message-run-repository"
+export { ProjectRepository } from "./project-repository"
+export { ThreadRepository } from "./thread-repository"
+export type { ThreadChatSql } from "./database"
diff --git a/lib/thread-chat/infrastructure/repositories/message-repository.ts b/lib/thread-chat/infrastructure/repositories/message-repository.ts
new file mode 100644
index 00000000..f3a0af0b
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/message-repository.ts
@@ -0,0 +1,327 @@
+import type { UIMessage } from "ai"
+import { assertMessageCanBeReplaced, type Message } from "../../domain/message"
+import { invariant } from "../../domain/domain-error"
+import type { MessageId, ThreadId, UserId } from "../../domain/ids"
+import {
+  mapMessage,
+  toSqlJsonText,
+  toSqlTimestamp,
+  type ThreadChatSql,
+} from "./database"
+
+export class MessageRepository {
+  constructor(private readonly sql: ThreadChatSql) {}
+
+  async findOwnedById(
+    actorId: UserId,
+    messageId: MessageId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        m.id,
+        m.thread_id as "threadId",
+        m.sequence,
+        m.role,
+        m.parts,
+        m.replaces_message_id as "replacesMessageId",
+        m.superseded_at as "supersededAt",
+        m.finalized_at as "finalizedAt",
+        m.created_at as "createdAt"
+      from thread_chat.messages m
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where m.id = ${messageId} and p.owner_user_id = ${actorId}
+    `
+    return row ? mapMessage(row) : null
+  }
+
+  async listEffectiveOwned(
+    actorId: UserId,
+    threadId: ThreadId,
+    limit = 200
+  ): Promise {
+    const rows = await this.sql[]>`
+      select * from (
+        select
+          m.id,
+          m.thread_id as "threadId",
+          m.sequence,
+          m.role,
+          m.parts,
+          m.replaces_message_id as "replacesMessageId",
+          m.superseded_at as "supersededAt",
+          m.finalized_at as "finalizedAt",
+          m.created_at as "createdAt"
+        from thread_chat.messages m
+        join thread_chat.threads t on t.id = m.thread_id
+        join thread_chat.projects p on p.id = t.project_id
+        where m.thread_id = ${threadId}
+          and m.superseded_at is null
+          and p.owner_user_id = ${actorId}
+        order by m.sequence desc
+        limit ${limit}
+      ) message_window
+      order by sequence asc
+    `
+    return rows.map(mapMessage)
+  }
+
+  async listEffectiveWindow(input: {
+    actorId: UserId
+    threadId: ThreadId
+    limit: number
+    beforeSequence?: number
+  }): Promise {
+    const rows = await this.sql[]>`
+      select * from (
+        select
+          m.id, m.thread_id as "threadId", m.sequence, m.role, m.parts,
+          m.replaces_message_id as "replacesMessageId",
+          m.superseded_at as "supersededAt", m.finalized_at as "finalizedAt",
+          m.created_at as "createdAt"
+        from thread_chat.messages m
+        join thread_chat.threads t on t.id = m.thread_id
+        join thread_chat.projects p on p.id = t.project_id
+        where m.thread_id = ${input.threadId}
+          and m.superseded_at is null
+          and p.owner_user_id = ${input.actorId}
+          and (${input.beforeSequence ?? null}::integer is null or m.sequence < ${input.beforeSequence ?? null})
+        order by m.sequence desc
+        limit ${input.limit}
+      ) message_window
+      order by sequence asc
+    `
+    return rows.map(mapMessage)
+  }
+
+  async listByIdsOwned(
+    actorId: UserId,
+    messageIds: readonly MessageId[]
+  ): Promise {
+    if (messageIds.length === 0) return []
+    const rows = await this.sql[]>`
+      select
+        m.id, m.thread_id as "threadId", m.sequence, m.role, m.parts,
+        m.replaces_message_id as "replacesMessageId",
+        m.superseded_at as "supersededAt", m.finalized_at as "finalizedAt",
+        m.created_at as "createdAt"
+      from thread_chat.messages m
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where m.id in ${this.sql(messageIds)} and p.owner_user_id = ${actorId}
+    `
+    return rows.map(mapMessage)
+  }
+
+  async findOwnedByIdForUpdate(
+    actorId: UserId,
+    messageId: MessageId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        m.id, m.thread_id as "threadId", m.sequence, m.role, m.parts,
+        m.replaces_message_id as "replacesMessageId",
+        m.superseded_at as "supersededAt", m.finalized_at as "finalizedAt",
+        m.created_at as "createdAt"
+      from thread_chat.messages m
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where m.id = ${messageId} and p.owner_user_id = ${actorId}
+      for update of m
+    `
+    return row ? mapMessage(row) : null
+  }
+
+  async isLastEffective(message: Message): Promise {
+    const [row] = await this.sql`
+      select 1 from thread_chat.messages
+      where thread_id = ${message.threadId}
+        and superseded_at is null
+        and sequence > ${message.sequence}
+      limit 1
+    `
+    return !row
+  }
+
+  async isLastEffectiveUser(message: Message): Promise {
+    const [row] = await this.sql`
+      select 1 from thread_chat.messages
+      where thread_id = ${message.threadId}
+        and role = 'user'
+        and superseded_at is null
+        and sequence > ${message.sequence}
+      limit 1
+    `
+    return !row
+  }
+
+  async supersedeEffectiveSuffix(input: {
+    actorId: UserId
+    threadId: ThreadId
+    fromSequence: number
+    supersededAt: Date
+  }): Promise {
+    const rows = await this.sql<{ id: string }[]>`
+      update thread_chat.messages m
+      set superseded_at = ${toSqlTimestamp(input.supersededAt)}::timestamptz
+      from thread_chat.threads t, thread_chat.projects p
+      where m.thread_id = ${input.threadId}
+        and m.sequence >= ${input.fromSequence}
+        and m.superseded_at is null
+        and m.thread_id = t.id
+        and t.project_id = p.id
+        and p.owner_user_id = ${input.actorId}
+      returning m.id
+    `
+    return rows.map((row) => row.id)
+  }
+
+  async supersedeEffectiveRange(input: {
+    actorId: UserId
+    threadId: ThreadId
+    afterSequence: number
+    beforeSequence: number
+    supersededAt: Date
+  }): Promise {
+    const rows = await this.sql<{ id: string }[]>`
+      update thread_chat.messages m
+      set superseded_at = ${toSqlTimestamp(input.supersededAt)}::timestamptz
+      from thread_chat.threads t, thread_chat.projects p
+      where m.thread_id = ${input.threadId}
+        and m.sequence > ${input.afterSequence}
+        and m.sequence < ${input.beforeSequence}
+        and m.superseded_at is null
+        and m.thread_id = t.id
+        and t.project_id = p.id
+        and p.owner_user_id = ${input.actorId}
+      returning m.id
+    `
+    return rows.map((row) => row.id)
+  }
+
+  /** 锁定 Thread 后分配 sequence,并在同一事务完成 insert。 */
+  async append(input: {
+    actorId: UserId
+    id: MessageId
+    threadId: ThreadId
+    role: Message["role"]
+    parts: UIMessage["parts"] | null
+    finalizedAt: Date | null
+    replacesMessageId?: MessageId | null
+  }): Promise {
+    const [thread] = await this.sql`
+      select t.id
+      from thread_chat.threads t
+      join thread_chat.projects p on p.id = t.project_id
+      where t.id = ${input.threadId} and p.owner_user_id = ${input.actorId}
+      for update of t
+    `
+    invariant(
+      thread,
+      "project_owner_mismatch",
+      "Thread 不存在或不属于当前 actor。"
+    )
+
+    let source: Message | null = null
+    if (input.replacesMessageId) {
+      const [sourceRow] = await this.sql[]>`
+        select
+          id,
+          thread_id as "threadId",
+          sequence,
+          role,
+          parts,
+          replaces_message_id as "replacesMessageId",
+          superseded_at as "supersededAt",
+          finalized_at as "finalizedAt",
+          created_at as "createdAt"
+        from thread_chat.messages
+        where id = ${input.replacesMessageId}
+        for update
+      `
+      invariant(sourceRow, "entity_not_found", "replacement source 不存在。")
+      source = mapMessage(sourceRow)
+      assertMessageCanBeReplaced(source, {
+        threadId: input.threadId,
+        role: input.role,
+        replacesMessageId: input.replacesMessageId,
+      })
+    }
+
+    const [sequenceRow] = await this.sql<{ nextSequence: number }[]>`
+      select coalesce(max(sequence), 0)::integer + 1 as "nextSequence"
+      from thread_chat.messages
+      where thread_id = ${input.threadId}
+    `
+    const [row] = await this.sql[]>`
+      insert into thread_chat.messages (
+        id,
+        thread_id,
+        sequence,
+        role,
+        parts,
+        replaces_message_id,
+        finalized_at
+      ) values (
+        ${input.id},
+        ${input.threadId},
+        ${sequenceRow.nextSequence},
+        ${input.role},
+        ${input.parts === null ? null : toSqlJsonText(input.parts)}::jsonb,
+        ${input.replacesMessageId ?? null},
+        ${toSqlTimestamp(input.finalizedAt)}::timestamptz
+      )
+      returning
+        id,
+        thread_id as "threadId",
+        sequence,
+        role,
+        parts,
+        replaces_message_id as "replacesMessageId",
+        superseded_at as "supersededAt",
+        finalized_at as "finalizedAt",
+        created_at as "createdAt"
+    `
+
+    if (source) {
+      await this.sql`
+        update thread_chat.messages
+        set superseded_at = now()
+        where id = ${source.id} and superseded_at is null
+      `
+    }
+    return mapMessage(row)
+  }
+
+  /** finalized assistant parts 是单次封存,不提供 finalized Message 的通用更新入口。 */
+  async finalizeAssistantOnce(input: {
+    actorId: UserId
+    messageId: MessageId
+    parts: UIMessage["parts"]
+    finalizedAt: Date
+  }): Promise {
+    const [row] = await this.sql[]>`
+      update thread_chat.messages m
+      set parts = ${toSqlJsonText(input.parts)}::jsonb,
+          finalized_at = ${toSqlTimestamp(input.finalizedAt)}::timestamptz
+      from thread_chat.threads t, thread_chat.projects p
+      where m.id = ${input.messageId}
+        and m.thread_id = t.id
+        and t.project_id = p.id
+        and p.owner_user_id = ${input.actorId}
+        and m.role = 'assistant'
+        and m.finalized_at is null
+      returning
+        m.id,
+        m.thread_id as "threadId",
+        m.sequence,
+        m.role,
+        m.parts,
+        m.replaces_message_id as "replacesMessageId",
+        m.superseded_at as "supersededAt",
+        m.finalized_at as "finalizedAt",
+        m.created_at as "createdAt"
+    `
+    return row ? mapMessage(row) : null
+  }
+}
diff --git a/lib/thread-chat/infrastructure/repositories/message-run-repository.ts b/lib/thread-chat/infrastructure/repositories/message-run-repository.ts
new file mode 100644
index 00000000..f2b23915
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/message-run-repository.ts
@@ -0,0 +1,363 @@
+import type { UIMessage } from "ai"
+import { invariant } from "../../domain/domain-error"
+import type {
+  MessageId,
+  MessageRunId,
+  ProjectId,
+  ThreadId,
+  UserId,
+} from "../../domain/ids"
+import {
+  assertMessageRunTransition,
+  type MessageRun,
+  type MessageRunStatus,
+} from "../../domain/message-run"
+import {
+  mapMessageRun,
+  toSqlJsonText,
+  toSqlTimestamp,
+  type ThreadChatSql,
+} from "./database"
+
+export class MessageRunRepository {
+  constructor(private readonly sql: ThreadChatSql) {}
+
+  async insertQueued(input: {
+    actorId: UserId
+    id: MessageRunId
+    assistantMessageId: MessageId
+    modelId: string
+  }): Promise {
+    const [message] = await this.sql`
+      select m.id
+      from thread_chat.messages m
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where m.id = ${input.assistantMessageId}
+        and m.role = 'assistant'
+        and p.owner_user_id = ${input.actorId}
+      for update of m
+    `
+    invariant(
+      message,
+      "project_owner_mismatch",
+      "Message 不属于 actor,或不是 assistant Message。"
+    )
+    const [row] = await this.sql[]>`
+      insert into thread_chat.message_runs (
+        id, assistant_message_id, status, model_id
+      ) values (
+        ${input.id}, ${input.assistantMessageId}, 'queued', ${input.modelId}
+      )
+      returning
+        id,
+        assistant_message_id as "assistantMessageId",
+        status,
+        model_id as "modelId",
+        event_sequence as "eventSequence",
+        checkpoint_parts as "checkpointParts",
+        error_code as "errorCode",
+        error_message as "errorMessage",
+        heartbeat_at as "heartbeatAt",
+        stop_requested_at as "stopRequestedAt",
+        finished_at as "finishedAt",
+        created_at as "createdAt",
+        updated_at as "updatedAt"
+    `
+    return mapMessageRun(row)
+  }
+
+  async findOwnedByAssistantMessageId(
+    actorId: UserId,
+    assistantMessageId: MessageId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        r.id,
+        r.assistant_message_id as "assistantMessageId",
+        r.status,
+        r.model_id as "modelId",
+        r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts",
+        r.error_code as "errorCode",
+        r.error_message as "errorMessage",
+        r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt",
+        r.finished_at as "finishedAt",
+        r.created_at as "createdAt",
+        r.updated_at as "updatedAt"
+      from thread_chat.message_runs r
+      join thread_chat.messages m on m.id = r.assistant_message_id
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where r.assistant_message_id = ${assistantMessageId}
+        and p.owner_user_id = ${actorId}
+    `
+    return row ? mapMessageRun(row) : null
+  }
+
+  async findOwnedByAssistantMessageIdForUpdate(
+    actorId: UserId,
+    assistantMessageId: MessageId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        r.id, r.assistant_message_id as "assistantMessageId", r.status,
+        r.model_id as "modelId", r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts", r.error_code as "errorCode",
+        r.error_message as "errorMessage", r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt", r.finished_at as "finishedAt",
+        r.created_at as "createdAt", r.updated_at as "updatedAt"
+      from thread_chat.message_runs r
+      join thread_chat.messages m on m.id = r.assistant_message_id
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where r.assistant_message_id = ${assistantMessageId}
+        and p.owner_user_id = ${actorId}
+      for update of r
+    `
+    return row ? mapMessageRun(row) : null
+  }
+
+  async findOwnedByAssistantMessageIds(
+    actorId: UserId,
+    assistantMessageIds: readonly MessageId[]
+  ): Promise {
+    if (assistantMessageIds.length === 0) return []
+    const rows = await this.sql[]>`
+      select
+        r.id, r.assistant_message_id as "assistantMessageId", r.status,
+        r.model_id as "modelId", r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts", r.error_code as "errorCode",
+        r.error_message as "errorMessage", r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt", r.finished_at as "finishedAt",
+        r.created_at as "createdAt", r.updated_at as "updatedAt"
+      from thread_chat.message_runs r
+      join thread_chat.messages m on m.id = r.assistant_message_id
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where r.assistant_message_id in ${this.sql(assistantMessageIds)}
+        and p.owner_user_id = ${actorId}
+    `
+    return rows.map(mapMessageRun)
+  }
+
+  async findExecutionContext(messageRunId: MessageRunId): Promise<{
+    run: MessageRun
+    actorId: UserId
+    projectId: ProjectId
+    threadId: ThreadId
+  } | null> {
+    const [row] = await this.sql[]>`
+      select
+        r.id, r.assistant_message_id as "assistantMessageId", r.status,
+        r.model_id as "modelId", r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts", r.error_code as "errorCode",
+        r.error_message as "errorMessage", r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt", r.finished_at as "finishedAt",
+        r.created_at as "createdAt", r.updated_at as "updatedAt",
+        p.owner_user_id as "actorId", p.id as "projectId", t.id as "threadId"
+      from thread_chat.message_runs r
+      join thread_chat.messages m on m.id = r.assistant_message_id
+      join thread_chat.threads t on t.id = m.thread_id
+      join thread_chat.projects p on p.id = t.project_id
+      where r.id = ${messageRunId}
+    `
+    return row
+      ? {
+          run: mapMessageRun(row),
+          actorId: String(row.actorId),
+          projectId: String(row.projectId),
+          threadId: String(row.threadId),
+        }
+      : null
+  }
+
+  async listQueuedIds(limit: number): Promise {
+    const rows = await this.sql<{ id: string }[]>`
+      select id
+      from thread_chat.message_runs
+      where status = 'queued' and stop_requested_at is null
+      order by created_at, id
+      limit ${limit}
+    `
+    return rows.map((row) => row.id)
+  }
+
+  async assertNoActiveForThread(
+    actorId: UserId,
+    threadId: string
+  ): Promise {
+    const [row] = await this.sql`
+      select r.id
+      from thread_chat.threads t
+      join thread_chat.projects p on p.id = t.project_id
+      join thread_chat.messages m on m.thread_id = t.id
+      join thread_chat.message_runs r on r.assistant_message_id = m.id
+      where t.id = ${threadId}
+        and p.owner_user_id = ${actorId}
+        and r.status in ('queued', 'running')
+      for update of r
+      limit 1
+    `
+    invariant(
+      !row,
+      "thread_generation_in_progress",
+      "Thread 已有 queued 或 running MessageRun。"
+    )
+  }
+
+  async transition(input: {
+    actorId: UserId
+    messageRunId: MessageRunId
+    expectedStatus: MessageRunStatus
+    nextStatus: MessageRunStatus
+    finishedAt?: Date | null
+    error?: { code: string; message: string } | null
+    incrementEventSequence?: boolean
+  }): Promise {
+    assertMessageRunTransition(input.expectedStatus, input.nextStatus)
+    const [row] = await this.sql[]>`
+      update thread_chat.message_runs r
+      set status = ${input.nextStatus},
+          event_sequence = event_sequence + ${input.incrementEventSequence ? 1 : 0},
+          finished_at = ${toSqlTimestamp(input.finishedAt)}::timestamptz,
+          error_code = ${input.error?.code ?? null},
+          error_message = ${input.error?.message ?? null},
+          updated_at = now()
+      from thread_chat.messages m,
+           thread_chat.threads t,
+           thread_chat.projects p
+      where r.id = ${input.messageRunId}
+        and r.status = ${input.expectedStatus}
+        and r.assistant_message_id = m.id
+        and m.thread_id = t.id
+        and t.project_id = p.id
+        and p.owner_user_id = ${input.actorId}
+      returning
+        r.id,
+        r.assistant_message_id as "assistantMessageId",
+        r.status,
+        r.model_id as "modelId",
+        r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts",
+        r.error_code as "errorCode",
+        r.error_message as "errorMessage",
+        r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt",
+        r.finished_at as "finishedAt",
+        r.created_at as "createdAt",
+        r.updated_at as "updatedAt"
+    `
+    return row ? mapMessageRun(row) : null
+  }
+
+  async checkpoint(input: {
+    actorId: UserId
+    messageRunId: MessageRunId
+    expectedEventSequence: number
+    checkpointParts: UIMessage["parts"]
+    heartbeatAt: Date
+  }): Promise {
+    const [row] = await this.sql[]>`
+      update thread_chat.message_runs r
+      set checkpoint_parts = ${toSqlJsonText(input.checkpointParts)}::jsonb,
+          event_sequence = event_sequence + 1,
+          heartbeat_at = ${toSqlTimestamp(input.heartbeatAt)}::timestamptz,
+          updated_at = now()
+      from thread_chat.messages m,
+           thread_chat.threads t,
+           thread_chat.projects p
+      where r.id = ${input.messageRunId}
+        and r.status = 'running'
+        and r.event_sequence = ${input.expectedEventSequence}
+        and r.assistant_message_id = m.id
+        and m.thread_id = t.id
+        and t.project_id = p.id
+        and p.owner_user_id = ${input.actorId}
+      returning
+        r.id,
+        r.assistant_message_id as "assistantMessageId",
+        r.status,
+        r.model_id as "modelId",
+        r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts",
+        r.error_code as "errorCode",
+        r.error_message as "errorMessage",
+        r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt",
+        r.finished_at as "finishedAt",
+        r.created_at as "createdAt",
+        r.updated_at as "updatedAt"
+    `
+    return row ? mapMessageRun(row) : null
+  }
+
+  async heartbeat(input: {
+    actorId: UserId
+    messageRunId: MessageRunId
+    heartbeatAt: Date
+  }): Promise {
+    const [row] = await this.sql[]>`
+      update thread_chat.message_runs r
+      set heartbeat_at = ${toSqlTimestamp(input.heartbeatAt)}::timestamptz,
+          updated_at = now()
+      from thread_chat.messages m,
+           thread_chat.threads t,
+           thread_chat.projects p
+      where r.id = ${input.messageRunId}
+        and r.status = 'running'
+        and r.assistant_message_id = m.id
+        and m.thread_id = t.id
+        and t.project_id = p.id
+        and p.owner_user_id = ${input.actorId}
+      returning
+        r.id, r.assistant_message_id as "assistantMessageId", r.status,
+        r.model_id as "modelId", r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts", r.error_code as "errorCode",
+        r.error_message as "errorMessage", r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt", r.finished_at as "finishedAt",
+        r.created_at as "createdAt", r.updated_at as "updatedAt"
+    `
+    return row ? mapMessageRun(row) : null
+  }
+
+  async requestStop(
+    actorId: UserId,
+    assistantMessageId: MessageId,
+    requestedAt: Date
+  ): Promise {
+    const [row] = await this.sql[]>`
+      update thread_chat.message_runs r
+      set stop_requested_at = coalesce(
+            r.stop_requested_at,
+            ${toSqlTimestamp(requestedAt)}::timestamptz
+          ),
+          updated_at = now()
+      from thread_chat.messages m,
+           thread_chat.threads t,
+           thread_chat.projects p
+      where r.assistant_message_id = ${assistantMessageId}
+        and r.status in ('queued', 'running')
+        and r.assistant_message_id = m.id
+        and m.thread_id = t.id
+        and t.project_id = p.id
+        and p.owner_user_id = ${actorId}
+      returning
+        r.id,
+        r.assistant_message_id as "assistantMessageId",
+        r.status,
+        r.model_id as "modelId",
+        r.event_sequence as "eventSequence",
+        r.checkpoint_parts as "checkpointParts",
+        r.error_code as "errorCode",
+        r.error_message as "errorMessage",
+        r.heartbeat_at as "heartbeatAt",
+        r.stop_requested_at as "stopRequestedAt",
+        r.finished_at as "finishedAt",
+        r.created_at as "createdAt",
+        r.updated_at as "updatedAt"
+    `
+    return row ? mapMessageRun(row) : null
+  }
+}
diff --git a/lib/thread-chat/infrastructure/repositories/project-repository.ts b/lib/thread-chat/infrastructure/repositories/project-repository.ts
new file mode 100644
index 00000000..c43dc137
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/project-repository.ts
@@ -0,0 +1,190 @@
+import { invariant } from "../../domain/domain-error"
+import type { ProjectId, UserId } from "../../domain/ids"
+import type { Project, ProjectTarget } from "../../domain/project"
+import {
+  mapProject,
+  toSqlJsonText,
+  toSqlTimestamp,
+  type ThreadChatSql,
+} from "./database"
+
+export class ProjectRepository {
+  constructor(private readonly sql: ThreadChatSql) {}
+
+  async findOwnedById(
+    actorId: UserId,
+    projectId: ProjectId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        id,
+        owner_user_id as "ownerUserId",
+        auto_title as "autoTitle",
+        custom_title as "customTitle",
+        target,
+        instruction,
+        archived_at as "archivedAt",
+        artifact_change_sequence as "artifactChangeSequence",
+        created_at as "createdAt",
+        updated_at as "updatedAt"
+      from thread_chat.projects
+      where id = ${projectId} and owner_user_id = ${actorId}
+    `
+    return row ? mapProject(row) : null
+  }
+
+  async insert(input: {
+    id: ProjectId
+    ownerUserId: UserId
+    autoTitle?: string | null
+    customTitle?: string | null
+    target?: ProjectTarget | null
+    instruction?: string | null
+  }): Promise {
+    const [row] = await this.sql[]>`
+      insert into thread_chat.projects (
+        id, owner_user_id, auto_title, custom_title, target, instruction
+      ) values (
+        ${input.id},
+        ${input.ownerUserId},
+        ${input.autoTitle ?? null},
+        ${input.customTitle ?? null},
+        ${input.target ? toSqlJsonText(input.target) : null}::jsonb,
+        ${input.instruction ?? null}
+      )
+      returning
+        id,
+        owner_user_id as "ownerUserId",
+        auto_title as "autoTitle",
+        custom_title as "customTitle",
+        target,
+        instruction,
+        archived_at as "archivedAt",
+        artifact_change_sequence as "artifactChangeSequence",
+        created_at as "createdAt",
+        updated_at as "updatedAt"
+    `
+    return mapProject(row)
+  }
+
+  async listOwned(input: {
+    actorId: UserId
+    status: "active" | "archived" | "all"
+    limit: number
+    before?: { updatedAt: Date; id: ProjectId }
+  }): Promise> {
+    const rows = await this.sql[]>`
+      select
+        p.id,
+        p.owner_user_id as "ownerUserId",
+        p.auto_title as "autoTitle",
+        p.custom_title as "customTitle",
+        p.target,
+        p.instruction,
+        p.archived_at as "archivedAt",
+        p.artifact_change_sequence as "artifactChangeSequence",
+        p.created_at as "createdAt",
+        p.updated_at as "updatedAt",
+        count(distinct t.id)::integer as "threadCount",
+        count(distinct m.id)::integer as "messageCount"
+      from thread_chat.projects p
+      left join thread_chat.threads t on t.project_id = p.id
+      left join thread_chat.messages m on m.thread_id = t.id
+      where p.owner_user_id = ${input.actorId}
+        and (${input.status} = 'all'
+          or (${input.status} = 'active' and p.archived_at is null)
+          or (${input.status} = 'archived' and p.archived_at is not null))
+        and (${toSqlTimestamp(input.before?.updatedAt)}::timestamptz is null
+          or (p.updated_at, p.id) < (${toSqlTimestamp(input.before?.updatedAt)}::timestamptz, ${input.before?.id ?? null}::uuid))
+      group by p.id
+      order by p.updated_at desc, p.id desc
+      limit ${input.limit}
+    `
+    return rows.map((row) => ({
+      ...mapProject(row),
+      threadCount: Number(row.threadCount),
+      messageCount: Number(row.messageCount),
+    }))
+  }
+
+  async updateMetadata(input: {
+    actorId: UserId
+    projectId: ProjectId
+    customTitle?: string | null
+    target?: ProjectTarget | null
+    instruction?: string | null
+  }): Promise {
+    const [row] = await this.sql[]>`
+      update thread_chat.projects
+      set custom_title = case when ${input.customTitle !== undefined} then ${input.customTitle ?? null} else custom_title end,
+          target = case when ${input.target !== undefined} then ${input.target ? toSqlJsonText(input.target) : null}::jsonb else target end,
+          instruction = case when ${input.instruction !== undefined} then ${input.instruction ?? null} else instruction end,
+          updated_at = now()
+      where id = ${input.projectId} and owner_user_id = ${input.actorId}
+      returning
+        id, owner_user_id as "ownerUserId", auto_title as "autoTitle",
+        custom_title as "customTitle", target, instruction,
+        archived_at as "archivedAt",
+        artifact_change_sequence as "artifactChangeSequence",
+        created_at as "createdAt", updated_at as "updatedAt"
+    `
+    return row ? mapProject(row) : null
+  }
+
+  async setArchived(input: {
+    actorId: UserId
+    projectId: ProjectId
+    archived: boolean
+    now: Date
+  }): Promise {
+    const [row] = await this.sql[]>`
+      update thread_chat.projects
+      set archived_at = case
+            when ${input.archived} then coalesce(
+              archived_at,
+              ${toSqlTimestamp(input.now)}::timestamptz
+            )
+            else null
+          end,
+          updated_at = case
+            when (${input.archived} and archived_at is null)
+              or (not ${input.archived} and archived_at is not null)
+            then now()
+            else updated_at
+          end
+      where id = ${input.projectId} and owner_user_id = ${input.actorId}
+      returning
+        id, owner_user_id as "ownerUserId", auto_title as "autoTitle",
+        custom_title as "customTitle", target, instruction,
+        archived_at as "archivedAt",
+        artifact_change_sequence as "artifactChangeSequence",
+        created_at as "createdAt", updated_at as "updatedAt"
+    `
+    return row ? mapProject(row) : null
+  }
+
+  async assertOwnedForUpdate(
+    actorId: UserId,
+    projectId: ProjectId
+  ): Promise {
+    const [row] = await this.sql`
+      select id
+      from thread_chat.projects
+      where id = ${projectId} and owner_user_id = ${actorId}
+      for update
+    `
+    invariant(
+      row,
+      "project_owner_mismatch",
+      "Project 不存在或不属于当前 actor。"
+    )
+  }
+
+  async deleteOwned(actorId: UserId, projectId: ProjectId): Promise {
+    const result = await this.sql`
+      delete from thread_chat.projects
+      where id = ${projectId} and owner_user_id = ${actorId}
+    `
+    return result.count === 1
+  }
+}
diff --git a/lib/thread-chat/infrastructure/repositories/thread-repository.ts b/lib/thread-chat/infrastructure/repositories/thread-repository.ts
new file mode 100644
index 00000000..5c4a27db
--- /dev/null
+++ b/lib/thread-chat/infrastructure/repositories/thread-repository.ts
@@ -0,0 +1,232 @@
+import {
+  validateBaseContext,
+  type BaseContextV1,
+} from "../../domain/base-context"
+import { invariant } from "../../domain/domain-error"
+import type { ProjectId, ThreadId, UserId } from "../../domain/ids"
+import type { ForkSourceSnapshot, Thread } from "../../domain/thread"
+import {
+  mapThread,
+  toSqlJsonText,
+  toSqlTimestamp,
+  type ThreadChatSql,
+} from "./database"
+import { ProjectRepository } from "./project-repository"
+
+export class ThreadRepository {
+  constructor(private readonly sql: ThreadChatSql) {}
+
+  async findOwnedById(
+    actorId: UserId,
+    threadId: ThreadId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        t.id,
+        t.project_id as "projectId",
+        t.parent_thread_id as "parentThreadId",
+        t.source_message_id as "sourceMessageId",
+        t.fork_source_snapshot as "forkSourceSnapshot",
+        t.base_context as "baseContext",
+        t.auto_title as "autoTitle",
+        t.custom_title as "customTitle",
+        t.archived_at as "archivedAt",
+        t.created_at as "createdAt",
+        t.updated_at as "updatedAt"
+      from thread_chat.threads t
+      join thread_chat.projects p on p.id = t.project_id
+      where t.id = ${threadId} and p.owner_user_id = ${actorId}
+    `
+    return row ? mapThread(row) : null
+  }
+
+  async findOwnedByIdForUpdate(
+    actorId: UserId,
+    threadId: ThreadId
+  ): Promise {
+    const [row] = await this.sql[]>`
+      select
+        t.id, t.project_id as "projectId", t.parent_thread_id as "parentThreadId",
+        t.source_message_id as "sourceMessageId", t.fork_source_snapshot as "forkSourceSnapshot",
+        t.base_context as "baseContext", t.auto_title as "autoTitle",
+        t.custom_title as "customTitle", t.archived_at as "archivedAt",
+        t.created_at as "createdAt", t.updated_at as "updatedAt"
+      from thread_chat.threads t
+      join thread_chat.projects p on p.id = t.project_id
+      where t.id = ${threadId} and p.owner_user_id = ${actorId}
+      for update of t
+    `
+    return row ? mapThread(row) : null
+  }
+
+  async listOwnedTopology(
+    actorId: UserId,
+    projectId: ProjectId
+  ): Promise {
+    const rows = await this.sql[]>`
+      select
+        t.id,
+        t.project_id as "projectId",
+        t.parent_thread_id as "parentThreadId",
+        t.source_message_id as "sourceMessageId",
+        t.fork_source_snapshot as "forkSourceSnapshot",
+        t.base_context as "baseContext",
+        t.auto_title as "autoTitle",
+        t.custom_title as "customTitle",
+        t.archived_at as "archivedAt",
+        t.created_at as "createdAt",
+        t.updated_at as "updatedAt"
+      from thread_chat.threads t
+      join thread_chat.projects p on p.id = t.project_id
+      where t.project_id = ${projectId} and p.owner_user_id = ${actorId}
+      order by t.created_at, t.id
+    `
+    return rows.map(mapThread)
+  }
+
+  async insertRoot(input: {
+    actorId: UserId
+    id: ThreadId
+    projectId: ProjectId
+  }): Promise {
+    await new ProjectRepository(this.sql).assertOwnedForUpdate(
+      input.actorId,
+      input.projectId
+    )
+    return this.insert({ id: input.id, projectId: input.projectId })
+  }
+
+  async insertBranch(input: {
+    actorId: UserId
+    id: ThreadId
+    projectId: ProjectId
+    parentThreadId: ThreadId
+    sourceMessageId: string
+    forkSourceSnapshot: ForkSourceSnapshot
+    baseContext: unknown
+  }): Promise {
+    await new ProjectRepository(this.sql).assertOwnedForUpdate(
+      input.actorId,
+      input.projectId
+    )
+    const baseContext = validateBaseContext(input.baseContext)
+    const [relation] = await this.sql`
+      select 1
+      from thread_chat.threads parent
+      join thread_chat.messages source
+        on source.id = ${input.sourceMessageId}
+       and source.thread_id = parent.id
+      where parent.id = ${input.parentThreadId}
+        and parent.project_id = ${input.projectId}
+      for update of parent, source
+    `
+    invariant(
+      relation,
+      "thread_source_invalid",
+      "Parent/source Message 必须属于同一 Project 与 Parent Thread。"
+    )
+
+    return this.insert({
+      id: input.id,
+      projectId: input.projectId,
+      parentThreadId: input.parentThreadId,
+      sourceMessageId: input.sourceMessageId,
+      forkSourceSnapshot: input.forkSourceSnapshot,
+      baseContext,
+    })
+  }
+
+  async updateBranchMetadata(input: {
+    actorId: UserId
+    threadId: ThreadId
+    customTitle?: string | null
+    archived?: boolean
+    now: Date
+  }): Promise {
+    const current = await this.findOwnedById(input.actorId, input.threadId)
+    if (!current) return null
+    invariant(
+      current.parentThreadId !== null,
+      input.archived === undefined
+        ? "root_thread_title_owned_by_project"
+        : "root_thread_archive_owned_by_project",
+      "Root Thread 的 metadata 由 Project 管理。"
+    )
+    const hasArchived = input.archived !== undefined
+    const archived = input.archived ?? false
+    const [row] = await this.sql[]>`
+      update thread_chat.threads t
+      set custom_title = case when ${input.customTitle !== undefined} then ${input.customTitle ?? null} else t.custom_title end,
+          archived_at = case
+            when ${!hasArchived} then t.archived_at
+            when ${archived} then coalesce(
+              t.archived_at,
+              ${toSqlTimestamp(input.now)}::timestamptz
+            )
+            else null
+          end,
+          updated_at = case
+            when ${input.customTitle !== undefined}
+              or (${hasArchived && archived} and t.archived_at is null)
+              or (${hasArchived && !archived} and t.archived_at is not null)
+            then now()
+            else t.updated_at
+          end
+      from thread_chat.projects p
+      where t.id = ${input.threadId}
+        and t.project_id = p.id
+        and p.owner_user_id = ${input.actorId}
+      returning
+        t.id, t.project_id as "projectId", t.parent_thread_id as "parentThreadId",
+        t.source_message_id as "sourceMessageId", t.fork_source_snapshot as "forkSourceSnapshot",
+        t.base_context as "baseContext", t.auto_title as "autoTitle",
+        t.custom_title as "customTitle", t.archived_at as "archivedAt",
+        t.created_at as "createdAt", t.updated_at as "updatedAt"
+    `
+    return row ? mapThread(row) : null
+  }
+
+  private async insert(input: {
+    id: ThreadId
+    projectId: ProjectId
+    parentThreadId?: ThreadId
+    sourceMessageId?: string
+    forkSourceSnapshot?: ForkSourceSnapshot
+    baseContext?: BaseContextV1
+  }): Promise {
+    const [row] = await this.sql[]>`
+      insert into thread_chat.threads (
+        id,
+        project_id,
+        parent_thread_id,
+        source_message_id,
+        fork_source_snapshot,
+        base_context
+      ) values (
+        ${input.id},
+        ${input.projectId},
+        ${input.parentThreadId ?? null},
+        ${input.sourceMessageId ?? null},
+        ${
+          input.forkSourceSnapshot
+            ? toSqlJsonText(input.forkSourceSnapshot)
+            : null
+        }::jsonb,
+        ${input.baseContext ? toSqlJsonText(input.baseContext) : null}::jsonb
+      )
+      returning
+        id,
+        project_id as "projectId",
+        parent_thread_id as "parentThreadId",
+        source_message_id as "sourceMessageId",
+        fork_source_snapshot as "forkSourceSnapshot",
+        base_context as "baseContext",
+        auto_title as "autoTitle",
+        custom_title as "customTitle",
+        archived_at as "archivedAt",
+        created_at as "createdAt",
+        updated_at as "updatedAt"
+    `
+    return mapThread(row)
+  }
+}
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/.openspec.yaml b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/.openspec.yaml
new file mode 100644
index 00000000..e685d45e
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-25
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/backend-verification.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/backend-verification.md
new file mode 100644
index 00000000..1beef65e
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/backend-verification.md
@@ -0,0 +1,21 @@
+# 后端领域验收证据
+
+## 自动化门槛
+
+| 门槛 | 命令 | 结果 |
+|---|---|---|
+| 领域单元测试 | `pnpm test:unit` | 4 个文件、14 个测试通过 |
+| Repository / Application / Runtime 集成测试 | `pnpm test:integration` | 5 个文件、20 个测试通过;使用隔离的 `thread-chat-test` PostgreSQL |
+| TypeScript | `pnpm typecheck` | 通过 |
+| OpenSpec | `pnpm openspec:validate` | 27 项严格校验通过 |
+
+## 覆盖映射
+
+| 领域能力 | 自动化证据 |
+|---|---|
+| Root / Branch、无环拓扑、BaseContext、replacement、Fork 资格、MessageRun 状态机、Prompt History | `tests/unit/domain-model.test.ts` |
+| owner scope、唯一 Root、并发 sequence、finalized 不可变、Artifact provenance、单一 MessageRun | `tests/integration/normalized-schema.test.ts`、`tests/integration/repositories.test.ts` |
+| create、send、Fork、Edit、Regenerate、metadata、feedback、delete 及失败回滚 | `tests/integration/application.test.ts` |
+| 条件领取、delta checkpoint、eventSequence、heartbeat、completed、failed、Stop、queued scanner、Artifact tool output | `tests/integration/message-runner.test.ts` |
+
+自动化测试只使用 `FakeAiRuntime`,不会请求真实模型供应商。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/cleanup-evidence.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/cleanup-evidence.md
new file mode 100644
index 00000000..67d3d466
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/cleanup-evidence.md
@@ -0,0 +1,7 @@
+# Domain cleanup evidence
+
+- 旧 `branch_trees`、`branch_generations`、conversation/workspace Schema 和整树写入路径已删除;源码检索未发现旧权威入口。
+- 本地 `thread-chat` 与隔离 `thread-chat-test` 均从空 `thread_chat` schema 执行 `db:push`;当前领域表仅为 `projects`、`threads`、`messages`、`message_runs`、`artifacts`、`message_feedback`。
+- 领域、Repository、Application、Fake AI Runtime、API 与客户端自动测试共 87 项通过。
+- `pnpm typecheck`、lint、production build 与 OpenSpec strict validation 通过。
+- Ego Browser 已验证服务端 Project ID 跳转、SSE 完成、Artifact Summary 与 Artifact-by-ID Drawer;完整交互与 UI parity 证据见 Client/API change 的 `e2e-evidence.md`。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design.md
new file mode 100644
index 00000000..3d11412f
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design.md
@@ -0,0 +1,461 @@
+## Context
+
+本设计实现 [proposal.md](./proposal.md) 与 [domain 增量规范](./specs/domain/spec.md) 的目标模型。它从当前已运行的 ThreadChat 模型出发,而不是从讨论阶段的草案出发。
+
+当前基线由代码和数据库共同确定:
+
+```text
+登录 User
+└── branch_trees row(treeId 由客户端生成)
+    └── state: ThreadTreeState JSONB
+        ├── threads: Record
+        │   └── messages: Message[](parentMessageId + activeLeafMessageId)
+        └── artifacts: Record
+
+branch_generations ── 以 treeId/threadId/messageId 关联生成 attempt
+branch_message_feedback ── 以 treeId/threadId/messageId 关联反馈
+```
+
+当前不存在 Project 表,也不存在独立的 Thread、Message 表。`branch_trees.state` 是整棵树的持久化权威;`branch_generations` 是生成 attempt 的服务端 sidecar。新设计必须明确这些现有事实如何被 Project、Thread、Message 与 MessageRun 接替。
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- 将核心内容模型收敛为 `Project → Thread → Message`。
+- 让 Project 同时成为列表项、Thread 拓扑、标题、共享资源和永久删除边界。
+- 让 Project 在 MVP 中直接归属于当前登录用户,不预建尚不存在的团队/分组层。
+- 保留已确认的 sequence、Message replacement、BaseContext.messageIds、Fork 与 MessageRun 机制。
+- 给出接替 `branch_trees`、嵌入实体和 `branch_generations` 权威职责的 PostgreSQL 目标 Schema 与模块骨架。
+
+**Non-Goals:**
+
+- 本 change 的 design/spec 固定整体领域基线;代码、数据库、API 和前端通过 `tasks.md` 中的分拆交付链逐步落地,不在单一步骤整体重写。
+- 不设计团队、成员、组织切换或平台级共享;出现真实需求后再独立建模。
+- 不完整设计 Project Memory、Instruction、Target 和 File 的内容协议、检索、版本或同步机制;本设计只固定它们的 Project 归属边界。
+- 不引入 Turn、Message Variant、Generation Chat 实体、通用 revision、V1 幂等系统或 ArtifactVersion。
+
+## Decisions
+
+### D1. 由 User → Project 接替当前 User → Thread Tree 基线
+
+目标所有权与聊天内容:
+
+```text
+User
+└── Project
+    ├── Project Resources
+    │   ├── Memory
+    │   ├── Instruction
+    │   ├── Target
+    │   ├── Files
+    │   └── Artifacts
+    └── Thread
+        ├── Message[]
+        └── Child Thread
+            └── Message[]
+```
+
+服务端从 Session 获得 `actorId`,并校验 Project 的 `ownerUserId` 或未来独立引入的访问授权;客户端只以 `projectId` 作为 ThreadChat 内容入口。这个设计与当前 `branch_trees.user_id` 的直接用户所有权一致,不凭空增加新的中间实体。
+
+若未来出现团队协作,应该新增明确的 Project 成员或平台权限模型,并迁移 `ownerUserId`;不在当前领域模型里提前建立没有生命周期和 UI 的空壳层。
+
+### D2. Project 是一整簇 Thread 的唯一聚合边界
+
+```mermaid
+erDiagram
+    USER ||--o{ PROJECT : owns
+    PROJECT ||--|{ THREAD : contains
+    THREAD o|--o{ THREAD : parent_of
+    THREAD ||--o{ MESSAGE : contains
+    MESSAGE o|--o| MESSAGE : replaces
+    MESSAGE ||--o| MESSAGE_RUN : executes
+    PROJECT ||--o{ PROJECT_MEMORY : shares
+    PROJECT ||--o{ PROJECT_FILE : shares
+    PROJECT ||--o{ ARTIFACT : shares
+    MESSAGE ||--o{ ARTIFACT : produces
+
+    PROJECT {
+      uuid id PK
+      text owner_user_id FK
+      string title
+      text target
+      text instruction
+    }
+    THREAD {
+      uuid id PK
+      uuid project_id FK
+      uuid parent_thread_id FK
+      uuid source_message_id FK
+      jsonb base_context
+    }
+    MESSAGE {
+      uuid id PK
+      uuid thread_id FK
+      bigint sequence
+      string role
+      jsonb parts
+      uuid replaces_message_id FK
+      timestamptz superseded_at
+    }
+    MESSAGE_RUN {
+      uuid id PK
+      uuid assistant_message_id FK
+      string status
+    }
+    ARTIFACT {
+      uuid id PK
+      uuid project_id FK
+      uuid source_message_id FK
+    }
+```
+
+Project 的确定边界:
+
+- Project 有且仅有一个 Root Thread。
+- 其他 Thread 都通过 Parent/Fork 关系属于同一 Project。
+- “新对话”创建 Project + Root Thread。
+- “对话列表”列出 Projects。
+- `/thread-chat/{projectId}` 是 canonical 内容 URL。
+- Project 永久删除会清理整个 Thread 族群与共享资源。
+
+Project 在目标模型中接替当前一条 `branch_trees` 记录所表达的产品边界,但不继承整树 JSON 的物理存储方式。新 Project ID 由服务端生成;旧 `treeId` 不再作为新模型的资源身份,也不建立历史数据映射。
+
+### D3. Project Resource 共享范围与来源分离
+
+Project Resource 的共同规则:
+
+```text
+同一 Project 的全部 Thread 可按权限使用
+不同 Project 默认隔离
+同一用户拥有多个 Project 也不代表内容自动共享
+```
+
+Target 与 Instruction 是 Project 当前配置;Memory 和 Files 是 Project 下的集合。Artifact 同时具有两种关系:
+
+```text
+projectId        决定可用范围和生命周期
+sourceMessageId  记录产生来源
+```
+
+因此 Artifact 不再“只属于 Message”。Message 提供 provenance,Project 提供 ownership。BaseContext 仍只保存 Message ID,不复制 Artifact 正文。
+
+### D4. Thread 角色由关系推导
+
+统一 Thread 实体通过关系决定角色:
+
+```text
+parentThreadId = null  → Root Thread
+parentThreadId != null → Branch/Child Thread
+```
+
+Fork 不建立独立 `thread_forks` 表;Child Thread 直接保存:
+
+```ts
+type ForkFacts = {
+  parentThreadId: string
+  sourceMessageId: string
+  forkSourceSnapshot: {
+    schemaVersion: 1
+    quote?: string
+    sourceRole: "user" | "assistant"
+    sourceSequence: number
+  }
+  baseContext: BaseContextV1
+}
+```
+
+Project、Parent Thread、来源 Message 与 Child Thread 必须属于同一 Project。
+
+### D5. sequence 表示 Thread 内服务端写入顺序
+
+每个 Thread 的 Message 拥有唯一、单调递增的 `sequence`:
+
+```text
+seq=1 user
+seq=2 user
+seq=3 assistant
+seq=4 user
+```
+
+系统不要求角色交替,不保存 `prevMessageId/nextMessageId`,也不使用客户端时间排序。默认有效时间线为:
+
+```sql
+SELECT *
+FROM messages
+WHERE thread_id = :thread_id
+  AND superseded_at IS NULL
+ORDER BY sequence ASC;
+```
+
+replacement 获得新 sequence 并追加到尾部;旧 Message 保留原 sequence,通过 `supersededAt` 退出默认时间线。编辑最后一条有效 user Message 时,依赖旧内容的有效后缀一起 superseded,再追加 replacement 与新回复。这是上下文后缀失效,不是 user/assistant 配对。
+
+完整流程见:[按 sequence 拉取 Thread 当前有效消息](./design/load-thread-messages-by-sequence.md)。
+
+### D6. finalized Message 不可变,修改使用 replacement
+
+不可变边界:
+
+- user Message 的 `parts` 写入后立即不可改。
+- assistant 生成增量写入 `MessageRun.checkpointParts`。
+- assistant completed 时,最终 `parts` 与 `finalizedAt` 只写一次。
+- finalized Message 的 role、parts、sequence 与来源关系不可更新。
+- `supersededAt` 是时间线状态,不是内容修改。
+
+Edit/Regenerate 必须创建新 Message;Regenerate 还必须创建该 assistant Message 唯一的新 MessageRun。旧 Message 不是孤儿:它仍属于原 Thread、保留 sequence,可被 BaseContext 与 Artifact provenance 引用。
+
+未来实现必须在 Message Repository、Edit/Regenerate Command 与 Project 永久删除入口加入明确代码注释:finalized 内容不可覆盖,单 Message 不允许 hard delete。
+
+完整 Regenerate 事务见:[创建 replacement assistant Message](./design/regenerate-replacement-assistant-message.md)。
+
+### D7. BaseContext 只保存有序 messageIds
+
+```ts
+type BaseContextV1 = {
+  schemaVersion: 1
+  messageIds: string[]
+}
+```
+
+Root Thread 没有 BaseContext。Branch Thread 的 BaseContext 由服务端在 Fork 时计算并永久冻结:
+
+```text
+Child BaseContext
+= Parent.baseContext.messageIds
++ Parent 从开头到 sourceMessage 的有效且具备 Prompt 资格的 Message IDs
+```
+
+ID 顺序是 Prompt 顺序,不要求跨 Thread sequence 可直接比较。使用 ID 而不是 Parts 可以避免复制长文本、Tool Result 与 Artifact,并让 Parent replacement 后 Child 历史保持不变。
+
+该方案依赖一个明确前提:单 Message 不 hard delete;只有 Project 永久删除时,引用它的全部 Child Thread 一并清理。
+
+### D8. Fork 资格由前后端共同约束
+
+| Message 状态 | 进入 BaseContext | 可作为 Fork source |
+|---|---:|---:|
+| 有效 user,finalized | 是 | 是 |
+| 有效 assistant,completed | 是 | 是 |
+| assistant,queued/running | 否 | 否 |
+| assistant,failed/stopped | 否 | 否 |
+| superseded,但已被旧 BaseContext 引用 | 保留既有引用 | 新 Fork 默认否 |
+
+最后一条 assistant 仍 queued/running 时,前端禁用 Fork,服务端仍做最终拒绝。Fork 事务校验来源、计算 BaseContext、生成 Child Thread ID,并原子保存全部 ForkFacts。Parent 后续变化不删除 Child,也不重算 BaseContext。
+
+### D9. 每条 assistant Message 恰有一条 MessageRun
+
+```text
+user Message      → 0 MessageRun
+assistant Message → 1 MessageRun
+```
+
+Regenerate 创建新的 assistant Message,因此也创建新的 MessageRun;不会为旧 Message 增加 attempt 2。
+
+```mermaid
+stateDiagram-v2
+    [*] --> queued
+    queued --> running
+    queued --> failed
+    queued --> stopped
+    running --> completed
+    running --> failed
+    running --> stopped
+    completed --> [*]
+    failed --> [*]
+    stopped --> [*]
+```
+
+浏览器断开只停止订阅。刷新后通过 assistantMessageId、status、checkpointParts 与 eventSequence 恢复,不创建第二个 Run。完整流程见:[刷新后恢复正在生成的 assistant Message](./design/resume-running-message-after-refresh.md)。
+
+### D10. PostgreSQL 目标 Schema
+
+字段使用 snake_case;所有新实体 ID 由服务端生成 UUID。目标表继续引用项目现有认证 `user.id`,不在本 change 新增其他所有权层。
+
+#### 当前权威职责到目标实体的接替关系
+
+| 当前真实来源 | 目标 | 本 change 固定的职责接替边界 |
+|---|---|---|
+| `branch_trees` 一行 | 一个 Project | 接替整簇对话、标题和 owner 边界;Project ID 改由服务端生成 |
+| `branch_trees.state.threads` | `threads` 多行 | 接替嵌入 Thread;Parent、source 与 Fork snapshot 成为规范化关系 |
+| `Thread.messages` 消息图 | `messages` 多行 | 接替消息图;默认时间线改由服务端 sequence 与 superseded 状态表达 |
+| `state.artifacts` | `artifacts` 多行 | 接替内嵌 registry;内容独立保存,并增加 Project ownership 与 Message provenance |
+| `branch_generations` | `message_runs` | 接替 attempt sidecar;目标关系固定为每条 assistant Message 恰有一条 Run |
+| `branch_message_feedback` | Message feedback 表 | 接替 tree/thread/message 复合身份,直接关联规范化 assistant Message |
+
+上表用于防止实现误读当前基线,不表示需要迁移旧记录。本地开发不保留现有 `branch_trees` 数据,不建立 treeId 映射、未认领记录处理或历史 generation attempt 转换逻辑。
+
+#### `projects`
+
+| 字段 | 类型 | 语义 |
+|---|---|---|
+| `id` | uuid | PK |
+| `owner_user_id` | text | FK → 现有 user.id,NOT NULL |
+| `auto_title` | text | 机器派生标题 |
+| `custom_title` | text | 用户标题,展示优先 |
+| `target` | text/jsonb | Project 目标;协议后续细化 |
+| `instruction` | text/jsonb | Project 指令;协议后续细化 |
+| `archived_at` | timestamptz | 归档时间 |
+| `created_at`, `updated_at` | timestamptz | NOT NULL |
+
+展示标题为 `coalesce(custom_title, auto_title)`。索引 `(owner_user_id, updated_at DESC)` 支持当前用户的对话列表和最近 Project。
+
+#### `threads`
+
+| 字段 | 类型 | 语义 |
+|---|---|---|
+| `id` | uuid | PK |
+| `project_id` | uuid | FK → projects,NOT NULL |
+| `parent_thread_id` | uuid | 同 Project 自引用;Root 为 NULL |
+| `source_message_id` | uuid | Branch 来源 Message;Root 为 NULL |
+| `fork_source_snapshot` | jsonb | Branch 必填 |
+| `base_context` | jsonb | Branch 必填,BaseContextV1 |
+| `auto_title`, `custom_title` | text | Branch 局部标题 |
+| `archived_at` | timestamptz | NULLABLE |
+| `created_at`, `updated_at` | timestamptz | NOT NULL |
+
+关键约束:每个 `project_id` 只能有一个 `parent_thread_id IS NULL`;Root 的 Fork 字段全部为 NULL,Branch 全部非 NULL;Parent 与来源必须属于同一 Project。
+
+#### `messages`
+
+| 字段 | 类型 | 语义 |
+|---|---|---|
+| `id` | uuid | PK |
+| `thread_id` | uuid | FK → threads,NOT NULL |
+| `sequence` | bigint | Thread 内服务端顺序 |
+| `role` | text | user/assistant;不要求交替 |
+| `parts` | jsonb | AI SDK v7 UIMessage.parts;运行中 assistant 可为 NULL |
+| `replaces_message_id` | uuid | 同 Thread 自引用 |
+| `superseded_at` | timestamptz | NULL 表示当前有效 |
+| `finalized_at` | timestamptz | 内容封存时间 |
+| `created_at` | timestamptz | NOT NULL |
+
+约束:`UNIQUE(thread_id, sequence)`;一条 Message 最多一个直接 replacement;replacement 必须同 Thread。
+
+#### `message_runs`
+
+| 字段 | 类型 | 语义 |
+|---|---|---|
+| `id` | uuid | PK |
+| `assistant_message_id` | uuid | UNIQUE FK → messages |
+| `status` | text | queued/running/completed/failed/stopped |
+| `model_id` | text | 实际执行模型 |
+| `event_sequence` | bigint | 流恢复游标 |
+| `checkpoint_parts` | jsonb | 运行中持久化内容 |
+| `error_code`, `error_message` | text | 失败信息 |
+| `heartbeat_at`, `stop_requested_at`, `finished_at` | timestamptz | 生命周期时间 |
+| `created_at`, `updated_at` | timestamptz | NOT NULL |
+
+运行状态通过条件更新保护,不引入通用 revision。
+
+#### Project 共享资源骨架
+
+```text
+project_memory_items
+  id PK
+  project_id FK
+  content JSONB
+  source_message_id FK NULLABLE
+  created_at / updated_at
+
+project_files
+  id PK
+  project_id FK
+  storage_key
+  filename / mime_type / size / status
+  created_at / updated_at
+
+artifacts
+  id PK
+  project_id FK
+  source_message_id FK NOT NULL
+  kind / title / content
+  created_at
+```
+
+这些表只固定 Project ownership。Memory 提取、File 存储协议、Artifact 编辑与版本化必须由后续 change 定义。
+
+#### 删除策略
+
+普通 Repository 不暴露单 Message hard delete。永久删除 Project 使用明确授权命令,在事务中清理 Project Resources、MessageRun、Message、Thread 和 Project。删除用户时由现有认证外键策略清理或触发其全部 Project 的删除流程。
+
+### D11. 模块骨架
+
+```text
+thread-chat/
+├── domain/
+│   ├── project.ts
+│   ├── project-resource.ts
+│   ├── thread.ts
+│   ├── message.ts
+│   ├── message-run.ts
+│   ├── base-context.ts
+│   └── artifact.ts
+├── application/
+│   ├── commands/
+│   │   ├── create-project.ts
+│   │   ├── append-user-message.ts
+│   │   ├── edit-last-user-message.ts
+│   │   ├── regenerate-assistant-message.ts
+│   │   ├── fork-thread.ts
+│   │   └── delete-project-permanently.ts
+│   ├── queries/
+│   │   ├── list-projects.ts
+│   │   ├── load-project-bootstrap.ts
+│   │   └── load-thread-messages.ts
+│   └── prompt/
+│       └── resolve-prompt-history.ts
+├── infrastructure/
+│   ├── repositories/
+│   ├── message-runner/
+│   └── events/
+└── transport/
+    ├── http/
+    └── stream/
+```
+
+领域层不依赖 React、HTTP、Drizzle 或具体 AI Runtime。AI SDK v7 UIMessage.parts 是 Message 内容兼容契约;Runtime 更换只影响 adapter。
+
+### D12. ProjectBootstrap 与伪代码索引
+
+进入 `/thread-chat/{projectId}` 时,首屏加载:
+
+```text
+全量 Project 的轻量 Thread topology
+                    +
+Root Thread 的有效 Messages 与 AssistantRunState
+```
+
+不全量加载所有 Branch Message、BaseContext 或大型 Project Resource。具体流程:
+
+1. [从首页进入 ThreadChat](./design/enter-thread-chat-from-home.md):首页导航、`/new` 无实体草稿边界与已有 Project 入口。
+2. [按 sequence 拉取 Thread 当前有效消息](./design/load-thread-messages-by-sequence.md):`sequence + supersededAt` 构造默认时间线。
+3. [Regenerate:创建 replacement assistant Message](./design/regenerate-replacement-assistant-message.md):旧 Message 不可变,新 Message 与 Run 原子创建。
+4. [刷新后恢复正在生成的 assistant Message](./design/resume-running-message-after-refresh.md):checkpoint、eventSequence 与重新订阅。
+
+Provider、Zustand、路由交接和多栏异步加载属于客户端/API 设计,详见:
+
+- [`/thread-chat/new` 首条消息与 AI 回复生命周期](../design-thread-chat-client-api/design/new-project-first-message-lifecycle.md)
+- [打开已有 Project 生命周期](../design-thread-chat-client-api/design/open-existing-project-lifecycle.md)
+- [Thread Message 异步加载设计](../design-thread-chat-client-api/design/thread-message-loading.md)
+
+## Risks / Trade-offs
+
+- **[未来需要团队共享 Project]** → 等成员、邀请、角色和所有权转移的真实生命周期明确后,再新增 Project 访问模型;MVP 保持与当前 `branch_trees.user_id` 一致的直接用户所有权。
+- **[Project 既是“对话”又是长期工作项,用户文案可能混淆]** → 代码与规范统一使用 Project;UI 可暂时显示“对话”,但交互测试明确其创建/列表对象是 Project。
+- **[BaseContext.messageIds 位于 JSONB,数组元素没有普通 FK]** → 禁止单 Message hard delete;Fork 时验证 ID,Project 删除时统一清理。
+- **[superseded Message 增加存储]** → MVP 接受追加历史,不预建 GC;Project 删除时统一清理。
+- **[Project Resource 协议尚未完整设计]** → 当前只固定 ownership 与隔离边界,Memory、Files 和 Artifact 演进分别走后续 change。
+- **[MessageRun checkpoint 与最终 Message 竞态]** → 完成事务条件更新 Run,并原子写入最终 parts、finalizedAt 与 completed。
+
+## Migration Plan
+
+本 change 先固定设计,并通过 `tasks.md` 跟踪后端实施。交付顺序:
+
+1. 冻结术语、职责与测试门;建立 Vitest、隔离 PostgreSQL 测试数据库和 Fake AI Runtime。
+2. 使用 `pnpm db:push` 在测试数据库建立规范化 Schema,不迁移本地旧数据。
+3. 实现领域、Repository、Application Command/Query 与 MessageRun 后台执行,并通过后端集成测试。
+4. 由 `design-thread-chat-client-api` 实现 `/api/v1`、SSE、前端状态架构和现有 UI 接入;后端/API 测试全部通过前不得开始 UI 接入。
+5. E2E 验收通过后删除旧 ThreadTree/branch generation 权威代码与表,再次 `pnpm db:push`。
+6. 先归档本 domain change,再归档依赖它的客户端/API change,消除循环等待。
+7. 归档完成后,把稳定目标架构提炼到 `openspec/specs/domain/architecture.md`。
+
+开发期间旧页面可以继续运行,但新旧后端不双写;前端切换前的回退方式是保留旧实现代码,而不是维护两套数据同步。`tasks.md` 记录唯一实施顺序、阶段门、旧权威退役、归档及永久架构文档提炼。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/enter-thread-chat-from-home.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/enter-thread-chat-from-home.md
new file mode 100644
index 00000000..77dfbb9a
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/enter-thread-chat-from-home.md
@@ -0,0 +1,102 @@
+# 从首页进入 ThreadChat
+
+## 决定性结论
+
+登录 Session 已经确定 actor。首页只决定导航目标,不提前创建空 Project,也不把当前 Project ID 写入 App Store:
+
+```text
+“新对话”
+  → /thread-chat/new
+  → 用户首次发送前没有 Project、Thread、Message 或 MessageRun
+
+“继续最近对话”
+  → 有最近可访问 Project:/thread-chat/{projectId}
+  → 没有 Project:/thread-chat/new
+
+“对话列表”
+  → 分页列出当前用户可访问的 ProjectSummary
+```
+
+Project Catalog 不是进入 Project 页面之前必须完成的前置请求。用户直接打开 `/thread-chat/{projectId}` 时,URL 是当前 Project 的唯一权威;ProjectProvider 可以独立加载 ProjectBootstrap。
+
+## 首页导航伪代码
+
+```ts
+async function onStartChatClick() {
+  const latest = selectLatestLoadedProjectSummary(appStore.getState())
+
+  if (latest) {
+    router.push(threadChatRoutes.project(latest.id))
+    return
+  }
+
+  // Catalog 尚未加载完成时可以查询最近 ProjectSummary;该查询不创建实体。
+  const result = await threadChatApi.listProjects({
+    status: "active",
+    limit: 1,
+  })
+
+  const project = result.items[0]
+  router.push(
+    project
+      ? threadChatRoutes.project(project.id)
+      : threadChatRoutes.newProject(),
+  )
+}
+
+function onNewProjectClick() {
+  // 这里只导航,不请求服务端创建空 Project。
+  router.push(threadChatRoutes.newProject())
+}
+```
+
+## `/thread-chat/new` 的领域边界
+
+```ts
+type NewProjectDraftState = {
+  parts: UIMessage["parts"]
+  requestedModelId?: string
+  submitState: "idle" | "submitting" | "error"
+}
+```
+
+`NewProjectDraftState` 是本地草稿,不是 Chat Entity。只有用户提交第一条有效 Message 后,服务端才在同一事务创建:
+
+```text
+Project
+└── Root Thread
+    ├── U1 finalized
+    └── A1 pending ── MessageRun queued
+```
+
+请求不得携带任何待创建实体 ID。服务端返回 `CreationBundle` 后,客户端才能建立 ProjectRuntime,并使用 `threadChatRoutes.project(bundle.project.id)` 构造目标页面路径后执行 `router.replace`。服务端不返回 Web 页面 URL。
+
+完整的无抖动 Provider/Store 交接、AI 事件订阅和终态合并见:
+
+- [新 Project 首条消息与 AI 回复生命周期](../../design-thread-chat-client-api/design/new-project-first-message-lifecycle.md)
+
+## 已有 Project 的 Bootstrap 边界
+
+进入 `/thread-chat/{projectId}` 时,服务端返回:
+
+```text
+Project
++ 全量轻量 Thread topology
++ ProjectArtifactSummary
++ Root ThreadMessageBundle
+```
+
+不返回所有 Branch Message、BaseContext、Prompt History 或全部大型 Artifact 正文。刷新前恢复出的 Branch Column 由客户端分别异步加载 MessageBundle。
+
+完整的有/无工作台 Snapshot、Runtime Message Cache 和多个 Branch 并行加载流程见:
+
+- [打开已有 Project 生命周期](../../design-thread-chat-client-api/design/open-existing-project-lifecycle.md)
+
+## 决定性不变量
+
+- `/thread-chat/new` 中的 `new` 不是 Project ID。
+- 用户首次发送前不得创建 Project 或 Root Thread。
+- 当前 Project 身份只来自 URL/ProjectProvider,不来自 App Store 的 selected 状态。
+- Project Catalog 只保存分页摘要,不保存 Thread 或 Message。
+- ProjectBootstrap 不创建领域实体,只读取服务端事实。
+- Root Message 随 Bootstrap 加载;Branch Message 按 Thread 异步加载。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/load-thread-messages-by-sequence.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/load-thread-messages-by-sequence.md
new file mode 100644
index 00000000..0210a761
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/load-thread-messages-by-sequence.md
@@ -0,0 +1,132 @@
+# 按 sequence 拉取 Thread 当前有效消息
+
+## 这个流程解决什么
+
+给定已有 `threadId`,服务端返回该 Thread 当前有效时间线的一个窗口:
+
+- 只返回 `supersededAt IS NULL` 的 Message。
+- 默认读取最新最多 200 条,再按 `sequence ASC` 输出。
+- user Message 没有 MessageRun;assistant Message 必须有唯一 MessageRun。
+- Bundle 同时返回本窗口渲染所需且允许内联的 Artifact。
+- 不假设 user/assistant 交替,也不依赖客户端时间或 `prevMessageId/nextMessageId`。
+
+## 核心伪代码
+
+```ts
+async function loadThreadMessages(query: {
+  actorId: UserId
+  threadId: ThreadId
+  limit?: number
+  beforeSequence?: number
+}): Promise {
+  const limit = validateIntegerRange(query.limit ?? 200, 1, 200)
+  const beforeSequence = validateOptionalPositiveInteger(
+    query.beforeSequence,
+  )
+
+  const thread = await threadRepository.findById(query.threadId)
+  if (!thread) throw new DomainError("thread_not_found")
+
+  await authorization.requireProjectReadAccess({
+    actorId: query.actorId,
+    projectId: thread.projectId,
+  })
+
+  /**
+   * DESC + limit+1 用于高效取得“最新窗口”和 hasOlderMessages;
+   * 输出前再翻转为 UI 所需的 sequence ASC。
+   */
+  const rows = await messageRepository.findActiveWindow({
+    threadId: thread.id,
+    sequenceLessThan: beforeSequence,
+    orderBy: { sequence: "desc" },
+    limit: limit + 1,
+  })
+
+  const hasOlderMessages = rows.length > limit
+  const selected = rows.slice(0, limit).reverse()
+
+  assertStrictlyIncreasingAndUnique(
+    selected.map((message) => message.sequence),
+  )
+
+  const assistantMessageIds = selected
+    .filter((message) => message.role === "assistant")
+    .map((message) => message.id)
+
+  const runsByAssistantMessageId =
+    await messageRunRepository.findByAssistantMessageIds(
+      assistantMessageIds,
+    )
+
+  for (const assistantMessageId of assistantMessageIds) {
+    if (!runsByAssistantMessageId.has(assistantMessageId)) {
+      throw new DataIntegrityError("assistant_message_run_missing")
+    }
+  }
+
+  const includedArtifacts =
+    await artifactQuery.loadRenderProjectionForMessages({
+      projectId: thread.projectId,
+      sourceMessageIds: selected.map((message) => message.id),
+    })
+
+  return {
+    threadId: thread.id,
+    messages: selected.map(toMessageDTO),
+    assistantRuns: assistantMessageIds.map((messageId) =>
+      toAssistantRunStateDTO(runsByAssistantMessageId.get(messageId)!),
+    ),
+    includedArtifacts,
+    hasOlderMessages,
+    oldestReturnedSequence: selected[0]?.sequence ?? null,
+    newestReturnedSequence: selected.at(-1)?.sequence ?? null,
+  }
+}
+```
+
+## 对应查询语义
+
+```sql
+SELECT messages.*
+FROM messages
+WHERE messages.thread_id = :threadId
+  AND messages.superseded_at IS NULL
+  AND (
+    :beforeSequence IS NULL
+    OR messages.sequence < :beforeSequence
+  )
+ORDER BY messages.sequence DESC
+LIMIT :limit + 1;
+```
+
+服务端丢弃多取的一条后,将窗口翻转为 `sequence ASC`。`beforeSequence` 是独占边界。
+
+## sequence 有空缺是正常结果
+
+```text
+数据库历史:
+seq=1  U1  active
+seq=2  A1  superseded
+seq=3  A2  active,replaces A1
+seq=4  U2  active
+
+当前有效时间线:
+seq=1  U1
+seq=3  A2
+seq=4  U2
+```
+
+系统不得为了消除空缺而重排或重写 sequence。
+
+## 决定性不变量
+
+- sequence 只由服务端分配,在同一 Thread 内唯一且单调递增。
+- sequence 表示写入顺序,不表示 user/assistant 配对。
+- replacement 获得新 sequence;旧 Message 保留原 sequence。
+- `supersededAt` 决定 Message 是否进入默认有效时间线。
+- `beforeSequence` 是独占边界;返回窗口按 sequence 升序。
+- `assistantRuns` 必须且只覆盖本窗口中的 assistant Message。
+- `includedArtifacts` 只包含渲染本窗口 Message 所需且允许内联的 Artifact。
+- Thread 读取权限必须通过所属 Project 校验。
+- 查询不得创建 Message、MessageRun、Artifact 或 BaseContext。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/regenerate-replacement-assistant-message.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/regenerate-replacement-assistant-message.md
new file mode 100644
index 00000000..f8e552d7
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/regenerate-replacement-assistant-message.md
@@ -0,0 +1,203 @@
+# Regenerate:创建 replacement assistant Message
+
+## 这个流程解决什么
+
+Regenerate 的含义不是“修改旧回答”,而是:
+
+```text
+旧 assistant Message 退出当前有效时间线
+                    +
+创建新的 assistant Message 和新的 MessageRun
+```
+
+```text
+Regenerate 前:
+seq=1  U1  active
+seq=2  A1  active       ── R1 completed
+
+Regenerate 后:
+seq=1  U1  active
+seq=2  A1  superseded   ── R1 completed
+seq=3  A2  active       ── R2 queued
+            replaces A1
+```
+
+A1 不会被删除、清空或改写。既有 BaseContext 即使引用 A1,也继续解析 A1,不会自动切换到 A2。
+
+A1、A2、R1 和 R2 都通过 Thread 归属于同一个 Project;Project 是权限与永久删除边界。
+
+## 核心伪代码
+
+```ts
+async function regenerateAssistantMessage(command: {
+  actorId: UserId
+  sourceAssistantMessageId: MessageId
+  requestedModelId?: ModelId
+}): Promise {
+  const result = await database.transaction(async (tx) => {
+    /**
+     * 锁定来源 Message,把“它是否仍然 active”变成事务内事实。
+     * 不使用 Project revision 或 Thread revision。
+     */
+    const sourceMessage = await messageRepository.findByIdForUpdate(
+      tx,
+      command.sourceAssistantMessageId,
+    )
+
+    if (!sourceMessage) {
+      throw new DomainError("message_not_found")
+    }
+
+    await authorization.requireThreadWriteAccess({
+      actorId: command.actorId,
+      threadId: sourceMessage.threadId,
+    })
+
+    /**
+     * MVP 只允许重新生成当前时间线末尾已经完成的 assistant Message。
+     * 历史位置需要保留另一条路线时,使用 Fork。
+     */
+    assert(sourceMessage.role === "assistant")
+    assert(sourceMessage.supersededAt === null)
+    assert(sourceMessage.finalizedAt !== null)
+    assert(
+      await messageRepository.isLastActiveMessage(tx, {
+        threadId: sourceMessage.threadId,
+        messageId: sourceMessage.id,
+      }),
+    )
+
+    const sourceRun =
+      await messageRunRepository.findByAssistantMessageIdForUpdate(
+        tx,
+        sourceMessage.id,
+      )
+
+    if (!sourceRun) {
+      throw new DataIntegrityError("assistant_message_run_missing")
+    }
+
+    if (sourceRun.status !== "completed") {
+      throw new DomainError("assistant_message_not_regeneratable")
+    }
+
+    /**
+     * sequence allocator 必须在 Thread 范围内串行化,
+     * 让并发写入也只能得到不同、递增的 sequence。
+     */
+    const replacementSequence =
+      await messageRepository.allocateNextSequence(tx, {
+        threadId: sourceMessage.threadId,
+      })
+
+    /**
+     * 这是 Regenerate 的核心:创建一条全新的 Message。
+     * finalized Message 的 role、parts、sequence 绝不能原地更新。
+     */
+    const replacementAssistantMessage = await messageRepository.insert(
+      tx,
+      {
+        id: idGenerator.newMessageId(),
+        threadId: sourceMessage.threadId,
+        sequence: replacementSequence,
+        role: "assistant",
+        parts: null, // 最终 parts 只在 completed 时写入一次
+        replacesMessageId: sourceMessage.id,
+        supersededAt: null,
+        finalizedAt: null,
+        createdAt: clock.now(),
+      },
+    )
+
+    /**
+     * 一条 assistant Message 恰有一条 MessageRun。
+     * A2 获得 R2;不会给 A1/R1 增加 attempt=2。
+     */
+    const replacementMessageRun = await messageRunRepository.insert(
+      tx,
+      {
+        id: idGenerator.newMessageRunId(),
+        assistantMessageId: replacementAssistantMessage.id,
+        status: "queued",
+        modelId: await modelPolicy.resolve({
+          actorId: command.actorId,
+          threadId: sourceMessage.threadId,
+          requestedModelId: command.requestedModelId,
+        }),
+        eventSequence: 0,
+        checkpointParts: [],
+        createdAt: clock.now(),
+      },
+    )
+
+    /**
+     * A1 只退出默认有效时间线:
+     * - 不改 A1.parts;
+     * - 不改 A1.sequence;
+     * - 不删除 A1/R1;
+     * - 不修改引用 A1 的既有 BaseContext。
+     */
+    const superseded = await messageRepository.markSupersededIfActive(
+      tx,
+      {
+        messageId: sourceMessage.id,
+        supersededAt: clock.now(),
+      },
+    )
+
+    if (!superseded) {
+      throw new ConcurrencyError("message_already_superseded")
+    }
+
+    return {
+      supersededMessageId: sourceMessage.id,
+      replacementAssistantMessage,
+      replacementMessageRun,
+    }
+  })
+
+  /**
+   * 先提交 durable queued Run,再唤醒后台执行器。
+   * 唤醒失败时,queued Run 扫描器仍可恢复执行。
+   */
+  try {
+    await messageRunDispatcher.wakeUpAfterCommit(
+      result.replacementMessageRun.id,
+    )
+  } catch (error) {
+    logger.error("replacement_run_wakeup_failed", error)
+    // durable queued Run 由扫描器恢复;不能把已提交 replacement 误报为回滚。
+  }
+
+  /**
+   * API 不向客户端暴露内部 MessageRun ID;客户端使用 assistantMessageId
+   * 关联运行状态并订阅事件。
+   */
+  return {
+    supersededMessageIds: [result.supersededMessageId],
+    createdMessages: [toMessageDTO(result.replacementAssistantMessage)],
+    assistantRun: toAssistantRunStateDTO(result.replacementMessageRun),
+  }
+}
+```
+
+## 为什么顺序是“先创建 replacement,再标记旧消息”
+
+这些写入处在同一事务,外部不可观察到中间状态。先插入 replacement 可以让数据库的:
+
+```text
+UNIQUE(replaces_message_id)
+```
+
+尽早阻止两个并发请求同时替换 A1;随后使用条件更新将 A1 标记为 superseded。任一步失败都回滚全部写入。
+
+## 决定性不变量
+
+- Regenerate 只接受当前末尾、active、finalized 且 Run=completed 的 assistant Message。
+- A1 的 ID、parts、sequence、finalizedAt 和 R1 运行事实保持不变。
+- A2 由服务端生成新 ID,使用 Thread 的下一个 sequence,并通过 `replacesMessageId` 指向 A1。
+- A2 与 R2 必须在同一事务创建;A2 只对应 R2。
+- 对客户端返回 `ReplacementBundle`;MessageRun 内部 ID 不进入普通客户端模型。
+- replacement、来源 Message 与 Thread 必须属于同一个 Project。
+- replacement 事务不需要 Thread/Project revision。
+- 单条 Message 永不 hard delete;A1 只在 Project 永久删除时统一清理。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/resume-running-message-after-refresh.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/resume-running-message-after-refresh.md
new file mode 100644
index 00000000..97fbfccd
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/design/resume-running-message-after-refresh.md
@@ -0,0 +1,236 @@
+# 刷新后恢复正在生成的 assistant Message
+
+## 这个流程解决什么
+
+浏览器刷新、关闭页面或事件连接断开只取消本地订阅;后台 MessageRun 继续运行。重新加载包含该 assistant Message 的 ThreadMessageBundle 后,客户端必须:
+
+1. 合并当前有效 Message 与独立 AssistantRunState。
+2. 立即显示服务端持久化的 `checkpointParts`。
+3. 使用 `assistantMessageId + eventSequence` 恢复订阅。
+4. completed 事件直接携带 finalized Message,不再终态后二次 GET Message。
+5. 不创建新的 Message 或 MessageRun。
+
+Project 页如何恢复多个 Branch Column、并行加载各自 MessageBundle,见 [打开已有 Project 生命周期](../../design-thread-chat-client-api/design/open-existing-project-lifecycle.md)。
+
+## 后端 Thread Bundle
+
+```ts
+async function loadThreadForRefresh(query: {
+  actorId: UserId
+  threadId: ThreadId
+}): Promise {
+  // 复用统一查询:active Message、sequence 窗口、独立 Run 与 Artifact。
+  return loadThreadMessages({
+    ...query,
+    limit: 200,
+  })
+}
+```
+
+客户端按 `assistantMessageId` 关联 Message 与 Run;公开 DTO 不包含服务端内部 MessageRun ID。
+
+## 前端合并与恢复
+
+```ts
+function applyThreadBundleAndResume(bundle: ThreadMessageBundle) {
+  // 一次 Store Action 合并 Message、Run、Artifact 与 Thread ready 状态。
+  store.getState().applyMessageBundle(bundle)
+
+  for (const run of bundle.assistantRuns) {
+    switch (run.status) {
+      case "completed":
+        // 最终内容已经位于对应 finalized Message.parts。
+        generationCoordinator.unsubscribeAssistant(
+          run.assistantMessageId,
+        )
+        break
+
+      case "failed":
+      case "stopped":
+        // checkpoint 可以展示,但终态不自动重启或重连。
+        generationCoordinator.unsubscribeAssistant(
+          run.assistantMessageId,
+        )
+        break
+
+      case "queued":
+      case "running":
+        // selector 立即用 checkpointParts 恢复画面,不等待下一 token。
+        generationCoordinator.subscribeAssistant(
+          run.assistantMessageId,
+        )
+        break
+    }
+  }
+}
+```
+
+## 事件订阅
+
+```ts
+function subscribeAssistant(assistantMessageId: MessageId) {
+  const current = selectAssistantRun(
+    store.getState(),
+    assistantMessageId,
+  )
+
+  if (!current || !isQueuedOrRunning(current.status)) return
+  if (connections.has(assistantMessageId)) return
+
+  const connection = api.subscribeAssistantEvents({
+    assistantMessageId,
+    afterEventSequence: current.eventSequence,
+
+    onSnapshot(event) {
+      /**
+       * 首个业务事件固定为 run.snapshot:
+       * - run.checkpointParts 是当前持久化内容;
+       * - cursor === run.eventSequence;
+       * - snapshot 可以是终态。
+       */
+      store.getState().applyRunEvent(event)
+      streamBuffer.dropThrough({
+        assistantMessageId,
+        eventSequence: event.cursor,
+      })
+
+      if (isTerminal(event.run.status)) {
+        connections.delete(assistantMessageId)
+        connection.close()
+      }
+    },
+
+    onDelta(event) {
+      const latest = selectAssistantRun(
+        store.getState(),
+        assistantMessageId,
+      )
+
+      const lastReceived = selectLastReceivedEventSequence(
+        store.getState(),
+        assistantMessageId,
+      )
+
+      if (
+        !latest ||
+        event.eventSequence <= Math.max(
+          latest.eventSequence,
+          lastReceived,
+        )
+      ) return
+
+      // 高频 chunk 先进入 buffer,按 UI frame 合并,不逐 token set Zustand。
+      streamBuffer.enqueue(assistantMessageId, event)
+    },
+
+    onCompleted(event) {
+      /**
+       * completed 事件原子携带:
+       * finalized Message、completed Run、includedArtifacts、Artifact Summary。
+       * 不需要再 GET Message,也不能以前端累计 chunk 作为最终权威。
+       */
+      store.getState().applyRunEvent(event)
+      streamBuffer.clear(assistantMessageId)
+      connections.delete(assistantMessageId)
+      connection.close()
+    },
+
+    onFailedOrStopped(event) {
+      store.getState().applyRunEvent(event)
+      streamBuffer.clear(assistantMessageId)
+      connections.delete(assistantMessageId)
+      connection.close()
+    },
+  })
+
+  connections.set(assistantMessageId, connection)
+}
+```
+
+`run.snapshot` 与 `run.completed` 携带的 Artifact Summary 必须按 `changeSequence` 合并;较小的乱序 Summary 不得覆盖较新的页面统计。
+
+## 后台完成
+
+```ts
+async function completeMessageRun(input: {
+  messageRunId: MessageRunId
+  finalParts: UIMessagePart[]
+}) {
+  const completed = await database.transaction(async (tx) => {
+    const run = await messageRunRepository.findByIdForUpdate(
+      tx,
+      input.messageRunId,
+    )
+
+    // 迟到结果不得覆盖 failed/stopped/completed 终态。
+    if (run.status !== "running") return null
+
+    const assistantMessage = await messageRepository.findByIdForUpdate(
+      tx,
+      run.assistantMessageId,
+    )
+
+    assert(assistantMessage.role === "assistant")
+    assert(assistantMessage.finalizedAt === null)
+
+    const thread = await threadRepository.findByIdTx(
+      tx,
+      assistantMessage.threadId,
+    )
+    if (!thread) throw new DataIntegrityError("message_thread_missing")
+
+    const finalizedMessage =
+      await messageRepository.finalizeAssistantMessageOnce(tx, {
+        messageId: assistantMessage.id,
+        parts: input.finalParts,
+        finalizedAt: clock.now(),
+      })
+
+    const completedRun =
+      await messageRunRepository.transitionIfCurrent(tx, {
+        messageRunId: run.id,
+        expectedStatus: "running",
+        nextStatus: "completed",
+        finishedAt: clock.now(),
+      })
+
+    if (!completedRun) {
+      throw new ConcurrencyError("message_run_status_changed")
+    }
+
+    const includedArtifacts =
+      await artifactQuery.loadRenderProjectionForMessagesTx(tx, {
+        projectId: thread.projectId,
+        sourceMessageIds: [assistantMessage.id],
+      })
+
+    const artifactSummary =
+      await artifactQuery.loadProjectSummaryTx(tx, {
+        projectId: thread.projectId,
+      })
+
+    return {
+      message: finalizedMessage,
+      run: completedRun,
+      includedArtifacts,
+      artifactSummary,
+    }
+  })
+
+  if (!completed) return
+
+  // 事务提交后发布;订阅者收到自包含的终态事件。
+  await runEventPublisher.publishCompleted(completed)
+}
+```
+
+## 决定性不变量
+
+- 刷新和取消订阅不得改变 MessageRun 状态。
+- queued/running 恢复来源是 `checkpointParts + eventSequence`。
+- 客户端只按 `assistantMessageId` 关联和订阅 Run。
+- 同一 assistantMessageId 的订阅必须去重。
+- completed 最终内容来自事件携带的 finalized Message,不来自前端累计 chunk。
+- completed/failed/stopped 终态不自动重启。
+- 页面刷新不得创建第二条 assistant Message 或 MessageRun。
+- MessageRun 终态转换使用条件更新,不需要通用 revision。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/proposal.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/proposal.md
new file mode 100644
index 00000000..a7600fee
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/proposal.md
@@ -0,0 +1,43 @@
+## Why
+
+当前 ThreadChat 的真实权威模型是 `ThreadTreeState`:浏览器生成 `treeId`,`branch_trees` 以一行 `state JSONB` 保存整棵树,Thread、Message 与 Artifact 都嵌在该 JSON 中;生成执行则由 `branch_generations` 作为 sidecar 持久化。代码和数据库中目前没有 Project 实体,也没有独立的 Thread、Message 表。
+
+整树读写把内容身份、拓扑、消息、运行状态和界面协作绑在同一个大对象里,已经成为后续分享、独立 API、服务端权限校验和前端增量数据流的共同阻碍。本 change 要从这个真实基线出发,建立规范化的 `Project → Thread → Message` 目标模型:Project 接替当前一棵 Thread Tree 的产品边界,Thread 与 Message 成为可独立寻址的服务端实体。
+
+## What Changes
+
+- **BREAKING**:以 Project 取代当前 Thread Tree 作为一整簇分叉对话的聚合根、列表项、URL 身份与永久删除边界。
+- **BREAKING**:退出 `branch_trees.state` 整树 JSON 权威写入;将 Project、Thread、Message 与 MessageRun 规范化持久化。
+- **BREAKING**:新实体 ID 全部由服务端生成;客户端不再生成 `treeId` 或待创建的 Thread、Message、MessageRun ID。
+- MVP 沿用当前直接用户所有权:Project 通过 `ownerUserId` 归属于用户;本 change 不新建团队、成员或其他平台分组实体。
+- Project 成为一整簇 Thread 的唯一聚合边界,拥有且仅拥有一个 Root Thread,并拥有全部后代 Thread。
+- “新对话”创建新的 `Project + Root Thread`;当前 UI 的“对话列表”列出当前用户拥有或可访问的 Project。
+- Project 负责整簇 Thread 共享的 Memory、Instruction、Target、Files 与 Artifacts;精确资源协议由后续独立 change 定义。
+- Artifact 归属于 Project,同时保留来源 Message 身份,用于解释它由哪个 Thread 中的哪条消息产生。
+- Root Thread 与 Branch Thread 是统一 Thread 的关系角色;目标模型不保留当前 `MainThread`、`ForkedThread` 或整棵 `ThreadTreeState` 作为持久化实体。
+- Thread 内 Message 使用服务端分配的 `sequence` 形成线性追加历史,不要求 user/assistant 角色交替,也不沿用当前 `parentMessageId + activeLeafMessageId` 消息图作为目标时间线模型。
+- finalized Message 不允许原地改写;Edit 与 Regenerate 创建 replacement Message,并使用 `supersededAt` 与 `replacesMessageId` 让旧 Message 退出默认时间线。
+- Fork 仍由服务端创建 Child Thread,并保存 `parentThreadId`、`sourceMessageId`、`forkSourceSnapshot` 和只包含有序 `messageIds` 的 BaseContext。
+- Fork 只能使用 finalized 且具备 Prompt 资格的 Message;最后一条 assistant queued/running 时,前端禁用且后端拒绝 Fork。
+- 单条 Message 不执行 hard delete;只有永久删除整个 Project 时,统一清理其 Thread、Message、MessageRun 与 Project 附属资源。
+- 每条 assistant Message 对应一条持久化 MessageRun;它取代当前 `branch_generations` 的 attempt/current sidecar 语义。user Message 不具有 MessageRun,浏览器断开不停止后台运行。
+- 本 change 作为 Issue #34 的领域基线与后端交付清单;数据库、领域、Repository、Application 与后台运行按 `tasks.md` 的阶段门实施,不再为同一目标增建一组管理性 change。
+
+## Capabilities
+
+### New Capabilities
+
+- 无。
+
+### Modified Capabilities
+
+- `domain`:将当前 `Thread Tree → embedded Thread → embedded Message` 基线重构为规范化的 `Project → Thread → Message`,并明确 Fork、replacement、BaseContext 与 MessageRun 的目标边界。
+
+## Impact
+
+- OpenSpec:重写 `domain` 增量规范和设计;后续实现必须以当前 `ThreadTreeState / branch_trees / branch_generations` 为被替换的真实基线。
+- 后端目标:新增 `projects`、`threads`、`messages`、`message_runs` 等规范化表;逐步退役 `branch_trees.state` 与 `branch_generations` 权威路径。
+- 前端目标:`/thread-chat/{projectId}` 加载 `ProjectBootstrap`;“新对话”创建 Project;“对话列表”列出 Projects;客户端 Store 从整树快照改为规范化实体与衍生拓扑。
+- 身份与权限:Project 先直接引用当前登录用户;所有读取和命令都从服务端 Session 校验用户对 Project 的访问权。
+- 共享资源:Project Memory、Instruction、Target、Files 和 Artifacts 对该 Project 的全部 Thread 可用,不跨 Project 自动共享。
+- 本地数据:当前处于本地开发阶段,不保留旧 `branch_trees` 数据;目标 Schema 通过隔离测试数据库验证后使用 `pnpm db:push` 重建,不实现旧 treeId、嵌入实体或 `branch_generations` 的数据迁移器。
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/specs/domain/spec.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/specs/domain/spec.md
new file mode 100644
index 00000000..3d131bc2
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/specs/domain/spec.md
@@ -0,0 +1,215 @@
+## MODIFIED Requirements
+
+### Requirement: 使用统一的核心术语
+系统 MUST 使用以下术语表达目标模型:
+
+- **Project**:一整项可持续工作的内容聚合边界,直接归属于当前用户,拥有一个 Root Thread、全部后代 Thread,以及这些 Thread 共享的 Memory、Instruction、Target、Files 和 Artifacts。当前 UI 的一个“对话列表项”对应一个 Project。
+- **Thread**:Project 中一列可独立继续的线性对话。Root Thread 与 Branch Thread 只是关系角色,不是不同实体类型。
+- **Fork**:从已有 Thread 的确定 Message 创建 Child Thread 的原子操作。
+- **Message**:Thread 中具有稳定 ID、角色、内容和服务端 sequence 的消息实体。
+- **MessageRun**:仅为 assistant Message 持久化的后台生成记录;它属于运行基础设施,不是可独立导航的聊天内容实体。
+- **BaseContext**:Fork 时由服务端冻结的有序 Message ID 列表,表示 Child Thread 继承的有效 Prompt 历史。
+- **ForkSourceSnapshot**:Fork 时冻结的来源定位与引用展示信息。
+- **Project Resource**:在同一 Project 的全部 Thread 间共享、但不跨 Project 自动共享的 Memory、Instruction、Target、File 或 Artifact。
+- **Target**:Project 希望实现的目标集合,用于表达该 Project 的终极目标,以及为实现终极目标而设定的短期目标和中期目标。Target 属于 Project 级共享信息,不表示某个 Thread 的临时任务,也不等同于单条用户消息中的请求。
+- **Artifact**:归属于 Project,并保留来源 Message 身份的持久化产物。
+- **Title**:Project 或 Branch Thread 的短标题。
+
+目标实现 MUST 由 Project 接替当前 Thread Tree 的聚合职责,并将 Thread、Message 和 Artifact 保存为可独立寻址的实体。目标规范、数据库、API 和客户端实体模型 MUST 使用 Project 表达整簇 Thread 的聚合边界,且 MUST NOT 继续把 Thread Tree、MainThread、ForkedThread、独立 ThreadFork、Turn、Generation 或 Message Variant 作为目标领域实体。
+
+#### Scenario: 将一簇分叉对话称为 Project
+- **WHEN** 用户查看由一个 Root Thread 和多列 Branch Thread 组成的工作项
+- **THEN** 系统 MUST 将整体领域实体称为 Project,并将每一列称为 Thread
+
+#### Scenario: 从关系推导 Thread 角色
+- **WHEN** 一个 Thread 没有 Parent Thread
+- **THEN** 系统 MUST 将其视为 Root Thread
+- **AND** 当一个 Thread 具有 Parent Thread 时,系统 MUST 将其视为 Branch Thread
+
+#### Scenario: 描述非根线程
+- **WHEN** 产品或代码需要描述由 Fork 创建的非根对话列
+- **THEN** 系统 MUST 将该节点称为 Child Thread 或按相对角色称为 Branch Thread
+- **AND** 必须使用 Fork 描述创建动作,不得把 ForkedThread 作为独立实体类型
+
+### Requirement: 维护 Project 的 Thread 拓扑不变量
+系统 MUST 使用 Project 与 Thread 关系维护分叉拓扑:
+
+- 每个 Project MUST 归属于且仅归属于一个用户;其他访问关系不属于本 change。
+- 每个 Project MUST 有且仅有一个没有 Parent Thread 的 Root Thread。
+- 每个 Thread MUST 归属于且仅归属于一个 Project。
+- 每个非 Root Thread MUST 具有同一 Project 内的 Parent Thread、确定的来源 Message、ForkSourceSnapshot 和 BaseContext。
+- Child Thread MUST 可以继续产生自己的 Child Thread,形成任意深度的有向无环层级。
+- 系统 MUST 阻止跨 Project 的 Parent Thread、来源 Message 或 Fork 关系,并阻止形成环。
+- Fork MUST 由服务端原子创建 Child Thread 及全部来源事实;客户端不得构造 Child Thread 新 ID 或 BaseContext。
+
+#### Scenario: 创建 Project 根 Thread
+- **WHEN** 系统创建一个新 Project
+- **THEN** 系统 MUST 在同一事务建立该 Project 唯一的 Root Thread
+- **AND** Root Thread MUST 不包含 Parent Thread 或 Fork 来源
+
+#### Scenario: 创建嵌套分叉
+- **WHEN** 用户从 Branch Thread 的一条合格 Message 再次 Fork
+- **THEN** 系统 MUST 在同一 Project 中创建新的 Child Thread
+- **AND** 新 Thread MUST 指向该 Branch Thread 与确定来源 Message
+
+#### Scenario: 拒绝跨 Project Fork
+- **WHEN** Fork 来源 Thread 或来源 Message 不属于目标 Project
+- **THEN** 系统 MUST 拒绝请求且不创建任何部分数据
+
+#### Scenario: 拒绝 Fork 与拓扑矛盾的状态
+- **WHEN** 非 Root Thread 缺少 Parent Thread、来源 Message、ForkSourceSnapshot 或 BaseContext,或者关系会形成环
+- **THEN** 系统 MUST 拒绝该 Project 状态
+
+#### Scenario: Fork 原子失败
+- **WHEN** ForkSourceSnapshot、BaseContext 或 Child Thread 中任一项无法持久化
+- **THEN** 系统 MUST 回滚整个 Fork 操作
+
+### Requirement: 明确标题的归属与优先级
+系统 MUST 区分 Project 标题与 Branch Thread 标题:
+
+1. Project 标题描述整项工作,同时作为 Project 列表项和 Root Thread 列头的展示标题。
+2. Branch Thread 标题描述局部主题,仅覆盖对应列的标题展示。
+3. 用户账户展示名称不得替代 Project 或 Thread 标题。
+
+#### Scenario: 展示 Root Thread 标题
+- **WHEN** 客户端展示 Project 的 Root Thread
+- **THEN** 客户端 MUST 使用 Project 标题作为该列标题
+
+#### Scenario: 主线自动标题与用户重命名并存
+- **WHEN** Project 已生成自动标题,且用户随后设置自定义 Project 标题
+- **THEN** Project 列表和 Root Thread 列头 MUST 展示自定义标题
+- **AND** 系统 MUST 保留自动标题作为机器派生信息
+
+#### Scenario: 展示 Branch Thread 标题
+- **WHEN** 客户端展示具有局部标题的 Branch Thread
+- **THEN** 客户端 MUST 使用该 Thread 标题
+- **AND** 不得因此修改 Project 标题
+
+## ADDED Requirements
+
+### Requirement: 使用 Project 作为对话列表与共享资源边界
+“新对话”操作 MUST 创建新的 Project 与唯一 Root Thread;“对话列表” MUST 列出当前用户拥有或可访问的 Project。
+
+Project 的 Memory、Instruction、Target、Files 和 Artifacts MUST 可供该 Project 的全部 Thread 使用,并 MUST NOT 仅因多个 Project 属于同一用户而自动跨 Project 共享。
+
+#### Scenario: 创建新对话
+- **WHEN** 用户点击“新对话”
+- **THEN** 系统 MUST 创建新的 Project 与唯一 Root Thread
+- **AND** 导航到以服务端 Project ID 标识的 ThreadChat 页面
+
+#### Scenario: 展示对话列表
+- **WHEN** 用户打开“对话列表”
+- **THEN** 系统 MUST 展示当前用户拥有或可访问的 Projects
+
+#### Scenario: Thread 使用 Project 资源
+- **WHEN** Project 中任一 Thread 构建允许使用 Project 上下文的请求
+- **THEN** 系统 MUST 允许它引用该 Project 的 Memory、Instruction、Target、Files 和 Artifacts
+
+#### Scenario: Project 资源隔离
+- **WHEN** 另一个 Project 的 Thread 未获得显式授权
+- **THEN** 系统 MUST NOT 自动向它提供当前 Project 的共享资源
+
+### Requirement: 维护 Thread 的线性消息顺序
+系统 MUST 将每个 Thread 内的 Message 保存为严格线性的追加序列。服务端 MUST 为新 Message 分配在该 Thread 内单调递增且唯一的 `sequence`;客户端 MUST 使用 `sequence` 而不是客户端时间、角色交替或前后消息指针恢复稳定顺序。
+
+系统 MUST 允许连续多条 user Message,且 MUST NOT 将 `user → assistant` 一一配对作为数据库不变量。“只允许编辑当前最后一条有效 user Message”是 MVP 应用策略,不代表 user Message 必须拥有配对的 assistant Message。
+
+#### Scenario: 连续发送多条 user Message
+- **WHEN** 用户在 assistant 尚未产生最终回复前又发送一条 user Message
+- **THEN** 系统 MUST 将两条 user Message 保存为同一 Thread 内具有不同 sequence 的独立 Message
+- **AND** 不得仅因角色没有交替而拒绝或重排它们
+
+#### Scenario: 按服务端顺序读取消息
+- **WHEN** 客户端读取一个 Thread 的消息
+- **THEN** 系统 MUST 按 sequence 升序返回有效时间线
+- **AND** 相同 Thread 内不得存在重复 sequence
+
+### Requirement: 使用不可变 Message replacement
+Message 内容在创建完成或 finalized 后 MUST NOT 原地改写。Edit 和 Regenerate MUST 创建具有新 ID 与新 sequence 的 replacement Message,通过 `replacesMessageId` 指向旧 Message,并将旧 Message 标记为 `superseded`。
+
+默认时间线 MUST 隐藏 superseded Message,但持久化层 MUST 保留其 ID、sequence、内容和来源关系。MVP MUST NOT 提供 Message Variant,也 MUST NOT hard delete 单条 Message;只有永久删除整个 Project 时,系统才 MUST 统一清理 Project 下的 Thread、Message、MessageRun 和附属资源。
+
+#### Scenario: Regenerate assistant 回复
+- **WHEN** 用户对当前可重新生成的 assistant Message 执行 Regenerate
+- **THEN** 系统 MUST 创建新的 assistant Message 与新的 MessageRun
+- **AND** 旧 assistant Message MUST 被标记为 superseded,但其 ID、sequence 和内容 MUST 保持不变
+
+#### Scenario: 编辑最后一条 user Message
+- **WHEN** 用户编辑当前 Thread 最后一条有效 user Message
+- **THEN** 系统 MUST 创建 replacement user Message
+- **AND** 原 Message 及所有 sequence 更大、依赖原内容的有效 Message MUST 退出默认时间线但继续保留
+- **AND** replacement user Message 及后续新回复 MUST 以新 sequence 追加到 Thread 尾部
+
+#### Scenario: 拒绝编辑历史 user Message
+- **WHEN** 用户尝试编辑并非当前 Thread 最后一条有效 user Message 的消息
+- **THEN** 系统 MUST 按 MVP 策略拒绝操作,并提示使用 Fork 保留另一条历史
+
+#### Scenario: 永久删除 Project
+- **WHEN** 已获授权的用户确认永久删除整个 Project
+- **THEN** 系统 MUST 将该 Project 的共享资源、Thread、Message 和 MessageRun 作为完整边界清理
+
+### Requirement: 冻结 Fork 的消息身份上下文
+Fork 时,服务端 MUST 根据来源 Thread 在 Fork 点之前的有效 Prompt 历史生成不可变 BaseContext。BaseContext MUST 包含 schema 版本和按 Prompt 顺序排列的 `messageIds`,不得复制 Message Parts,也不得由客户端提交或重建。
+
+进入 BaseContext 或作为 Fork source 的 Message MUST 已 finalized 且具备 Prompt 资格。有效 user Message 可以进入;仅 completed assistant Message 可以进入并作为来源;queued、running、failed 或 stopped assistant Message 不得进入,也不得作为 Fork source。Parent 后续发生 replacement、追加或归档时,既有 Child Thread 的 BaseContext MUST 保持不变。
+
+#### Scenario: 从 completed assistant Message Fork
+- **WHEN** 用户从 finalized 且 completed 的有效 assistant Message 发起 Fork
+- **THEN** 服务端 MUST 在同一 Project 创建 Child Thread
+- **AND** BaseContext MUST 冻结到来源 Message 为止的有序有效 Message ID
+
+#### Scenario: 生成期间不允许 Fork
+- **WHEN** 当前 Thread 最后一条 assistant Message 的 MessageRun 处于 queued 或 running
+- **THEN** 客户端 MUST 隐藏或禁用 Fork
+- **AND** 服务端 MUST 拒绝绕过客户端发起的 Fork 请求
+
+#### Scenario: Parent Message 后续被 replacement
+- **WHEN** Child Thread 的 BaseContext 引用了之后被 superseded 的 Parent Message
+- **THEN** Child Thread MUST 继续按原 Message ID 解析冻结历史
+- **AND** replacement Message MUST NOT 自动替换 BaseContext 中的 ID
+
+#### Scenario: 客户端试图提交 BaseContext
+- **WHEN** Fork 请求包含客户端构造的 BaseContext 或待创建 Child Thread ID
+- **THEN** 服务端 MUST 忽略或拒绝这些字段,并只使用服务端计算和生成的值
+
+### Requirement: 持久化 assistant MessageRun
+每条 assistant Message MUST 具有且仅具有一条持久化 MessageRun,user Message MUST NOT 具有 MessageRun。MessageRun MUST 至少表达 queued、running、completed、failed 和 stopped 状态,并保存恢复流式展示所需的运行进度。
+
+浏览器刷新、关闭或流连接断开 MUST 只终止该客户端订阅,不得自动停止后台 MessageRun。客户端重新加载 Thread 时,系统 MUST 通过 assistant Message 及其 MessageRun 恢复最终内容、生成中、失败或停止状态。
+
+#### Scenario: 创建 assistant Message
+- **WHEN** 服务端接受一次需要 AI 回复的生成命令
+- **THEN** 服务端 MUST 在同一原子边界创建 assistant Message 与唯一 MessageRun
+
+#### Scenario: 刷新后恢复运行状态
+- **WHEN** assistant Message 的 MessageRun 仍为 queued 或 running 且用户刷新页面
+- **THEN** 客户端 MUST 加载该状态并恢复生成展示与事件订阅
+- **AND** 刷新不得创建第二条 MessageRun
+
+#### Scenario: 队列启动失败
+- **WHEN** MessageRun 在进入 running 前无法启动
+- **THEN** 系统 MUST 允许它从 queued 转为 failed
+
+### Requirement: 维护 Project 共享的 Artifact
+Artifact MUST 归属于且仅归属于一个 Project,并 MUST 保留产生它的来源 Message 身份。该 Project 的全部 Thread MUST 能按权限引用 Artifact;其他 Project MUST NOT 仅因归属于同一用户而自动获得访问权。
+
+BaseContext MUST 通过 Message ID 间接保留 Artifact 的来源语义,不得复制大型 Artifact 内容。MVP MUST NOT 因本重构引入 ArtifactVersion、通用资源图或内容寻址存储。
+
+#### Scenario: 同 Project 的 Thread 使用 Artifact
+- **WHEN** Project 中一个 Thread 的 Message 产生 Markdown Artifact
+- **THEN** 同 Project 的其他 Thread MUST 能按权限引用该 Artifact
+- **AND** 系统 MUST 保留 sourceMessageId 作为来源
+
+#### Scenario: Fork 历史包含 Artifact
+- **WHEN** BaseContext 引用的 Message 产生过 Artifact
+- **THEN** 系统 MUST 能通过 Message ID 解析其 Project Artifact 关联
+- **AND** BaseContext 不得复制 Artifact 正文
+
+#### Scenario: 隔离其他 Project
+- **WHEN** 另一个 Project 未获得显式授权
+- **THEN** 系统 MUST NOT 向它暴露当前 Project 的 Artifact
+
+## RENAMED Requirements
+
+- FROM: `### Requirement: 维护线程树的层级不变量`
+- TO: `### Requirement: 维护 Project 的 Thread 拓扑不变量`
diff --git a/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/tasks.md b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/tasks.md
new file mode 100644
index 00000000..ac4c5165
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-define-thread-chat-domain-model/tasks.md
@@ -0,0 +1,80 @@
+## 0. 阶段 0:冻结实施边界
+
+- [x] 0.1 对本 change 与 `design-thread-chat-client-api` 执行严格校验,确认 Project、Thread、Message、MessageRun、BaseContext、replacement 与 Artifact 术语一致。
+- [x] 0.2 固定职责分界:本 change 只交付数据库、领域、Repository、Application 与 MessageRun 后台执行;API、客户端、UI 和 E2E 由 `design-thread-chat-client-api` 承担。
+- [x] 0.3 固定本地数据库策略:不迁移现有 `branch_trees` 数据,不建立 treeId 映射或双写;使用独立测试数据库验证后,通过 `pnpm db:push` 重建本地目标 Schema。
+- [x] 0.4 固定测试策略:Vitest 负责单元、Repository 和 Application 集成测试;真实 PostgreSQL 不 mock;AI Runtime、邮件和外部存储在自动测试中使用可控 adapter。
+- [x] 0.5 固定阶段门与归档顺序:后端领域通过后才能实现 API,后端/API 全部通过后才能接入前端,E2E 通过后先归档本 change,再归档客户端/API change。
+
+## 1. 建立后端测试基础设施
+
+- [x] 1.1 增加 Vitest 及 `test`、`test:unit`、`test:integration`、`test:api`、`test:watch` 脚本;默认命令不得调用真实模型供应商。
+- [x] 1.2 建立物理隔离的 PostgreSQL 测试数据库配置,例如 `thread-chat-test`,并让测试专用 Drizzle 配置只读取 `TEST_DATABASE_URL`。
+- [x] 1.3 在测试初始化中校验数据库名称或明确 allowlist;目标不是测试数据库时立即终止,禁止误删开发数据库。
+- [x] 1.4 建立测试 Schema 重建流程:只删除测试库的 `thread_chat` schema,再执行测试配置对应的 `drizzle-kit push`。
+- [x] 1.5 建立用户、Project、Thread、Message、Artifact 与 MessageRun fixture factory;测试 ID 仍由服务端/fixture factory 生成,不进入生产客户端逻辑。
+- [x] 1.6 定义可注入的 AI Runtime capability 与 Fake AI Runtime,支持固定 delta、completed、failed、stopped、Markdown Artifact tool output 和恢复事件。
+- [x] 1.7 约束测试并行:纯单元测试可以并行;共享 PostgreSQL 的集成/API 测试在证明数据隔离前使用单 worker 或独立事务边界。
+
+## 2. 建立规范化数据库 Schema
+
+- [x] 2.1 在 `lib/db/schema.ts` 中新增 `projects`、`threads`、`messages`、`message_runs`、`artifacts` 与 Message feedback 表;Project Memory/File 只保留后续扩展边界,不实现完整协议。
+- [x] 2.2 为 Project owner、唯一 Root Thread、Thread 内唯一 sequence、同 Message 单一 replacement 和 assistant Message 单一 MessageRun 建立数据库可表达的约束与索引。
+- [x] 2.3 为 Root/Branch ForkFacts 的空值组合、Message role、MessageRun status 和非负 eventSequence 建立数据库可表达的约束。
+- [x] 2.4 明确无法由普通 CHECK 表达的同 Project Parent/source/replacement 关系,交给事务内 Repository 校验,不伪造不可靠约束。
+- [x] 2.5 为 Project 永久删除定义级联边界;普通 Message Repository 和 Route 不得暴露单 Message hard delete。
+- [x] 2.6 对隔离测试数据库执行 `pnpm db:push` 等价的测试配置命令,并验证从空库可以一次建立完整目标 Schema。
+
+## 3. 实现纯领域模型与 Repository
+
+- [x] 3.1 按 design 的模块骨架建立不依赖 React、HTTP、Drizzle 或具体 AI Runtime 的 Project、Thread、Message、MessageRun、BaseContext 与 Artifact 领域类型。
+- [x] 3.2 实现 Root/Branch 关系判定、唯一 Root、同 Project Parent/source、无环拓扑与 ForkFacts 完整性验证。
+- [x] 3.3 实现 Thread 内服务端 sequence 分配,并用数据库唯一约束与并发测试保证无重复 sequence。
+- [x] 3.4 实现 finalized Message 不可变与 replacement 规则;Repository 的更新入口必须明确禁止原地覆盖 finalized parts 和 sequence。
+- [x] 3.5 实现 BaseContextV1 的验证、持久化和有序 Message ID 解析;BaseContext 只能由服务端计算。
+- [x] 3.6 实现 MessageRun 的 queued/running/completed/failed/stopped 条件状态转换、checkpointParts、eventSequence 与 Stop 请求持久化。
+- [x] 3.7 实现 Project、Thread、Message、MessageRun、Artifact 与 feedback Repository;所有 Query 从 actor 校验 Project owner scope。
+- [x] 3.8 实现 Artifact 独立持久化及 `sourceMessageId` provenance;Message 的 AI SDK v7 tool output 只保存 `artifactId`,不复制 Markdown 正文。
+
+## 4. 实现核心 Application Command 与 Query
+
+- [x] 4.1 原子创建 `Project + Root Thread + U1 + A1 + queued MessageRun`,所有新实体 ID 由服务端生成,提交前不启动 AI Runtime。
+- [x] 4.2 原子追加 `user Message + assistant Message + queued MessageRun`,不把 user/assistant 角色交替建成数据库不变量。
+- [x] 4.3 实现 Fork Thread:验证 finalized source、冻结 ForkSourceSnapshot 和 BaseContext、创建 Child Thread,任一步失败整体回滚。
+- [x] 4.4 实现 Regenerate:保留旧 assistant Message 内容和 sequence,标记 superseded,追加 replacement assistant Message 与新 MessageRun。
+- [x] 4.5 实现 Edit last user Message:追加 replacement user Message,将依赖旧输入的有效后缀 supersede,并创建新 assistant Message 与 MessageRun。
+- [x] 4.6 实现 Project metadata、Branch metadata、archive/unarchive、feedback 与 Project 永久删除 Application Command。
+- [x] 4.7 实现 Project 列表、ProjectBootstrap、Thread Message 和 Artifact-by-ID Query;Bootstrap 只返回轻量 topology 与 Root bundle,Thread 默认最多 200 条有效 Message。
+- [x] 4.8 实现 Prompt History:`BaseContext.messageIds + 当前 Thread 有效 Prompt Message`,排除不合格 assistant 状态,且不依赖客户端已加载窗口。
+
+## 5. 实现 MessageRun 后台执行
+
+- [x] 5.1 将 `message_runs.status=queued` 作为持久化待执行事实;事务提交后才尝试唤醒执行器,不建立通用任务平台。
+- [x] 5.2 实现条件领取 queued Run、heartbeat、checkpoint/eventSequence 持久化和 completed/failed/stopped 原子终态提交,防止同一 Run 重复执行。
+- [x] 5.3 实现最小 queued scanner,补偿事务已提交但即时唤醒失败的 Run;不得创建第二条 assistant Message 或 MessageRun。
+- [x] 5.4 接入真实 AI Runtime adapter,并确保领域/Application 只依赖 capability;自动测试使用 Fake AI Runtime,不发起真实计费请求。
+- [x] 5.5 完成 Markdown Artifact 工具结果投影:创建独立 Artifact,最终 Message tool output 保存稳定 `artifactId`。
+- [x] 5.6 实现显式 Stop;浏览器刷新、断开连接或客户端 Runtime 销毁不得调用 Stop,也不得终止后台执行。
+
+## 6. 后端领域验收门
+
+- [x] 6.1 完成领域单元测试:Root/Branch、拓扑无环、sequence、replacement、Fork 资格、BaseContext、Prompt History 与 MessageRun 状态机。
+- [x] 6.2 完成 Repository 集成测试:真实 PostgreSQL owner scope、唯一 Root、并发 sequence、finalized 不可变、replacement、Artifact provenance 与单一 MessageRun。
+- [x] 6.3 完成 Application 集成测试:create/send/Fork/Edit/Regenerate/Stop/delete 的事务提交与整体回滚。
+- [x] 6.4 使用 Fake AI Runtime 测试 delta、完成、失败、Stop、queued scanner 补偿、刷新时 checkpoint 恢复与 Artifact tool output。
+- [x] 6.5 执行 `pnpm typecheck`、后端 unit/integration tests 和 `pnpm openspec:validate`,全部通过后记录实现证据;未通过不得进入 API Route 实现。
+
+## 7. E2E 通过后的旧后端退役
+
+- [x] 7.1 等待 `design-thread-chat-client-api` 完成 API、前端接入与 Ego Browser E2E;在此前保留旧实现代码,但不建立新旧数据双写。
+- [x] 7.2 E2E 通过后删除旧 `branch_trees.state`、`branch_generations`、消息图 active leaf 与旧整体保存的后端权威职责。
+- [x] 7.3 从 Drizzle Schema 删除不再使用的旧表/列,并对本地与隔离测试数据库执行 `pnpm db:push`;无需迁移或保留旧数据。
+- [x] 7.4 再次执行后端、API、前端集成、build、E2E 与 OpenSpec 严格校验,确认代码中不存在旧写入路径。
+- [x] 7.5 对照 domain spec 的 Requirement/Scenario 记录自动测试或手动验收证据。
+- [x] 7.6 在全部门槛满足后先归档 `define-thread-chat-domain-model`,将增量 domain spec 合入正式 `openspec/specs/domain/spec.md`。
+
+## 8. 归档后提炼永久领域架构
+
+- [x] 8.1 从已归档 design 提炼稳定目标架构到 `openspec/specs/domain/architecture.md`,只保留实体、关系、ER 图、不变量、最终 DB Schema 与模块边界。
+- [x] 8.2 在正式 `openspec/specs/domain/spec.md` 中链接 `architecture.md`,明确冲突时可验证 Requirement 优先。
+- [x] 8.3 校验永久架构文档与正式 spec、实际 Drizzle Schema 和模块结构一致,并把后续同步更新列为验收项。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/.openspec.yaml b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/.openspec.yaml
new file mode 100644
index 00000000..e685d45e
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-25
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/backend-api-verification.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/backend-api-verification.md
new file mode 100644
index 00000000..cb48bc28
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/backend-api-verification.md
@@ -0,0 +1,28 @@
+# 后端 API 验收证据
+
+## 自动化门槛
+
+| 门槛 | 命令 | 结果 |
+|---|---|---|
+| 领域单元测试 | `pnpm test:unit` | 4 个文件、14 个测试通过 |
+| Repository / Application / Runtime 集成测试 | `pnpm test:integration` | 5 个文件、20 个测试通过;使用隔离的 `thread-chat-test` PostgreSQL |
+| API 合同与集成测试 | `pnpm test:api` | 5 个文件、32 个测试通过;使用 Fake AI Runtime,不请求真实模型 |
+| TypeScript | `pnpm typecheck` | 通过 |
+| 生产构建 | `pnpm build --webpack` | Next.js 16.3.1 官方 webpack 构建通过;编译、类型检查、静态生成和全部 `/api/v1` Route 收集完成 |
+| OpenSpec | `pnpm openspec:validate` | 27 项严格校验通过 |
+
+默认 Turbopack 构建已执行到 PostCSS 处理阶段,但当前受管执行环境禁止其内部进程绑定端口并触发 Turbopack panic。使用 Next.js 16.3.1 官方 `--webpack` 回退后生产构建完整通过,未修改项目默认构建器。
+
+## 覆盖映射
+
+| API 能力 | 自动化证据 |
+|---|---|
+| 严格 Zod DTO、实体归属、非法 user part、Artifact tool output 仅含 `artifactId` | `tests/api/contracts.test.ts` |
+| JSON Transport 请求编码、严格响应、ClientError 与 SSE frame 解析 | `tests/api/transport.test.ts` |
+| Session、owner scope、Project cursor、空 Query、窗口边界、metadata、archive、delete、feedback | `tests/api/handlers.test.ts` |
+| create、send、Fork、Edit、Regenerate、Stop 的关联响应、严格输入、资格失败与无半成品回滚 | `tests/api/handlers.test.ts` |
+| 公开领域错误、Route fallback、Zod details 与未知异常隐藏 | `tests/api/errors.test.ts` |
+| snapshot、cursor、重复连接、取消订阅、completed、failed、stopped 与断线重连 | `tests/api/sse.test.ts` |
+| Artifact 正文只由按 ID Query 返回,Bootstrap、MessageBundle 与 SSE 只携带引用或摘要 | `tests/api/handlers.test.ts`、`tests/api/sse.test.ts` |
+
+API 自动化测试不会唤醒真实后台模型执行,也不会请求真实模型供应商。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design.md
new file mode 100644
index 00000000..9b183728
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design.md
@@ -0,0 +1,1584 @@
+## Context
+
+本设计依赖 `define-thread-chat-domain-model` 已确认的服务端领域事实:Project 是一整簇 Thread 的聚合边界,Thread 内 Message 以服务端 sequence 构成线性时间线,Edit/Regenerate 使用 replacement,Fork 的 BaseContext 由服务端冻结,每条 assistant Message 恰有一条 MessageRun。
+
+当前前端的真实基线是:
+
+```text
+ThreadTreeState 整棵树
+        ↓
+createThreadStore(原地修改)
+        ↓
+全局 version++
+        ↓
+所有订阅者重新读取整树
+
+chat-controller 同时承担:
+客户端实体 ID + 整树持久化屏障 + Prompt 拼装
++ Command 协调 + SSE 消费 + Store 修改
+```
+
+当前实现中有四个应保留的方向:`useThreadChatRuntime` 作为页面组合根、headless 纯 selector、细分 `net/commands`、流式 delta 合帧。需要退出的是整树权威、客户端实体 ID、全局 version 粗粒度订阅和 controller 职责混合。
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- 定义前端实体、运行态、UI 态和 ViewModel 的唯一边界。
+- 定义 ThreadChatAppStore 与 Project-scoped ThreadChatProjectStore 的 Zustand 结构。
+- 定义 selector、Store Action、Application Command、Hook 与 UI 的依赖方向。
+- 定义 `/thread-chat/new` 和 `/thread-chat/{projectId}` 的完整生命周期。
+- 给出后端 `/api/v1` 所需的全部 MVP Query、Command、参数关系、响应 DTO 和可手动测试案例。
+- 让 API 契约既服务 Web UI,也能作为未来 CLI/MCP/Token API 的基础。
+
+**Non-Goals:**
+
+- 不在单一步骤整体重写 Store、Hook、Route Handler 或旧页面;通过 `tasks.md` 中的小步交付链逐层接入。
+- P0 事件 Transport 固定为 SSE;具体 HTTP client、SSE parser、重试器和认证包装库由实现 change 选择,但不得改变共享契约。
+- 不实现离线命令队列、通用 Idempotency-Key、跨设备草稿、协同编辑或 Message Variant。
+- 不重新设计服务端数据库 Schema、BaseContext 或 PromptBuilder。
+- 不在本 change 重新设计 Markdown Artifact 的身份关系;Message 的 AI SDK v7 tool result 只保存 `artifactId` 引用,Artifact 内容仍是独立服务端实体并在用户打开时按 ID 加载。
+
+## Decisions
+
+### D1. 客户端只有一份服务端事实
+
+数据依赖方向固定为:
+
+```mermaid
+flowchart LR
+    UI[UI Components] --> Hooks[Selector / Command Hooks]
+    Hooks --> Commands[Application Commands]
+    Hooks --> LocalActions[Local UI Store Actions]
+    Commands --> API[API Capabilities]
+    API --> Server[Server]
+    Server --> DTO[Validated DTO]
+    DTO --> Commands
+    Commands --> StoreActions[Store Actions]
+    LocalActions --> StoreActions
+    StoreActions --> Store[ThreadChatAppStore / ThreadChatProjectStore]
+    Store --> Selectors[Pure Selectors]
+    Selectors --> Hooks
+```
+
+entities slice 只保存服务端确认 DTO。生成流和 UI 可以暂时领先于最终实体,但不得伪造实体身份。Application Command 是唯一允许编排 API 结果的边界;它必须调用 Store Action 写入 Store。Transport、Command 和 React 组件都不直接调用 Zustand `set`/`setState`。
+
+弃选:保存服务端实体后再构造一份可修改 `ThreadTreeState`。它会重新产生双向同步、整树覆盖和身份漂移问题。
+
+### D2. 前端实体模型
+
+```ts
+/** 所有服务端实体 ID 对客户端都是不透明字符串;客户端不得解析或自行构造。 */
+type ProjectId = string
+type ThreadId = string
+type MessageId = string
+type ArtifactId = string
+
+/** 纯客户端工作台中的物理列身份;不是服务端实体 ID,绝不能传给领域 API。 */
+type ColumnSlotId = string
+
+type JsonValue =
+  | string
+  | number
+  | boolean
+  | null
+  | JsonValue[]
+  | { [key: string]: JsonValue }
+
+type ProjectTarget = {
+  /** Project 最终希望达到的结果;不是当前任务标题,也不是完成状态。 */
+  ultimate: string | null
+
+  /** 当前阶段应优先完成的目标集合;MVP 不为单项分配 Goal ID。 */
+  shortTerm: string[]
+
+  /** 连接短期工作与终极目标的阶段性目标集合。 */
+  midTerm: string[]
+}
+
+type ProjectEntity = {
+  id: ProjectId
+
+  /** 服务端授权归属;客户端只读取,绝不能通过请求修改。 */
+  ownerUserId: string
+
+  /** 服务端根据内容生成的回退标题。 */
+  autoTitle: string | null
+
+  /** 用户显式设置的标题;非空时展示优先级高于 autoTitle。 */
+  customTitle: string | null
+
+  /** 整个 Project 及其全部 Thread 共享的目标。 */
+  target: ProjectTarget | null
+
+  /** 构造该 Project 内模型请求时统一应用的 Project 指令。 */
+  instruction: string | null
+
+  /** 非空表示从默认 Project 列表隐藏,但内容和 Thread 仍然保留。 */
+  archivedAt: string | null
+  createdAt: string
+  updatedAt: string
+}
+
+type ForkSourceSnapshot = {
+  /** 允许未来升级快照形状;不是 Project/Thread revision。 */
+  schemaVersion: 1
+
+  /** Fork 时用于来源展示的冻结引用文本;来源以后变化也不回写。 */
+  quote?: string
+
+  /** Fork 当时来源 Message 的角色,用于稳定展示和审计。 */
+  sourceRole: "user" | "assistant"
+
+  /** Fork 当时来源 Message 在 Parent Thread 内的 sequence。 */
+  sourceSequence: number
+}
+
+type ThreadEntity = {
+  id: ThreadId
+
+  /** Thread 所属聚合边界;Parent、来源 Message 和 Child 必须同 Project。 */
+  projectId: ProjectId
+
+  /** null 表示 Project 唯一 Root;非 null 表示 Branch/Child Thread。 */
+  parentThreadId: ThreadId | null
+
+  /** Branch 的 Fork 来源 Message;Root 必须为 null。 */
+  sourceMessageId: MessageId | null
+
+  /** Branch 创建时冻结的来源展示事实;Root 必须为 null。 */
+  forkSourceSnapshot: ForkSourceSnapshot | null
+
+  /** 服务端生成的 Branch 回退标题;Root 的展示标题来自 Project。 */
+  autoTitle: string | null
+
+  /** 用户设置的 Branch 标题;不得为 Root 建立第二套标题权威。 */
+  customTitle: string | null
+
+  /** 非空只改变 Branch 的默认导航可见性,不删除 Message 或 Child。 */
+  archivedAt: string | null
+  createdAt: string
+  updatedAt: string
+}
+
+type MessageEntity = {
+  id: MessageId
+
+  /** Message 只属于一个 Thread;跨 Thread 上下文由服务端 BaseContext 解析。 */
+  threadId: ThreadId
+
+  /** 服务端分配的 Thread 内单调顺序;可有间隙,replacement 后不得重排。 */
+  sequence: number
+
+  role: "user" | "assistant"
+
+  /** AI SDK v7 UIMessage.parts;运行中 assistant 可为 null,最终内容只封存一次。 */
+  parts: UIMessage["parts"] | null
+
+  /** 当前 Message 替代的旧 Message;replacement 仍然是追加的新实体。 */
+  replacesMessageId: MessageId | null
+
+  /** 非空表示该 Message 已退出默认有效时间线,但仍保留内容、sequence 和引用。 */
+  supersededAt: string | null
+
+  /** 非空表示内容已经封存;封存后的 parts 不允许原地覆盖。 */
+  finalizedAt: string | null
+  createdAt: string
+}
+
+type ArtifactEntity = {
+  id: ArtifactId
+
+  /** Artifact 对整个 Project 可用,不只属于产生它的 Thread。 */
+  projectId: ProjectId
+
+  /** 产生该 Artifact 的 Message,用于 provenance 和 BaseContext 间接解析。 */
+  sourceMessageId: MessageId
+
+  /** Artifact 语义类型;具体 kind 协议由后续 Artifact change 固定。 */
+  kind: string
+  title: string
+
+  /** 通过 Artifact Query 按 ID 加载的完整内容,不嵌入 ThreadMessageBundle。 */
+  content: JsonValue
+  createdAt: string
+}
+
+type MarkdownArtifactToolOutput = {
+  /** tool result 只保存独立 Artifact 的稳定引用,不复制 Markdown 正文。 */
+  artifactId: ArtifactId
+}
+
+type ProjectArtifactSummary = {
+  /**
+   * 服务端在 Project Artifact 集合发生变化时单调递增的读模型游标。
+   * 客户端只用于拒绝乱序旧快照;它不是 Project revision,也不进入请求。
+   */
+  changeSequence: number
+
+  /** 当前 Project 下全部 Artifact 数量,不受客户端已加载窗口影响。 */
+  total: number
+
+  /** 按服务端稳定 kind 统计;Markdown 数量读取 `byKind.markdown ?? 0`。 */
+  byKind: Record
+}
+
+type AssistantRunState = {
+  /** 客户端关联键;不暴露服务端内部 MessageRun ID。 */
+  assistantMessageId: MessageId
+
+  /** 生成生命周期;它不是 Message 自身的 status 字段。 */
+  status: "queued" | "running" | "completed" | "failed" | "stopped"
+
+  /** 服务端实际接受并执行的模型,可能不同于客户端 requestedModelId。 */
+  modelId: string
+
+  /** 已持久化的运行中内容;刷新后先用它恢复画面,再续接事件。 */
+  checkpointParts: UIMessage["parts"]
+
+  /** 该 assistant Run 内严格递增的恢复游标,不是 Thread revision。 */
+  eventSequence: number
+
+  /** 仅保存可展示/可判断的结构化失败,不保存服务端堆栈。 */
+  error: { code: string; message: string } | null
+
+  /** 非空表示 Stop 已被服务端接受;不代表 Run 已进入 stopped 终态。 */
+  stopRequestedAt: string | null
+
+  /** completed/failed/stopped 的终态时间;queued/running 为 null。 */
+  finishedAt: string | null
+}
+```
+
+关键约束:
+
+- `ProjectTarget` 先只表达终极、短期和中期目标,不引入 Goal ID、进度或截止时间。
+- `Message.parts` 严格使用项目 AI SDK v7 UIMessage parts 协议。
+- Message 不存 `status`;展示状态由 `finalizedAt/supersededAt + AssistantRunState` 派生。
+- Thread 不存 `children/depth/isRoot/breadcrumb`;全部从 Parent 关系派生。
+- AssistantRunState 是前端运行视图,不是可导航 Chat Entity。
+- BaseContext、Prompt History 和服务端 MessageRun ID 不进入普通客户端实体模型。
+
+### D3. 两种 Store 生命周期,而不是 Store 树
+
+“App 级”和“Project 级”表示生命周期,不表示父 Store 内嵌子 Store。两者都是独立 vanilla Zustand Store 实例,通过 Provider/Runtime 组合。
+
+#### 共同请求状态
+
+```ts
+type LoadState =
+  | { status: "idle" }
+  | { status: "loading" }
+  | { status: "ready" }
+  | { status: "error"; error: ClientError }
+
+type CommandState =
+  | { status: "submitting" }
+  | { status: "error"; error: ClientError }
+
+type ThreadMessageWindowState = {
+  /** 该 Thread 的 Message bundle 是否已经加载。 */
+  loadState: LoadState
+
+  /** 服务端是否还有更早有效 Message;MVP 只保留能力,不自动翻页。 */
+  hasOlderMessages: boolean
+
+  /** 当前已合并窗口的 sequence 边界;空 Thread 时均为 null。 */
+  oldestReturnedSequence: number | null
+  newestReturnedSequence: number | null
+}
+```
+
+`commandByScope` 中不存在 key 表示当前没有提交或错误;成功后删除 key,失败时保留 error 供 UI 展示。`threadMessagesById` 中不存在 Thread key 表示从未请求,不能与“已成功加载但返回空 Message”混淆。
+
+#### App 级 ThreadChatAppStore
+
+```ts
+type ProjectSummary = {
+  id: ProjectId
+
+  /** 已应用 customTitle > autoTitle 回退规则,可直接用于列表。 */
+  displayTitle: string
+
+  /** 用于 active/archived 列表过滤。 */
+  archivedAt: string | null
+
+  /** 用于 `updatedAt DESC, id DESC` 的稳定最近访问排序。 */
+  updatedAt: string
+
+  /** 列表统计,不携带 Thread topology 或 Message 正文。 */
+  threadCount: number
+  messageCount: number
+}
+
+type ProjectCatalogState = {
+  /** 服务端确认的轻量 Project 摘要;不保存 Project 完整实体。 */
+  projectsById: Record
+
+  /** 当前列表查询顺序;与 projectsById 分离以避免复制摘要内容。 */
+  orderedProjectIds: ProjectId[]
+
+  /** 首次加载、刷新或加载下一页的整体状态。 */
+  loadState: LoadState
+
+  /** 当前列表查询条件;不是当前选中的 Project。 */
+  activeFilter: "active" | "archived"
+
+  /** 服务端签发的下一页不透明游标;null 表示没有下一页。 */
+  nextCursor: string | null
+}
+
+type AppShellUiState = {
+  /** 左侧 Project 导航是否展开;只影响当前设备布局。 */
+  sidebarOpen: boolean
+
+  /** 用户调整后的 Sidebar 像素宽度;与服务端 Project 内容无关。 */
+  sidebarWidth: number
+
+  /** 仅用于客户端过滤已加载 ProjectSummary,不写回服务端。 */
+  projectSearchQuery: string
+
+  /**
+   * 用户已经点击并开始路由跳转、但目标 ProjectProvider 尚未就绪的 Project。
+   * 它只驱动列表 loading/防重复点击;当前 Project 的唯一权威仍是 URL。
+   * 路由完成、失败或被取消时必须清为 null。
+   */
+  pendingProjectId: ProjectId | null
+}
+
+type ThreadChatAppState = {
+  /** Project 列表的服务端确认摘要和查询窗口。 */
+  catalog: ProjectCatalogState
+
+  /** 跨 Project 页面持续存在、但不属于任何 Project 的 App 外壳 UI。 */
+  shellUi: AppShellUiState
+}
+
+type ThreadChatAppActions = {
+  mergeProjectPage: (result: ListProjectsResult) => void
+  upsertProjectSummary: (summary: ProjectSummary) => void
+  removeProjectSummary: (projectId: ProjectId) => void
+  setCatalogFilter: (filter: ProjectCatalogState["activeFilter"]) => void
+  setProjectRoutePending: (projectId: ProjectId | null) => void
+  setSidebarOpen: (open: boolean) => void
+  setSidebarWidth: (width: number) => void
+  setProjectSearchQuery: (query: string) => void
+}
+
+type ThreadChatAppStore = ThreadChatAppState & ThreadChatAppActions
+```
+
+`ThreadChatAppStore` 取代原先只描述一半的 `ProjectCatalogStore`:Catalog 是服务端列表摘要 slice,AppShellUi 是跨 Project 的本地外壳 slice。这里仍然没有 `selectedProjectId`;Sidebar 选中态必须由 `routeProjectId` 派生。
+
+“App 级”不等于模块级服务器单例。Next.js 下必须由 `ThreadChatAppProvider` 创建 vanilla Store,避免跨请求共享状态。
+
+无 `initialCatalog` 时,App Store 从空 `projectsById/orderedProjectIds`、`loadState=idle`、`nextCursor=null` 初始化;AppShellUi 使用产品默认 Sidebar 设置,且 `pendingProjectId=null`。
+
+#### Project 级 ThreadChatProjectStore
+
+```ts
+type ThreadChatEntitiesState = {
+  /** Bootstrap 前为 null;合并后必须与 Provider 的 projectId 一致。 */
+  project: ProjectEntity | null
+
+  /** 当前 Project 的全量轻量 topology,以及已按命令新增的 Thread。 */
+  threadsById: Record
+
+  /** 已加载 Message 的唯一内容表;可以包含已 superseded 的历史实体。 */
+  messagesById: Record
+
+  /**
+   * Thread → 已加载 Message ID 的查询索引,不复制 Message 内容。
+   * normalizer 负责去重并按 sequence 排序;有效时间线再由 selector 过滤 supersededAt。
+   */
+  messageIdsByThreadId: Record
+
+  /** 只保存用户实际打开并通过 Artifact Query 加载过的完整 Artifact。 */
+  artifactsById: Record
+}
+
+type StreamBuffer = {
+  /** 尚未在下一次 UI flush 中合入 checkpoint 的 AI SDK v7 增量事件。 */
+  pendingChunks: UIMessageChunk[]
+
+  /** 已接收的最新事件游标,用于丢弃重复或倒序 delta。 */
+  lastReceivedEventSequence: number
+
+  /** 是否已经安排 requestAnimationFrame/批量 flush,防止每 token setState。 */
+  flushScheduled: boolean
+}
+
+type ThreadChatRunsState = {
+  /** 每条 assistant Message 的权威运行视图;内部 MessageRun ID 不进入客户端。 */
+  byAssistantMessageId: Record
+
+  /** 只在生成期间存在的高频缓冲;不得持久化到 localStorage。 */
+  streamBuffersByAssistantMessageId: Record
+}
+
+type ThreadChatRequestsState = {
+  /** 当前 ProjectBootstrap 的请求状态。 */
+  bootstrap: LoadState
+
+  /** 每个 Thread 的加载状态和已合并窗口边界,避免重复请求。 */
+  threadMessagesById: Record
+
+  /** 用户打开 Artifact Drawer 后按 ID 加载内容;MessageBundle 只提供 artifactId 引用。 */
+  artifactById: Record
+
+  /**
+   * 当前页面的命令 busy/error 状态,key 由命令名和既有资源 ID 构成,
+   * 例如 `send:${threadId}`、`regenerate:${messageId}`;它不是网络级幂等记录。
+   */
+  commandByScope: Record
+}
+
+type ThreadChatReadModelsState = {
+  /**
+   * 服务端确认的 Project 级 Artifact 统计投影。
+   * 它不是 Artifact 实体集合;不能从局部加载的 artifactsById 反推或覆盖。
+   */
+  artifactSummary: ProjectArtifactSummary | null
+}
+
+type ThreadColumnSlot = {
+  /**
+   * 物理列的稳定本地身份。切换本列 Thread 时保持不变;它可以由客户端生成,
+   * 因为它不是 Project、Thread、Message 或 Artifact 等服务端实体 ID。
+   */
+  slotId: ColumnSlotId
+
+  /** 槽位当前展示的 Branch Thread;Root 固定在主列,不进入 slots。 */
+  threadId: ThreadId
+
+  /** true 表示折叠为窄条,保留位置但不计入展开列上限。 */
+  folded: boolean
+
+  /**
+   * 用户为该物理列提交的像素宽度;null 表示自动均分。
+   * 它属于 Slot 而不是 Thread,因此切换 Thread 后宽度不变。
+   */
+  widthPx: number | null
+}
+
+type TextSelectionState = {
+  /** 发生选区的既有 Message。 */
+  messageId: MessageId
+
+  /** 用户实际选择的规范文本,用作 Fork anchor 输入而不是服务端历史事实。 */
+  exactQuote: string
+
+  /** 在规范文本投影中的 UTF-16 [start,end);无法稳定定位时允许省略。 */
+  textPosition?: { start: number; end: number }
+}
+
+type OverlayState = {
+  /** 当前文本选区/Fork 气泡的语义输入;DOM Rect 和动画状态留在组件局部。 */
+  selection: TextSelectionState | null
+
+  /** Thread 切换器当前作用域;DOM 坐标和退场动画仍留在组件局部状态。 */
+  threadSwitcherScope:
+    | { kind: "global" }
+    | { kind: "column"; slotId: ColumnSlotId }
+    | { kind: "subtree"; rootThreadId: ThreadId }
+    | null
+
+  treeListOpen: boolean
+  helpPanelOpen: boolean
+  artifactDrawerOpen: boolean
+}
+
+type ThreadChatUiState = {
+  /**
+   * Root 右侧的有序 Branch 槽位及折叠态,是分栏布局的唯一权威。
+   * `visibleThreadIds` 必须由它派生,不再作为第二份 State 保存。
+   */
+  columnSlots: ThreadColumnSlot[]
+
+  /**
+   * 当前接收全局快捷键/工具栏命令的物理列。`"root"` 表示固定 Root 列;
+   * 非 null 的 ColumnSlotId 必须指向一个未折叠 Slot。当前 Thread 由该 Slot 派生。
+   */
+  focusedSlotId: "root" | ColumnSlotId | null
+
+  /** Root 物理列的显式像素宽度;null 表示自动均分。 */
+  rootColumnWidthPx: number | null
+
+  /** null 表示根据视口自适应;非 null 是用户强制的展开列数量上限。 */
+  forceColumnCount: number | null
+
+  /** replace 在列满时替换一个槽;fold 保留槽位并把一列折叠为细条。 */
+  placementMode: "replace" | "fold"
+
+  /** columns 用于并排深读;canvas 用于查看整个 Thread topology。 */
+  viewMode: "columns" | "canvas"
+
+  /** Canvas 中 Thread 的设备本地坐标;不存在条目的 Thread 使用自动布局。 */
+  canvasPins: Record
+
+  /** 每个 Thread 独立的未发送输入;切换列时不丢失草稿。 */
+  composerDraftByThreadId: Record
+
+  /** 当前 Artifact Drawer 的目标;Drawer 关闭后可以保留,便于重新打开。 */
+  selectedArtifactId: ArtifactId | null
+
+  /** 支撑列满 LRU 策略的本地逻辑时钟;不是服务端活动时间。 */
+  activationClock: number
+
+  /** 物理列最近一次获得焦点/被打开时的逻辑顺序,用于选择替换或折叠候选。 */
+  lastActivatedOrderBySlotId: Record<"root" | ColumnSlotId, number>
+
+  /** 只保存跨组件需要共享的语义开关;DOM 引用、坐标和动画计数不进 Store。 */
+  overlays: OverlayState
+}
+
+type ThreadChatProjectState = {
+  /** 服务端确认的 Project/Thread/Message/Artifact 事实。 */
+  entities: ThreadChatEntitiesState
+
+  /** assistant Message 的运行视图和未 flush 流缓冲。 */
+  runs: ThreadChatRunsState
+
+  /** Query/Command 的客户端请求状态;不是服务端领域状态。 */
+  requests: ThreadChatRequestsState
+
+  /** Project 级服务端统计等非实体读模型。 */
+  readModels: ThreadChatReadModelsState
+
+  /** 当前设备上的 Project 工作台布局和交互焦点。 */
+  ui: ThreadChatUiState
+}
+
+type ThreadChatProjectActions = {
+  mergeCreationBundle: (bundle: CreationBundle) => void
+  mergeBootstrap: (bootstrap: ProjectBootstrap) => void
+  applyMessageBundle: (bundle: ThreadMessageBundle) => void
+  applyMessageCreationBundle: (bundle: MessageCreationBundle) => void
+  applyThreadCreated: (thread: ThreadEntity) => void
+  applyReplacementBundle: (bundle: ReplacementBundle) => void
+  applyRunEvent: (event: AssistantMessageEvent) => void
+  applyArtifact: (artifact: ArtifactEntity) => void
+  setBootstrapLoadState: (state: LoadState) => void
+  setCommandState: (scope: string, state: CommandState | null) => void
+  setThreadMessageLoadState: (threadId: ThreadId, state: LoadState) => void
+  setArtifactLoadState: (artifactId: ArtifactId, state: LoadState) => void
+  restoreWorkbenchSnapshot: (snapshot: ThreadWorkbenchSnapshotV1) => void
+  resetWorkbenchToDefault: () => void
+  openThread: (threadId: ThreadId, sourceSlotId: "root" | ColumnSlotId) => void
+  switchColumnThread: (slotId: ColumnSlotId, threadId: ThreadId) => void
+  closeColumn: (slotId: ColumnSlotId) => void
+  focusColumn: (slotId: "root" | ColumnSlotId) => void
+  commitColumnWidths: (
+    widths: Partial>,
+  ) => void
+  setPlacementMode: (mode: ThreadChatUiState["placementMode"]) => void
+  setViewMode: (mode: ThreadChatUiState["viewMode"]) => void
+}
+
+type ThreadChatProjectStore =
+  & ThreadChatProjectState
+  & ThreadChatProjectActions
+```
+
+同一个 Project Store 内使用 slices,可以由一个 Store Action 通过一次 `set` 同时更新 Message、Run 和 request state;这不是把 slices 物理拆成多个 Store。组件通过细粒度 selector 订阅。
+
+`columnSlots` 取代原先不充分的 `visibleThreadIds` State:fold 模式必须保存槽位折叠态。每个 Slot 用稳定 `slotId` 表示物理列,`threadId` 只是该列当前展示的内容;切换 Thread 不得重建 Slot 或转移列宽。`selectVisibleThreadIds` 和 `selectVisibleColumns` 从 Root + `columnSlots` 派生,避免两份分栏权威。
+
+现有分栏分割线拖拽能力必须保留。分割线位于相邻两个展开列之间;拖拽时同时调整左右两列,并继续遵守当前 UI/CSS 的最小宽度约束。拖拽过程中的 Pointer 坐标、临时宽度和捕获状态保留在 Resizer Hook/组件局部,不得按每个 Pointer Move 写入 Zustand。Pointer Up、键盘步进或双击复位时,通过一次 `commitColumnWidths` 原子提交受影响物理列的最终宽度;双击复位写入 `null`,恢复当前自动均分行为。
+
+Root 宽度写入 `rootColumnWidthPx`,Branch 宽度写入对应 `ThreadColumnSlot.widthPx`。折叠细条不参与相邻列拖拽;切换 Slot 的 Thread 必须保留宽度。提交后的宽度进入 `ThreadWorkbenchSnapshotV1`,刷新后恢复;拖拽瞬时状态和 Pointer 信息不得持久化。该交互完全是本地工作台行为,不调用后端 API。
+
+Project Store 初始化和分栏变更必须保持:
+
+- 空 Runtime 从 `project=null`、空实体表、`bootstrap=idle`、`artifactSummary=null`、空 `columnSlots` 和 `focusedSlotId=null` 初始化。
+- Bootstrap 后 `focusedSlotId` 默认是 `"root"`;Root 固定渲染,不进入 `columnSlots`。
+- `columnSlots.slotId` 和 `columnSlots.threadId` 都必须唯一;Thread 必须属于当前 Project 且不是 Root。恢复 localStorage 时过滤失效 Thread ID、重复 Slot ID 和重复 Thread ID。
+- `focusedSlotId` 非 Root 时必须指向未折叠槽位;打开/展开 Thread 同时使对应 Slot 获得焦点。
+- 关闭或折叠当前焦点时,按相邻展开槽位、Root 的顺序选择新焦点,不允许留下悬空 ID。
+- App Store 和 Project Store 的 UI slice 可以写入设备 localStorage;entities、runs 和 requests 不得以它作为持久化权威。
+
+#### Project 工作台视图刷新恢复
+
+刷新页面后必须恢复刷新前的 Project 工作台视图,但不恢复任何列的滚动位置。持久化对象使用独立、带版本的投影,不能直接序列化整个 `ThreadChatUiState`:
+
+```ts
+type ThreadWorkbenchSnapshotV1 = {
+  schemaVersion: 1
+
+  /** Root 右侧的物理列、当前 Thread、折叠态和物理列宽。 */
+  columnSlots: ThreadColumnSlot[]
+
+  /** 刷新前接收全局快捷键和工具栏操作的列。 */
+  focusedSlotId: "root" | ColumnSlotId
+
+  /** 固定 Root 列的显式宽度;null 表示自动均分。 */
+  rootColumnWidthPx: number | null
+
+  forceColumnCount: number | null
+  placementMode: "replace" | "fold"
+  viewMode: "columns" | "canvas"
+
+  /** Canvas 节点的设备本地摆放;不存在的 Thread 使用自动布局。 */
+  canvasPins: Record
+}
+```
+
+该投影按 `projectId + schemaVersion` 写入设备 localStorage,并采用防抖保存。Bootstrap 成功后才能恢复,恢复时必须:
+
+1. 只保留 topology 中仍然存在、属于当前 Project 且不是 Root 的 Slot Thread。
+2. 去除重复或非法的 `slotId`、重复 `threadId`、非法宽度和无效 Canvas pin。
+3. 如果 `focusedSlotId` 已失效或指向折叠列,则回退到相邻未折叠列;仍无候选时回退 `"root"`。
+4. 恢复 Slot、宽度、折叠状态、焦点、列数偏好、放置模式、Columns/Canvas 模式与 Canvas pin。
+5. 不恢复滚动条位置、DOM 引用、弹层坐标、动画状态、文本选区、Switcher/Help 临时打开态、请求错误、命令 busy 状态、流缓冲或 Generation 连接。
+6. Composer 草稿不是“视图恢复”的一部分;如果以后要求跨刷新草稿恢复,必须使用独立草稿投影,不得混入本 Snapshot。
+
+localStorage 不可用、内容损坏或版本未知时,必须安全回退到默认视图:只显示 Root、`focusedSlotId="root"`,其余使用当前 UI 默认值。恢复工作台视图不代表恢复服务端内容;Message 与 Run 仍然只能来自 Bootstrap、MessageBundle 和生成事件。
+
+`artifactSummary` 的合并必须比较 `changeSequence`:只接受大于当前值的快照;相同 sequence 的内容必须完全一致;更小的乱序快照必须忽略。这个游标仅解决多个生成事件乱序覆盖页面统计的问题,不是 Project/Thread revision,客户端不得在 Command 中提交它。
+
+### D4. Provider、Runtime 与当前 Project 选择
+
+Provider 解决的是“组件树应该使用哪个 Store 实例”,不是第三套业务 State。当前 Project 身份只来自 `/thread-chat/{projectId}` 的路由参数;Catalog 和 AppShellUi 都不保存 `selectedProjectId`。
+
+页面路径属于客户端 Router,不属于后端领域契约。所有 ThreadChat 页面导航必须通过同一个集中式路由构造器完成,Application Command、Hook 和组件不得自行拼接 URL:
+
+```ts
+type ThreadChatRoutes = {
+  /** 无持久化 Project 身份的本地草稿入口。 */
+  newProject: () => string
+
+  /** 根据服务端返回的资源 ID 构造客户端页面路径。 */
+  project: (projectId: ProjectId) => string
+}
+
+const threadChatRoutes: ThreadChatRoutes = {
+  newProject: () => "/thread-chat/new",
+  project: (projectId) =>
+    `/thread-chat/${encodeURIComponent(projectId)}`,
+}
+```
+
+`ThreadChatRoutes` 是客户端 navigation/router 模块的 capability,不进入 Zustand State,也不进入 API DTO。服务端只返回 `project.id`;Web、CLI、MCP 或未来其他客户端各自决定如何呈现或导航该资源。路由结构变化时,只修改客户端路由模块及 Router 配置,不修改后端创建命令。
+
+#### Runtime 类型
+
+```ts
+type ThreadChatAppCommands = {
+  /** 加载或继续加载轻量 Project Catalog。 */
+  loadProjectCatalog: (input?: { reset?: boolean }) => Promise
+
+  /** 设置 pendingProjectId 后执行路由跳转;不直接选择 Project Store。 */
+  navigateToProject: (projectId: ProjectId) => void
+
+  /** Project 删除成功后更新 Catalog,并在当前路由命中时导航离开。 */
+  deleteProject: (projectId: ProjectId) => Promise
+}
+
+type ThreadChatProjectCommands = {
+  /** 只在未 seed、未 ready 时加载当前 Provider Project 的 Bootstrap。 */
+  loadProjectBootstrap: () => Promise
+  ensureThreadMessages: (threadId: ThreadId) => Promise
+  ensureArtifact: (artifactId: ArtifactId) => Promise
+  updateProject: (patch: PatchProjectRequest) => Promise
+  updateThread: (threadId: ThreadId, patch: PatchThreadRequest) => Promise
+  archiveThread: (threadId: ThreadId) => Promise
+  sendMessage: (threadId: ThreadId, parts: UIMessage["parts"]) => Promise
+  forkThread: (input: ForkThreadRequest) => Promise
+  editLastUserMessage: (input: EditMessageRequest) => Promise
+  regenerateAssistant: (input: RegenerateMessageRequest) => Promise
+  submitFeedback: (input: PutMessageFeedbackRequest) => Promise
+
+  /** 唯一会请求服务端停止 Run 的入口;关闭连接不得调用它。 */
+  stopAssistant: (assistantMessageId: MessageId) => Promise
+}
+
+type GenerationCoordinator = {
+  /** 扫描已加载 queued/running Run,并按 assistantMessageId 去重订阅。 */
+  resumeLoadedRuns: () => void
+
+  /** 从 Store 中现有 eventSequence 建立或复用一条事件连接。 */
+  subscribeAssistant: (assistantMessageId: MessageId) => void
+
+  /** 只断开浏览器订阅;不调用 Stop API。 */
+  unsubscribeAssistant: (assistantMessageId: MessageId) => void
+
+  /** Runtime 销毁时关闭全部客户端连接与 flush 调度。 */
+  destroy: () => void
+}
+
+type ThreadMessageLoader = {
+  /**
+   * 确保指定 Thread 的 MessageBundle 已加载;ready 时直接返回,
+   * 同一 threadId 已在飞时复用同一个 Promise。
+   */
+  ensure: (threadId: ThreadId) => Promise
+
+  /** Runtime 销毁时中止当前 Project 的全部 Message Query。 */
+  destroy: () => void
+}
+
+type ThreadChatAppRuntime = {
+  /** Provider-scoped App Store;不是模块级服务器单例。 */
+  appStore: StoreApi
+
+  /** 管理 ProjectRuntime 身份和从 /new 到已创建 Project 路由的一次性交接。 */
+  projectRuntimeRegistry: ProjectRuntimeRegistry
+
+  /** 命令工厂内部共享的已校验 API capability;React 组件不得直接调用。 */
+  api: ThreadChatApiCapabilities
+
+  /** 只操作 Catalog、AppShell 和跨 Project 路由的 App 级命令。 */
+  commands: ThreadChatAppCommands
+
+  /** AppProvider 卸载时销毁所有未释放 Runtime 和客户端连接。 */
+  destroy: () => void
+}
+
+type ThreadChatProjectRuntime = {
+  /** Runtime 与路由、Store.entities.project 必须始终一致的身份。 */
+  projectId: ProjectId
+
+  /** 当前 Project 唯一的 Zustand Store 实例。 */
+  store: StoreApi
+
+  /** 已绑定 api、store 和 coordinator 的 Project 级业务命令集合。 */
+  commands: ThreadChatProjectCommands
+
+  /**
+   * 管理 threadId → in-flight Promise/AbortController 的非序列化加载器。
+   * Promise 与 AbortController 不进入 Zustand;UI 只订阅 requests slice。
+   */
+  threadMessageLoader: ThreadMessageLoader
+
+  /**
+   * 管理该 Project 内 assistantMessageId → 事件连接的客户端协调器。
+   * 连接对象不进入 Zustand State;断开只取消订阅,不发送 Stop。
+   */
+  generationCoordinator: GenerationCoordinator
+}
+
+type ProjectRuntimeRegistry = {
+  /**
+   * `/new` 创建成功后用 CreationBundle 建立已初始化 Runtime,等待目标路由 Provider 接管。
+   * 这是一次性 navigation handoff,不是无边界 Project cache。
+   */
+  seedFromCreation: (bundle: CreationBundle) => ThreadChatProjectRuntime
+
+  /**
+   * 为路由 projectId 取得唯一 Runtime:优先消费 seed;否则创建空 Store 等待 Bootstrap。
+   * 同一 Provider 生命周期内重复 acquire 必须返回同一实例。
+   */
+  acquire: (projectId: ProjectId) => ThreadChatProjectRuntime
+
+  /**
+   * ProjectProvider 卸载时释放租约、关闭浏览器事件连接并销毁无租约 Runtime。
+   * 它不得调用服务端 Stop;后台 MessageRun 继续运行。
+   */
+  release: (projectId: ProjectId) => void
+
+  /** 只用于诊断和测试是否已有实例,不得成为 UI 选择当前 Project 的方式。 */
+  peek: (projectId: ProjectId) => ThreadChatProjectRuntime | null
+}
+```
+
+Registry 不是 Zustand Store,也不参与 selector。它只管理包含 Store、Commands 和连接协调器的非序列化 Runtime 对象。P0 在最后一个 Provider release 后立即销毁 ProjectRuntime,不建立 LRU 多 Project 缓存。
+
+#### Provider 类型与职责
+
+```ts
+type ThreadChatRoute =
+  /** 无持久化实体 ID 的首次创建入口。 */
+  | { kind: "new" }
+
+  /** projectId 直接来自 `/thread-chat/{projectId}` 路由参数。 */
+  | { kind: "project"; projectId: ProjectId }
+
+type ThreadChatAppProviderProps = {
+  children: ReactNode
+
+  /** 可选的首屏 Catalog 数据,用于服务端渲染与客户端 hydration 一致。 */
+  initialCatalog?: ListProjectsResult
+}
+
+type ThreadChatProjectProviderProps = {
+  children: ReactNode
+
+  /** 直接来自路由 params;不是 Catalog Store 的 selected 状态。 */
+  projectId: ProjectId
+}
+
+type NewProjectDraftProviderProps = {
+  children: ReactNode
+
+  /** `/new` 可选初始模型偏好;这里不存在 Project/Thread 实体 ID。 */
+  initialRequestedModelId?: string
+}
+```
+
+`ThreadChatRoute` 是 Router 的解析结果,不存入 Zustand。它决定挂载 DraftProvider 还是 ProjectProvider,也因此是当前 Project 身份的唯一来源。
+
+`ThreadChatAppProvider`:
+
+- 在 ThreadChat App 外壳边界用 `createStore` 创建一次 `ThreadChatAppStore`、Registry 和 API capabilities。
+- 通过 React Context 向 Sidebar 与 Project route 提供 `ThreadChatAppRuntime`。
+- “App 级”只表示在 ThreadChat 路由之间持续存在;不得在 Next.js 服务端用模块变量跨请求共享。
+
+`ThreadChatProjectProvider`:
+
+- 使用 route `projectId` 调用 `registry.acquire(projectId)`,并向下提供唯一 `ThreadChatProjectRuntime`。
+- Provider 必须以 `key={projectId}` 挂载;路由身份变化时旧 Provider release,新 Provider acquire。
+- 若 Runtime 尚未由 `/new` seed,则发起 Bootstrap;若已 seed,则直接使用 CreationBundle 内容并恢复 Run 订阅。
+- Bootstrap DTO 的 `project.id` 与任何 Thread `projectId` 不匹配 Provider 身份时,必须拒绝合并。
+- 当前 Provider 就绪或失败时,如果 `pendingProjectId` 等于自己的 projectId,必须将它清为 null。
+
+`NewProjectDraftProvider`:
+
+- 只存在于 `/thread-chat/new`,保存无实体 ID 的本地草稿与提交状态。
+- 创建成功后调用 `registry.seedFromCreation(bundle)`,再执行 `router.replace(threadChatRoutes.project(bundle.project.id))`。
+- 目标 ProjectProvider acquire 同一 seeded Runtime,因此无需为了路由切换重新请求 Bootstrap。
+
+Provider 必须只创建一次 vanilla Store/Runtime,不能在 React 重渲染时重新执行 factory:
+
+```tsx
+const ThreadChatAppRuntimeContext =
+  createContext(null)
+
+const ThreadChatProjectRuntimeContext =
+  createContext(null)
+
+function ThreadChatAppProvider({
+  children,
+  initialCatalog,
+}: ThreadChatAppProviderProps) {
+  /** useState initializer 保证同一次 Provider 生命周期只有一个 AppRuntime。 */
+  const [runtime] = useState(() =>
+    createThreadChatAppRuntime({ initialCatalog }),
+  )
+
+  useEffect(() => {
+    return () => runtime.destroy()
+  }, [runtime])
+
+  return (
+    
+      {children}
+    
+  )
+}
+
+function ThreadChatProjectProvider({
+  children,
+  projectId,
+}: ThreadChatProjectProviderProps) {
+  const appRuntime = useThreadChatAppRuntime()
+
+  /** 外层 key={projectId} 保证 initializer 捕获的身份不会在实例内漂移。 */
+  const [runtime] = useState(() =>
+    appRuntime.projectRuntimeRegistry.acquire(projectId),
+  )
+
+  useProjectRuntimeLifecycle(runtime)
+
+  useEffect(() => {
+    return () => {
+      appRuntime.projectRuntimeRegistry.release(projectId)
+    }
+  }, [appRuntime, projectId])
+
+  return (
+    
+      {children}
+    
+  )
+}
+```
+
+#### Provider 组合
+
+```tsx
+
+  
+
+  {route.kind === "new" ? (
+    
+      
+    
+  ) : (
+    
+      
+    
+  )}
+
+```
+
+#### Runtime 与 Store Hooks
+
+```ts
+function useThreadChatAppRuntime(): ThreadChatAppRuntime
+function useThreadChatProjectRuntime(): ThreadChatProjectRuntime
+
+function useThreadChatAppStore(
+  selector: (state: ThreadChatAppStore) => T,
+): T {
+  return useStore(useThreadChatAppRuntime().appStore, selector)
+}
+
+function useThreadChatStore(
+  selector: (state: ThreadChatProjectStore) => T,
+): T {
+  return useStore(useThreadChatProjectRuntime().store, selector)
+}
+
+function useThreadChatCommands(): ThreadChatProjectCommands {
+  return useThreadChatProjectRuntime().commands
+}
+```
+
+两个 Runtime Hook 是基础设施 Hook,供 Store/Command/Lifecycle Hook 组合使用;普通 UI 组件应优先使用细粒度 Selector Hook 和 Command Hook,不直接取得 `api`、Registry 或 Coordinator。
+
+三个“当前”必须分开:
+
+```text
+current Project       = URL routeProjectId
+current Project Store = ProjectProvider 当前提供的 Runtime.store
+focused Column        = Runtime.store.ui.focusedSlotId
+focused Thread        = 由 focusedSlotId 对应的 Root/Slot 派生
+```
+
+一个 Project 可以同时展示多个 `columnSlots`,所以没有全局唯一的“selected Thread 内容”。ThreadColumn 的命令始终使用自己 Slot 当前的 `threadId`;`focusedSlotId` 只服务全局快捷键、工具栏和无列级参数的 UI 操作。切换本列 Thread 时 Slot 身份与焦点保持不变。
+
+#### Provider 生命周期
+
+```mermaid
+sequenceDiagram
+    participant R as Router
+    participant AP as ThreadChatAppProvider
+    participant PP as ThreadChatProjectProvider
+    participant Reg as ProjectRuntimeRegistry
+    participant RT as ProjectRuntime
+    participant API as Bootstrap API
+
+    R->>PP: mount(projectId from URL)
+    PP->>Reg: acquire(projectId)
+    alt 已由 /new seed
+        Reg-->>PP: initialized Runtime
+    else 首次打开已有 Project
+        Reg-->>PP: empty Runtime
+        PP->>API: GET /projects/{projectId}/bootstrap
+        API-->>PP: validated ProjectBootstrap
+        PP->>RT: mergeBootstrap Store Action
+    end
+    PP->>RT: resume queued/running subscriptions
+    R->>PP: unmount / projectId changed
+    PP->>Reg: release(projectId)
+    Reg->>RT: close client subscriptions + destroy Store
+    Note over Reg,RT: 不发送服务端 Stop
+```
+
+### D5. 明确什么不能存进 Store
+
+以下数据必须用 selector 计算:
+
+```text
+rootThreadId
+childThreadIds
+threadDepth
+breadcrumbs
+activeMessages
+visibleThreadIds
+threadTreeRows
+branchCount
+artifactCount
+markdownArtifactCount
+canFork
+canEdit
+canRegenerate
+threadColumnView
+threadColumnHeaderView
+projectHeaderView
+```
+
+页面统计的来源必须明确区分:`threadCount` 与 `branchCount` 从完整 `threadsById` topology 派生;`artifactCount` 与 `markdownArtifactCount` 从服务端 `artifactSummary` 派生。客户端不得用局部加载的 `artifactsById` 长度冒充 Project 总数。
+
+```ts
+type ProjectHeaderView = {
+  displayTitle: string
+  threadCount: number
+  branchCount: number
+  artifactCount: number
+  markdownArtifactCount: number
+}
+
+type ThreadColumnHeaderView = {
+  /** `"root"` 表示固定 Root 列;其余值标识稳定物理 Slot。 */
+  slotId: "root" | ColumnSlotId
+  threadId: ThreadId
+  title: string
+  isRoot: boolean
+  depth: number
+
+  /** 从 Root 到当前 Thread 的 lineage;不是 Thread Entity 内的持久化数组。 */
+  breadcrumbs: Array<{
+    threadId: ThreadId
+    title: string
+    isCurrent: boolean
+  }>
+
+  /** 只包含直接 Child;完整 Project 搜索仍由全局 Thread 切换器承担。 */
+  directChildCount: number
+  directChildren: Array<{
+    threadId: ThreadId
+    title: string
+    isOpen: boolean
+  }>
+
+  /**
+   * Root 为 null。needs_load 表示仅因 Parent Message 尚未加载而无法判断,
+   * 不得把“本地不存在”误判成来源失效。
+   */
+  forkSource: {
+    parentThreadId: ThreadId
+    sourceMessageId: MessageId
+    availability: "available" | "needs_load" | "unavailable"
+  } | null
+
+  canSwitch: boolean
+  canCollapse: boolean
+}
+```
+
+核心 selectors:
+
+```ts
+selectRootThreadId(state): ThreadId
+selectChildThreadIds(state, parentThreadId): ThreadId[]
+selectThreadLineage(state, threadId): ThreadEntity[]
+selectActiveMessages(state, threadId): MessageEntity[]
+selectMessageView(state, messageId): MessageView
+selectForkAvailability(state, messageId): ForkAvailability
+selectThreadColumnView(state, slotId): ThreadColumnView
+selectThreadColumnHeaderView(state, slotId): ThreadColumnHeaderView
+selectProjectTreeRows(state): ProjectTreeRow[]
+selectVisibleThreadIds(state): ThreadId[]
+selectVisibleColumns(state): ThreadColumnView[]
+selectFocusedThreadId(state): ThreadId | null
+selectProjectHeaderView(state): ProjectHeaderView
+```
+
+`selectActiveMessages` 的唯一规则:
+
+```text
+message.threadId === threadId
+AND supersededAt === null
+ORDER BY sequence ASC
+```
+
+ViewModel 可以组合实体、Run、Artifact 和 UI,但永远不是 API 请求体或持久化对象。
+
+### D6. Store Actions 与 Application Commands 取代 monolithic chat-controller
+
+#### Store Actions
+
+Store Action 与对应 Zustand Store/slice 共置,只执行同步、可测试的 State Transition,并且是客户端唯一允许调用 `set`/`setState` 的位置。MVP 至少包含:
+
+```ts
+mergeBootstrap          → 跨 entities/runs/requests 原子初始化 Project
+setBootstrapLoadState   → 只设置 Bootstrap loading/error;ready 由 mergeBootstrap 提交
+applyMessageBundle      → 合并一个 Thread 的有效 Message 与 Run;Artifact 只保留在 Message parts 的 ID 引用
+setThreadMessageLoadState → 独立设置某个 Thread 的 loading/error;ready 由 Bundle 原子提交
+applyArtifact/setArtifactLoadState → 用户打开 Drawer 时按 ID 合并独立 Artifact 内容与加载状态
+applyMessageCreationBundle → 合并发送命令返回的 user、assistant 与 Run
+applyThreadCreated      → 合并服务端创建的 Child Thread
+applyReplacementBundle  → 原子标记 superseded 并合并 replacement 与新 Run
+applyRunEvent           → 更新对应 assistantMessageId,并在事件携带时合并 Artifact Summary
+openThread/closeColumn  → 根据现有 placementMode 更新稳定 columnSlots
+switchColumnThread      → 只替换 Slot 的 threadId,保留 slotId、宽度和物理位置
+focusColumn             → 更新全局快捷键与工具栏的物理列焦点
+commitColumnWidths      → 原子提交分割线左右物理列的最终宽度或复位为自动均分
+restoreWorkbenchSnapshot/resetWorkbenchToDefault → Bootstrap 后恢复合法视图或使用当前默认值
+```
+
+合并类 Store Action 是内部写入口,不直接暴露给 UI;`openThread`、`switchColumnThread`、`closeColumn`、`focusColumn` 这类纯本地 UI Action 可以由 Command Hook 直接调用。Store Action 不调用 API、不导航、不建立 SSE,并且不得原地改写已确认实体内容。
+
+这遵循 Zustand 将 state 与 actions 共置、使用 `set`/`setState` 提交更新以及用 slices 组织大型 Store 的推荐方式。我们额外规定 Store Action 保持同步、IO 放入 Application Command;这是本项目为了固定副作用边界采用的更严格约束,不是 Zustand 强制要求。
+
+#### Application Commands
+
+目标结构:
+
+```text
+client/application/
+├── project-commands
+│   ├── loadProjectCatalog
+│   ├── createProjectWithFirstMessage
+│   ├── updateProject
+│   ├── archiveProject
+│   └── deleteProject
+├── thread-commands
+│   ├── loadProjectBootstrap
+│   ├── ensureThreadMessages
+│   ├── forkThread
+│   ├── updateThread
+│   └── archiveThread
+├── message-commands
+│   ├── sendMessage
+│   ├── editLastUserMessage
+│   ├── regenerateAssistant
+│   └── submitFeedback
+└── generation-coordinator
+    ├── resumeLoadedRuns
+    ├── subscribeAssistant
+    ├── handleEvent
+    ├── unsubscribeAssistant
+    └── destroy
+```
+
+Application Command 的共同算法:
+
+```text
+读取最小本地前置条件
+→ 调用 Store Action 设置 scope busy(只防当前页面重复提交)
+→ 调用一个 API capability
+→ 校验响应 DTO
+→ 调用一次语义化 Store Action 原子合并服务端事实
+→ 启动后续生命周期动作(订阅、导航、打开列)
+→ 调用 Store Action 清理 busy / 写结构化错误
+```
+
+Application Command 不生成实体 ID、不构造 Prompt、不提交 BaseContext、不序列化整棵 Project,也不直接调用 `set`/`setState`。
+
+### D7. `/thread-chat/new` 是草稿模式,不是 Project
+
+```ts
+type NewProjectDraftState = {
+  /** 明确表示本 Store 没有持久化 Project 身份,禁止把 "new" 当作 ProjectId。 */
+  kind: "new"
+
+  /** 首次发送前的 AI SDK v7 输入草稿;API 失败时必须保留。 */
+  draftParts: UIMessage["parts"]
+
+  /** 用户为首次回答选择的模型偏好;实际模型仍以服务端 Run 为准。 */
+  requestedModelId: string
+
+  /** 只描述首次创建命令,不代表任何 Project/Message 运行状态。 */
+  status: "idle" | "submitting" | "error"
+
+  /** 创建命令的可展示错误;idle/submitting 时为 null。 */
+  error: ClientError | null
+}
+
+type NewProjectDraftActions = {
+  setDraftParts: (parts: UIMessage["parts"]) => void
+  setRequestedModelId: (modelId: string) => void
+  markSubmitting: () => void
+  markError: (error: ClientError) => void
+  resetSubmission: () => void
+}
+
+type NewProjectDraftStore = NewProjectDraftState & NewProjectDraftActions
+```
+
+`NewProjectDraftStore` 由 `NewProjectDraftProvider` 创建并随 `/new` 页面销毁,不放进 ThreadChatAppStore,也不插入 ThreadChatProjectStore.entities。UI 通过 `NewProjectScreen` 复用 Composer 和空白列外观,但传递的是 `onSubmitDraft`,不是假的 threadId。
+
+从空白 Draft 提交、服务端原子创建、Registry seed、目标 ProjectProvider 接管、无空白帧路由交接和完整 AI 回复事件流程见 [`/thread-chat/new` 首条消息与 AI 回复生命周期](./design/new-project-first-message-lifecycle.md)。
+
+```mermaid
+sequenceDiagram
+    participant U as User
+    participant UI as /thread-chat/new
+    participant A as ProjectCommand
+    participant Reg as ProjectRuntimeRegistry
+    participant S as POST /api/v1/projects
+    participant DB as Database
+    participant W as MessageRun Worker
+
+    U->>UI: 输入并发送第一条 Message
+    UI->>A: createProjectWithFirstMessage(parts, model)
+    A->>S: 不携带任何新实体 ID
+    S->>DB: BEGIN
+    S->>DB: Project + Root + U1 + A1 + queued Run
+    S->>DB: COMMIT
+    S-->>A: 201 CreationBundle
+    S->>W: commit 后唤醒 Run
+    A->>A: threadChatRoutes.project(project.id)
+    A->>Reg: seedFromCreation(CreationBundle)
+    Reg-->>A: initialized ProjectRuntime
+    A->>UI: router.replace(projectUrl)
+    A->>Reg: Runtime 按 A1 + eventSequence 订阅
+```
+
+如果事务提交前失败,保留草稿并停留 `/new`。如果服务端已经提交但响应丢失,P0 的按钮防抖无法证明是否创建成功;这是暂缓通用幂等带来的已知限制。
+
+### D8. 已有 Project 的 Bootstrap 生命周期
+
+`ProjectBootstrap`:
+
+```ts
+type ProjectBootstrap = {
+  /** 当前 Provider projectId 对应的完整 Project 实体。 */
+  project: ProjectEntity
+
+  /** 当前 Project 的全量轻量 Thread topology;不包含 Branch Message。 */
+  threadTopology: ThreadEntity[]
+
+  /** Project 全量 Artifact 的服务端统计投影,不受本次内联 Artifact 窗口影响。 */
+  artifactSummary: ProjectArtifactSummary
+
+  /** 唯一 Root Thread 的首屏 Message window。 */
+  initialThread: ThreadMessageBundle
+}
+
+type ThreadMessageBundle = {
+  /** Bundle 所属 Thread;其余 Message 必须全部匹配该 ID。 */
+  threadId: ThreadId
+
+  /** 当前有效时间线窗口,按 sequence 升序;不返回 superseded Message。 */
+  messages: MessageEntity[]
+
+  /** 必须且只包含本窗口 assistant Message 对应的 Run。 */
+  assistantRuns: AssistantRunState[]
+
+  /** true 表示服务端还存在更早有效 Message;MVP 不自动加载。 */
+  hasOlderMessages: boolean
+
+  /** 返回窗口首尾 sequence;空窗口时均为 null。 */
+  oldestReturnedSequence: number | null
+  newestReturnedSequence: number | null
+}
+```
+
+生命周期:
+
+```text
+解析 URL projectId
+→ ThreadChatProjectProvider acquire(projectId)
+→ 若 Runtime 已由 /new seed:跳过 Bootstrap
+→ 否则 GET ProjectBootstrap
+→ 校验唯一 Root、Provider projectId、所有 Thread projectId 和 Artifact Summary
+→ mergeBootstrap 一次合并 entities/runs/requests/readModels state
+→ 读取、迁移并过滤该 Project 的 localStorage ThreadWorkbenchSnapshot
+→ 默认 focusedSlotId="root";Snapshot 合法时恢复列槽、列宽、折叠态、焦点和视图模式
+→ 立即渲染已 ready 的 Root 和各 Branch Column Loading Shell
+→ 对恢复出的每个 Branch 非阻塞、并行调用 ensureThreadMessages(threadId)
+→ 每个 Branch 独立进入 ready 或 error;一个失败不得阻塞其他列
+→ 每个 MessageBundle 合并后恢复其中 queued/running Runs
+```
+
+打开尚未加载的 Branch 时,`ensureThreadMessages(threadId)` 请求一个 ThreadMessageBundle;加载状态为 ready 时直接复用,不重复请求。
+
+同一 `threadId` 的并发 ensure 必须复用 `ThreadMessageLoader` 中同一个 in-flight Promise;不同 Thread 可以并行加载。关闭或切换 Column 不取消已经开始的 Thread Query,成功结果继续按 `threadId` 合并并成为当前 Project Runtime 的缓存;只有 ProjectRuntime 销毁才统一 Abort。Abort 不得写入可重试 error。
+
+Message Query 与 Assistant Run 是两个正交状态:Column 只有在 Message Query ready 后才有完整 Message 时间线;若 Bundle 中某个 Run 为 queued/running,则先展示其 checkpoint,再由 GenerationCoordinator 从 eventSequence 恢复。Loader 的 Promise、AbortController 和 in-flight Map 不进入 Zustand。
+
+完整状态机、刷新并行恢复时序、去重与迟到响应规则见 [Thread Message 异步加载设计](./design/thread-message-loading.md)。
+
+已有 Project 的完整冷启动、无 Workbench Snapshot、有 Snapshot、多 Branch 并行加载和 Runtime Message Cache 分支见 [打开已有 Project 生命周期](./design/open-existing-project-lifecycle.md)。
+
+MVP 返回最新最多 200 条有效 Message,并按 sequence 升序交给客户端。`hasOlderMessages` 只保留能力边界,当前 UI 不实现树内自动分页替换。Markdown tool result 在 Message parts 中保存 `artifactId`;ThreadMessageBundle 不返回 Markdown 正文。只有用户打开 Artifact 时才按 ID 加载独立 Artifact 内容。
+
+### D9. 核心 Application Command 伪代码
+
+#### 首次创建
+
+```ts
+async function createProjectWithFirstMessage(input: {
+  parts: UIMessage["parts"]
+  requestedModelId?: string
+}) {
+  const { appStore, projectRuntimeRegistry, api } = appRuntime
+  newDraftStore.getState().markSubmitting()
+
+  try {
+    const bundle = await api.createProject({
+      initialMessage: { parts: input.parts },
+      requestedModelId: input.requestedModelId,
+    })
+    const projectUrl = threadChatRoutes.project(bundle.project.id)
+
+    const runtime = projectRuntimeRegistry.seedFromCreation(bundle)
+    appStore.getState().upsertProjectSummary(toProjectSummary(bundle.project))
+    appStore.getState().setProjectRoutePending(bundle.project.id)
+
+    runtime.generationCoordinator.subscribeAssistant(
+      bundle.assistantRun.assistantMessageId,
+    )
+    router.replace(projectUrl)
+  } catch (error) {
+    newDraftStore.getState().markError(normalizeError(error))
+  }
+}
+```
+
+#### 发送后续消息
+
+```ts
+async function sendMessage(threadId: ThreadId, parts: UIMessage["parts"]) {
+  const scope = `send:${threadId}`
+  const state = store.getState()
+  if (state.requests.commandByScope[scope]?.status === "submitting") return
+  state.setCommandState(scope, { status: "submitting" })
+
+  try {
+    const bundle = await api.sendMessage({ threadId, parts })
+    store.getState().applyMessageCreationBundle(bundle)
+    store.getState().setCommandState(scope, null)
+    generationCoordinator.subscribeAssistant(
+      bundle.assistantRun.assistantMessageId,
+    )
+  } catch (error) {
+    store.getState().setCommandState(scope, {
+      status: "error",
+      error: normalizeError(error),
+    })
+  }
+}
+```
+
+服务端响应前只显示 submitting,不创建 optimistic Message。这样牺牲几十毫秒即时气泡,换取零临时 ID、零 ID 替换和明确的服务端身份。
+
+#### Fork
+
+```ts
+async function forkThread(input: {
+  /** 纯客户端来源物理列,只用于成功后的工作台放置,不进入 API Body。 */
+  sourceSlotId: "root" | ColumnSlotId
+  sourceThreadId: ThreadId
+  sourceMessageId: MessageId
+  anchor?: TextAnchor
+}) {
+  const availability = selectForkAvailability(store.getState(), input.sourceMessageId)
+  if (!availability.allowed) return availability.failure
+
+  const child = await api.forkThread({
+    sourceThreadId: input.sourceThreadId,
+    sourceMessageId: input.sourceMessageId,
+    anchor: input.anchor,
+  })
+  store.getState().applyThreadCreated(child)
+  store.getState().openThread(child.id, input.sourceSlotId)
+}
+```
+
+#### Regenerate
+
+```ts
+async function regenerateAssistant(
+  sourceAssistantMessageId: MessageId,
+  requestedModelId?: string,
+) {
+  const result = await api.regenerate({
+    sourceAssistantMessageId,
+    requestedModelId,
+  })
+
+  store.getState().applyReplacementBundle(result)
+  generationCoordinator.subscribeAssistant(
+    result.assistantRun.assistantMessageId,
+  )
+}
+```
+
+### D10. Hooks 是窄胶水,不是业务层
+
+#### Store 基础绑定
+
+```ts
+useThreadChatAppStore(selector)
+useThreadChatStore(selector)
+useThreadChatAppRuntime()
+useThreadChatProjectRuntime()
+```
+
+#### Selector Hooks
+
+```text
+useProjectCatalog()
+useAppShellUi()
+useProject()
+useProjectTarget()
+useThread(threadId)
+useThreadMessages(threadId)
+useThreadColumnView(slotId)
+useThreadColumnHeaderView(slotId)
+useProjectTreeRows()
+useAssistantRun(assistantMessageId)
+useArtifact(artifactId)
+useForkAvailability(messageId)
+useVisibleThreadColumns()
+useProjectHeaderView()
+useFocusedColumnId()
+useFocusedThreadId()
+```
+
+#### Command Hooks
+
+```text
+useAppShellCommands()
+useProjectCommands()
+useThreadCommands(threadId)
+useMessageCommands(messageId)
+```
+
+Command Hook 只绑定 Application Command、纯本地 Store Action 和作用域 ID,例如:
+
+```ts
+function useThreadCommands(threadId: string) {
+  const commands = useThreadChatCommands()
+  return useMemo(() => ({
+    send: (parts) => commands.sendMessage(threadId, parts),
+    fork: (messageId, anchor) => commands.forkThread({
+      sourceThreadId: threadId,
+      sourceMessageId: messageId,
+      anchor,
+    }),
+    stop: (assistantMessageId) => commands.stopAssistant(assistantMessageId),
+  }), [commands, threadId])
+}
+```
+
+#### 生命周期 Hooks
+
+```text
+useProjectRuntimeLifecycle()      // 仅由 ThreadChatProjectProvider 调用
+useEnsureThreadMessagesLoaded(threadId)
+useActiveGenerationSubscriptions()
+useWorkbenchPersistence()        // projectId 从当前 ProjectRuntime 取得
+useEnsureArtifactLoaded(artifactId) // 仅在 Artifact Drawer 打开时按 ID 加载
+```
+
+生命周期 Hook 可以使用 Effect;Root/children/messages/canFork 等衍生状态禁止用 Effect 镜像。
+
+### D11. API 通用契约
+
+Base URL:`/api/v1`。
+
+共同规则:
+
+- Session 决定 actor;body 不接受 ownerUserId 作为授权依据。
+- 客户端只传既有资源 ID;新实体 ID 全由服务端生成。
+- Request/Response 使用共享 Zod schema;Message.parts 兼容 AI SDK v7。
+- 普通 JSON 成功响应返回 `{ data: T }` 中的服务端权威 DTO,不只返回 `{ ok: true }`;204 和事件流除外。
+- 错误统一为 `{ error: { code, message, details? } }`。
+- Request Object 严格拒绝未声明字段,避免客户端注入新实体 ID 或服务端内部事实。
+- append/Fork/generation 不使用 Thread/Project revision。
+- P0 不强制 Idempotency-Key;客户端 busy guard 只防重复点击。
+- `requestedModelId` 是本次运行请求,服务端校验后在 AssistantRunState 返回实际 `modelId`。
+
+推荐 HTTP 状态:
+
+| 状态 | 语义 |
+|---:|---|
+| 200 | Query、更新或幂等终态操作成功 |
+| 201 | 创建 Project、Fork 或新 Message bundle 成功 |
+| 400 | DTO 形状或字段非法 |
+| 401 | Session 无效 |
+| 403/404 | 无权访问或资源不存在 |
+| 409 | 当前领域状态不允许该命令 |
+| 422 | 资源存在但不满足业务资格 |
+
+### D12. API 契约索引
+
+API 总表、参数之间的决定性关系、完整输入输出 Schema、错误码与接口 Case 统一由 [ThreadChat V1 API 详细合同](./design/api-contracts.md) 管理。
+
+### D13. 手动验收案例
+
+#### Case 1:首次创建
+
+```text
+1. 打开 /thread-chat/new。
+2. 不发送,刷新或离开。
+   预期:Project 列表不新增记录。
+3. 返回 /new,发送“帮我设计支付系统”。
+   预期:201;返回 Project/Root/U1/A1/Run;URL replace 为 projectId。
+4. 查询 Bootstrap。
+   预期:能读到相同 ID,Run 为 queued/running/completed 之一。
+```
+
+#### Case 2:Fork 资格
+
+```text
+1. A1 正在 running 时请求 Fork。
+   预期:409/422 fork_source_not_finalized,零 Child Thread。
+2. A1 completed 后再次请求。
+   预期:201,Child Thread 的 projectId 与 Parent 相同。
+3. 请求体伪造 baseContext/newThreadId。
+   预期:validation_error 或字段被严格拒绝。
+```
+
+#### Case 3:Regenerate replacement
+
+```text
+1. 记录 A1.id、sequence、parts。
+2. POST A1/regenerations。
+3. 预期返回 A2 + R2,A2.replacesMessageId=A1.id。
+4. 再读 Thread。
+5. 默认时间线只含 A2;审计查询中 A1 内容和 sequence 未变化。
+```
+
+#### Case 4:刷新恢复
+
+```text
+1. 发送消息并等待 Run=running。
+2. 记录 eventSequence,刷新页面。
+3. Bootstrap 返回 checkpoint + sequence。
+4. 事件订阅使用 afterEventSequence。
+5. 预期不创建第二个 Run,终态后 Message.parts 与服务端一致。
+```
+
+#### Case 5:Target
+
+```text
+1. PATCH Project,设置 ultimate、shortTerm、midTerm。
+2. 重新请求 Bootstrap。
+3. 预期完整返回 Target,其他 Project 不受影响。
+4. PATCH 只提交 customTitle。
+5. 预期 Target 保持不变。
+```
+
+### D14. Transport 后置,但边界已固定
+
+后续 Transport 只需实现:
+
+```text
+API capability interface
+→ HTTP method/path/body
+→ auth/session recovery
+→ Zod response validation
+→ ClientError normalization
+→ JSON 或 event stream 解码
+```
+
+Transport 不决定业务原子性、不访问 Zustand、不生成实体 ID、不构造 ViewModel。P0 使用 SSE 承载生成事件;未来替换 Transport 也不得改变 `assistantMessageId + eventSequence` 的逻辑契约。
+
+## Risks / Trade-offs
+
+- **[服务端响应前不插入 optimistic Message,视觉上略慢]** → 使用 thread-scoped submitting 状态和 Composer loading;换取无临时 ID、无实体替换。
+- **[P0 无 Idempotency-Key,响应丢失后首次创建可能重复]** → 当前只做 busy guard 并明确限制;V2 为有副作用命令增加幂等协议。
+- **[一个 Project 一个 Store,频繁切换会重载]** → ProjectCatalog 保留轻量列表;是否引入有界 LRU Store cache 等真实性能数据出现后决定。
+- **[messageIdsByThreadId 与 messagesById 可能不一致]** → 只允许共享 normalizer/merge 函数更新,测试去重、排序和归属不变量。
+- **[Bootstrap 同时返回 topology 与 Root bundle,DTO 较复合]** → 这是一次请求避免首屏瀑布;仍不下载 Branch Message 和大型资源。
+- **[最新 200 条隐藏更早 UI 历史]** → 返回 hasOlder/boundary 保留升级路径;Prompt 历史始终由服务端构造,不受 UI 是否加载完整影响。
+- **[Target 以后需要进度、依赖和截止时间]** → 当前 ProjectTarget 是最小结构;出现目标管理需求后新增 Goal 实体,不提前过度设计。
+
+## Migration Plan
+
+本 change 不再为同一目标建立七个额外管理性 change,统一按 `tasks.md` 的阶段门实施:
+
+1. 共享 DTO/error schemas、Transport capability 与 API 测试夹具。
+2. Project/Thread Query、Command、Artifact Query、SSE 与 Stop。
+3. 后端集成测试和 API 合同测试;全部通过前不得开始前端重构。
+4. Project-scoped Store、Runtime、Application Commands、Coordinator 与 Hooks。
+5. 使用 Ego Browser 固定现有 UI 参考截图和交互清单后,将新数据链路无损接入现有组件。
+6. 前端集成与 E2E 通过后删除旧客户端权威代码,等待 domain change 清理旧后端并先行归档。
+7. domain change 归档后归档本 change,并提炼稳定客户端架构与 API 合同。
+
+开发期间保留旧页面代码作为切换前回退边界,但不引入 feature flag、双写或新旧实体同步。P0 使用 SSE;Store 和 Application Command 可以先通过接口注入的测试 adapter 验证。
+
+## Resolved P0 Boundaries
+
+- P0 不提供“只追加 user Message”的独立命令。每次发送原子创建 `user Message + assistant Message + MessageRun`;数据库领域模型不要求角色交替,保留未来增加独立 user append 命令的能力。
+- P0 沿用 Artifact 引用关系:Markdown tool result 保存 `artifactId`,正文由独立 Artifact Query 在用户打开时加载。MessageBundle 不复制 Markdown 正文,因此不需要设计“多大才拆开”的阈值。
+- P0 生成事件使用 SSE。客户端按 `assistantMessageId + eventSequence` 恢复;未来若改用其他 Transport,不改变该逻辑契约。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/api-contracts.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/api-contracts.md
new file mode 100644
index 00000000..91a4f255
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/api-contracts.md
@@ -0,0 +1,931 @@
+# ThreadChat V1 API 详细合同
+
+本文是 `design.md` 引用的 API 可实现合同。接口实现、共享 Zod Schema、服务端集成测试和客户端 Transport 必须以本文为准;总表只用于导航。
+
+### D1. API 总表
+
+本节只是能力索引。逐接口的 Path、Query、Body、成功响应、输出类型、错误码、参数关系和手动 Case 以 [ThreadChat V1 API 详细合同](#threadchat-v1-api-详细合同) 为准。
+
+| Method / Path | 功能 | 关键输入 | 核心输出 | 用户场景 |
+|---|---|---|---|---|
+| `GET /api/v1/projects` | Project 列表 | `status?`, `limit?`, `cursor?` | `ProjectSummary[]` | 打开对话列表 |
+| `POST /api/v1/projects` | 首次发送并建 Project | `initialMessage.parts`, `requestedModelId?` | CreationBundle | `/thread-chat/new` 第一次发送 |
+| `GET /api/v1/projects/{id}/bootstrap` | 首屏恢复 | projectId | ProjectBootstrap | 打开已有 Project |
+| `PATCH /api/v1/projects/{id}` | 更新标题/Target/Instruction | patch 字段 | ProjectEntity | 修改 Project 设置 |
+| `POST /api/v1/projects/{id}/archive` | 归档 Project | projectId | ProjectEntity | 从默认列表隐藏 |
+| `POST /api/v1/projects/{id}/unarchive` | 取消归档 | projectId | ProjectEntity | 恢复 Project |
+| `DELETE /api/v1/projects/{id}` | 永久删除 | projectId;确认由 UI 在调用前完成 | 204 | 删除整个工作项 |
+| `GET /api/v1/threads/{id}/messages` | 加载一个 Thread | `limit<=200`, `beforeSequence?` | ThreadMessageBundle | 首次展开 Branch |
+| `PATCH /api/v1/threads/{id}` | 更新 Branch 标题 | `customTitle` | ThreadEntity | 重命名分支列 |
+| `POST /api/v1/threads/{id}/archive` | 归档 Thread | threadId | ThreadEntity | 收起历史分支 |
+| `POST /api/v1/threads/{id}/unarchive` | 取消归档 Thread | threadId | ThreadEntity | 恢复分支 |
+| `POST /api/v1/threads/{id}/messages` | 发送并启动回答 | `parts`, `requestedModelId?` | MessageCreationBundle | 普通继续对话 |
+| `POST /api/v1/threads/{id}/forks` | 创建 Child Thread | `sourceMessageId`, `anchor?` | ThreadEntity | 从某条 finalized Message 分叉 |
+| `POST /api/v1/messages/{id}/edits` | Edit 最后一条 user | `parts`, `requestedModelId?` | ReplacementBundle | 修改最后问题并重答 |
+| `POST /api/v1/messages/{id}/regenerations` | Regenerate assistant | `requestedModelId?` | ReplacementBundle | 重新生成最后回答 |
+| `PUT /api/v1/messages/{id}/feedback` | 设置评价 | `positive/negative/null` | FeedbackDTO | 点赞/点踩 |
+| `GET /api/v1/artifacts/{id}` | 加载 Artifact 内容 | artifactId | ArtifactDTO | 用户打开 Markdown 文档 |
+| `GET /api/v1/assistant-messages/{id}/events` | 恢复事件 | `afterEventSequence` | Event stream | 刷新后续接生成 |
+| `POST /api/v1/assistant-messages/{id}/stop` | 停止生成 | assistantMessageId | AssistantRunState | 用户点击 Stop |
+
+### D2. 参数之间的决定性关系
+
+以下内容只保留最核心的关系速查;完整 Schema 与所有边界条件见 [ThreadChat V1 API 详细合同](#threadchat-v1-api-详细合同)。
+
+#### 创建 Project
+
+```ts
+type CreateProjectRequest = {
+  initialMessage: {
+    parts: UIMessage["parts"]
+  }
+  requestedModelId?: string
+}
+```
+
+没有 projectId/threadId/messageId。服务端在同一事务生成全部身份。
+
+#### 发送 Message
+
+```ts
+type SendMessageRequest = {
+  parts: UIMessage["parts"]
+  requestedModelId?: string
+}
+```
+
+threadId 只来自 URL path;body 不重复声明,避免两个值冲突。服务端根据 Thread 找到 Project 和权限。
+
+#### Fork
+
+```ts
+type ForkThreadRequest = {
+  sourceMessageId: string
+  anchor?: {
+    exactQuote: string
+    textPosition?: { start: number; end: number }
+  }
+}
+```
+
+sourceThreadId 来自 path。服务端验证 sourceMessage.threadId 等于 path Thread;anchor 是用户选择输入,不是权威 BaseContext。
+
+#### Project Patch
+
+```ts
+type PatchProjectRequest = {
+  customTitle?: string | null
+  target?: ProjectTarget | null
+  instruction?: string | null
+}
+```
+
+字段缺省表示保持不变;显式 null 表示清空。Target 中短期、中期、终极目标属于一个整体值,MVP 一次整体替换。
+
+#### Replacement
+
+```ts
+type ReplacementBundle = {
+  supersededMessageIds: string[]
+  createdMessages: MessageEntity[]
+  assistantRun: AssistantRunState
+}
+```
+
+来源 Message ID 来自 path;body 不提交 replacement ID、sequence 或 supersededAt。
+
+## 1. 合同边界
+
+### 1.1 共同规则
+
+- Base URL 固定为 `/api/v1`。
+- JSON 字段使用 `camelCase`,时间使用 UTC ISO 8601 字符串。
+- Session 决定 actor;请求不得提交 `ownerUserId` 作为授权依据。
+- Path 只放正在操作的既有资源 ID;Body 不重复同一个 ID。
+- 待创建的 Project、Thread、Message 和 MessageRun ID 全部由服务端生成。
+- JSON Object 默认严格校验;未声明字段返回 `400 validation_error`,不得静默接收 `newThreadId`、`baseContext` 等字段。
+- `Message.parts` 必须通过项目基于 AI SDK v7 `UIMessage["parts"]` 建立的共享 Schema。
+- 普通 JSON 成功响应统一为 `{ data: T }`;`204` 和 SSE 除外。
+- append、Fork 和生成命令不使用 Project/Thread revision 或 `If-Match`。
+- P0 不要求 `Idempotency-Key`;同一次按钮交互由客户端 busy guard 防止重复点击。这不是网络级幂等保证。
+
+### 1.2 基础类型
+
+```ts
+type ProjectId = string       // 服务端 UUID;客户端按不透明字符串处理
+type ThreadId = string
+type MessageId = string
+type DateTime = string        // UTC ISO 8601,例如 2026-08-25T03:20:10.000Z
+type Cursor = string          // 服务端签发的不透明游标,客户端不得解析或构造
+
+type JsonPrimitive = string | number | boolean | null
+type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
+
+type ApiResponse = {
+  data: T
+}
+
+type ApiErrorResponse = {
+  error: {
+    code: ApiErrorCode
+    message: string
+    details?: JsonValue
+  }
+}
+```
+
+`message` 用于开发者诊断,不作为 UI 稳定文案;客户端分支判断只依赖 `code`。
+
+### 1.3 Message Parts
+
+```ts
+import type { UIMessage, UIMessageChunk } from "ai"
+
+type MessageParts = UIMessage["parts"]
+type UserMessageParts = UIMessage["parts"]
+```
+
+`UserMessageParts` 复用 AI SDK v7 的传输结构,但服务端仍必须做角色侧白名单校验。例如,客户端不得伪造仅应由 assistant 或服务端 Tool 执行产生的 Part。至少包含一个有意义内容的数组才是合法输入;空数组、纯空白文本或未知 Part 必须返回 `validation_error`。
+
+### 1.4 权威 DTO
+
+```ts
+type ProjectTargetDTO = {
+  ultimate: string | null
+  shortTerm: string[]
+  midTerm: string[]
+}
+
+type ProjectDTO = {
+  id: ProjectId
+  ownerUserId: string
+  autoTitle: string | null
+  customTitle: string | null
+  target: ProjectTargetDTO | null
+  instruction: string | null
+  archivedAt: DateTime | null
+  createdAt: DateTime
+  updatedAt: DateTime
+}
+
+type ProjectSummaryDTO = {
+  id: ProjectId
+  displayTitle: string
+  archivedAt: DateTime | null
+  updatedAt: DateTime
+  threadCount: number
+  messageCount: number
+}
+
+type ForkSourceSnapshotDTO = {
+  schemaVersion: 1
+  quote?: string
+  sourceRole: "user" | "assistant"
+  sourceSequence: number
+}
+
+type ThreadDTO = {
+  id: ThreadId
+  projectId: ProjectId
+  parentThreadId: ThreadId | null
+  sourceMessageId: MessageId | null
+  forkSourceSnapshot: ForkSourceSnapshotDTO | null
+  autoTitle: string | null
+  customTitle: string | null
+  archivedAt: DateTime | null
+  createdAt: DateTime
+  updatedAt: DateTime
+}
+
+type MessageDTO = {
+  id: MessageId
+  threadId: ThreadId
+  sequence: number
+  role: "user" | "assistant"
+  parts: MessageParts | null
+  replacesMessageId: MessageId | null
+  supersededAt: DateTime | null
+  finalizedAt: DateTime | null
+  createdAt: DateTime
+}
+
+type AssistantRunStatus =
+  | "queued"
+  | "running"
+  | "completed"
+  | "failed"
+  | "stopped"
+
+type AssistantRunStateDTO = {
+  assistantMessageId: MessageId
+  status: AssistantRunStatus
+  modelId: string
+  checkpointParts: MessageParts
+  eventSequence: number
+  error: { code: string; message: string } | null
+  stopRequestedAt: DateTime | null
+  finishedAt: DateTime | null
+}
+
+type ArtifactDTO = {
+  id: string
+  projectId: ProjectId
+  sourceMessageId: MessageId
+  kind: string
+  title: string
+  content: JsonValue
+  createdAt: DateTime
+}
+
+/**
+ * Markdown Artifact 工具完成后的目标 output 协议。
+ *
+ * 当前项目基线由服务端在工具完成后生成 Artifact ID,再写入领域消息的
+ * `message.artifactIds`;V1 契约将这层关系收敛到 AI SDK v7 tool output,
+ * 使 Message 本身携带稳定引用,但仍不复制 Markdown 正文。
+ */
+type MarkdownArtifactToolOutput = {
+  artifactId: string
+}
+
+type ProjectArtifactSummaryDTO = {
+  /** Project Artifact 集合变化时由服务端单调递增;只用于客户端拒绝旧统计。 */
+  changeSequence: number
+
+  /** 当前 Project 下全部 Artifact 数量,不受本次响应内联窗口影响。 */
+  total: number
+
+  /** 按稳定 Artifact kind 聚合;Markdown 数量读取 `byKind.markdown ?? 0`。 */
+  byKind: Record
+}
+
+type FeedbackValue = "positive" | "negative" | null
+
+type FeedbackDTO = {
+  messageId: MessageId
+  value: FeedbackValue
+  updatedAt: DateTime
+}
+```
+
+决定性不变量:
+
+- `ThreadDTO.parentThreadId === null` 时,它是 Project 唯一 Root,且三个 Fork 字段必须全部为 `null`。
+- `parentThreadId !== null` 时,它是 Branch,`sourceMessageId` 与 `forkSourceSnapshot` 必须非空。
+- `MessageDTO.sequence` 由服务端在 Thread 内分配且唯一、单调递增;客户端不得提交。
+- user Message 创建时即 finalized;运行中的 assistant Message 可有 `parts=null`、`finalizedAt=null`。
+- `AssistantRunStateDTO.assistantMessageId` 必须指向 role 为 assistant 的 Message;每条 assistant Message 恰有一个 Run。
+- `status=completed` 时,Message 必须已有不可变 `parts` 和 `finalizedAt`;`status=failed|stopped` 时 `finishedAt` 非空。
+- `stopRequestedAt` 非空表示服务端已经接受 Stop;它不等于 Run 已进入 `stopped` 终态。
+- Markdown tool result 必须通过 AI SDK v7 Message part 的 output 保存 `artifactId` 引用;其目标 output 必须符合 `MarkdownArtifactToolOutput`,不得把 Markdown 正文复制进 tool result。
+- `ArtifactDTO` 只由独立 Artifact Query 返回;ThreadMessageBundle、ProjectBootstrap 和生成事件不得内联 Artifact 正文。
+- `ProjectArtifactSummaryDTO.changeSequence` 必须是非负整数,并在该 Project 的 Artifact 集合发生变化时单调递增;它只排序统计快照,不是 revision、ETag 或客户端写入前置条件。
+- `ProjectArtifactSummaryDTO.total` 必须等于 `byKind` 所有非负整数值之和;它统计 Project 全量 Artifact,不能退化为客户端当前 `artifactsById` 缓存的数量。
+
+### 1.5 复合响应
+
+```ts
+type ThreadMessageBundleDTO = {
+  threadId: ThreadId
+  messages: MessageDTO[]
+  assistantRuns: AssistantRunStateDTO[]
+  hasOlderMessages: boolean
+  oldestReturnedSequence: number | null
+  newestReturnedSequence: number | null
+}
+
+type ProjectBootstrapDTO = {
+  project: ProjectDTO
+  threadTopology: ThreadDTO[]
+  artifactSummary: ProjectArtifactSummaryDTO
+  initialThread: ThreadMessageBundleDTO
+}
+
+type CreationBundleDTO = {
+  project: ProjectDTO
+  rootThread: ThreadDTO
+  artifactSummary: ProjectArtifactSummaryDTO
+  userMessage: MessageDTO
+  assistantMessage: MessageDTO
+  assistantRun: AssistantRunStateDTO
+}
+
+type MessageCreationBundleDTO = {
+  userMessage: MessageDTO
+  assistantMessage: MessageDTO
+  assistantRun: AssistantRunStateDTO
+}
+
+type ReplacementBundleDTO = {
+  supersededMessageIds: MessageId[]
+  createdMessages: MessageDTO[]
+  assistantRun: AssistantRunStateDTO
+}
+```
+
+`ThreadMessageBundleDTO.messages` 只返回当前有效时间线,即 `supersededAt === null` 的 Message,并按 `sequence ASC` 排列。旧 replacement Message 仍保存在数据库,但不混入默认 UI 时间线。
+
+`assistantRuns` 必须且只包含本 bundle 中 assistant Message 的 Run;不得返回属于其他 Thread 或未返回 Message 的 Run。Message 通过 tool result 中的 `artifactId` 引用 Artifact;本 bundle 不复制 Artifact 正文。
+
+`ProjectBootstrapDTO.artifactSummary` 与 `CreationBundleDTO.artifactSummary` 是 Project 级统计读模型。它不枚举 Artifact,也不能由客户端根据已加载的 `artifactsById` 重建;首次创建且尚未产生 Artifact 时必须返回 `{ changeSequence: 0, total: 0, byKind: {} }`。
+
+## 2. Project API
+
+### 2.1 列出 Project
+
+```http
+GET /api/v1/projects?status=active&limit=50&cursor=...
+```
+
+功能:为首页“对话列表”加载轻量 Project 列表,不加载 Thread topology 或 Message 正文。
+
+Query:
+
+```ts
+type ListProjectsQuery = {
+  status?: "active" | "archived" | "all" // 默认 active
+  limit?: number                           // 整数,1..100,默认 50
+  cursor?: Cursor                          // 首次请求省略
+}
+```
+
+成功响应:`200 ApiResponse`。
+
+```ts
+type ListProjectsResult = {
+  items: ProjectSummaryDTO[]
+  nextCursor: Cursor | null
+}
+```
+
+决定性关系:
+
+- 排序固定为 `updatedAt DESC, id DESC`;相同时间使用 ID 保证稳定顺序。
+- `status=active` 只返回 `archivedAt=null`;`archived` 只返回非空;`all` 返回两者。
+- Cursor 绑定 actor、status、排序与上页边界。带 cursor 时更换 status 返回 `400 invalid_cursor`。
+- `items.length < limit` 时 `nextCursor` 必须为 `null`。
+
+主要错误:`invalid_query`、`invalid_cursor`、`unauthorized`。
+
+手动 Case:先以 `limit=2` 请求,记录 `nextCursor`;第二次带同一 `status` 和 cursor,预期不重复第一页 ID。再把 `status` 改为 `archived` 并复用 cursor,预期 `invalid_cursor`。
+
+### 2.2 首次发送并创建 Project
+
+```http
+POST /api/v1/projects
+Content-Type: application/json
+```
+
+```ts
+type CreateProjectRequest = {
+  initialMessage: {
+    parts: UserMessageParts
+  }
+  requestedModelId?: string
+}
+```
+
+成功响应:`201 ApiResponse`。
+
+决定性关系:
+
+- 请求中没有 Project、Thread、Message 或 Run ID。
+- `initialMessage.parts` 决定 U1 内容;服务端不得接受客户端提交的 role,U1 固定为 user。
+- `requestedModelId` 是请求偏好;服务端校验并选择实际模型,结果以 `assistantRun.modelId` 为准。
+- 服务端在同一事务创建 Project、唯一 Root、U1、A1 与 queued Run;U1/A1 属于 Root,且 `U1.sequence < A1.sequence`。
+- 初始事务尚未生成 Artifact,因此 `artifactSummary` 必须是 `{ changeSequence: 0, total: 0, byKind: {} }`;后续由生成事件携带最新统计。
+- 创建响应只返回服务端资源身份与领域数据,不返回或拼接 Web 页面 URL。客户端必须使用 `project.id` 和集中式路由构造器决定导航目标。
+- 模型 Worker 只能在事务提交后唤醒。
+
+主要错误:`validation_error`、`model_not_available`、`unauthorized`。
+
+请求示例:
+
+```json
+{
+  "initialMessage": {
+    "parts": [{ "type": "text", "text": "帮我设计一个支付系统" }]
+  },
+  "requestedModelId": "provider/model"
+}
+```
+
+手动 Case:提交合法内容,验证响应五个对象的关联 ID;再提交空 `parts`,预期 400 且数据库没有残留 Project。
+
+### 2.3 加载 Project 首屏
+
+```http
+GET /api/v1/projects/{projectId}/bootstrap
+```
+
+Path:
+
+```ts
+type ProjectPath = { projectId: ProjectId }
+```
+
+Body:无。Query:无。
+
+成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- `project.id` 必须等于 path `projectId`。
+- `threadTopology` 返回该 Project 全部轻量 Thread,必须恰有一个 `parentThreadId=null` 的 Root。
+- 每个 Branch 的 `parentThreadId` 必须能在同一 `threadTopology` 找到,且不得形成环。
+- `artifactSummary` 必须统计该 Project 的全部 Artifact,不能只统计客户端已经按 ID 加载的 Artifact。
+- `initialThread.threadId` 必须等于 Root ID。
+- Root bundle 默认返回最新最多 200 条有效 Message,再按 sequence 升序输出。
+- Bootstrap 不返回 BaseContext、Branch Message、Prompt History 或全部 Project Resource 正文。
+
+主要错误:`project_not_found`、`forbidden`、`unauthorized`。
+
+手动 Case:创建 Root + 两个 Branch,仅向 Branch 写入消息。Bootstrap 应返回三个 topology item,但 `initialThread.messages` 只能属于 Root。
+
+### 2.4 更新 Project 元数据
+
+```http
+PATCH /api/v1/projects/{projectId}
+Content-Type: application/json
+```
+
+```ts
+type PatchProjectRequest = {
+  customTitle?: string | null
+  target?: ProjectTargetDTO | null
+  instruction?: string | null
+}
+```
+
+校验边界:
+
+- Body 至少出现一个字段。
+- `customTitle` 去除首尾空白后为 1..120 字符;`null` 清除自定义标题。
+- `instruction` 最大 20,000 字符;`null` 清除。
+- `target.ultimate` 最大 4,000 字符;短期/中期数组各最多 50 项,每项去除首尾空白后为 1..500 字符。
+- `target=null` 清除整个 Target;字段缺省则保持原值。
+
+成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- 缺省字段表示“不修改”,显式 `null` 表示“清空”,二者不得混淆。
+- `target` 是一个整体值;提交后一次整体替换,不按数组项做 merge。
+- 更新 Project metadata 不得修改 Thread、Message 或其他 Project。
+- MVP 使用最后一次成功写入生效,不要求 revision。
+
+主要错误:`validation_error`、`project_not_found`、`forbidden`。
+
+手动 Case:先设置 Target,再只提交 `customTitle`,重新读取 Bootstrap,预期 Target 完全不变;随后提交 `target:null`,预期 Target 被清空。
+
+### 2.5 归档与取消归档 Project
+
+```http
+POST /api/v1/projects/{projectId}/archive
+POST /api/v1/projects/{projectId}/unarchive
+```
+
+Body:无。Query:无。
+
+成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- archive 将 `archivedAt` 设置为服务端当前时间;重复 archive 返回当前实体,不重复制造副作用。
+- unarchive 将 `archivedAt` 清为 `null`;重复 unarchive 返回当前实体。
+- 归档只影响默认导航可见性,不删除 Thread、Message 或资源。
+
+主要错误:`project_not_found`、`forbidden`、`unauthorized`。
+
+### 2.6 永久删除 Project
+
+```http
+DELETE /api/v1/projects/{projectId}
+```
+
+Body:无。Query:无。UI 的二次确认是调用前置条件,不作为可伪造的 `confirmed=true` 参数。
+
+成功响应:`204 No Content`,响应体必须为空。
+
+决定性关系:
+
+- 这是 Project 聚合的唯一永久删除入口。
+- 服务端在受控事务中删除其资源、Run、Message、Thread 和 Project;不得暴露单 Message hard delete。
+- 成功后 Bootstrap 返回 `project_not_found`。
+
+主要错误:`project_not_found`、`forbidden`、`project_delete_conflict`。
+
+## 3. Thread API
+
+### 3.1 按 sequence 加载 Thread Message
+
+```http
+GET /api/v1/threads/{threadId}/messages?limit=200&beforeSequence=...
+```
+
+```ts
+type GetThreadMessagesQuery = {
+  limit?: number           // 整数,1..200,默认 200
+  beforeSequence?: number  // 正整数;独占边界
+}
+```
+
+成功响应:`200 ApiResponse`。
+
+服务端核心查询语义:
+
+```sql
+WHERE thread_id = :threadId
+  AND superseded_at IS NULL
+  AND (:beforeSequence IS NULL OR sequence < :beforeSequence)
+ORDER BY sequence DESC
+LIMIT :limit + 1
+```
+
+服务端取出窗口后丢弃多取的一条,再按 `sequence ASC` 输出。
+
+决定性关系:
+
+- `bundle.threadId` 必须等于 path ID;所有 Message 的 `threadId` 也必须一致。
+- `beforeSequence` 是独占边界;不会再次返回等于该值的 Message。
+- `oldestReturnedSequence`/`newestReturnedSequence` 分别等于返回数组首/尾 sequence;空数组时均为 `null`。
+- 多取到第 `limit+1` 条时 `hasOlderMessages=true`,否则 false。
+- sequence 可有间隙;replacement 退出有效时间线后,不得重新编号。
+
+主要错误:`invalid_query`、`thread_not_found`、`forbidden`。
+
+手动 Case:构造有效 sequence `[1, 2, 5, 8]`,以 `limit=2` 首次读取应得到 `[5, 8]`;再以 `beforeSequence=5` 读取应得到 `[1, 2]`,不得因缺少 3、4 报错。
+
+### 3.2 更新 Branch 标题
+
+```http
+PATCH /api/v1/threads/{threadId}
+Content-Type: application/json
+```
+
+```ts
+type PatchThreadRequest = {
+  customTitle: string | null
+}
+```
+
+成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- 只允许 Branch;Root 展示标题由 Project 管理,修改 Root 返回 `root_thread_title_owned_by_project`。
+- 字符串规则与 Project customTitle 相同;`null` 清除。
+- Body 不接受 projectId、parentThreadId 或 ForkFacts,防止用 metadata API 改写拓扑。
+
+主要错误:`validation_error`、`thread_not_found`、`root_thread_title_owned_by_project`。
+
+### 3.3 归档与取消归档 Branch
+
+```http
+POST /api/v1/threads/{threadId}/archive
+POST /api/v1/threads/{threadId}/unarchive
+```
+
+Body:无。成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- 只允许 Branch;Root 必须通过 Project archive/unarchive 管理。
+- 归档保留 Message、Child Thread、ForkFacts 和 BaseContext。
+- 重复命令返回当前状态,不创建额外实体。
+
+主要错误:`thread_not_found`、`root_thread_archive_owned_by_project`、`forbidden`。
+
+### 3.4 在既有 Thread 发送 Message
+
+```http
+POST /api/v1/threads/{threadId}/messages
+Content-Type: application/json
+```
+
+```ts
+type SendMessageRequest = {
+  parts: UserMessageParts
+  requestedModelId?: string
+}
+```
+
+成功响应:`201 ApiResponse`。
+
+决定性关系:
+
+- threadId 只来自 path;Body 不接受 threadId、role、sequence 或新 ID。
+- 服务端在同一事务创建 finalized user Message、占位 assistant Message 与 queued Run。
+- 两条 Message 获得 Thread 当前最大 sequence 之后的两个新 sequence,但系统不要求整条历史角色交替。
+- `assistantRun.assistantMessageId === assistantMessage.id`。
+- 当前产品策略下,同 Thread 已存在 queued/running Run 时返回 `thread_generation_in_progress`,且不创建任何 Message。
+- Prompt History、Branch BaseContext 和 Project Instruction 由服务端解析;客户端不得提交。
+
+主要错误:`validation_error`、`thread_not_found`、`thread_archived`、`thread_generation_in_progress`、`model_not_available`。
+
+手动 Case:在已有最大 sequence=8 的 Thread 发送,响应 U/A sequence 应大于 8 且递增;同时发起第二次命令,只有一个事务成功,另一个返回冲突且没有半条 Message。
+
+### 3.5 Fork Thread
+
+```http
+POST /api/v1/threads/{sourceThreadId}/forks
+Content-Type: application/json
+```
+
+```ts
+type ForkThreadRequest = {
+  sourceMessageId: MessageId
+  anchor?: {
+    exactQuote: string
+    textPosition?: {
+      start: number // UTF-16 code unit,含 start
+      end: number   // UTF-16 code unit,不含 end,且 end > start
+    }
+  }
+}
+```
+
+成功响应:`201 ApiResponse<{ thread: ThreadDTO }>`。
+
+决定性关系:
+
+- `sourceThreadId` 来自 path;`sourceMessageId` 是既有资源 ID,二者共同唯一确定 Fork 来源。
+- 服务端验证 `sourceMessage.threadId === sourceThreadId`;不满足返回 `fork_source_thread_mismatch`。
+- 来源必须未 superseded、已 finalized 且具备 Prompt 资格。assistant 还必须对应 completed Run。
+- `anchor` 只表达用户选区。若同时提交 position,则服务端验证 source Message 文本在 `[start,end)` 等于 `exactQuote`。
+- position 以来源 Message 的规范文本投影为坐标:按 parts 顺序提取所有 text part,并以单个换行连接;非 text part 不占字符位置。前后端必须复用同一投影函数。
+- 客户端不得提交 Child ID、parentThreadId、Project ID、BaseContext 或 ForkSourceSnapshot。
+- 服务端事务内计算 BaseContext、生成 ForkSourceSnapshot、创建 Child;Child Project 必须与来源 Thread 相同。
+
+主要错误:`validation_error`、`thread_not_found`、`source_message_not_found`、`fork_source_thread_mismatch`、`fork_source_not_finalized`、`fork_source_superseded`、`fork_anchor_mismatch`。
+
+手动 Case:在 running assistant 上请求,预期 `fork_source_not_finalized` 且没有 Child;完成后再请求,预期返回 Branch,且请求/响应都不暴露 BaseContext。
+
+## 4. Message 与 replacement API
+
+### 4.1 Edit 最后一条有效 user Message
+
+```http
+POST /api/v1/messages/{sourceUserMessageId}/edits
+Content-Type: application/json
+```
+
+```ts
+type EditMessageRequest = {
+  parts: UserMessageParts
+  requestedModelId?: string
+}
+```
+
+成功响应:`201 ApiResponse`。
+
+决定性关系:
+
+- Path Message 必须 role=user、未 superseded,并且是当前 Thread 最后一条有效 user Message;否则返回 `fork_required`。
+- 该命令不是原地修改:旧 Message 的 parts 和 sequence 永远不变。
+- 服务端 supersede 从 source user 开始、所有依赖旧输入的当前有效后缀;角色不需要一问一答。
+- 随后在 Thread 尾部创建 replacement user、replacement assistant 和新 queued Run,均使用新 ID、新 sequence。
+- `createdMessages` 按 sequence 升序返回,必须恰含一条 user 和一条 assistant;user 的 `replacesMessageId` 指向 source。
+- `supersededMessageIds` 是本事务退出默认时间线的完整 ID 集合,客户端据此原子更新 Store。
+
+主要错误:`message_not_found`、`message_not_editable`、`fork_required`、`thread_generation_in_progress`、`model_not_available`。
+
+手动 Case:Thread 有 `U1(seq1), U2(seq2), A1(seq3)`,Edit U2。预期 U2/A1 被 supersede,再追加 U2b/A2;不得因为存在连续 user Message 而拒绝。
+
+### 4.2 Regenerate 当前 assistant Message
+
+```http
+POST /api/v1/messages/{sourceAssistantMessageId}/regenerations
+Content-Type: application/json
+```
+
+```ts
+type RegenerateMessageRequest = {
+  requestedModelId?: string
+}
+```
+
+成功响应:`201 ApiResponse`。
+
+决定性关系:
+
+- Path Message 必须 role=assistant、未 superseded、已 finalized,且原 Run 为 completed。
+- MVP 只允许重新生成当前有效时间线最后一个可重新生成的 assistant;历史位置返回 `fork_required`。
+- 旧 assistant 的 parts 和 sequence 不更新;事务只设置其 `supersededAt`。
+- 服务端在 Thread 尾部创建一条新的 assistant Message,并为它创建唯一 queued Run。
+- `createdMessages` 必须恰含一条 assistant;其 `replacesMessageId` 等于 path ID。
+- 新 assistant 可获得不同 `modelId`,但来源 Message 内容保持不变。
+
+主要错误:`message_not_found`、`message_not_regeneratable`、`fork_required`、`thread_generation_in_progress`、`model_not_available`。
+
+手动 Case:记录 A1 的 ID、parts、sequence 后 Regenerate。预期返回 A2/R2;重新读取默认时间线只见 A2,但数据库中 A1 的 parts 和 sequence 原样保留。
+
+### 4.3 设置 Message feedback
+
+```http
+PUT /api/v1/messages/{assistantMessageId}/feedback
+Content-Type: application/json
+```
+
+```ts
+type PutMessageFeedbackRequest = {
+  value: FeedbackValue
+}
+```
+
+成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- 目标必须是已 finalized、completed 且未 superseded 的 assistant Message。
+- `positive`/`negative` 使用 upsert;`null` 删除或清空评价,响应仍返回 `value:null`。
+- 相同 value 重复 PUT 返回当前结果,不修改 Message 或 Run。
+
+主要错误:`message_not_found`、`message_not_feedback_eligible`、`validation_error`。
+
+## 5. 生成事件与 Stop API
+
+### 5.1 订阅或恢复生成事件
+
+```http
+GET /api/v1/assistant-messages/{assistantMessageId}/events?afterEventSequence=42
+Accept: text/event-stream
+```
+
+```ts
+type GetAssistantEventsQuery = {
+  afterEventSequence?: number // 非负整数,默认 0
+}
+```
+
+成功响应:`200 text/event-stream`,不是 `ApiResponse`。
+
+SSE Event:
+
+```ts
+type RunSnapshotEvent = {
+  type: "run.snapshot"
+  cursor: number
+  run: AssistantRunStateDTO
+  message: MessageDTO
+  /** 当前 Project 的最新 Artifact 总量,用于刷新或重连后校正页面统计。 */
+  artifactSummary: ProjectArtifactSummaryDTO
+}
+
+type RunDeltaEvent = {
+  type: "run.delta"
+  eventSequence: number
+  chunk: UIMessageChunk
+}
+
+type RunCompletedEvent = {
+  type: "run.completed"
+  eventSequence: number
+  run: AssistantRunStateDTO
+  message: MessageDTO
+  /** 本次完成事务提交后的 Project Artifact 总量。 */
+  artifactSummary: ProjectArtifactSummaryDTO
+}
+
+type RunFailedEvent = {
+  type: "run.failed"
+  eventSequence: number
+  run: AssistantRunStateDTO
+}
+
+type RunStoppedEvent = {
+  type: "run.stopped"
+  eventSequence: number
+  run: AssistantRunStateDTO
+  message: MessageDTO
+}
+
+type AssistantMessageEvent =
+  | RunSnapshotEvent
+  | RunDeltaEvent
+  | RunCompletedEvent
+  | RunFailedEvent
+  | RunStoppedEvent
+```
+
+连接语义:
+
+1. 服务端先验证 assistant Message、所属 Project 权限和 Run。
+2. 首个业务事件固定为 `run.snapshot`。它是当前持久化 checkpoint,不占用新 event sequence;`cursor === run.eventSequence`。
+3. 客户端用 snapshot 原子替换本地 checkpoint,并丢弃本地 `eventSequence <= cursor` 的旧流片段。
+4. 若 Run 仍 queued/running,后续只发送 `eventSequence > cursor` 的 live event,且严格递增。
+5. 若 snapshot 已是终态,服务端发送 snapshot 后即可关闭;不得创建或重启 Run。
+6. `afterEventSequence` 用于校验客户端游标与服务端当前 Run 的关系;缺失事件已由 snapshot 合并恢复,因此 P0 不要求重放每个旧 token delta。
+7. 如果客户端游标大于服务端游标,返回 `409 invalid_event_cursor`;如果客户端游标小于服务端保留窗口,仍通过 snapshot 恢复。
+8. 网络断开只取消订阅,不停止 Run。客户端用最后接受的 eventSequence 重连。
+9. 客户端必须按 `changeSequence` 合并 `run.snapshot` 或 `run.completed` 携带的 `artifactSummary`:更大值原子替换,相同值必须一致,更小值忽略;不得按重复事件自行累加。
+
+HTTP 连接建立前的主要错误:`assistant_message_not_found`、`message_run_not_found`、`invalid_event_cursor`、`forbidden`。连接建立后的运行失败通过 `run.failed` 表达。
+
+手动 Case:A1 running、服务端 cursor=42 时刷新,以 `afterEventSequence=38` 重连。首事件必须是 cursor=42 的 snapshot;后续事件从大于 42 开始,数据库仍只有原 Run。
+
+### 5.2 停止生成
+
+```http
+POST /api/v1/assistant-messages/{assistantMessageId}/stop
+```
+
+Body:无。成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- queued/running Run 设置 `stopRequestedAt` 并返回提交后的最新状态;实际终态可稍后通过事件变为 stopped。
+- completed/failed/stopped 已是终态,重复 Stop 直接返回当前状态。
+- Stop 不创建 replacement Message 或第二个 Run。
+- 如果停止时存在可展示 checkpoint,最终 stopped Message 是否 finalized 由领域规则决定;MVP stopped assistant 仍不得进入 BaseContext 或作为 Fork source。
+
+主要错误:`assistant_message_not_found`、`message_run_not_found`、`forbidden`。
+
+## 6. Artifact API
+
+### 6.1 按 ID 加载 Artifact
+
+```http
+GET /api/v1/artifacts/{artifactId}
+```
+
+Query:无。Body:无。成功响应:`200 ApiResponse`。
+
+决定性关系:
+
+- 服务端必须从 Artifact 所属 Project 校验当前 actor 的访问权。
+- 返回的 `artifact.id` 必须等于 path `artifactId`。
+- Message tool result 中的 `artifactId` 只是引用;Artifact 正文只从本接口返回。
+- 客户端可以在 ProjectRuntime 生命周期内按 ID 缓存成功结果;刷新后按需重新加载。
+
+主要错误:`artifact_not_found`、`forbidden`、`unauthorized`。
+
+手动 Case:打开包含 Markdown tool result 的 Message,读取其中 `artifactId` 后请求本接口;预期返回对应 Markdown。使用其他用户 Project 的 artifactId 请求时,预期 not_found/forbidden。
+
+## 7. 错误码与 HTTP 映射
+
+```ts
+type ApiErrorCode =
+  | "validation_error"
+  | "invalid_query"
+  | "invalid_cursor"
+  | "invalid_event_cursor"
+  | "unauthorized"
+  | "forbidden"
+  | "project_not_found"
+  | "thread_not_found"
+  | "message_not_found"
+  | "assistant_message_not_found"
+  | "message_run_not_found"
+  | "artifact_not_found"
+  | "model_not_available"
+  | "thread_archived"
+  | "thread_generation_in_progress"
+  | "root_thread_title_owned_by_project"
+  | "root_thread_archive_owned_by_project"
+  | "source_message_not_found"
+  | "fork_source_thread_mismatch"
+  | "fork_source_not_finalized"
+  | "fork_source_superseded"
+  | "fork_anchor_mismatch"
+  | "message_not_editable"
+  | "message_not_regeneratable"
+  | "message_not_feedback_eligible"
+  | "fork_required"
+  | "project_delete_conflict"
+  | "internal_error"
+```
+
+| HTTP | 使用条件 |
+|---:|---|
+| 400 | 请求形状、字段值、Query 或客户端游标非法 |
+| 401 | 未登录或 Session 无效 |
+| 403 | actor 已识别但无访问权;部署也可统一返回 404 防止枚举 |
+| 404 | 目标资源不存在或按防枚举策略不可见 |
+| 409 | 当前状态发生冲突,例如已有运行中生成、无效事件游标或删除冲突 |
+| 422 | 资源存在,但不满足命令资格,例如 Fork source 未 finalized、历史 Edit 必须 Fork |
+| 500 | 未预期服务端错误;不得向客户端泄漏堆栈和数据库细节 |
+
+错误示例:
+
+```json
+{
+  "error": {
+    "code": "fork_source_not_finalized",
+    "message": "The source assistant message is still running.",
+    "details": {
+      "assistantMessageId": "msg_123",
+      "status": "running"
+    }
+  }
+}
+```
+
+## 7. Transport 与测试的强制落点
+
+- 服务端 route 输入、Application Service 命令输入和客户端 Transport 输入必须使用同一合同命名,避免 `id` 在不同层表示不同实体。
+- 数据库 Row 不得直接作为 API Response;必须显式映射为本文 DTO。
+- 客户端收到响应后先做 Schema 校验,再交给 Action 原子 merge;React 组件不得消费未校验 JSON。
+- 每个 Command 的集成测试至少覆盖:成功关联关系、授权失败、Schema 非法、领域资格失败、事务回滚。
+- 每个 Query 的集成测试至少覆盖:所属隔离、稳定顺序、空结果、边界窗口和 DTO 不泄漏内部字段。
+- `MessageRun.id`、BaseContext、Prompt History、数据库错误与内部 Worker 细节不得进入普通客户端合同。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/new-project-first-message-lifecycle.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/new-project-first-message-lifecycle.md
new file mode 100644
index 00000000..b342775c
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/new-project-first-message-lifecycle.md
@@ -0,0 +1,296 @@
+# `/thread-chat/new` 首条消息与 AI 回复生命周期
+
+## 1. 目标
+
+用户从空白 `/thread-chat/new` 发送首条 Message 后,系统必须原子创建 Project 和首个聊天链路,并无空白帧地交接到 `/thread-chat/{projectId}`:
+
+```text
+本地 Draft UI
+  → 服务端 CreationBundle
+  → 先建立 ready ProjectRuntime
+  → 客户端根据 project.id 构造目标 Project URL
+  → 再替换当前 URL
+  → 同一 Runtime 继续接收 AI 事件
+```
+
+不能先导航到 Project URL、显示 Bootstrap Loading,再等待 Project 创建结果;也不能创建 `tempProjectId/tempThreadId/tempMessageId` 后二次替换身份。
+
+## 2. 页面状态机
+
+```mermaid
+stateDiagram-v2
+    [*] --> DraftIdle: 打开 /thread-chat/new
+    DraftIdle --> DraftSubmitting: 提交首条 Message
+    DraftSubmitting --> DraftError: 创建事务前失败
+    DraftError --> DraftSubmitting: Retry
+    DraftSubmitting --> RuntimeSeeded: 收到 CreationBundle
+    RuntimeSeeded --> ProjectRoute: 客户端构造路由且 registry 已有 ready Runtime 后 router.replace
+    ProjectRoute --> Generating: A1 queued/running
+    Generating --> Completed: run.completed
+    Generating --> Failed: run.failed/stopped
+```
+
+`DraftSubmitting` 期间保持当前页面和 Composer 几何位置,不清空草稿、不挂载空 Project Store。按钮只进入 submitting/disabled 状态。旧 `/new` 页面必须持续渲染到目标 Project Route 已能从 Registry 取得 seeded Runtime 为止。
+
+## 3. 服务端原子创建
+
+```ts
+async function createProjectWithFirstMessage(command: {
+  actorId: UserId
+  initialMessageParts: UIMessage["parts"]
+  requestedModelId?: ModelId
+}): Promise {
+  const internal = await database.transaction(async (tx) => {
+    validateUserMessageParts(command.initialMessageParts)
+
+    const project = await projectRepository.insert(tx, {
+      id: idGenerator.newProjectId(),
+      ownerUserId: command.actorId,
+      autoTitle: null,
+      customTitle: null,
+      target: null,
+      instruction: null,
+    })
+
+    const rootThread = await threadRepository.insert(tx, {
+      id: idGenerator.newThreadId(),
+      projectId: project.id,
+      parentThreadId: null,
+      sourceMessageId: null,
+      forkSourceSnapshot: null,
+    })
+
+    const userMessage = await messageRepository.insert(tx, {
+      id: idGenerator.newMessageId(),
+      threadId: rootThread.id,
+      sequence: 1,
+      role: "user",
+      parts: command.initialMessageParts,
+      replacesMessageId: null,
+      supersededAt: null,
+      finalizedAt: clock.now(),
+    })
+
+    const assistantMessage = await messageRepository.insert(tx, {
+      id: idGenerator.newMessageId(),
+      threadId: rootThread.id,
+      sequence: 2,
+      role: "assistant",
+      parts: null,
+      replacesMessageId: null,
+      supersededAt: null,
+      finalizedAt: null,
+    })
+
+    const messageRun = await messageRunRepository.insert(tx, {
+      id: idGenerator.newMessageRunId(),
+      assistantMessageId: assistantMessage.id,
+      status: "queued",
+      modelId: await modelPolicy.resolve({
+        actorId: command.actorId,
+        projectId: project.id,
+        requestedModelId: command.requestedModelId,
+      }),
+      checkpointParts: [],
+      eventSequence: 0,
+    })
+
+    await messageRunOutbox.enqueue(tx, {
+      messageRunId: messageRun.id,
+    })
+
+    return {
+      project,
+      rootThread,
+      userMessage,
+      assistantMessage,
+      messageRun,
+    }
+  })
+
+  // 事务提交后才能唤醒 Worker;唤醒失败不得把已提交创建误报成整体失败。
+  try {
+    await messageRunDispatcher.wakeUpAfterCommit(
+      internal.messageRun.id,
+    )
+  } catch (error) {
+    logger.error("message_run_wakeup_failed", error)
+    // durable Outbox / queued scanner 后续恢复执行。
+  }
+
+  return {
+    project: toProjectDTO(internal.project),
+    rootThread: toThreadDTO(internal.rootThread),
+    artifactSummary: {
+      changeSequence: 0,
+      total: 0,
+      byKind: {},
+    },
+    userMessage: toMessageDTO(internal.userMessage),
+    assistantMessage: toMessageDTO(internal.assistantMessage),
+    assistantRun: toAssistantRunStateDTO(internal.messageRun),
+  }
+}
+```
+
+CreationBundle 返回前 Project、Root、U1、A1 和 Run 必须已经 durable。模型 Worker 可以已经从 queued 进入 running;事件订阅的首个 `run.snapshot` 会用服务端当前 checkpoint 校正 CreationBundle 中较早的 queued 视图。
+
+服务端不得返回或拼接 ThreadChat 页面 URL。URL 是 Web 客户端的展示与导航约定,不是 Project 领域数据;客户端只使用 CreationBundle 中的 `project.id` 通过集中式 `threadChatRoutes.project(projectId)` 构造目标路径。
+
+## 4. 客户端创建命令与无抖动交接
+
+```ts
+async function submitNewProjectDraft() {
+  const draft = newDraftStore.getState()
+  if (draft.status === "submitting") return
+
+  const frozenParts = cloneAndValidateUserParts(draft.draftParts)
+  const requestedModelId = draft.requestedModelId
+
+  // 不清空 Composer;保持 /new 当前画面直到目标 Project Route ready。
+  newDraftStore.getState().markSubmitting()
+
+  try {
+    const bundle = await api.createProject({
+      initialMessage: { parts: frozenParts },
+      requestedModelId,
+      // 不提交任何新实体 ID。
+    })
+
+    validateCreationBundle(bundle)
+    const projectUrl = threadChatRoutes.project(bundle.project.id)
+
+    /**
+     * 决定性顺序:先 seed,后导航。
+     * seedFromCreation 创建并完整初始化 ProjectRuntime:
+     * - entities.project/root/U1/A1
+     * - runs[A1]
+     * - readModels.artifactSummary
+     * - bootstrap=ready
+     * - Root Message window=ready
+     * - focusedSlotId="root",columnSlots=[]
+     */
+    const runtime = appRuntime.projectRuntimeRegistry.seedFromCreation(
+      bundle,
+    )
+
+    // Catalog 只能合并服务端确认的 ProjectSummary。
+    appRuntime.appStore.getState().upsertProjectSummary(
+      toProjectSummary(bundle.project),
+    )
+    appRuntime.appStore.getState().setProjectRoutePending(
+      bundle.project.id,
+    )
+
+    /**
+     * 订阅在导航前挂到 AppProvider 持有的 Runtime:
+     * 即使 AI 很快完成,终态也先进入同一个 Store,不会丢失。
+     */
+    runtime.generationCoordinator.subscribeAssistant(
+      bundle.assistantRun.assistantMessageId,
+    )
+
+    /**
+     * 使用客户端软导航替换 /new;当前页面保留到新 Route 提交。
+     * 目标 ProjectProvider acquire 的是同一个 seeded Runtime,
+     * 因此首帧直接是 Root + U1 + A1,不再请求 Bootstrap 或闪空白 Loading。
+     */
+    router.replace(projectUrl)
+  } catch (error) {
+    /**
+     * 明确 validation/authorization/事务回滚:仍停留 /new,保留草稿并可 Retry。
+     * 网络中断或响应丢失可能发生在服务端提交之后;P0 没有 Idempotency-Key,
+     * 这种 unknown outcome 不得自动重试,必须显示“创建结果未知”。
+     */
+    newDraftStore.getState().markError(
+      normalizeCreateProjectError(error),
+    )
+  }
+}
+```
+
+`seedFromCreation` 必须持有一次性 navigation handoff lease:`NewProjectDraftProvider` 卸载不能销毁该 Runtime;目标 `ThreadChatProjectProvider` 用相同 `projectId` acquire 后消费 seed 并接管租约。
+
+## 5. 目标 ProjectProvider 接管
+
+```ts
+function mountProjectProvider(projectId: ProjectId) {
+  const runtime = registry.acquire(projectId)
+
+  assert(runtime.projectId === projectId)
+
+  if (runtime.store.getState().requests.bootstrap.status === "ready") {
+    // /new seed handoff:跳过 GET ProjectBootstrap。
+    clearMatchingPendingProjectId(projectId)
+    return runtime
+  }
+
+  // 只有直接打开/刷新 URL、内存中没有 seed 时才走正常 Bootstrap。
+  void runtime.commands.loadProjectBootstrap()
+  return runtime
+}
+```
+
+为了避免 UI 抖动,页面组件边界必须满足:
+
+```text
+ThreadChatAppProvider / App Shell
+  在 /new 与 /{projectId} 之间保持挂载
+
+NewProjectScreen 与 Root Thread Screen
+  使用相同 Column/Composer 外形尺寸
+
+router.replace 之前
+  seeded Runtime 已经 ready
+
+目标 Project Route 首帧
+  不出现第二次 Bootstrap Loading
+```
+
+不要求 Draft 与 U1 使用相同 React key;身份从本地草稿切换为服务端 Message 时允许组件重建,但不得插入空白 Project 帧、重置整个 App Shell 或先清空 Composer 再等待路由。
+
+## 6. AI 回复事件
+
+```ts
+function onAssistantEvent(event: AssistantMessageEvent) {
+  switch (event.type) {
+    case "run.snapshot":
+      // 用持久化 checkpoint 校正 queued/running/terminal 状态。
+      runtime.store.getState().applyRunEvent(event)
+      break
+
+    case "run.delta":
+      // Store Action 只入 frame buffer 并调度合帧,不逐 token 刷新最终实体。
+      runtime.store.getState().applyRunEvent(event)
+      break
+
+    case "run.completed":
+      /**
+       * 一次合并 finalized A1、completed Run 和最新 Artifact Summary;
+       * Message tool result 只含 artifactId,正文等用户打开后再按 ID 加载。
+       * UI 从 checkpoint/running 直接过渡到 Message.parts/finalized。
+      */
+      runtime.store.getState().applyRunEvent(event)
+      break
+
+    case "run.failed":
+    case "run.stopped":
+      runtime.store.getState().applyRunEvent(event)
+      break
+  }
+}
+```
+
+如果浏览器在生成中刷新,内存 seed 消失,页面按“打开已有 Project”冷启动:Bootstrap 返回 Root A1 和当前 Run checkpoint,再从 eventSequence 恢复,不创建第二个 Run。
+
+## 7. 决定性不变量
+
+- 首次发送前只有 NewProjectDraftStore,没有 Project Store 或假实体。
+- 客户端不生成 Project、Thread、Message 或 MessageRun ID。
+- CreationBundle 完整初始化 Runtime 后才能导航。
+- 目标 ProjectProvider 必须接管同一 seeded Runtime,并跳过 Bootstrap。
+- `/new` 当前画面保留到目标 Project Route 可提交,不显示中间空白 Project。
+- AI 订阅可以在导航前开始;事件始终写入 seeded Runtime。
+- completed 事件携带 finalized Message;不以前端累计 token 作为最终权威。
+- 浏览器刷新只改变客户端恢复路径,不改变后台 MessageRun。
+- P0 对响应丢失后的创建结果无法安全判定;在幂等命令落地前不得自动重试 unknown outcome。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/open-existing-project-lifecycle.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/open-existing-project-lifecycle.md
new file mode 100644
index 00000000..eea90b61
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/open-existing-project-lifecycle.md
@@ -0,0 +1,293 @@
+# 打开已有 Project 生命周期
+
+## 1. 先区分三种“缓存”
+
+打开 `/thread-chat/{projectId}` 时可能存在三种完全不同的缓存,不能混为一谈:
+
+```text
+Project Catalog Cache
+  只含 ProjectSummary;不影响 Project 页面能否加载
+
+ThreadWorkbenchSnapshot(localStorage)
+  只含 Column Slot、Thread ID、宽度、折叠态、焦点和视图模式
+  不含 Project/Thread/Message/Run 实体
+
+ProjectRuntime Message Cache(内存)
+  只在当前 ProjectRuntime 生命周期内存在
+  threadMessagesById[threadId]=ready 时可避免重复请求
+```
+
+浏览器 HTTP Cache 不作为领域正确性或 Zustand 恢复机制。P0 在 ProjectRuntime 最后一个 Provider lease 释放后销毁 Runtime,不承诺跨 Project 路由保存 Message 内存缓存;页面刷新后一定重新 Bootstrap。
+
+## 2. 路由与 Provider
+
+```ts
+function enterExistingProjectRoute(projectId: ProjectId) {
+  // projectId 直接来自 URL;不写入 App Store selectedProjectId。
+  const runtime = appRuntime.projectRuntimeRegistry.acquire(projectId)
+
+  if (runtime.store.getState().requests.bootstrap.status === "ready") {
+    // /new seed handoff 或同一 Runtime 已经完成 Bootstrap。
+    runtime.generationCoordinator.resumeLoadedRuns()
+    return runtime
+  }
+
+  // Catalog 是否已经加载不构成前置条件。
+  void runtime.commands.loadProjectBootstrap()
+  return runtime
+}
+```
+
+在 Bootstrap 完成前,Project 页面只有一个 Project-level loading shell。不能根据 Catalog Summary 构造假 Project Entity 或 Root Thread。
+
+## 3. 冷启动 Bootstrap
+
+```ts
+async function loadProjectBootstrap(): Promise {
+  const state = store.getState()
+
+  if (state.requests.bootstrap.status === "ready") return
+  if (bootstrapPromise) return bootstrapPromise
+
+  state.setBootstrapLoadState({ status: "loading" })
+
+  bootstrapPromise = api
+    .getProjectBootstrap(runtime.projectId)
+    .then((bootstrap) => {
+      validateProjectBootstrap(bootstrap, {
+        expectedProjectId: runtime.projectId,
+      })
+
+      /**
+       * 一次 State Transition 合并:
+       * - Project Entity
+       * - 全量轻量 Thread topology
+       * - Artifact Summary
+       * - Root Message/Run;Artifact 正文仍按 ID 加载
+       * - Root ThreadMessageWindow ready
+       * - Bootstrap ready
+       */
+      store.getState().mergeBootstrap(bootstrap)
+
+      restoreWorkbenchAndLoadBranches(bootstrap.threadTopology)
+      generationCoordinator.resumeLoadedRuns()
+    })
+    .catch((error) => {
+      if (runtimeDisposed || isAbortError(error)) return
+      store.getState().setBootstrapLoadState({
+        status: "error",
+        error: normalizeError(error),
+      })
+    })
+    .finally(() => {
+      bootstrapPromise = null
+    })
+
+  return bootstrapPromise
+}
+```
+
+## 4. 没有 Workbench Snapshot
+
+```ts
+function restoreWithoutSnapshot(rootThreadId: ThreadId) {
+  // Store Action 使用当前 UI 默认值:只显示并聚焦 Root。
+  store.getState().resetWorkbenchToDefault()
+
+  // Root MessageBundle 已在 Bootstrap 中 ready,不再请求。
+  assertThreadMessagesReady(rootThreadId)
+}
+```
+
+结果:
+
+```text
+Root Column       ready
+Branch Columns    没有打开,因此不请求 Branch Message
+```
+
+完整 topology 已在 Store 中,Tree Switcher、Child Count 和 Breadcrumb selector 可以工作,但不代表 Branch Message 已加载。
+
+## 5. 有 Workbench Snapshot
+
+Workbench Snapshot 必须等 Bootstrap topology 到达后才能校验和恢复:
+
+```ts
+function restoreWorkbenchAndLoadBranches(
+  threadTopology: ThreadEntity[],
+) {
+  const raw = workbenchStorage.read(runtime.projectId)
+  const snapshot = sanitizeWorkbenchSnapshot(raw, {
+    projectId: runtime.projectId,
+    threadTopology,
+  })
+
+  if (!snapshot) {
+    restoreWithoutSnapshot(selectRootThreadId(store.getState()))
+    return
+  }
+
+  // 只恢复 UI 事实;不得覆盖 Bootstrap entities/topology。
+  store.getState().restoreWorkbenchSnapshot(snapshot)
+
+  for (const slot of snapshot.columnSlots) {
+    /**
+     * 非阻塞 fan-out:不 await 全部 Branch 后再渲染。
+     * Slot 已经恢复,因此 Column 可以立刻按自己的 Query State
+     * 显示 loading / ready / error。
+     */
+    void runtime.commands.ensureThreadMessages(slot.threadId)
+  }
+}
+```
+
+Snapshot 只恢复“之前打开了哪些 Thread”,不缓存 Message。冷启动/刷新时,多个恢复 Branch 通常都需要分别请求 `ThreadMessageBundle`。
+
+```mermaid
+sequenceDiagram
+    participant R as Router
+    participant P as ProjectProvider
+    participant B as Bootstrap API
+    participant S as Project Store
+    participant LS as Workbench Snapshot
+    participant L as ThreadMessageLoader
+    participant UI as Columns
+
+    R->>P: /thread-chat/{projectId}
+    P->>B: GET ProjectBootstrap
+    B-->>P: Project + topology + Root bundle + stats
+    P->>S: mergeBootstrap
+    S-->>UI: Root ready
+    P->>LS: read + sanitize after topology
+    LS-->>P: Slots A / B / C
+    P->>S: restoreWorkbenchSnapshot
+    S-->>UI: A/B/C Column loading shells
+
+    par Thread A
+        P->>L: ensure(A)
+        L-->>S: A ready/error
+    and Thread B
+        P->>L: ensure(B)
+        L-->>S: B ready/error
+    and Thread C
+        P->>L: ensure(C)
+        L-->>S: C ready/error
+    end
+
+    Note over UI,L: Root 与各 Branch 独立展示,不存在全页面 Promise.all 等待
+```
+
+## 6. 有 Runtime Message Cache
+
+在同一个 ProjectRuntime 仍存活时,用户关闭后重新打开某个 Thread,或多个入口同时请求同一 Thread:
+
+```ts
+async function ensureThreadMessagesInsideLoader(threadId: ThreadId) {
+  const window = store.getState().requests.threadMessagesById[threadId]
+
+  if (window?.loadState.status === "ready") {
+    // 已有 Message Runtime Cache;零请求。
+    return
+  }
+
+  const inFlight = inFlightByThreadId.get(threadId)
+  if (inFlight) {
+    // 同一 Thread 请求去重。
+    return inFlight
+  }
+
+  return loadValidateAndMergeThreadMessages(threadId)
+}
+```
+
+不同 Thread 的请求互不复用,可以并行;同一 Thread 只能有一个 in-flight Promise。
+
+## 7. 每列独立状态
+
+```ts
+function selectThreadColumnView(
+  state: ThreadChatProjectStore,
+  slotId: "root" | ColumnSlotId,
+): ThreadColumnView {
+  const threadId = selectSlotThreadId(state, slotId)
+  const window = state.requests.threadMessagesById[threadId]
+
+  if (!window || window.loadState.status === "loading") {
+    return createLoadingColumnView(slotId, threadId)
+  }
+
+  if (window.loadState.status === "error") {
+    return createErrorColumnView({
+      slotId,
+      threadId,
+      error: window.loadState.error,
+      canRetry: true,
+    })
+  }
+
+  return createReadyColumnView({
+    slotId,
+    thread: state.entities.threadsById[threadId],
+    messages: selectActiveMessages(state, threadId),
+    assistantRuns: selectRunsForThread(state, threadId),
+    artifactRefs: selectArtifactRefsFromMessages(state, threadId),
+    hasOlderMessages: window.hasOlderMessages,
+  })
+}
+```
+
+可能同时存在:
+
+```text
+Root       ready
+Branch A   running assistant
+Branch B   loading messages
+Branch C   error,可 Retry
+Branch D   ready
+```
+
+这些状态互不覆盖。一个 Branch Query 失败不得把 Project Bootstrap 或其他 Column 改为 error。
+
+## 8. Slot 切换与迟到响应
+
+```text
+Slot S 显示 Thread A,A 正在加载
+→ 用户把 S 切换成 Thread B
+→ A 的响应后来到达
+→ Bundle 只合并到 messages/requests[A]
+→ S 继续显示 B
+→ A 留在 Runtime Message Cache
+```
+
+`applyMessageBundle` 使用 `bundle.threadId` 合并;绝不能使用“发起请求时所在 slotId”决定写入目标。关闭或切换 Column 不取消 Query;ProjectRuntime 销毁时才统一 Abort,且 Abort 不写 error。
+
+## 9. Message 与 Generation 恢复
+
+每个 Bundle 合并成功后:
+
+```ts
+function afterMessageBundleMerged(bundle: ThreadMessageBundle) {
+  for (const run of bundle.assistantRuns) {
+    if (run.status === "queued" || run.status === "running") {
+      // UI 已能先显示 checkpointParts。
+      generationCoordinator.subscribeAssistant(
+        run.assistantMessageId,
+      )
+    }
+  }
+}
+```
+
+Message Query ready 与 Assistant Run running 可以同时成立。事件订阅只更新对应 `assistantMessageId`,不触发其他 Thread 重新加载。
+
+## 10. 决定性不变量
+
+- Project Catalog 加载不是 ProjectBootstrap 的前置条件。
+- URL/Provider 是当前 Project 身份权威,App Store 不保存 selectedProjectId。
+- Workbench Snapshot 只缓存视图,不缓存服务端实体或 Message。
+- 冷启动无 Snapshot 时只打开 Root,不请求未打开 Branch。
+- 有 Snapshot 时先恢复 Column Shell,再非阻塞并行 ensure 各 Branch。
+- 同 Thread 请求去重,不同 Thread 独立并行和独立 error。
+- Runtime Message Cache 只在当前 ProjectRuntime 生命周期内有效。
+- Bundle 始终按 threadId 合并,迟到响应不得改变 Slot 当前 Thread。
+- ProjectRuntime 销毁才 Abort Thread Query;Abort 不停止服务端 MessageRun。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/thread-message-loading.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/thread-message-loading.md
new file mode 100644
index 00000000..d56b37a2
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/design/thread-message-loading.md
@@ -0,0 +1,245 @@
+# Thread Message 异步加载设计
+
+## 1. 边界与权威
+
+Project Catalog 只分页加载 `ProjectSummary`;它不保存当前 Project 的 Thread 或 Message。当前 Project 身份由 `/thread-chat/{projectId}` URL 决定,`ThreadChatProjectProvider` 据此取得独立 `ThreadChatProjectRuntime`。
+
+```mermaid
+flowchart TD
+    App[ThreadChatAppStore] -->|分页 ProjectSummary| Catalog[Project Catalog]
+    URL["URL /thread-chat/{projectId}"] --> Provider[ThreadChatProjectProvider]
+    Provider --> Runtime[ThreadChatProjectRuntime]
+    Runtime --> Store[ThreadChatProjectStore]
+    Runtime --> Loader[ThreadMessageLoader]
+    Runtime --> Runs[GenerationCoordinator]
+    Loader --> API[Thread Message API]
+    API --> Action[applyMessageBundle]
+    Action --> Store
+    Store --> Selectors[Pure Selectors]
+    Selectors --> Columns[Root / Branch Columns]
+```
+
+数据职责:
+
+- App Store:Project 列表摘要与跨 Project 外壳 UI,不保存 `selectedProjectId`。
+- URL/Provider:决定当前 ProjectRuntime。
+- Project Store:规范化保存当前 Project 的 Thread、已加载 Message、Run、按需加载的 Artifact、请求状态和本地工作台状态。
+- ThreadMessageLoader:保存非序列化 `threadId → Promise/AbortController`,负责请求去重与 Runtime 级取消。
+- Column Slot:只决定某个物理列当前展示哪个 Thread,不拥有 Message 数据。
+
+## 2. Bootstrap 与按需加载
+
+`ProjectBootstrap` 一次返回 Project、完整轻量 Thread topology、Project Artifact Summary 和 Root Thread MessageBundle。Root 在 Bootstrap 合并后视为 ready;Bootstrap 不下载所有 Branch Message。
+
+Branch Message 在以下时机调用 `ensureThreadMessages(threadId)`:
+
+1. 用户首次打开尚未加载的 Branch。
+2. 刷新恢复 `ThreadWorkbenchSnapshot` 后,恢复出的 Branch Column 尚未 ready。
+3. 用户对 error Column 明确 Retry。
+
+```mermaid
+sequenceDiagram
+    participant UI as Page
+    participant P as ProjectProvider
+    participant API as Bootstrap API
+    participant S as Project Store
+    participant LS as localStorage
+    participant L as ThreadMessageLoader
+
+    UI->>P: route projectId
+    P->>API: GET /projects/{projectId}/bootstrap
+    API-->>P: Project + topology + Root bundle + stats
+    P->>S: mergeBootstrap
+    S-->>UI: Root ready,立即渲染
+    P->>LS: 读取并校验 Workbench Snapshot
+    LS-->>P: Branch Slots A / B / C
+    P->>S: 恢复 Slot、宽度、折叠态与焦点
+    S-->>UI: Branch Column Loading Shell
+
+    par Branch A
+        P->>L: ensure(A)
+    and Branch B
+        P->>L: ensure(B)
+    and Branch C
+        P->>L: ensure(C)
+    end
+
+    Note over UI,L: 不等待全部 Branch;每列独立 ready/error
+```
+
+Provider 不得 `await Promise.all(...)` 后才展示页面。Root 与 topology 合并后立即渲染;恢复出的 Branch 使用各自 `ThreadMessageWindowState` 展示 loading、ready 或 error。
+
+## 3. Store 中的加载状态
+
+`requests.threadMessagesById` 按 `threadId` 隔离:
+
+```ts
+type ThreadMessageWindowState = {
+  loadState: LoadState
+  hasOlderMessages: boolean
+  oldestReturnedSequence: number | null
+  newestReturnedSequence: number | null
+}
+```
+
+决定性语义:
+
+```text
+不存在 threadMessagesById[threadId]
+  = 从未请求
+
+loadState.loading
+  = 已有请求在飞
+
+loadState.ready + 空 messageIds
+  = 请求成功,但 Thread 当前有效时间线为空
+
+loadState.error
+  = 请求失败,可以独立 Retry
+```
+
+不得用 `messageIds.length === 0` 判断是否需要请求。
+
+```mermaid
+stateDiagram-v2
+    [*] --> NotRequested: 不存在 Thread key
+    NotRequested --> Loading: ensure
+    Loading --> Ready: Bundle 校验并合并成功
+    Loading --> Error: 网络/权限/DTO 校验失败
+    Error --> Loading: Retry
+    Ready --> Ready: 再次打开直接复用
+```
+
+## 4. ThreadMessageLoader
+
+Loader 是 ProjectRuntime 的非序列化基础设施,不是 Zustand slice:
+
+```ts
+type ThreadMessageLoader = {
+  ensure(threadId: ThreadId): Promise
+  destroy(): void
+}
+```
+
+内部至少维护:
+
+```ts
+inFlightByThreadId: Map>
+abortByThreadId: Map
+disposed: boolean
+```
+
+核心伪代码:
+
+```ts
+async function ensureThreadMessages(threadId: ThreadId): Promise {
+  assertThreadBelongsToRuntimeProject(threadId)
+
+  const window = store.getState().requests.threadMessagesById[threadId]
+  if (window?.loadState.status === "ready") return
+
+  const existing = inFlightByThreadId.get(threadId)
+  if (existing) return existing
+
+  store.getState().setThreadMessageLoadState(threadId, {
+    status: "loading",
+  })
+
+  const abortController = new AbortController()
+  abortByThreadId.set(threadId, abortController)
+
+  const request = api
+    .getThreadMessages(threadId, { signal: abortController.signal })
+    .then((bundle) => {
+      if (disposed) return
+
+      validateMessageBundle(bundle, {
+        expectedProjectId: runtime.projectId,
+        expectedThreadId: threadId,
+      })
+
+      // 一次同步 State Transition 合并 Message、Run 和 ready 窗口;Artifact 正文按 ID 另行加载。
+      store.getState().applyMessageBundle(bundle)
+
+      // 只恢复本 Bundle 中 queued/running 的 assistant Run。
+      generationCoordinator.resumeLoadedRuns()
+    })
+    .catch((error) => {
+      if (disposed || isAbortError(error)) return
+      store.getState().setThreadMessageLoadState(threadId, {
+        status: "error",
+        error: normalizeError(error),
+      })
+    })
+    .finally(() => {
+      inFlightByThreadId.delete(threadId)
+      abortByThreadId.delete(threadId)
+    })
+
+  inFlightByThreadId.set(threadId, request)
+  return request
+}
+```
+
+同一 Thread 的多个调用复用同一个 Promise;不同 Thread 的请求互不等待。`applyMessageBundle` 必须验证 Bundle 的 Thread/Project 归属、Message 的 `threadId`、sequence 窗口和 Run 关联后再原子合并。Message 中的 `artifactId` 只是引用,不在此流程加载正文。
+
+## 5. Column 与迟到响应
+
+请求按 `threadId` 写入实体和请求索引,不按 `slotId` 写入。因此切换物理列不会产生“旧响应覆盖新列”的竞态:
+
+```text
+Slot S 展示 Thread A
+→ ensure(A)
+→ 用户把 Slot S 切换为 Thread B
+→ A 的响应稍后到达
+→ 响应只合并到 messageIdsByThreadId[A] 和 requests[A]
+→ Slot S 继续展示 B
+→ A 成为 ProjectRuntime 内可复用缓存
+```
+
+关闭或切换 Column 不取消已经开始的 Thread Query。这样可以避免 UI 快速切换导致重复网络请求;只有 ProjectProvider 释放并销毁整个 ProjectRuntime 时,Loader 才 Abort 全部请求。Abort 只是生命周期结束,不得写入 `loadState.error`。
+
+## 6. Message Query 与 Generation 状态
+
+一个 Column 的展示由两个正交状态组合:
+
+```text
+Thread Message Query
+  not_requested / loading / ready / error
+
+Assistant Run
+  queued / running / completed / failed / stopped
+```
+
+Message Query ready 后,如果 Bundle 中存在 queued/running Run:
+
+1. 立即使用 `checkpointParts` 恢复已经持久化的生成内容。
+2. 使用 `assistantMessageId + eventSequence` 建立或复用事件订阅。
+3. Column 继续显示 ready 的 Message 时间线,同时对应 Assistant Message 显示 running。
+
+单个 Branch Message Query 失败只影响该列;不得使 Bootstrap、Root、其他 Branch 或 GenerationCoordinator 进入全局 error。
+
+## 7. Selector 与 UI 输入
+
+Column 不自行读取整个 Store 或发请求。它通过 Slot 找到当前 Thread,再由 selector 组合:
+
+```text
+ColumnSlot.threadId
+  + ThreadEntity
+  + ThreadMessageWindowState
+  + active MessageEntity[]
+  + AssistantRunState
+  + Message parts 中的 artifactId 引用
+  → ThreadColumnView
+```
+
+`ThreadColumnView` 至少表达列级:
+
+```ts
+type ThreadColumnLoadView =
+  | { status: "loading" }
+  | { status: "error"; error: ClientError; canRetry: true }
+  | { status: "ready"; hasOlderMessages: boolean }
+```
+
+不存在 Thread key 时,已经打开的 Column 也应呈现 loading shell,并由 Lifecycle/Command Hook 触发 ensure;React 组件不得直接 `fetch` 或自行维护第二份 Message loading state。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/e2e-evidence.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/e2e-evidence.md
new file mode 100644
index 00000000..26e09a8b
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/e2e-evidence.md
@@ -0,0 +1,73 @@
+# 阶段 9 前后端集成与 E2E 证据
+
+日期:2026-08-25
+环境:`thread-chat-test` PostgreSQL、Next.js `http://localhost:4040`、Ego Browser task space `11`、`1674 × 963` viewport。
+
+## 最终清理复验
+
+- Domain change 归档后重新执行 87/87 自动测试、`pnpm typecheck` 与默认 Turbopack `pnpm build`,全部通过。
+- Ego Browser 在隔离 `thread-chat-test` + Fake AI Runtime 上再次验证 `/new` 创建、服务端 `projectId` URL、SSE 完成与硬刷新恢复;刷新前后 prompt/reply 均存在,URL 不含旧 tree 路径。
+- 清理前的完整交互矩阵与 UI parity 证据保留如下;清理只替换权威数据路径并删除退役代码,未改变既有 UI 输出。
+
+## 确定性 AI Runtime
+
+- 本地 E2E 没有调用真实模型。服务端仅在 `NODE_ENV !== production` 且
+  `DATABASE_URL` 的数据库名严格等于 `thread-chat-test` 时自动使用
+  `IsolatedTestAiRuntime`。
+- 该选择没有 feature flag,也不接受任意环境变量覆盖;production 或任何非 allowlist
+  数据库始终走真实 `AiSdkRuntime`。
+- Runtime 用自然测试输入提供确定性完成、慢生成、显式 Stop 和 Markdown Artifact,单元测试
+  覆盖选择边界、delta/terminal、Artifact 与 abort。
+
+## 自动集成测试
+
+- `/new`:无实体草稿只提交 user parts;CreationBundle 先 seed 唯一 Project Runtime、先订阅
+  assistant SSE,再执行 route replace。测试让 terminal event 早于目标 Provider 挂载,确认
+  handoff 不丢事件且不二次 Bootstrap。
+- Project 冷启动:Bootstrap 完成前保持 loading;无 Snapshot 使用默认 Root 视图,有合法
+  Snapshot 时在 Bootstrap 后恢复稳定 Slot、焦点、Root/Branch 宽度和列数。
+- Branch 并行加载:两个不同 Thread 同时发起 Message Query;一个成功、一个失败,失败状态只
+  落在目标 Thread,Root 和成功 Branch 仍为 ready。
+- running Run 刷新恢复:Bootstrap 返回既有 running Run 后,Coordinator 以同一
+  `assistantMessageId` 和持久 `eventSequence` 订阅并进入 completed,没有创建第二个 Run。
+
+## Ego Browser E2E
+
+- 通过 UI 注册专用本地账号 `thread-chat-e2e-20260825@example.com`;没有真实邮箱验证。
+- `/new` 首次发送创建服务端 Project 并 replace 到
+  `/thread-chat/da6e6e77-83f3-4bd9-88ea-7c29ff32ed0d`。逐帧探针记录 212 帧,其中
+  172 帧位于目标路由,`blankProjectFrames=0`、`loadingProjectFrames=0`。
+- 首条和后续消息均得到确定性回复。输入“E2E 刷新恢复”时先观察持久 checkpoint 和 Stop,
+  生成中刷新后立即恢复同一 partial,随后进入 completed。
+- 输入“请持续生成,直到我停止”后点击显式 Stop;最终保留 checkpoint,后台状态和 Stop
+  控件消失,没有把 SSE 断开当作 Stop。
+- Edit 把最后一轮有效 user Message 替换为“E2E 编辑后的消息”并生成新 assistant;
+  Regenerate 再次生成不同 assistant ID 后缀的回复,旧 finalized 内容没有原地改写。
+- 从 completed assistant 划选原文创建 L1 Fork,再从 L1 assistant 创建 L2 嵌套 Fork;
+  页面显示 Root/L1/L2 三栏,三个 Message Query 和生成互不阻塞。
+- Header Child 选择器可在收起 L2 后重新打开既有 Thread;重复 Thread 的 `⇄ 切换`交换两个
+  物理 Slot,再次切换可恢复原顺序;L2 breadcrumb 回到已打开 L1 时只收起重复后代列。
+- 三栏初始宽度约 `558 / 558 / 557px`。第一条分割线向右拖 `80px` 后为
+  `638 / 478 / 557px`,只改变相邻列;刷新后恢复相同 Thread ID、L1/L2 层级与列宽。
+- 嵌套 Branch 请求 Markdown 后,消息 API 的 tool output 只含
+  `artifactId=0516d587-5d46-45af-9f15-520bb078627f`,没有正文;消息卡片和 Header 计数为 1,
+  Drawer 按 ID 加载标题与正文。
+- 在隔离测试库临时建立另一个 owner 的 Project。当前浏览器 session 请求其 Bootstrap 得到
+  HTTP 404,Project list 不泄露该 ID;断言完成后临时 owner 已级联删除,残留计数为 0。
+
+## UI parity
+
+阶段 9 沿用阶段 8 的同一 Ego Browser task space、账号、viewport、组件和 CSS 类,并逐项重放
+空白页、三栏、Header Child、Switcher、收起、breadcrumb、Fork Composer、Artifact Drawer
+和分割线交互。对照 `ui-parity-baseline/` 与 `ui-parity-implementation/`,除规范批准的
+Project/Artifact 异步 loading/error 外,没有发现最终样式、布局或交互输出变化。
+
+## 阶段验收
+
+- `pnpm test`:87 项通过(17 unit、17 client、20 PostgreSQL integration、33 API)。
+- `pnpm typecheck`:通过。
+- `pnpm lint`:0 errors;3 个既有 warnings。
+- `pnpm openspec:validate`:27 个 change/spec 严格校验全部通过。
+- Next.js production build:默认 Turbopack 在受限网络请求 Google Fonts 时持续等待且 0% CPU;
+  停止该单一构建进程后,使用 Next.js 16.3.1 官方 `next build --webpack` 完成同一生产构建,
+  编译、TypeScript、23 个静态页面、page data 与 build traces 全部成功。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/frontend-state-verification.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/frontend-state-verification.md
new file mode 100644
index 00000000..9335c56b
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/frontend-state-verification.md
@@ -0,0 +1,23 @@
+# 前端状态架构验收证据
+
+## 自动化门槛
+
+| 门槛 | 命令 | 结果 |
+|---|---|---|
+| 客户端 Store / Runtime / Hook 测试 | `pnpm test:client` | 3 个文件、13 个测试通过;使用 jsdom、Testing Library 与 user-event |
+| 全量自动化测试 | `pnpm test` | 领域单元 14、客户端 13、PostgreSQL 集成 20、API 32,共 79 个测试通过 |
+| TypeScript | `pnpm typecheck` | 通过 |
+| 客户端静态检查 | `pnpm exec eslint lib/thread-chat/client tests/client vitest.client.config.ts` | 通过 |
+| OpenSpec | `pnpm openspec:validate` | 27 项严格校验通过 |
+
+## 覆盖映射
+
+| 客户端能力 | 自动化证据 |
+|---|---|
+| App Store、Project Store、normalizer、sequence、replacement、乱序 Artifact Summary、稳定 Slot、宽度、折叠与 Snapshot 过滤 | `tests/client/store.test.ts` |
+| ThreadMessageLoader 同 ID 去重、跨 Thread 并行、Runtime Abort;ArtifactLoader 按 ID 缓存与 Project 隔离 | `tests/client/runtime.test.ts` |
+| GenerationCoordinator 连接去重、checkpoint 合帧、terminal、断线重连、取消订阅不触发 Stop | `tests/client/runtime.test.ts` |
+| Application Command 请求边界、服务端 ID 合并与订阅启动;ProjectRuntimeRegistry seed handoff 与 lease | `tests/client/runtime.test.ts` |
+| Provider-scoped Runtime、React Strict Mode 生命周期、Selector/Command Hook 与 `/new` 先 seed 后导航 | `tests/client/providers.test.tsx` |
+
+客户端测试通过注入的 `ThreadChatApiCapabilities` adapter 运行,不接现有 UI,不请求真实后端或模型。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/proposal.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/proposal.md
new file mode 100644
index 00000000..dc5ed462
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/proposal.md
@@ -0,0 +1,37 @@
+## Why
+
+`define-thread-chat-domain-model` 已经确立服务端目标模型 `Project → Thread → Message`,但当前前端仍围绕整棵 `ThreadTreeState`、全局 version 和承担多重职责的 `chat-controller` 工作。若不先固定客户端的实体边界、状态职责、Store Action/Application Command 语义和服务端能力,后续 API、Store、Hooks 与 UI 会再次各自发明关系和状态。
+
+本 change 以已确认的领域模型为前提,定义前端如何保存服务端事实、如何从 Store 派生 UI ViewModel、用户操作如何通过 Application Command 变成服务端命令,以及后端 `/api/v1` 必须提供哪些 Query、Command 与生成恢复能力。
+
+## What Changes
+
+- 定义规范化的前端实体模型:Project、Thread、Message、Artifact,以及以 `assistantMessageId` 为关联键的 AssistantRunState。
+- 定义轻量全局 ProjectCatalogStore 与按 `projectId` 创建的 ThreadChatStore;同一个 Project Store 以高内聚 slices 管理服务端实体、加载状态、生成流状态和本地工作台状态。
+- 明确服务端确认实体、运行态、本地 UI 态和派生 ViewModel 的边界;禁止恢复整棵 `ThreadTreeState` 或把派生树形数据复制为第二份权威状态。
+- 统一 Store State、Store Action、Application Command、Selector Hook、Command Hook 和 Lifecycle Hook 术语。
+- 定义纯 Selector 与三类 Hook 的职责,避免 UI 组件直接 `fetch`、拼 Prompt 或修改实体。
+- 定义前端 Application Commands:Project 生命周期、Thread 加载与 Fork、消息发送/Edit/Regenerate、生成订阅/Stop、feedback 和 Project 元数据更新。
+- 定义 `/thread-chat/new` 为无实体 ID 的本地草稿入口;第一次发送时服务端原子创建 Project、Root Thread、首条 user Message、assistant Message 与 MessageRun,客户端再根据返回的 `project.id` 通过集中式路由构造器替换为已创建 Project 的页面 URL。
+- 定义后端 `/api/v1` 的资源标识、请求参数、响应 DTO、错误、原子性、权限与适用场景;所有新实体 ID 均由服务端生成。
+- MVP 首次加载返回全量轻量 Thread topology 和 Root Thread 数据;其他 Thread 按需一次加载最多 200 条有效 Message,不实现复杂分页替换。
+- 本 change 作为客户端状态与 `/api/v1` 契约的唯一交付清单;Zustand Store、Hooks、Route Handler、SSE Client 和 Transport 按 `tasks.md` 的阶段门实施,不再为同一目标增建管理性 change。
+
+## Capabilities
+
+### New Capabilities
+
+- `thread-chat-client-state`:前端规范化实体、Project-scoped Zustand Store、Store Actions、Application Commands、selectors、Hooks、工作台状态与 `/new` 生命周期。
+- `thread-chat-command-api`:支撑 Project、Thread、Message、Fork 和 MessageRun 的 `/api/v1` Query/Command/恢复契约。
+
+### Modified Capabilities
+
+- 无。
+
+## Impact
+
+- 前端目标:替换当前整树 Store、全局 version 订阅、客户端实体 ID 和 `chat-controller` 混合职责;保留组合根、纯 selector、流式合帧与设备本地工作台偏好等正确方向。
+- 后端目标:为新领域模型提供 ProjectBootstrap、Thread Message Query、原子业务 Command 和以 `assistantMessageId + eventSequence` 为核心的生成恢复接口。
+- 共享契约:请求/响应 DTO 必须由前后端共用 schema 校验;具体 HTTP client、认证包装和 SSE 解析属于后续 Transport 设计。
+- 路由:新增 `/thread-chat/new` 草稿入口;已持久化内容使用 `/thread-chat/{projectId}`。
+- OpenSpec:依赖 `define-thread-chat-domain-model` 中 Project、Thread、Message、MessageRun、BaseContext 和 replacement 不变量,不重新定义后端持久化 Schema。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/specs/thread-chat-client-state/spec.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/specs/thread-chat-client-state/spec.md
new file mode 100644
index 00000000..34c5d321
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/specs/thread-chat-client-state/spec.md
@@ -0,0 +1,290 @@
+## ADDED Requirements
+
+### Requirement: 统一客户端状态架构术语
+客户端规范、设计和实现 MUST 使用以下术语,并 MUST NOT 用同一个 `Action` 同时指代本地状态转换和跨边界业务流程:
+
+- **Store State**:Zustand Store 保存的数据,包括服务端确认实体、运行态、请求状态和本地 UI 状态。
+- **Store Action**:与对应 Store 或 slice 共置、通过 Zustand `set`/`setState` 执行一次受控 State Transition 的函数。Store Action MUST NOT 调用后端 API、管理路由或建立事件连接。
+- **Application Command**:位于 Store 外、代表一个用户或生命周期业务意图的可测试流程。它 MAY 调用 API、协调订阅与路由,但 MUST 通过 Store Action 提交状态变化,不得自行调用 `set`/`setState`。
+- **Selector Hook**:通过细粒度 selector 订阅 Store,并向 UI 暴露 State 或衍生 ViewModel 的读取 Hook。
+- **Command Hook**:把作用域 ID 与 Application Command 或纯本地 Store Action 绑定为 UI 事件接口的 Hook;它不得包含业务规则或直接修改 State。
+- **Lifecycle Hook**:负责 Bootstrap、按需加载、生成订阅和本地偏好持久化等 React 生命周期接入的 Hook;真正流程仍委托给 Application Command 或 coordinator。
+
+本文中的“状态变更”表示通过 `set`/`setState` 产生新状态并通知订阅者,不表示对现有 State 对象做任意原地修改。
+
+#### Scenario: API 成功后提交状态
+- **WHEN** Application Command 收到并校验合法服务端 DTO
+- **THEN** 它 MUST 调用语义明确的 Store Action 原子合并 DTO
+- **AND** Application Command、Transport 和 React 组件 MUST NOT 绕过 Store Action 直接调用 `set`/`setState`
+
+#### Scenario: 纯本地 UI 操作
+- **WHEN** 用户只改变可见列、画布位置或 overlay 等本地 UI State
+- **THEN** Command Hook MAY 直接调用对应 UI slice 的 Store Action
+- **AND** 不得为了纯本地 State Transition 建立没有跨边界职责的 Application Command
+
+### Requirement: 使用规范化客户端实体模型
+客户端 MUST 以 Project、Thread、Message 和 Artifact 的服务端 DTO 作为已确认内容事实,并 MUST 以 `assistantMessageId` 为关联键保存 AssistantRunState。客户端 MUST NOT 把整棵 Thread 拓扑、Message 列表和运行态重新组合成可整体写回的 `ThreadTreeState`。
+
+Thread 的 Root/Branch、children、depth 和 breadcrumb MUST 由 `parentThreadId` 派生;Message 默认时间线 MUST 由 `supersededAt IS NULL` 和 `sequence ASC` 派生。BaseContext MUST 保持为服务端内部事实,不得进入普通客户端实体状态或由客户端重建。
+
+#### Scenario: 合并 ProjectBootstrap
+- **WHEN** 客户端收到包含 Project、Thread topology、Root Message、AssistantRunState 和 Project Artifact Summary 的合法 ProjectBootstrap
+- **THEN** 客户端 MUST 按实体 ID 规范化合并这些 DTO 与读模型
+- **AND** 不得保存第二份整树权威快照
+- **AND** Bootstrap 中的 Message 只通过 tool result 的 `artifactId` 引用 Artifact,不得附带 Artifact 正文
+
+#### Scenario: 派生 Branch 关系
+- **WHEN** 一个 Thread 的 `parentThreadId` 指向同 Project 的另一个 Thread
+- **THEN** selector MUST 将它作为该 Parent 的 Child/Branch Thread 展示
+- **AND** 客户端不得要求服务端同时返回可独立修改的 `children` 数组
+
+### Requirement: 按生命周期划分 Zustand Store
+客户端 MUST 使用一个轻量 ProjectCatalogStore 管理 Project 列表,并 MUST 为每个打开的 `projectId` 创建独立 ThreadChatStore。ThreadChatStore MUST 以高内聚 slice 区分已确认 entities、服务端统计 read models、加载/命令状态、生成流状态和本地 workbench UI 状态;它们可以位于同一个 Zustand store,但不得互相复制权威数据。
+
+ProjectCatalogStore MUST NOT 保存 Thread 或 Message。Project-scoped Store 在离开对应 Project 页面后 MUST 可被销毁,不得让多个 Project 的 Message 长期堆积在无边界全局 Store 中。
+
+#### Scenario: 打开两个 Project
+- **WHEN** 用户先后打开 Project A 和 Project B
+- **THEN** 两个 Project 的 ThreadChatStore MUST 具有隔离的实体、生成和工作台状态
+- **AND** ProjectCatalogStore MUST 只保留两者的轻量列表信息
+
+#### Scenario: 高频流事件只更新相关订阅者
+- **WHEN** 某条 assistant Message 收到生成增量
+- **THEN** Store MUST 只改变该 `assistantMessageId` 对应的生成流状态
+- **AND** 只订阅其他 Thread 或 Project 元数据的组件 MUST NOT 因全局 version 而被强制重算
+
+### Requirement: 区分服务端事实、运行态和本地 UI 态
+Project、Thread、Message、Artifact、Project Artifact Summary 和 MessageRun 终态 MUST 以服务端响应为权威。运行中的 checkpoint 和 eventSequence MUST 保存于生成 slice;visibleThreadIds、Column Slot、列宽、画布位置、composer 草稿、overlay 和选中 Artifact MUST 保存为本地 UI 态。
+
+客户端 MAY 将设备相关 workbench UI 态持久化到 localStorage,但 MUST NOT 将它提交为 Project 内容。客户端 MUST NOT 为乐观展示伪造 Project、Thread、Message 或 MessageRun ID。
+
+#### Scenario: 发送期间展示等待态
+- **WHEN** 用户提交命令但服务端尚未返回新实体
+- **THEN** 客户端 MUST 使用本地 submitting/busy 状态展示等待
+- **AND** 不得向 entities slice 插入 `temp-*`、`main` 或客户端 UUID 形式的待创建实体
+
+#### Scenario: 恢复工作台偏好
+- **WHEN** ProjectBootstrap 成功且浏览器存在该 Project 的列布局偏好
+- **THEN** 客户端 MUST 过滤其中已经不存在的 Thread ID、重复 Slot/Thread、非法宽度和无效 Canvas pin 后恢复布局
+- **AND** 本地布局不得覆盖服务端返回的 Thread topology
+
+### Requirement: 使用稳定 Column Slot 表达物理列
+客户端 MUST 使用纯本地 `ColumnSlotId` 表示 Root 右侧物理列,并 MUST 将 Slot 当前展示的 `threadId` 与 Slot 身份分离。Root MUST 使用独立固定列身份,不得进入 Branch Slot 数组。
+
+切换一个物理列所展示的 Thread 时,客户端 MUST 保留该 Slot 的 `slotId`、物理位置、折叠态和显式列宽,只替换 `threadId`。`ColumnSlotId` 不是服务端实体 ID,MUST NOT 进入 Project、Thread、Message、Fork 或 Generation API 请求。
+
+#### Scenario: 本列切换 Thread
+- **WHEN** 用户通过现有列 Header 切换器把 Slot S 从 Thread A 切换到 Thread B
+- **THEN** Store MUST 保持 S 的 `slotId`、宽度、折叠态和位置不变,并将 S 的 `threadId` 更新为 B
+- **AND** 不得因为切换内容而重建物理列或把 A 的宽度改绑到 Thread B 实体
+
+#### Scenario: 恢复重复 Thread 的非法 Snapshot
+- **WHEN** 本地 Snapshot 中两个 Slot 指向同一个 Thread 或复用了同一个 Slot ID
+- **THEN** 客户端 MUST 按稳定顺序只保留第一个合法 Slot
+- **AND** 不得在同一工作台恢复两列相同 Thread
+
+### Requirement: 支持分栏分割线拖拽
+客户端 MUST 保留现有相邻展开列之间的分割线拖拽能力。分割线 MUST 同时调整左右两个物理列,并遵守当前 UI 的最小列宽约束;Root 宽度 MUST 归属于固定 Root 列,Branch 宽度 MUST 归属于稳定 Column Slot,而不是 Thread Entity。
+
+Pointer Move 期间的坐标、临时宽度和 Pointer Capture MUST 保持为 Resizer Hook 或组件局部瞬时状态,不得逐帧写入 Zustand。Pointer Up、键盘步进或双击复位时,客户端 MUST 通过一次 Store Action 原子提交受影响列的最终宽度;提交后的宽度 MUST 进入工作台 Snapshot,滚动位置和拖拽瞬时状态不得持久化。该操作 MUST NOT 调用服务端 API。
+
+#### Scenario: 拖拽相邻列分割线
+- **WHEN** 用户拖动两个展开列之间的分割线并释放 Pointer
+- **THEN** Store MUST 在一次 State Transition 中提交左右两列最终宽度
+- **AND** 不得在每次 Pointer Move 时修改 Zustand 或触发服务端请求
+
+#### Scenario: 切换 Thread 后保留列宽
+- **WHEN** 用户调整 Slot S 的宽度后,通过现有 Header 切换器把 S 从 Thread A 切换到 Thread B
+- **THEN** Slot S MUST 保留原宽度
+- **AND** Thread A 与 Thread B Entity 均不得保存该宽度
+
+#### Scenario: 双击分割线恢复自动宽度
+- **WHEN** 用户双击现有分割线复位
+- **THEN** 客户端 MUST 清除受影响物理列的显式宽度并恢复当前自动均分行为
+- **AND** 下一次工作台 Snapshot MUST 保存复位后的状态
+
+### Requirement: 刷新后恢复 Project 工作台视图
+客户端 MUST 按 `projectId` 将带 `schemaVersion` 的工作台视图投影防抖保存到设备 localStorage。刷新并完成 ProjectBootstrap 后,客户端 MUST 恢复合法的列槽、每列当前 Thread、折叠态、物理列宽、焦点列、列数偏好、放置模式、Columns/Canvas 模式和 Canvas pin。
+
+工作台 Snapshot MUST NOT 直接序列化整个 UI Store,也 MUST NOT 包含服务端实体、Message/Run、滚动位置、DOM 引用、弹层坐标、动画、文本选区、临时 Switcher/Help 打开态、请求状态、命令状态、流缓冲、Generation 连接或 Composer 草稿。
+
+#### Scenario: 刷新恢复多栏视图
+- **WHEN** 用户在 Project P 中打开多个 Branch、调整列宽、折叠一列并刷新页面
+- **THEN** Bootstrap 成功后客户端 MUST 恢复刷新前仍然合法的 Slot、Thread、列宽、折叠态和焦点
+- **AND** 每列滚动位置 MUST 使用当前 UI 默认行为,不得从 Snapshot 恢复
+
+#### Scenario: Snapshot 损坏或版本未知
+- **WHEN** localStorage 不可用、Snapshot 无法解析或 `schemaVersion` 不受支持
+- **THEN** 客户端 MUST 安全回退为只显示 Root 且聚焦 Root 的当前默认视图
+- **AND** 不得影响 ProjectBootstrap、Message 展示或 Generation 恢复
+
+### Requirement: 通过 selector 生成 UI ViewModel
+客户端 MUST 使用纯 selector 从 entities、服务端 read models、AssistantRunState 和 UI state 生成 ThreadColumnView、ThreadColumnHeaderView、ProjectTreeRows、MessageView、ForkAvailability 和 ProjectHeaderView 等 ViewModel。派生结果 MUST NOT 作为另一份可独立修改的领域状态持久化。
+
+Selector MUST 至少覆盖 Root Thread、Child Thread、depth、breadcrumb、有效 Message 时间线、Message 运行展示、Artifact 来源、Fork 可用性、可见列组合、物理 Slot、页面统计和列 Header 操作信息。
+
+#### Scenario: replacement 后重算消息视图
+- **WHEN** Store 合并一条 replacement Message 并将来源 Message 标记为 superseded
+- **THEN** Thread Message selector MUST 隐藏来源 Message并按 sequence 展示 replacement
+- **AND** 不得通过手动修改 UI Message 数组维持第二份时间线
+
+#### Scenario: 组合 ThreadColumnView
+- **WHEN** UI 请求一个 Thread 的列视图
+- **THEN** selector MUST 组合 Thread、有效 Messages、相关 AssistantRunState、Message 中的 Artifact 引用、breadcrumb 和操作可用性
+- **AND** 该 ViewModel 不得成为 API 写入对象
+
+#### Scenario: 打开 Artifact Drawer
+- **WHEN** 用户打开 Message tool result 中 `artifactId` 指向的 Artifact
+- **THEN** Lifecycle/Command Hook MUST 按 ID 调用 Artifact Query,并将成功结果缓存到当前 Project Store
+- **AND** Message 组件不得自行请求或复制 Artifact 正文
+
+#### Scenario: 组合每列 Header ViewModel
+- **WHEN** UI 请求 Root 或某个 Branch Slot 的 Header
+- **THEN** selector MUST 派生标题、Root/Branch、depth、breadcrumbs、直接 Child 数量与列表、Fork 来源可用性以及 switch/collapse 能力
+- **AND** `directChildren` MUST 只包含直接 Child;不得把它作为可修改 children 数组写回 Thread Entity
+
+#### Scenario: 组合页面统计
+- **WHEN** UI 请求 ProjectHeaderView
+- **THEN** `threadCount` 与 `branchCount` MUST 从完整 Thread topology 派生,Artifact 与 Markdown 总数 MUST 从服务端 Artifact Summary 派生
+- **AND** 不得使用局部 `artifactsById` 数量冒充 Project Artifact 总数
+
+#### Scenario: 多个 Run 的 Artifact Summary 乱序到达
+- **WHEN** 当前 Summary 的 `changeSequence=8`,随后收到另一个 Run 携带的 `changeSequence=7`
+- **THEN** Store MUST 忽略该旧 Summary 并继续展示 sequence 8 的统计
+- **AND** `changeSequence` MUST NOT 被提交给任何 Project、Thread、Message 或 Generation Command
+
+### Requirement: 使用 Application Command 执行业务意图
+UI 组件和 React Hook MUST NOT 直接调用 `fetch`、构造 Prompt、创建实体 ID或修改服务端实体。跨越 API、事件连接或路由的用户意图 MUST 进入可独立测试的 Application Command,由 Command 调用 API capability、校验结果并通过 Store Action 原子合并 Store。
+
+Application Command MUST 至少覆盖:加载/创建 Project、加载 Thread、发送首条消息、发送后续消息、Fork、Edit、Regenerate、Stop、feedback、更新 Project 元数据以及生成恢复。
+
+#### Scenario: Fork Command 成功
+- **WHEN** UI 调用 Fork Application Command 并传入已有 sourceThreadId、sourceMessageId 和选区信息
+- **THEN** Command MUST 调用服务端 Fork API 并通过 Store Action 合并服务端返回的 Child Thread
+- **AND** UI 层 MAY 在成功后打开新 Thread,但不得自行计算 BaseContext 或 Child Thread ID
+
+#### Scenario: Application Command 失败
+- **WHEN** 服务端返回结构化领域错误
+- **THEN** Command MUST 保持既有已确认 entities 不变并通过 Store Action 更新相应命令错误状态
+- **AND** Hook MUST 向 UI 暴露可展示的失败结果
+
+### Requirement: 使用 Hooks 连接 Store、Command 与 UI
+客户端 MUST 将 Hook 分为 Selector Hook、Command Hook 和 Lifecycle Hook。Selector Hook MUST 通过细粒度 selector 订阅 Store;Command Hook MUST 调用 Application Command 或纯本地 Store Action;Lifecycle Hook MUST 只负责把 React 生命周期接入 Bootstrap、按需加载、生成订阅和本地偏好持久化流程。
+
+普通衍生数据 MUST NOT 通过 `useEffect + setState` 镜像 Store。单个 Message 组件 MUST NOT 自行发起 Message 或 Generation 查询。
+
+#### Scenario: Thread 组件读取数据
+- **WHEN** ThreadColumn 渲染指定 `threadId`
+- **THEN** 它 MUST 通过 Selector Hook 获得 ThreadColumnView
+- **AND** 不得接收整棵 Project 状态后自行遍历并维护副本
+
+#### Scenario: 页面恢复生成订阅
+- **WHEN** 生命周期 Hook 发现已加载 Message 中存在 queued/running AssistantRunState
+- **THEN** 它 MUST 交给统一 generation coordinator 从 eventSequence 恢复订阅
+- **AND** 每个 Message 组件不得建立重复订阅
+
+### Requirement: 支持无实体 ID 的新 Project 入口
+`/thread-chat/new` MUST 表示本地新 Project 草稿入口,不得把 `new` 当作 Project ID。页面 MUST 在没有 Project/Thread 实体的情况下复用空白聊天 UI;用户第一次发送前不得写入 Project 列表或创建假的 Root Thread。
+
+第一次发送成功后,客户端 MUST 合并服务端原子返回的 Project、Root Thread、user Message、assistant Message 和 AssistantRunState,并 MUST 使用集中式路由构造器根据 `project.id` 得到目标 Project URL,再通过 `router.replace` 进入该页面。失败时 MUST 留在 `/thread-chat/new` 并保留草稿。
+
+#### Scenario: 打开后直接离开 new 页面
+- **WHEN** 用户打开 `/thread-chat/new` 但未发送任何内容便离开
+- **THEN** 系统 MUST NOT 创建 Project、Thread、Message 或 MessageRun
+
+#### Scenario: 首次发送成功
+- **WHEN** 用户在 `/thread-chat/new` 发送第一条有效 Message
+- **THEN** 客户端 MUST 调用创建 Project 的 Application Command,并通过 Store Action 使用服务端返回的实体初始化 Project Store
+- **AND** URL MUST 被替换为客户端路由构造器根据服务端 `project.id` 生成的目标 Project URL
+
+#### Scenario: CreationBundle 无空白帧交接
+- **WHEN** `/new` 创建命令返回合法 CreationBundle
+- **THEN** 客户端 MUST 先用 CreationBundle 建立 bootstrap-ready 的 seeded ProjectRuntime,再执行 `router.replace(threadChatRoutes.project(bundle.project.id))`
+- **AND** 目标 ProjectProvider MUST acquire 同一个 seeded Runtime 并跳过第二次 ProjectBootstrap
+- **AND** `/new` 当前页面 MUST 保持渲染到目标 Project Route 可提交,不得插入空白 Project、第二次 Bootstrap Loading 或清空后等待的 Composer 帧
+- **AND** 客户端 MUST NOT 从 CreationBundle 读取页面 URL
+
+#### Scenario: AI 事件早于目标 ProjectProvider 挂载
+- **WHEN** seeded Runtime 在路由交接完成前收到 A1 的 snapshot、delta 或 terminal 事件
+- **THEN** GenerationCoordinator MUST 把事件合并到该 seeded Runtime
+- **AND** 目标 ProjectProvider 接管后 MUST 直接展示同一 Store 的最新状态,不得重新创建 Run 或丢弃已到达事件
+
+#### Scenario: 首次发送失败
+- **WHEN** 创建命令在服务端提交前失败
+- **THEN** 客户端 MUST 保留本地输入并允许重试
+- **AND** 不得向 ProjectCatalogStore 增加未确认 Project
+
+### Requirement: 采用轻量 Bootstrap 与按 Thread 加载
+进入持久化 Project 时,客户端 MUST 首先加载 ProjectBootstrap。Bootstrap MUST 包含 Project、全量轻量 Thread topology、Project Artifact Summary、Root Thread 的有效 Message 和相关 AssistantRunState,但 MUST NOT 包含所有 Branch Message、BaseContext 或 Artifact 正文。
+
+其他 Thread 的 Message MUST 在首次打开时按 Thread 加载。MVP 客户端 MUST 支持一次合并最多 200 条按 sequence 升序排列的有效 Message,并 MUST 保留服务端返回的 `hasOlderMessages` 信息,但不要求实现复杂的树内自动分页替换。
+
+P0 的 Markdown tool result MUST 只向客户端提供 `artifactId` 引用。只有用户打开 Artifact Drawer 时,客户端才 MUST 通过 Artifact Query 按 ID 加载正文;加载 ProjectBootstrap、ThreadMessageBundle 或 topology 不得提前下载 Markdown 正文。
+
+客户端 MUST 使用 ProjectRuntime 级 `ThreadMessageLoader` 管理 `threadId → in-flight Promise/AbortController`。Promise、AbortController 和 in-flight Map MUST NOT 进入 Zustand;Store 只保存每个 Thread 的 `LoadState`、窗口边界和已合并实体。同一 Thread 的并发 ensure MUST 复用同一个 Promise,不同 Thread MUST 可以并行加载。
+
+#### Scenario: 初次进入已有 Project
+- **WHEN** 用户打开 `/thread-chat/{projectId}`
+- **THEN** 客户端 MUST 先合并 ProjectBootstrap 并展示 Root Thread
+- **AND** 不得为了渲染 topology 下载全部 Branch Message
+
+#### Scenario: 刷新恢复多个 Branch Column
+- **WHEN** Bootstrap 后恢复出的工作台 Snapshot 包含多个尚未 ready 的 Branch Thread
+- **THEN** Lifecycle MUST 非阻塞地并行调用每个 Branch 的 `ensureThreadMessages`
+- **AND** Root MUST 立即渲染;不得等待所有 Branch 请求完成后才展示页面
+- **AND** 每个 Branch MUST 独立进入 loading、ready 或 error,一个失败不得阻塞其他列
+
+#### Scenario: Workbench Snapshot 不是 Message Cache
+- **WHEN** 页面刷新后 localStorage 存在多个 Branch Slot,但新的 ProjectRuntime 只有 Root MessageBundle
+- **THEN** 客户端 MUST 使用 Snapshot 恢复列布局,并分别 ensure 每个 Branch MessageBundle
+- **AND** 不得从 Snapshot 构造 Message、Run 或 ready 状态
+
+#### Scenario: 没有 Workbench Snapshot
+- **WHEN** ProjectBootstrap 成功但当前设备没有合法 Workbench Snapshot
+- **THEN** 客户端 MUST 使用当前默认视图,只展示且聚焦 Root
+- **AND** 不得主动加载任何未打开 Branch 的 Message
+
+#### Scenario: ProjectRuntime 中已有 Message Cache
+- **WHEN** 同一 ProjectRuntime 内某个 Branch 的 ThreadMessageWindow 已是 ready 后再次打开该 Thread
+- **THEN** 客户端 MUST 直接复用已合并实体和窗口状态
+- **AND** 不得重复请求该 Thread 的 MessageBundle
+
+#### Scenario: 首次打开 Branch Thread
+- **WHEN** 用户打开尚未加载 Message 的 Branch Thread
+- **THEN** 客户端 MUST 只请求该 Thread 的 Message bundle 并合并结果
+- **AND** 重复打开已加载 Thread 不得自动重复请求
+
+#### Scenario: 同一 Thread 并发 ensure
+- **WHEN** 两个 Lifecycle/Command Hook 在第一个请求完成前同时 ensure 同一 threadId
+- **THEN** ThreadMessageLoader MUST 返回同一个 in-flight Promise
+- **AND** 服务端只能收到一个对应 Message Query
+
+#### Scenario: Slot 切换后旧 Thread 响应迟到
+- **WHEN** Slot S 在 Thread A 请求期间切换到 Thread B,随后 A 的 Bundle 到达
+- **THEN** Bundle MUST 只按 threadId 合并到 A 的实体索引和请求状态
+- **AND** S MUST 继续展示 B;不得让迟到响应把 Slot 切回 A
+
+#### Scenario: ProjectRuntime 销毁
+- **WHEN** ProjectProvider 卸载并销毁 ProjectRuntime
+- **THEN** ThreadMessageLoader MUST Abort 该 Runtime 的全部在飞 Message Query
+- **AND** Abort MUST NOT 写入可重试 error,也不得影响服务端 MessageRun
+
+#### Scenario: 关闭 Column 时请求仍在进行
+- **WHEN** 用户关闭或切换正在加载的 Branch Column,但 ProjectRuntime 仍存活
+- **THEN** Loader MUST 允许请求完成并按 threadId 缓存合法结果
+- **AND** Column 操作不得把请求结果合并到当前 Slot 的新 Thread
+
+### Requirement: 恢复服务端 MessageRun
+客户端 MUST 使用 `assistantMessageId + eventSequence` 识别和恢复生成。queued/running 的展示 MUST 先使用服务端 checkpoint,再从最后 eventSequence 之后订阅;completed MUST 使用 finalized Message.parts;failed/stopped MUST 展示终态且不得自动重启。
+
+刷新、路由切换或取消订阅 MUST NOT 发送 Stop。只有明确的用户 Stop Application Command 可以请求服务端停止 MessageRun。
+
+#### Scenario: 刷新后恢复 running Message
+- **WHEN** ProjectBootstrap 或 Thread bundle 返回 running AssistantRunState
+- **THEN** 客户端 MUST 立即展示 checkpoint 并从返回的 eventSequence 之后恢复订阅
+- **AND** 不得创建新的 assistant Message 或 MessageRun
+
+#### Scenario: 收到 completed 事件
+- **WHEN** generation coordinator 收到合法 completed 终态
+- **THEN** 客户端 MUST 合并服务端给出的 finalized Message 和终态 Run
+- **AND** 必须清除对应的临时流缓冲
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/specs/thread-chat-command-api/spec.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/specs/thread-chat-command-api/spec.md
new file mode 100644
index 00000000..608bae9f
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/specs/thread-chat-command-api/spec.md
@@ -0,0 +1,207 @@
+## ADDED Requirements
+
+### Requirement: 使用版本化 API 和服务端实体身份
+ThreadChat 后端 MUST 在 `/api/v1` 下提供版本化 Query、Command 和事件接口。客户端 MUST 只提交正在操作的既有资源 ID;Project、Thread、Message 和 MessageRun 的新 ID MUST 由服务端生成并在成功响应中返回。
+
+服务端 MUST 从认证 Session 确定 actor,MUST 对每个 Project、Thread 和 Message 操作执行归属或访问授权校验。普通 JSON 成功响应 MUST 返回 `{ data: T }` 中的权威 DTO,错误 MUST 返回 `{ error: { code, message, details? } }`;204 和事件流除外。请求 Object MUST 严格校验未声明字段。MVP 普通追加、Fork 和生成命令 MUST NOT 要求 Project/Thread revision 或 If-Match。
+
+#### Scenario: 客户端提交待创建实体 ID
+- **WHEN** 创建或 Fork 请求包含 newProjectId、newThreadId、newMessageId 或 newMessageRunId
+- **THEN** 服务端 MUST 忽略或拒绝这些字段
+- **AND** 成功结果中的实体 ID MUST 来自服务端
+
+#### Scenario: 访问其他用户的 Project
+- **WHEN** actor 对目标 Project 没有访问权
+- **THEN** 服务端 MUST 返回 403 或不泄漏存在性的 404
+- **AND** 不得返回 Project topology、Message 或运行状态
+
+### Requirement: 提供 Project 列表与元数据命令
+服务端 MUST 提供列出、更新、归档和永久删除 Project 的能力。列表 MUST 为轻量 ProjectSummary,按 `updatedAt DESC, id DESC` 稳定排序,不得包含 Thread topology 或 Message 正文。
+
+Project metadata 更新 MUST 允许独立修改 customTitle、Target 和 Instruction;未提供字段 MUST 保持不变,显式 null MUST 按字段契约清空。MVP 对 metadata 使用服务端最后写入生效,不引入通用 revision。
+
+#### Scenario: 列出 Project
+- **WHEN** 客户端请求 `GET /api/v1/projects`
+- **THEN** 服务端 MUST 只返回 actor 可访问的 ProjectSummary
+- **AND** 每项 MUST 至少包含 id、展示标题、archivedAt、updatedAt 和轻量统计
+
+#### Scenario: 更新 Project Target
+- **WHEN** 客户端请求 `PATCH /api/v1/projects/{projectId}` 并提交合法 Target
+- **THEN** 服务端 MUST 更新该 Project 的终极、短期和中期目标集合
+- **AND** 不得修改 Thread、Message 或其他 Project
+
+#### Scenario: 永久删除 Project
+- **WHEN** 已授权 actor 明确请求永久删除 Project
+- **THEN** 服务端 MUST 按领域规范清理其 Thread、Message、MessageRun 和附属资源
+- **AND** 后续 Bootstrap MUST 返回 not_found
+
+### Requirement: 原子创建首个 Project 对话
+服务端 MUST 允许客户端用一条命令提交首条 user Message,并在同一数据库事务创建 Project、唯一 Root Thread、user Message、assistant Message 和 queued MessageRun。事务成功前不得唤醒模型执行;任一实体写入失败 MUST 回滚全部创建。
+
+请求 MUST 包含符合 AI SDK v7 UIMessage.parts 的 `initialMessage.parts`,并 MAY 包含 `requestedModelId`。请求不得包含新实体 ID。响应 MUST 返回全部创建实体、初始 ProjectArtifactSummary 和 AssistantRunState。服务端 MUST NOT 返回或构造 Web 页面 URL;页面路由由客户端根据响应中的 `project.id` 决定。
+
+#### Scenario: 首次发送成功
+- **WHEN** 客户端请求 `POST /api/v1/projects` 并提交合法 initialMessage
+- **THEN** 服务端 MUST 返回 201 及 Project、Root Thread、U1、A1 和 queued Run
+- **AND** 初始 ProjectArtifactSummary MUST 是 `{ changeSequence: 0, total: 0, byKind: {} }`
+- **AND** 数据库中不得存在缺少 Root Thread 或缺少 A1 Run 的部分 Project
+
+#### Scenario: 创建响应与 Web 路由解耦
+- **WHEN** 服务端成功创建首个 Project 对话
+- **THEN** CreationBundle MUST 包含可作为资源身份的 `project.id`
+- **AND** CreationBundle MUST NOT 包含 `canonicalUrl`、`pageUrl` 或其他客户端页面路径
+- **AND** Web 客户端 MUST 使用集中式路由构造器决定导航目标
+
+#### Scenario: 首次发送校验失败
+- **WHEN** initialMessage.parts 为空、非法或不符合允许的 UIMessage part 协议
+- **THEN** 服务端 MUST 返回 validation_error
+- **AND** 不得创建任何 Project 数据
+
+### Requirement: 提供 ProjectBootstrap Query
+服务端 MUST 提供 `GET /api/v1/projects/{projectId}/bootstrap`。响应 MUST 包含 Project DTO、全量轻量 ThreadTopologyItem、ProjectArtifactSummary、唯一 Root Thread 的 MessageBundle,以及恢复这些 Message 所需的 AssistantRunState。Message 中可以包含 Artifact ID 引用,但 Bootstrap 不得返回 Artifact 正文。
+
+Bootstrap MUST NOT 返回 BaseContext、所有 Branch Message、全部 Project File/Artifact 正文或服务端 Prompt。Thread topology MUST 足以由客户端派生 Root、Child、depth 和 breadcrumb。
+
+ProjectArtifactSummary MUST 统计该 Project 的全部 Artifact,并 MUST 至少返回服务端单调 `changeSequence`、总数和按稳定 `kind` 聚合的数量。它不得退化为客户端已经按 ID 加载的 Artifact 数量;`total` 必须等于各 kind 计数之和。`changeSequence` 只用于拒绝乱序旧统计,不得成为 Command 请求参数或写入前置条件。
+
+#### Scenario: 加载现有 Project
+- **WHEN** actor 请求可访问 Project 的 Bootstrap
+- **THEN** 服务端 MUST 返回且只返回一个 Root Thread,并返回全部轻量 topology
+- **AND** Root MessageBundle MUST 按 sequence 升序排列有效 Message
+- **AND** ProjectArtifactSummary MUST 覆盖该 Project 全量 Artifact
+
+#### Scenario: Project 不存在
+- **WHEN** projectId 不存在或不可访问
+- **THEN** 服务端 MUST 返回 not_found
+- **AND** 不得创建空 Project 作为降级结果
+
+#### Scenario: 未加载 Branch Artifact 仍计入统计
+- **WHEN** Project 的 Branch Thread 中存在 3 个 Markdown Artifact,但 Bootstrap 不返回该 Branch 的 Message
+- **THEN** `artifactSummary.byKind.markdown` MUST 仍然等于 3
+- **AND** Root bundle MUST NOT 因此返回这 3 个 Artifact 的正文
+
+### Requirement: 按 Thread 提供 MessageBundle
+服务端 MUST 提供 `GET /api/v1/threads/{threadId}/messages`,并 MUST 通过 Thread 所属 Project 校验访问权。响应 MUST 返回有效 Message 与相关 AssistantRunState;默认最多返回最新 200 条,再按 sequence 升序输出。
+
+响应 MUST 包含 `hasOlderMessages` 和可供未来向前加载的边界 sequence。MVP 客户端可以不请求更早页面,但 API 不得让调用方通过下载整棵 Project 才能读取一个 Thread。
+
+P0 中,Markdown tool result MUST 在符合 AI SDK v7 的 Message part 中保存 `artifactId`,不得复制 Markdown 正文。客户端需要展示正文时 MUST 通过独立 Artifact Query 按 ID 加载。
+
+#### Scenario: Thread 少于 200 条有效 Message
+- **WHEN** 客户端读取包含 80 条有效 Message 的 Thread
+- **THEN** 服务端 MUST 返回全部 80 条并设置 `hasOlderMessages=false`
+
+#### Scenario: Thread 超过 200 条有效 Message
+- **WHEN** 客户端未指定边界读取包含超过 200 条有效 Message 的 Thread
+- **THEN** 服务端 MUST 返回最新 200 条并按 sequence 升序排列
+- **AND** 必须设置 `hasOlderMessages=true` 和更早页面边界
+
+### Requirement: 提供 Thread 元数据命令
+服务端 MUST 允许授权 actor 更新 Branch Thread 的 customTitle,并 MUST 提供归档和取消归档 Thread 的显式命令。Root Thread 的展示标题 MUST 继续来自 Project,客户端不得通过 Thread metadata 命令为 Root 建立第二套标题权威。
+
+归档 Thread MUST 保留其 Message、Child Thread、Fork 来源和 BaseContext;它只改变默认导航可见性,不得等同于永久删除。
+
+#### Scenario: 重命名 Branch Thread
+- **WHEN** 客户端请求 `PATCH /api/v1/threads/{threadId}` 并提交合法 customTitle
+- **THEN** 服务端 MUST 更新该 Branch Thread 标题并返回最新 Thread DTO
+- **AND** 不得修改 Project 标题
+
+#### Scenario: 归档 Thread
+- **WHEN** 客户端请求 `POST /api/v1/threads/{threadId}/archive`
+- **THEN** 服务端 MUST 设置 archivedAt 并保留完整 Thread 内容和后代关系
+
+### Requirement: 原子发送后续消息并启动生成
+服务端 MUST 提供在既有 Thread 中发送 user Message 的常用原子命令。该命令 MUST 在同一事务创建 user Message、assistant Message 和 queued MessageRun,并返回三者;模型执行只能在事务提交后启动。
+
+Path MUST 包含已有 `threadId`;Body MUST 只包含合法 user `parts` 和可选 `requestedModelId`。服务端 MUST 根据当前有效历史构造 Prompt,客户端不得提交 Prompt History、BaseContext、待创建 ID 或整棵 Project 状态。
+
+P0 MUST NOT 额外提供“只创建 user Message、不创建 assistant Message 与 MessageRun”的发送命令。该限制只是当前 API 能力边界,不得被实现为 user/assistant 必须角色交替的数据库约束。
+
+P0 MUST NOT 额外提供“只创建 user Message、不创建 assistant Message 与 MessageRun”的发送命令。该限制只是当前 API 能力边界,不得被实现为 user/assistant 必须角色交替的数据库约束。
+
+#### Scenario: 在 Root Thread 发送消息
+- **WHEN** 客户端请求 `POST /api/v1/threads/{threadId}/messages` 并提交合法 user parts
+- **THEN** 服务端 MUST 分配新的 sequence 并返回 user Message、assistant Message 和 queued Run
+- **AND** 响应中的所有新 ID MUST 由服务端生成
+
+#### Scenario: 同 Thread 已有运行中生成
+- **WHEN** 当前产品策略不允许同一 Thread 并发生成且已有 queued/running Run
+- **THEN** 服务端 MUST 返回 `thread_generation_in_progress`
+- **AND** 不得依赖客户端 busy 状态作为唯一保护
+
+### Requirement: 原子创建 Fork Thread
+服务端 MUST 提供从已有 sourceThreadId 和 sourceMessageId 创建 Child Thread 的命令。请求 MAY 包含用户选区 anchor/quote,但 MUST NOT 包含 BaseContext、ForkSourceSnapshot 的权威字段或 Child Thread ID。
+
+服务端 MUST 验证来源资格、同 Project 关系和无环约束,在一个事务中计算并持久化 BaseContext、ForkSourceSnapshot 和 Child Thread,然后返回新 Thread DTO。
+
+#### Scenario: 从 completed assistant Message Fork
+- **WHEN** 客户端请求 `POST /api/v1/threads/{sourceThreadId}/forks` 且 sourceMessage 合格
+- **THEN** 服务端 MUST 返回 201 和服务端创建的 Child Thread
+- **AND** Child Thread 必须属于同一 Project并冻结到来源 Message 的 BaseContext
+
+#### Scenario: 从 running assistant Message Fork
+- **WHEN** sourceMessage 的 Run 为 queued 或 running
+- **THEN** 服务端 MUST 返回 `fork_source_not_finalized`
+- **AND** 不得创建部分 Child Thread
+
+### Requirement: 使用 replacement 命令实现 Edit 和 Regenerate
+服务端 MUST 分别提供 Edit 最后一条有效 user Message 和 Regenerate 当前可重新生成 assistant Message 的命令。两种命令 MUST 创建服务端 ID 的 replacement Message;Regenerate 以及 Edit 后的新回答 MUST 同时创建新的 queued MessageRun,且不得覆盖旧 Message.parts 或 sequence。
+
+Edit 请求 MUST 只提交 sourceUserMessageId、新 parts 和可选 requestedModelId。Regenerate 请求 MUST 只提交 sourceAssistantMessageId 和可选 requestedModelId。响应 MUST 返回被 superseded 的 Message ID、全部新增 Message 和新的 AssistantRunState。
+
+#### Scenario: Regenerate 当前 assistant Message
+- **WHEN** 客户端请求 `POST /api/v1/messages/{assistantMessageId}/regenerations` 且来源可重新生成
+- **THEN** 服务端 MUST 返回 replacement assistant Message 和新的 queued Run
+- **AND** 来源 Message 的内容与 sequence 必须保持不变
+
+#### Scenario: Edit 最后一条 user Message
+- **WHEN** 客户端请求 `POST /api/v1/messages/{userMessageId}/edits` 并提交新 parts
+- **THEN** 服务端 MUST 返回 replacement user Message、replacement assistant Message、queued Run 和被 superseded 的后缀 ID
+
+#### Scenario: Edit 历史 user Message
+- **WHEN** sourceUserMessageId 不是当前 Thread 最后一条有效 user Message
+- **THEN** 服务端 MUST 返回 `fork_required`
+- **AND** 不得修改任何既有 Message
+
+### Requirement: 通过 assistantMessageId 管理生成生命周期
+服务端 MUST 允许客户端使用 assistantMessageId 查询、通过 SSE 订阅并停止对应 MessageRun,而不要求客户端理解内部 MessageRun ID。SSE 事件流 MUST 使用严格递增的 eventSequence,并 MUST 支持 `afterEventSequence` 恢复。每次连接 MUST 先返回当前持久化 checkpoint snapshot 及其 cursor;若仍在运行,后续 live event MUST 严格大于该 cursor。旧 token delta MAY 不逐条重放,但恢复结果不得丢失已经持久化的生成内容。
+
+Stop MUST 是显式 Command;连接关闭、页面刷新或取消订阅不得自动停止 Run。终态事件 MUST 携带或允许随后取得 finalized Message 和终态 AssistantRunState。`run.snapshot` 与 `run.completed` MUST 携带当前 ProjectArtifactSummary,使客户端能够在刷新、重连和 Artifact 生成完成后校正 Project 页面统计;客户端不得依赖事件次数自行累加。
+
+#### Scenario: 恢复 running 事件流
+- **WHEN** 客户端请求 assistant Message 事件且提交 `afterEventSequence=42`
+- **THEN** 服务端 MUST 先发送不旧于该游标的当前 checkpoint snapshot
+- **AND** 后续 live event MUST 大于 snapshot cursor
+- **AND** 不得重新启动第二个 MessageRun
+
+#### Scenario: 显式停止生成
+- **WHEN** 客户端请求 `POST /api/v1/assistant-messages/{assistantMessageId}/stop`
+- **THEN** 服务端 MUST 对 queued/running Run 记录 stop 请求并返回最新 AssistantRunState
+- **AND** 重复 Stop MUST 返回相同终态或当前状态而不创建新 Run
+
+#### Scenario: 仅断开订阅
+- **WHEN** 浏览器关闭事件连接但未发送 Stop
+- **THEN** MessageRun MUST 继续由服务端执行
+
+### Requirement: 按 ID 加载 Artifact
+服务端 MUST 提供 `GET /api/v1/artifacts/{artifactId}`,并 MUST 通过 Artifact 所属 Project 校验访问权。Message、ThreadMessageBundle、ProjectBootstrap 和生成事件 MUST 只通过 `artifactId` 引用 Artifact,不得复制 Markdown 正文。
+
+#### Scenario: 打开 Markdown Artifact
+- **WHEN** 用户打开 Message tool result 引用的 Markdown Artifact
+- **THEN** 客户端 MUST 使用 `artifactId` 请求 Artifact Query
+- **AND** 服务端 MUST 返回对应 Artifact 的完整内容
+
+#### Scenario: 未打开 Artifact
+- **WHEN** 客户端只加载 ProjectBootstrap 或 ThreadMessageBundle,但用户没有打开 Artifact
+- **THEN** 服务端 MUST NOT 返回 Artifact 正文
+
+### Requirement: 提供 Message feedback 命令
+服务端 MUST 允许客户端按 assistantMessageId 设置 positive、negative 或 null feedback。只有 finalized 且允许评价的 assistant Message 可以接受 feedback;命令不得修改 Message 内容或 MessageRun。
+
+#### Scenario: 设置正向反馈
+- **WHEN** 客户端请求 `PUT /api/v1/messages/{assistantMessageId}/feedback` 并提交 `positive`
+- **THEN** 服务端 MUST 返回该 Message 最新 feedback
+
+#### Scenario: 评价 user Message
+- **WHEN** 目标 Message 不是 assistant Message
+- **THEN** 服务端 MUST 返回 `message_not_feedback_eligible`
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/tasks.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/tasks.md
new file mode 100644
index 00000000..e211b14f
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/tasks.md
@@ -0,0 +1,113 @@
+## 0. 阶段 0:冻结 API、客户端与 UI 实施边界
+
+- [x] 0.1 对本 change 与 `define-thread-chat-domain-model` 执行严格校验,确认 API/客户端只消费既定领域关系,不重新定义 Project、Thread、Message、MessageRun、replacement 或 BaseContext。
+- [x] 0.2 固定职责分界:本 change 负责共享契约、`/api/v1`、SSE、API 测试、客户端状态架构、现有 UI 接入与 E2E;数据库、领域、Repository 和 MessageRun 执行由 domain change 负责。
+- [x] 0.3 固定 P0:发送原子创建 `user + assistant + Run`、Markdown tool output 只保存 `artifactId`、生成 Transport 使用 SSE、通用幂等命令留到 V2。
+- [x] 0.4 固定 UI 硬门槛:允许改变 Project loading、拆分组件和替换数据胶水;最终布局、样式和既有交互行为不得改变。若实现必须产生用户可见差异,立即暂停并向用户确认。
+- [x] 0.5 固定实施顺序:后端领域门 → API 门 → 前端 Store/Runtime/Hooks → UI 无损接入 → 前后端集成 → Ego Browser E2E → 清理归档。
+- [x] 0.6 固定归档顺序:E2E 与旧权威清理完成后先归档 domain change,再归档本 change,避免循环依赖。
+
+## 1. 建立共享 V1 契约与 Transport 边界
+
+- [x] 1.1 为 `api-contracts.md` 中全部 ID、DTO、Request、Response、Error 和 SSE Event 建立前后端共享严格 Zod Schema。
+- [x] 1.2 为 AI SDK v7 `UIMessage.parts` 建立共享校验入口,限制客户端提交合法 user parts,并定义 Markdown tool output 的 `artifactId` 结构。
+- [x] 1.3 定义不依赖 Zustand、React 或具体 HTTP 库的 `ThreadChatApiCapabilities`,覆盖 Project、Thread、Message、Artifact、feedback、SSE 与 Stop。
+- [x] 1.4 实现 JSON Transport 的 Session/认证处理、请求编码、响应 Schema 校验和统一 `ClientError` 映射。
+- [x] 1.5 实现集中式 `threadChatRoutes`;页面 URL 只由客户端构造,服务端 DTO 不包含 `canonicalUrl` 或其他页面路径。
+- [x] 1.6 建立契约 fixture,覆盖成功 DTO、结构化错误、未知字段拒绝、错误实体归属和非法 AI SDK part。
+
+## 2. 实现 Project 与 Thread Query API
+
+- [x] 2.1 实现 `GET /api/v1/projects` 的 owner scope、active/archived 过滤、稳定排序、limit 和绑定查询条件的不透明 cursor。
+- [x] 2.2 实现 `GET /api/v1/projects/{projectId}/bootstrap`,返回 Project、全量轻量 topology、ProjectArtifactSummary 与唯一 Root ThreadMessageBundle。
+- [x] 2.3 实现 `GET /api/v1/threads/{threadId}/messages`,按有效 Message sequence 查询最新最多 200 条并返回窗口边界。
+- [x] 2.4 确保 Bootstrap 和 MessageBundle 不返回 BaseContext、Prompt History、未打开 Branch Message 或 Artifact 正文。
+- [x] 2.5 实现 `GET /api/v1/artifacts/{artifactId}`,从 Artifact 所属 Project 校验 actor,并只在按 ID 请求时返回完整内容。
+- [x] 2.6 实现 ProjectArtifactSummary 的服务端统计和单调 `changeSequence`,不得从客户端已加载 Artifact 数量反推。
+
+## 3. 实现 Project、Thread 与 Message Command API
+
+- [x] 3.1 实现 `POST /api/v1/projects`,调用后端 Application 原子创建 Project、Root、U1、A1 与 queued Run,并在响应中返回 CreationBundle。
+- [x] 3.2 实现 Project metadata、archive/unarchive 和永久删除 API,保持缺省字段与显式 null 的不同语义。
+- [x] 3.3 实现 Branch Thread title、archive/unarchive API,并拒绝通过 Thread metadata 修改 Root 标题或归档 Root。
+- [x] 3.4 实现 `POST /api/v1/threads/{threadId}/messages`,原子返回 user Message、assistant Message 与 queued Run;P0 不增加 user-only append API。
+- [x] 3.5 实现 Fork API,只接受既有 sourceThreadId、sourceMessageId 与 anchor,不接受 Child ID、BaseContext 或 ForkSourceSnapshot 权威字段。
+- [x] 3.6 实现 Edit 与 Regenerate replacement API,返回 ReplacementBundle,保证旧 finalized Message parts 与 sequence 不变。
+- [x] 3.7 实现 feedback API,只允许对合格 assistant Message 设置 positive、negative 或 null。
+- [x] 3.8 为全部 Route 加入 Session actor、owner scope、归档状态、业务资格、严格字段校验和统一错误映射。
+
+## 4. 实现 SSE 生成恢复与 Stop
+
+- [x] 4.1 实现 `GET /api/v1/assistant-messages/{id}/events` SSE;每次连接首个业务事件固定为持久化 `run.snapshot`。
+- [x] 4.2 实现 `afterEventSequence` 校验、重复/倒序过滤和 snapshot 后严格递增的 live event。
+- [x] 4.3 实现 `run.delta`、`run.completed`、`run.failed`、`run.stopped` Schema,并在 snapshot/completed 携带最新 Artifact Summary。
+- [x] 4.4 确保 finalized Message tool output 只包含 `artifactId`,SSE 不复制 Artifact 正文。
+- [x] 4.5 实现显式 Stop API;关闭页面、刷新、切换 Thread 或取消 SSE 不得触发 Stop。
+- [x] 4.6 验证 queued/running Run 刷新后复用同一 assistantMessageId 和 MessageRun,不启动第二次执行。
+
+## 5. 后端 API 验收门
+
+- [x] 5.1 在 domain change 的 Vitest 与隔离 PostgreSQL 基础上完成 API 合同测试;自动测试统一使用 Fake AI Runtime,不调用真实模型。
+- [x] 5.2 覆盖 Project list/bootstrap、Thread Message window、Artifact-by-ID、metadata、archive/delete、feedback 和全部错误码。
+- [x] 5.3 覆盖 create/send/Fork/Edit/Regenerate/Stop 的权限、严格输入、原子响应与事务回滚;确认服务端不返回页面 URL。
+- [x] 5.4 覆盖 SSE snapshot、eventSequence、delta、terminal、断开重连、重复连接和显式 Stop。
+- [x] 5.5 覆盖 Markdown Artifact:Message 只保存 `artifactId`、Bootstrap/MessageBundle/SSE 无正文、Artifact Query 按 ID 返回正文。
+- [x] 5.6 执行 `pnpm typecheck`、domain unit/integration、API tests、`pnpm build` 与 `pnpm openspec:validate`;全部通过并记录证据后才能开始第 6 节前端工作。
+
+## 6. 建立前端测试基础与 Zustand Store
+
+- [x] 6.1 增加 Testing Library、user-event 与适配 React Hooks 的 Vitest DOM 环境;不得把 Ego Browser E2E 混入单元测试。
+- [x] 6.2 使用 vanilla Zustand 建立 Provider-scoped `ThreadChatAppStore`,包含 Project Catalog 与 AppShellUi slices,不保存 selectedProjectId。
+- [x] 6.3 建立按 projectId 创建的 `ThreadChatProjectStore`,包含 entities、runs、requests、readModels 与 workbench ui slices。
+- [x] 6.4 实现共享 normalizer,维护 `messagesById + messageIdsByThreadId` 的归属、去重与 sequence 排序。
+- [x] 6.5 实现 Creation、Bootstrap、Message、replacement、Run Event、Artifact 与 Summary 的语义化 Store Actions。
+- [x] 6.6 实现稳定 Column Slot、Root/Branch 宽度、折叠、焦点、placement、Canvas pin、overlay 与 Workbench Snapshot Store Actions。
+- [x] 6.7 确保 Store Action 只同步调用 Zustand `set`/`setState`,不请求 API、不导航、不创建连接,也不原地修改已确认实体。
+- [x] 6.8 完成 Store/normalizer 测试:归属拒绝、sequence、replacement、重复 DTO、乱序 Summary、局部 Run 更新与 Snapshot 校验。
+
+## 7. 实现 Runtime、Application Commands 与 Hooks
+
+- [x] 7.1 实现 `ThreadChatAppRuntime`、`ThreadChatProjectRuntime` 与 `ProjectRuntimeRegistry`,保证 Provider 生命周期内 projectId 对应唯一 Runtime。
+- [x] 7.2 实现 `ThreadChatAppProvider`、`ThreadChatProjectProvider` 与 `NewProjectDraftProvider`,避免 Next.js 服务端模块级 Store 跨请求共享。
+- [x] 7.3 实现 `/thread-chat/new` seeded Runtime handoff:先合并 CreationBundle,再由客户端 route builder replace,目标 Provider 跳过第二次 Bootstrap。
+- [x] 7.4 实现 ThreadMessageLoader 的 threadId 级 Promise 去重、跨 Thread 并行与 Runtime destroy 统一 Abort。
+- [x] 7.5 实现 ArtifactLoader 的 artifactId 级按需缓存,不因 Thread 加载自动请求正文。
+- [x] 7.6 实现 GenerationCoordinator 的 assistantMessageId 级连接去重、snapshot 合并、断线重连、取消订阅和 destroy。
+- [x] 7.7 实现 Catalog、Bootstrap、send、Fork、Edit、Regenerate、Stop、feedback 和 metadata Application Commands;客户端不生成服务端实体 ID。
+- [x] 7.8 实现纯 selectors、Selector Hooks、Command Hooks 与 Lifecycle Hooks;组件不得直接 fetch 或维护第二份领域状态。
+- [x] 7.9 完成 Runtime、Loader、Coordinator、Command、Selector 与 Hook 的接口注入测试。
+
+## 8. 建立 UI Parity 基线并无损接入现有 UI
+
+- [x] 8.1 在修改 UI 数据接缝前,使用 Ego Browser 和专用本地测试账号记录现有空白页、单列、多栏、Header、Fork、Artifact Drawer、折叠/切换、breadcrumb 与分割线拖拽的参考截图和交互清单。
+- [x] 8.2 保持现有组件、CSS 类名、布局与交互输出,先只把 Project 列表、`/new` 和已有 Project 页面接到新的 Provider/Runtime。
+- [x] 8.3 将 Root/Branch Column 改为消费 ThreadColumnView 和 ThreadColumnHeaderView;允许内部拆分复用,但最终呈现不得变化。
+- [x] 8.4 接入现有 Header 的 Child 选择、Thread 切换、收起和 breadcrumb,保持稳定物理 Slot 与列宽。
+- [x] 8.5 保留相邻列分割线拖拽;Pointer Move 使用组件瞬时状态,Pointer Up/键盘/双击复位只提交一次 Store Action。
+- [x] 8.6 实现按 projectId 的 Workbench Snapshot 防抖保存和刷新恢复;恢复多栏、折叠、焦点和列宽,不恢复滚动条或 Composer 草稿。
+- [x] 8.7 接入 Artifact Drawer 按 `artifactId` 加载及独立 loading/error;生成期间禁用 Fork,服务端仍作最终校验。
+- [x] 8.8 逐项对照阶段 8.1 的截图和交互清单;任何必须产生用户可见差异的实现立即停止,记录影响并向用户确认后才能继续。
+
+## 9. 前后端集成与 Ego Browser E2E
+
+- [x] 9.1 完成 `/new` 无实体草稿、首次发送、seeded Runtime 无空白帧切换和 AI 事件早于目标 Provider 挂载的集成测试。
+- [x] 9.2 完成已有 Project 冷启动、有/无 Workbench Snapshot、多 Branch 并行加载、单列失败和刷新恢复 running Run 的集成测试。
+- [x] 9.3 使用 Ego Browser 通过邮箱注册专用本地测试账号;不得依赖真实邮箱验证或真实模型随机输出作为断言。
+- [x] 9.4 E2E 验证 Project 创建/列表、首条与后续消息、生成中刷新、Stop、Edit、Regenerate、Fork 与嵌套 Fork。
+- [x] 9.5 E2E 验证多栏异步加载、Header Child 选择、Thread 切换、收起、breadcrumb、分割线拖拽与刷新视图恢复。
+- [x] 9.6 E2E 验证 Markdown Artifact 创建、消息 `artifactId` 引用、Drawer 按 ID 加载和其他 Project 访问隔离。
+- [x] 9.7 对比 UI parity 基线,确认除已批准的 Project loading 外,最终样式和交互行为没有变化。
+
+## 10. 旧客户端退役与归档
+
+- [x] 10.1 E2E 通过后将 `/thread-chat/new`、`/thread-chat/{projectId}` 和 Project 列表切到新权威路径;开发期间不维护 feature flag 或双写。
+- [x] 10.2 删除客户端 treeId、新实体 UUID、整树 version 订阅、旧 ThreadTree 写回和 monolithic chat-controller 权威职责;保留仍被新组合根复用的纯 UI 部件。
+- [x] 10.3 通知 domain change 执行旧后端/Schema 退役,并等待其完成全量测试和先行归档。
+- [x] 10.4 domain change 归档后,执行 `pnpm typecheck`、全部自动测试、`pnpm build`、Ego Browser E2E 和 OpenSpec 严格校验。
+- [x] 10.5 对照两个增量 spec 的 Requirement/Scenario 汇总实现证据,在全部门槛满足后归档 `design-thread-chat-client-api`。
+
+## 11. 归档后沉淀稳定客户端与 API 文档
+
+- [x] 11.1 从已归档 design 提炼客户端架构到 `openspec/specs/thread-chat-client-state/architecture.md`,保留 Store、Runtime、Provider、Command、Hook 与异步加载边界。
+- [x] 11.2 将落地后的 API 合同提炼到 `openspec/specs/thread-chat-command-api/api-contracts.md`,并校验它与共享 Schema 和实际 Route Handler 一致。
+- [x] 11.3 在两个正式 `spec.md` 中链接对应说明文档,明确冲突时可验证 Requirement 优先,并把后续同步更新列为验收项。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/README.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/README.md
new file mode 100644
index 00000000..c8e1cf0e
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/README.md
@@ -0,0 +1,86 @@
+# Thread Chat UI parity baseline
+
+本目录是在客户端数据接缝修改前,通过 Ego Browser 任务空间 `11`
+(`thread-chat-ui-parity`)记录的旧 UI 参考。后续阶段 8.8 与 9.7 必须以这里的截图和
+交互清单为准;除已批准的 Project loading 外,不得把实现差异解释为新的设计。
+
+## 采集环境
+
+- 日期:2026-08-25
+- viewport:`1674 × 963` CSS px
+- 专用本地账号:`thread-chat-e2e-20260825@example.com`
+- 数据库:allowlist 保护的独立 `thread-chat-test` PostgreSQL
+- Fixture:一条 Main、两层嵌套 Fork、一个 Markdown Artifact;不调用真实模型
+- 旧路由:`/thread-chat/{treeId}`
+- 旧 UI 的历史 Message 省略 `status`,沿用“缺省即完成”的既有语义;没有伪造
+  generation sidecar,也没有建立新旧双写
+
+## 参考截图
+
+| 文件 | 状态与必须保持的输出 |
+| --- | --- |
+| `empty-project.png` | 空白 Project:单个 Main 列、固定 Header、底部居中 Composer |
+| `single-column.png` | Main 消息、Artifact 卡片、Header 计数,正文宽度与留白 |
+| `header-child-selector.png` | Header Child 选择器;列出直接和嵌套后代,显示脚注号 |
+| `two-columns.png` | Main + 第一层 Fork;相邻列、breadcrumb、引用与继承上下文 |
+| `three-columns-nested-fork.png` | Main + Fork + 嵌套 Fork;两个相邻分割线 |
+| `artifact-drawer.png` | Markdown Drawer 打开态;右半屏预览、来源与定位操作 |
+| `divider-dragged.png` | 第一条分割线向右拖动 110px 后的局部列宽变化 |
+| `collapsed-column.png` | 收起末端 Fork 后保留 Main + 上游 Fork,并重新均分空间 |
+| `thread-switcher.png` | 当前物理 Slot 的 Thread 切换器、搜索框和“本列”标记 |
+| `switched-slot.png` | 同一第二 Slot 从第一层 Fork 切换到嵌套 Fork |
+| `breadcrumb-back.png` | 点击上游 breadcrumb 后,同一 Slot 回到第一层 Fork |
+| `fork-composer.png` | 划选 assistant 原文后的 Fork Composer 与放置目标 |
+
+## 固定交互清单
+
+### Header 与基本布局
+
+1. 顶栏顺序保持为:新对话、对话列表、产品名、使用提示、列/画布、列数、列满放置
+   策略、会话树、Markdown、账号。
+2. Main Header 显示“锚定”、标题、副标题和 Child 数量;Branch Header 显示层级、标题、
+   Child、`⇄ 切换`、`收起`,其上方保留 breadcrumb。
+3. Composer 固定在每列底部;Main placeholder 为“继续在主线提问…”,Branch 为
+   “在这个分支里追问…”。
+4. 1674px viewport 下三栏初始宽度约为 `558.33 / 558.34 / 557.34px`。
+
+### Fork 与多栏
+
+1. 划选 assistant 文本后,原文保持浏览器选择高亮,并打开“在新分支中讨论这段”
+   Composer;问题可留空,放置目标显示现有物理 Slot 与右侧新增 `+`。
+2. Main Header 的 Child 选择器同时显示第一层和嵌套后代;点击第一层 Fork 在来源列紧邻
+   右侧打开,不改变 Main Slot。
+3. 从第一层 Branch 的 Child 选择器打开嵌套 Fork 后形成三栏;嵌套列的 breadcrumb 为
+   `Main › 第一层 › 当前 Thread`。
+4. 点击 Branch 的“收起”会移除该物理 Slot,并让剩余列重新分配可用宽度。
+
+### 切换与 breadcrumb
+
+1. `⇄ 切换`打开当前 Slot 的 Thread 选择器;选择器列出 Main 和全部 Branch,当前内容显示
+   “本列”,点击目标只替换该 Slot。
+2. 第二 Slot 从第一层切到嵌套 Fork 后仍保持两栏,各约半宽;不会额外打开第三栏。
+3. 点击嵌套 Fork breadcrumb 中的第一层标题,会在同一 Slot 回退到第一层并短暂使用
+   `flash` 状态提示定位。
+
+### 分割线
+
+1. 分割线使用 `role="separator"`,可由鼠标、键盘操作,且 aria-label 明确相邻 Thread。
+2. 第一条分割线从 `x≈558` 拖到 `x≈668` 后,列宽变为
+   `668.33 / 448.34 / 557.34px`:只改变相邻两列,第三列不变。
+3. 双击该分割线恢复三栏均分。
+
+### Artifact Drawer
+
+1. Header 的 Markdown 按钮显示 Artifact 数量;消息中的 Artifact 卡片可打开同一 Drawer。
+2. 在本 viewport 下 Drawer 从 `x=837` 开始,宽 `837px`,覆盖右半屏;左侧工作区保持原布局
+   状态而不是销毁。
+3. Drawer 显示 Artifact 标签、Markdown 正文、来源 Thread 和“定位来源会话”;关闭后恢复
+   原多栏视图。
+
+## Parity 验收规则
+
+- 后续截图使用相同 viewport、相同 Fixture 语义和相同交互顺序。
+- 对比 Header 控件顺序、列数与宽度、稳定 Slot、breadcrumb、引用卡、Composer、Drawer
+  尺寸以及拖拽前后数值。
+- 文案由 Project/Thread 新实体提供时可以改变数据内容,但 CSS 类、空间关系和交互输出必须
+  保持;任何必须造成用户可见差异的实现先停止并向用户确认。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/artifact-drawer.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/artifact-drawer.png
new file mode 100644
index 00000000..9cf66ca8
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/artifact-drawer.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/breadcrumb-back.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/breadcrumb-back.png
new file mode 100644
index 00000000..aebd9c59
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/breadcrumb-back.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/collapsed-column.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/collapsed-column.png
new file mode 100644
index 00000000..dacf6a94
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/collapsed-column.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/divider-dragged.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/divider-dragged.png
new file mode 100644
index 00000000..da9f9120
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/divider-dragged.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/empty-project.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/empty-project.png
new file mode 100644
index 00000000..0babbfab
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/empty-project.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/fork-composer.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/fork-composer.png
new file mode 100644
index 00000000..ef1685b9
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/fork-composer.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/header-child-selector.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/header-child-selector.png
new file mode 100644
index 00000000..cbe51d97
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/header-child-selector.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/single-column.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/single-column.png
new file mode 100644
index 00000000..13c916d4
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/single-column.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/switched-slot.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/switched-slot.png
new file mode 100644
index 00000000..cc6308f0
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/switched-slot.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/thread-switcher.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/thread-switcher.png
new file mode 100644
index 00000000..cbd97980
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/thread-switcher.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/three-columns-nested-fork.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/three-columns-nested-fork.png
new file mode 100644
index 00000000..64ece041
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/three-columns-nested-fork.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/two-columns.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/two-columns.png
new file mode 100644
index 00000000..10b744a6
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-baseline/two-columns.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/README.md b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/README.md
new file mode 100644
index 00000000..fa43a523
--- /dev/null
+++ b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/README.md
@@ -0,0 +1,38 @@
+# Normalized UI parity verification
+
+阶段 8 的实现态使用与基线相同的 Ego Browser task space、专用测试账号和
+`1674 × 963` viewport 验证。测试数据位于隔离的 `thread-chat-test` PostgreSQL,
+Project、Thread、Message、MessageRun 与 Artifact 全部来自新规范化表;页面没有读取或
+写入旧 `branch_trees`。
+
+## Screenshots
+
+| Screenshot | State |
+| --- | --- |
+| `empty-new-normalized.png` | `/thread-chat/new` 无实体草稿;关闭一次性帮助后与空白基线同态 |
+| `three-columns-normalized.png` | Root、第一层 Branch、嵌套 Branch 三栏完成独立 Message Query |
+| `artifact-drawer-normalized.png` | Artifact 按 `artifactId` 加载完成后的 50% Drawer |
+
+## Ego Browser interaction results
+
+- `/new` 保持单个 Main Column、Header、Composer、Project List,并可在空实体状态切换
+  Columns/Canvas;首次提交前没有伪造 Project、Thread 或 Message ID。
+- Root 脚注打开第一层 Branch,Branch 脚注打开嵌套 Branch;三栏在精确基线 viewport
+  下恢复为 `558.33 / 558.34 / 557.34px` 的原组件布局。
+- Header Child 选择器同时显示直接与嵌套后代;Thread 切换到已在另一 Slot 打开的目标时
+  交换两个 Slot 的 Thread 内容,Slot 宽度和物理位置不变。
+- Breadcrumb 回到已经打开的上游 Thread 时关闭当前重复 Slot;“收起”继续删除对应 Slot。
+- 第一条分割线向右拖动 `110px` 时只改变相邻两列,第三列不变;双击后三个列宽恢复自动
+  均分,Snapshot 中 Root/Branch 宽度恢复 `null`。
+- 强制两列并选择 `fold` 后打开嵌套 Branch,来源 Branch 原地折叠为细条;Snapshot 保存
+  `folded=true`,刷新后恢复。
+- Project-scoped Snapshot 恢复多栏、稳定 Slot、焦点、列宽、placement 和 view mode;
+  Drawer、滚动位置和 Composer 草稿不进入 Snapshot。
+- Artifact 卡片先建立 `artifactId` 引用,再由独立 loader 请求正文;完成态 Drawer 的
+  `x=837px`、`width=837px` 与基线一致,并正确显示来源 Thread 和 Markdown 正文。
+- Root assistant 文本选择可打开既有 Fork Composer;queued/running/failed assistant 仍禁止
+  Fork,服务端命令继续执行最终校验。
+- Project Catalog 来自 `/api/v1/projects`,显示当前 Project、Branch 数、重命名与删除入口。
+
+未发现需要批准的最终样式、页面布局、多栏、Header、Fork、breadcrumb、Drawer 或分割线
+交互变化。新增的 Project loading 与 Artifact loading/error 仅出现在规范要求的异步状态。
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/artifact-drawer-normalized.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/artifact-drawer-normalized.png
new file mode 100644
index 00000000..ec3d931a
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/artifact-drawer-normalized.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/empty-new-normalized.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/empty-new-normalized.png
new file mode 100644
index 00000000..5202d892
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/empty-new-normalized.png differ
diff --git a/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/three-columns-normalized.png b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/three-columns-normalized.png
new file mode 100644
index 00000000..d22d3a51
Binary files /dev/null and b/openspec/changes/archive/2026-08-25-design-thread-chat-client-api/ui-parity-implementation/three-columns-normalized.png differ
diff --git a/openspec/specs/domain/architecture.md b/openspec/specs/domain/architecture.md
new file mode 100644
index 00000000..6b1d32b5
--- /dev/null
+++ b/openspec/specs/domain/architecture.md
@@ -0,0 +1,62 @@
+# Thread Chat 领域架构
+
+本文记录已经落地的稳定目标架构。规范性行为以同目录 [spec.md](./spec.md) 的 Requirement/Scenario 为准;若二者冲突,以可验证规范为准。
+
+## 聚合与关系
+
+```mermaid
+erDiagram
+    USER ||--o{ PROJECT : owns
+    PROJECT ||--|{ THREAD : contains
+    THREAD o|--o{ THREAD : parent_of
+    THREAD ||--o{ MESSAGE : contains
+    MESSAGE o|--o| MESSAGE : replaces
+    MESSAGE ||--o| MESSAGE_RUN : executes
+    PROJECT ||--o{ ARTIFACT : owns
+    MESSAGE ||--o{ ARTIFACT : produces
+    MESSAGE ||--o| MESSAGE_FEEDBACK : receives
+```
+
+- `Project` 是 owner scope、共享资源范围、Thread 族群和永久删除边界;每个 Project 恰有一个 Root Thread。
+- `Thread` 是统一实体。`parentThreadId = null` 表示 Root;非空表示 Branch。Branch 同时冻结 `sourceMessageId`、`forkSourceSnapshot` 和 `baseContext`。
+- `Message` 属于一个 Thread,以服务端分配的唯一单调 `sequence` 排序。finalized 内容不可覆盖;Edit/Regenerate 追加 replacement 并 supersede 旧消息。
+- `BaseContextV1` 只保存有序 `messageIds`。Fork 时由服务端计算并冻结,不复制 parts 或 Artifact 正文。
+- 每条 assistant Message 恰有一条 `MessageRun`,user Message 没有 Run。状态为 `queued → running → completed | failed | stopped`,也允许 queued 直接进入 failed/stopped。
+- `Artifact` 归属于 Project,并以 `sourceMessageId` 保留 provenance;跨 Project 默认隔离。
+- `MessageFeedback` 仅属于合格的 assistant Message,值为 positive/negative,清除反馈会删除该记录。
+
+## 核心不变量
+
+1. Project、Parent Thread、source Message、Child Thread、Artifact 和反馈的 owner scope 必须一致;跨表关系在同一事务内锁定并校验。
+2. Project 只能有一个 Root;Thread 拓扑无环;Root 不含 Fork facts,Branch 必须完整包含全部 Fork facts。
+3. 相同 Thread 的 `sequence` 唯一且大于零;客户端时间和角色交替都不是排序事实。
+4. finalized Message 的 role、parts、sequence 与来源关系不可变;单条 Message 不 hard delete。
+5. replacement 必须同 Thread、同 role,旧 Message 最多有一个直接 replacement;默认时间线排除 superseded Message。
+6. queued/running/failed/stopped assistant 不具备 Fork Prompt 资格;既有 BaseContext 对后来 superseded 的 Message ID 仍保持有效。
+7. Run 的 checkpoint、`eventSequence`、heartbeat、stop request 和 terminal 时间均持久化;SSE 断开不等于 Stop。
+8. Project 删除通过外键级联清理 Threads、Messages、Runs、Artifacts 与 Feedback;不迁移旧表、不双写。
+
+## 最终 PostgreSQL Schema
+
+| 表 | 关键字段与约束 |
+|---|---|
+| `projects` | `owner_user_id`;标题/Target/Instruction;`artifact_change_sequence >= 0`;owner + update 游标索引 |
+| `threads` | `project_id`;自引用 parent;source、snapshot、baseContext 完整性 CHECK;每 Project 单一 Root 部分唯一索引 |
+| `messages` | `thread_id + sequence` 唯一;role/positive sequence CHECK;replacement 唯一;有效时间线索引 |
+| `message_runs` | `assistant_message_id` 唯一;状态 CHECK;checkpoint/eventSequence/heartbeat/stop/terminal;queued scanner 索引 |
+| `artifacts` | `project_id`、`source_message_id`、kind/title/content;Project 与 source 索引 |
+| `message_feedback` | `message_id` 唯一;`owner_user_id`;positive/negative CHECK |
+
+数据库 CHECK/外键负责单行、存在性、唯一性和级联;同 Project、角色资格、无环、finalized 不可变等跨行规则由 Repository/Application 在事务内负责。空库 `db:push` 是 Schema 验收项。
+
+## 模块边界
+
+```text
+lib/thread-chat/domain/                 纯实体、不变量、状态机、Prompt/BaseContext 规则
+lib/thread-chat/application/            Commands、Queries、事务编排、MessageRun runner
+lib/thread-chat/application/ports/      AI Runtime 等可替换端口
+lib/thread-chat/infrastructure/         PostgreSQL repositories、真实/Fake AI adapter
+lib/thread-chat/api/                    共享契约与服务端 transport(不重定义领域关系)
+```
+
+后续修改任何实体关系、Schema 或模块职责时,必须同时更新并验证正式 spec、本文、Drizzle Schema、Repository/Application 测试与 OpenSpec strict validation。
diff --git a/openspec/specs/domain/spec.md b/openspec/specs/domain/spec.md
index 42d3e2f1..9cbe6c3a 100644
--- a/openspec/specs/domain/spec.md
+++ b/openspec/specs/domain/spec.md
@@ -2,48 +2,215 @@
 
 ## Purpose
 
-定义 Thread Chat 的统一领域语言:树、线程、分叉、消息、生成、产物与标题的边界,使产品、设计和实现使用同一组术语沟通。
+定义 Thread Chat 的 Project、Thread、Message、MessageRun、BaseContext 与 Artifact 领域边界及可验证不变量。
+
+稳定架构说明见 [architecture.md](./architecture.md)。若说明文档与本规范冲突,以本规范中的可验证 Requirement 与 Scenario 为准;任何实现变更都 MUST 同步校验规范、架构文档、Drizzle Schema 与模块边界。
 
 ## Requirements
 
 ### Requirement: 使用统一的核心术语
-
-系统及项目文档 SHALL 使用以下术语:
-
-- **Thread Tree**:一个独立的树形工作区,拥有唯一的根线程与其全部后代。
-- **Thread**:Thread Tree 中的一个对话节点,也是界面中的一栏;它拥有自己的消息序列、模型选择和标题。
-- **MainThread**:Thread Tree 中唯一的根 Thread。
-- **ForkedThread**:由一次 Fork 创建的非根 Thread;它可以继续产生后代 Thread。
-- **Fork**:从某条消息的选区创建 ForkedThread 的关系与动作,不是 Thread 的同义词。
-- **Message**:属于一个 Thread 的用户或助手消息节点。
-- **Generation**:生成一条助手 Message 的一次模型执行尝试。
-- **Artifact**:由某条 Message 产生并持久化的独立内容。
-- **Title**:用于识别 Thread 或 Thread Tree 的人类可读标签。
+系统 MUST 使用以下术语表达目标模型:
+
+- **Project**:一整项可持续工作的内容聚合边界,直接归属于当前用户,拥有一个 Root Thread、全部后代 Thread,以及这些 Thread 共享的 Memory、Instruction、Target、Files 和 Artifacts。当前 UI 的一个“对话列表项”对应一个 Project。
+- **Thread**:Project 中一列可独立继续的线性对话。Root Thread 与 Branch Thread 只是关系角色,不是不同实体类型。
+- **Fork**:从已有 Thread 的确定 Message 创建 Child Thread 的原子操作。
+- **Message**:Thread 中具有稳定 ID、角色、内容和服务端 sequence 的消息实体。
+- **MessageRun**:仅为 assistant Message 持久化的后台生成记录;它属于运行基础设施,不是可独立导航的聊天内容实体。
+- **BaseContext**:Fork 时由服务端冻结的有序 Message ID 列表,表示 Child Thread 继承的有效 Prompt 历史。
+- **ForkSourceSnapshot**:Fork 时冻结的来源定位与引用展示信息。
+- **Project Resource**:在同一 Project 的全部 Thread 间共享、但不跨 Project 自动共享的 Memory、Instruction、Target、File 或 Artifact。
+- **Target**:Project 希望实现的目标集合,用于表达该 Project 的终极目标,以及为实现终极目标而设定的短期目标和中期目标。Target 属于 Project 级共享信息,不表示某个 Thread 的临时任务,也不等同于单条用户消息中的请求。
+- **Artifact**:归属于 Project,并保留来源 Message 身份的持久化产物。
+- **Title**:Project 或 Branch Thread 的短标题。
+
+目标实现 MUST 由 Project 接替当前 Thread Tree 的聚合职责,并将 Thread、Message 和 Artifact 保存为可独立寻址的实体。目标规范、数据库、API 和客户端实体模型 MUST 使用 Project 表达整簇 Thread 的聚合边界,且 MUST NOT 继续把 Thread Tree、MainThread、ForkedThread、独立 ThreadFork、Turn、Generation 或 Message Variant 作为目标领域实体。
+
+#### Scenario: 将一簇分叉对话称为 Project
+- **WHEN** 用户查看由一个 Root Thread 和多列 Branch Thread 组成的工作项
+- **THEN** 系统 MUST 将整体领域实体称为 Project,并将每一列称为 Thread
+
+#### Scenario: 从关系推导 Thread 角色
+- **WHEN** 一个 Thread 没有 Parent Thread
+- **THEN** 系统 MUST 将其视为 Root Thread
+- **AND** 当一个 Thread 具有 Parent Thread 时,系统 MUST 将其视为 Branch Thread
 
 #### Scenario: 描述非根线程
-
-- **WHEN** 产品或代码需要描述由选区创建的对话节点
-- **THEN** 使用 ForkedThread 描述该节点,并使用 Fork 描述其创建关系
-
-### Requirement: 维护线程树的层级不变量
-
-每个 Thread Tree SHALL 恰有一个 MainThread。每个 ForkedThread SHALL 有一个父 Thread 和一个来源 Fork;任意 ForkedThread 都可以作为新的 Fork 的来源。Thread 是统一节点类型,MainThread 与 ForkedThread 是其不同领域角色,而非两套不相容的会话模型。角色由树的根、父子关系和 Fork 来源定义;本规范不规定其在持久化状态中的具体字段或标识符表示。
+- **WHEN** 产品或代码需要描述由 Fork 创建的非根对话列
+- **THEN** 系统 MUST 将该节点称为 Child Thread 或按相对角色称为 Branch Thread
+- **AND** 必须使用 Fork 描述创建动作,不得把 ForkedThread 作为独立实体类型
+
+### Requirement: 维护 Project 的 Thread 拓扑不变量
+系统 MUST 使用 Project 与 Thread 关系维护分叉拓扑:
+
+- 每个 Project MUST 归属于且仅归属于一个用户;其他访问关系不属于本 change。
+- 每个 Project MUST 有且仅有一个没有 Parent Thread 的 Root Thread。
+- 每个 Thread MUST 归属于且仅归属于一个 Project。
+- 每个非 Root Thread MUST 具有同一 Project 内的 Parent Thread、确定的来源 Message、ForkSourceSnapshot 和 BaseContext。
+- Child Thread MUST 可以继续产生自己的 Child Thread,形成任意深度的有向无环层级。
+- 系统 MUST 阻止跨 Project 的 Parent Thread、来源 Message 或 Fork 关系,并阻止形成环。
+- Fork MUST 由服务端原子创建 Child Thread 及全部来源事实;客户端不得构造 Child Thread 新 ID 或 BaseContext。
+
+#### Scenario: 创建 Project 根 Thread
+- **WHEN** 系统创建一个新 Project
+- **THEN** 系统 MUST 在同一事务建立该 Project 唯一的 Root Thread
+- **AND** Root Thread MUST 不包含 Parent Thread 或 Fork 来源
 
 #### Scenario: 创建嵌套分叉
+- **WHEN** 用户从 Branch Thread 的一条合格 Message 再次 Fork
+- **THEN** 系统 MUST 在同一 Project 中创建新的 Child Thread
+- **AND** 新 Thread MUST 指向该 Branch Thread 与确定来源 Message
 
-- **WHEN** 用户从一个 ForkedThread 中的消息创建新的 Fork
-- **THEN** 系统创建新的 ForkedThread,并将该消息所在 Thread 记录为其父 Thread
+#### Scenario: 拒绝跨 Project Fork
+- **WHEN** Fork 来源 Thread 或来源 Message 不属于目标 Project
+- **THEN** 系统 MUST 拒绝请求且不创建任何部分数据
 
 #### Scenario: 拒绝 Fork 与拓扑矛盾的状态
+- **WHEN** 非 Root Thread 缺少 Parent Thread、来源 Message、ForkSourceSnapshot 或 BaseContext,或者关系会形成环
+- **THEN** 系统 MUST 拒绝该 Project 状态
 
-- **WHEN** 保存的 ForkedThread 缺少父 Thread 或来源 Fork
-- **THEN** 系统拒绝该 Thread Tree 状态
+#### Scenario: Fork 原子失败
+- **WHEN** ForkSourceSnapshot、BaseContext 或 Child Thread 中任一项无法持久化
+- **THEN** 系统 MUST 回滚整个 Fork 操作
 
 ### Requirement: 明确标题的归属与优先级
+系统 MUST 区分 Project 标题与 Branch Thread 标题:
+
+1. Project 标题描述整项工作,同时作为 Project 列表项和 Root Thread 列头的展示标题。
+2. Branch Thread 标题描述局部主题,仅覆盖对应列的标题展示。
+3. 用户账户展示名称不得替代 Project 或 Thread 标题。
 
-Title SHALL 描述其目标 Thread。MainThread 的自动 Title 同时作为 Thread Tree 的默认导航标题;ForkedThread 的 Title 描述对应列。用户为 Thread Tree 设置的自定义 Title SHALL 在树级导航和 MainThread 列头展示中优先于自动 Title。
+#### Scenario: 展示 Root Thread 标题
+- **WHEN** 客户端展示 Project 的 Root Thread
+- **THEN** 客户端 MUST 使用 Project 标题作为该列标题
 
 #### Scenario: 主线自动标题与用户重命名并存
+- **WHEN** Project 已生成自动标题,且用户随后设置自定义 Project 标题
+- **THEN** Project 列表和 Root Thread 列头 MUST 展示自定义标题
+- **AND** 系统 MUST 保留自动标题作为机器派生信息
+
+#### Scenario: 展示 Branch Thread 标题
+- **WHEN** 客户端展示具有局部标题的 Branch Thread
+- **THEN** 客户端 MUST 使用该 Thread 标题
+- **AND** 不得因此修改 Project 标题
+
+### Requirement: 使用 Project 作为对话列表与共享资源边界
+“新对话”操作 MUST 创建新的 Project 与唯一 Root Thread;“对话列表” MUST 列出当前用户拥有或可访问的 Project。
+
+Project 的 Memory、Instruction、Target、Files 和 Artifacts MUST 可供该 Project 的全部 Thread 使用,并 MUST NOT 仅因多个 Project 属于同一用户而自动跨 Project 共享。
+
+#### Scenario: 创建新对话
+- **WHEN** 用户点击“新对话”
+- **THEN** 系统 MUST 创建新的 Project 与唯一 Root Thread
+- **AND** 导航到以服务端 Project ID 标识的 ThreadChat 页面
+
+#### Scenario: 展示对话列表
+- **WHEN** 用户打开“对话列表”
+- **THEN** 系统 MUST 展示当前用户拥有或可访问的 Projects
+
+#### Scenario: Thread 使用 Project 资源
+- **WHEN** Project 中任一 Thread 构建允许使用 Project 上下文的请求
+- **THEN** 系统 MUST 允许它引用该 Project 的 Memory、Instruction、Target、Files 和 Artifacts
+
+#### Scenario: Project 资源隔离
+- **WHEN** 另一个 Project 的 Thread 未获得显式授权
+- **THEN** 系统 MUST NOT 自动向它提供当前 Project 的共享资源
+
+### Requirement: 维护 Thread 的线性消息顺序
+系统 MUST 将每个 Thread 内的 Message 保存为严格线性的追加序列。服务端 MUST 为新 Message 分配在该 Thread 内单调递增且唯一的 `sequence`;客户端 MUST 使用 `sequence` 而不是客户端时间、角色交替或前后消息指针恢复稳定顺序。
+
+系统 MUST 允许连续多条 user Message,且 MUST NOT 将 `user → assistant` 一一配对作为数据库不变量。“只允许编辑当前最后一条有效 user Message”是 MVP 应用策略,不代表 user Message 必须拥有配对的 assistant Message。
+
+#### Scenario: 连续发送多条 user Message
+- **WHEN** 用户在 assistant 尚未产生最终回复前又发送一条 user Message
+- **THEN** 系统 MUST 将两条 user Message 保存为同一 Thread 内具有不同 sequence 的独立 Message
+- **AND** 不得仅因角色没有交替而拒绝或重排它们
+
+#### Scenario: 按服务端顺序读取消息
+- **WHEN** 客户端读取一个 Thread 的消息
+- **THEN** 系统 MUST 按 sequence 升序返回有效时间线
+- **AND** 相同 Thread 内不得存在重复 sequence
+
+### Requirement: 使用不可变 Message replacement
+Message 内容在创建完成或 finalized 后 MUST NOT 原地改写。Edit 和 Regenerate MUST 创建具有新 ID 与新 sequence 的 replacement Message,通过 `replacesMessageId` 指向旧 Message,并将旧 Message 标记为 `superseded`。
+
+默认时间线 MUST 隐藏 superseded Message,但持久化层 MUST 保留其 ID、sequence、内容和来源关系。MVP MUST NOT 提供 Message Variant,也 MUST NOT hard delete 单条 Message;只有永久删除整个 Project 时,系统才 MUST 统一清理 Project 下的 Thread、Message、MessageRun 和附属资源。
+
+#### Scenario: Regenerate assistant 回复
+- **WHEN** 用户对当前可重新生成的 assistant Message 执行 Regenerate
+- **THEN** 系统 MUST 创建新的 assistant Message 与新的 MessageRun
+- **AND** 旧 assistant Message MUST 被标记为 superseded,但其 ID、sequence 和内容 MUST 保持不变
+
+#### Scenario: 编辑最后一条 user Message
+- **WHEN** 用户编辑当前 Thread 最后一条有效 user Message
+- **THEN** 系统 MUST 创建 replacement user Message
+- **AND** 原 Message 及所有 sequence 更大、依赖原内容的有效 Message MUST 退出默认时间线但继续保留
+- **AND** replacement user Message 及后续新回复 MUST 以新 sequence 追加到 Thread 尾部
+
+#### Scenario: 拒绝编辑历史 user Message
+- **WHEN** 用户尝试编辑并非当前 Thread 最后一条有效 user Message 的消息
+- **THEN** 系统 MUST 按 MVP 策略拒绝操作,并提示使用 Fork 保留另一条历史
+
+#### Scenario: 永久删除 Project
+- **WHEN** 已获授权的用户确认永久删除整个 Project
+- **THEN** 系统 MUST 将该 Project 的共享资源、Thread、Message 和 MessageRun 作为完整边界清理
+
+### Requirement: 冻结 Fork 的消息身份上下文
+Fork 时,服务端 MUST 根据来源 Thread 在 Fork 点之前的有效 Prompt 历史生成不可变 BaseContext。BaseContext MUST 包含 schema 版本和按 Prompt 顺序排列的 `messageIds`,不得复制 Message Parts,也不得由客户端提交或重建。
+
+进入 BaseContext 或作为 Fork source 的 Message MUST 已 finalized 且具备 Prompt 资格。有效 user Message 可以进入;仅 completed assistant Message 可以进入并作为来源;queued、running、failed 或 stopped assistant Message 不得进入,也不得作为 Fork source。Parent 后续发生 replacement、追加或归档时,既有 Child Thread 的 BaseContext MUST 保持不变。
+
+#### Scenario: 从 completed assistant Message Fork
+- **WHEN** 用户从 finalized 且 completed 的有效 assistant Message 发起 Fork
+- **THEN** 服务端 MUST 在同一 Project 创建 Child Thread
+- **AND** BaseContext MUST 冻结到来源 Message 为止的有序有效 Message ID
+
+#### Scenario: 生成期间不允许 Fork
+- **WHEN** 当前 Thread 最后一条 assistant Message 的 MessageRun 处于 queued 或 running
+- **THEN** 客户端 MUST 隐藏或禁用 Fork
+- **AND** 服务端 MUST 拒绝绕过客户端发起的 Fork 请求
+
+#### Scenario: Parent Message 后续被 replacement
+- **WHEN** Child Thread 的 BaseContext 引用了之后被 superseded 的 Parent Message
+- **THEN** Child Thread MUST 继续按原 Message ID 解析冻结历史
+- **AND** replacement Message MUST NOT 自动替换 BaseContext 中的 ID
+
+#### Scenario: 客户端试图提交 BaseContext
+- **WHEN** Fork 请求包含客户端构造的 BaseContext 或待创建 Child Thread ID
+- **THEN** 服务端 MUST 忽略或拒绝这些字段,并只使用服务端计算和生成的值
+
+### Requirement: 持久化 assistant MessageRun
+每条 assistant Message MUST 具有且仅具有一条持久化 MessageRun,user Message MUST NOT 具有 MessageRun。MessageRun MUST 至少表达 queued、running、completed、failed 和 stopped 状态,并保存恢复流式展示所需的运行进度。
+
+浏览器刷新、关闭或流连接断开 MUST 只终止该客户端订阅,不得自动停止后台 MessageRun。客户端重新加载 Thread 时,系统 MUST 通过 assistant Message 及其 MessageRun 恢复最终内容、生成中、失败或停止状态。
+
+#### Scenario: 创建 assistant Message
+- **WHEN** 服务端接受一次需要 AI 回复的生成命令
+- **THEN** 服务端 MUST 在同一原子边界创建 assistant Message 与唯一 MessageRun
+
+#### Scenario: 刷新后恢复运行状态
+- **WHEN** assistant Message 的 MessageRun 仍为 queued 或 running 且用户刷新页面
+- **THEN** 客户端 MUST 加载该状态并恢复生成展示与事件订阅
+- **AND** 刷新不得创建第二条 MessageRun
+
+#### Scenario: 队列启动失败
+- **WHEN** MessageRun 在进入 running 前无法启动
+- **THEN** 系统 MUST 允许它从 queued 转为 failed
+
+### Requirement: 维护 Project 共享的 Artifact
+Artifact MUST 归属于且仅归属于一个 Project,并 MUST 保留产生它的来源 Message 身份。该 Project 的全部 Thread MUST 能按权限引用 Artifact;其他 Project MUST NOT 仅因归属于同一用户而自动获得访问权。
+
+BaseContext MUST 通过 Message ID 间接保留 Artifact 的来源语义,不得复制大型 Artifact 内容。MVP MUST NOT 因本重构引入 ArtifactVersion、通用资源图或内容寻址存储。
+
+#### Scenario: 同 Project 的 Thread 使用 Artifact
+- **WHEN** Project 中一个 Thread 的 Message 产生 Markdown Artifact
+- **THEN** 同 Project 的其他 Thread MUST 能按权限引用该 Artifact
+- **AND** 系统 MUST 保留 sourceMessageId 作为来源
+
+#### Scenario: Fork 历史包含 Artifact
+- **WHEN** BaseContext 引用的 Message 产生过 Artifact
+- **THEN** 系统 MUST 能通过 Message ID 解析其 Project Artifact 关联
+- **AND** BaseContext 不得复制 Artifact 正文
 
-- **WHEN** MainThread 已生成自动 Title,且用户随后为其 Thread Tree 设置自定义 Title
-- **THEN** 树级导航和 MainThread 列头展示用户自定义 Title,同时保留自动 Title 作为机器派生信息
+#### Scenario: 隔离其他 Project
+- **WHEN** 另一个 Project 未获得显式授权
+- **THEN** 系统 MUST NOT 向它暴露当前 Project 的 Artifact
diff --git a/openspec/specs/thread-chat-client-state/architecture.md b/openspec/specs/thread-chat-client-state/architecture.md
new file mode 100644
index 00000000..4474ddf4
--- /dev/null
+++ b/openspec/specs/thread-chat-client-state/architecture.md
@@ -0,0 +1,53 @@
+# Thread Chat 客户端架构
+
+本文记录已经落地的稳定客户端边界。规范性行为以同目录 [spec.md](./spec.md) 为准;冲突时以可验证 Requirement/Scenario 为准。
+
+## 数据流与依赖方向
+
+```mermaid
+flowchart LR
+    UI[UI Components] --> Hooks[Selector / Command Hooks]
+    Hooks --> Commands[Application Commands]
+    Commands --> API[ThreadChatApiCapabilities]
+    API --> DTO[Strictly validated DTO / SSE]
+    DTO --> Actions[Semantic Store Actions]
+    Hooks --> Actions
+    Actions --> Stores[App Store / Project Store]
+    Stores --> Selectors[Pure Selectors]
+    Selectors --> Hooks
+```
+
+客户端只保存一份服务端事实。组件不直接 fetch、不创建服务端实体 ID、不维护可写 ThreadTree,也不直接调用 Zustand `setState`。Transport 负责会话、编码、严格 Schema 校验和错误映射;Application Commands 编排异步操作;Store Actions 只做同步语义合并。
+
+## Store
+
+- `ThreadChatAppStore`:Provider-scoped;保存 Project Catalog、分页请求状态和 AppShell UI,不保存全局 `selectedProjectId`。
+- `ThreadChatProjectStore`:以 `projectId` 隔离;保存 `entities`、`runs`、`requests`、`readModels` 与 `workbench.ui`。
+- normalizer 维护 `messagesById + messageIdsByThreadId`,校验归属、去重并按服务端 `sequence` 排序;finalized 实体不可被旧 DTO 或乱序事件覆盖。
+- Workbench UI 使用纯客户端 `ColumnSlotId`;列宽、折叠、焦点、placement、Canvas pin、overlay 和 Snapshot 都不进入领域 API。
+
+## Runtime 与 Provider
+
+- `ThreadChatAppRuntime` 组合 App Store、Catalog Commands、Transport、Navigation 与 `ProjectRuntimeRegistry`。
+- Registry 保证一个 Provider 生命周期内同一 `projectId` 只有一个 `ThreadChatProjectRuntime`,并负责 seeded handoff、lease/release 与统一 destroy。
+- `ThreadChatProjectRuntime` 组合 Project Store、Commands、ThreadMessageLoader、ArtifactLoader 和 GenerationCoordinator。
+- `ThreadChatAppProvider`、`ThreadChatProjectProvider`、`NewProjectDraftProvider` 均按 React/Next.js 生命周期创建实例,禁止服务端模块级可变 Store 跨请求共享。
+
+## 异步边界
+
+| 组件 | 去重键 | 职责 |
+|---|---|---|
+| ThreadMessageLoader | `threadId` | Promise 去重、跨 Thread 并行、局部 loading/error、destroy Abort |
+| ArtifactLoader | `artifactId` | 按需加载正文与缓存;Thread 加载不触发 Artifact 正文请求 |
+| GenerationCoordinator | `assistantMessageId` | 单连接、snapshot 合并、严格 eventSequence、断线重连、取消订阅 |
+| Workbench persistence | `projectId` | 防抖保存/校验 Snapshot;恢复列、焦点、折叠、列宽,不恢复草稿/滚动 |
+
+刷新、路由切换或 Runtime destroy 只取消客户端订阅,不调用 Stop。只有显式 Stop Command 才请求服务端停止 Run。
+
+## `/new` 与 Project 生命周期
+
+`/thread-chat/new` 只持有无实体 draft。首次发送获得 CreationBundle 后,App Runtime 先把服务端 Project/Root/Messages/Run 合入 seeded Project Runtime并启动事件协调,再通过集中 route builder replace 到 `/thread-chat/{projectId}`。目标 Provider acquire 同一 Runtime 并跳过重复 Bootstrap,因此没有空白帧或第二条 Run。
+
+已有 Project 页面先 acquire Runtime,再 Bootstrap 轻量 topology 与 Root bundle;其他 Branch Message 和 Artifact 正文按需并行加载。UI 继续复用原有组件、CSS、Header、多栏、Fork、breadcrumb、Artifact Drawer 与分割线交互。
+
+后续修改 Store shape、Runtime 组合、Loader/Coordinator 或 UI 接缝时,必须同步更新正式 spec、本文、共享 DTO、接口注入测试、Testing Library 测试与 Ego Browser UI parity 验收。
diff --git a/openspec/specs/thread-chat-client-state/spec.md b/openspec/specs/thread-chat-client-state/spec.md
new file mode 100644
index 00000000..66262be2
--- /dev/null
+++ b/openspec/specs/thread-chat-client-state/spec.md
@@ -0,0 +1,298 @@
+# thread-chat-client-state Specification
+
+## Purpose
+
+定义 Thread Chat 客户端的 Store、Runtime、Provider、Command、Hook、加载与 UI 状态边界。
+
+稳定说明见 [architecture.md](./architecture.md)。若说明文档与本规范冲突,以本规范中的可验证 Requirement 与 Scenario 为准;任何实现变更都 MUST 同步校验正式规范、说明文档、共享 Schema、Route/Runtime 实现与验收测试。
+
+## Requirements
+
+### Requirement: 统一客户端状态架构术语
+客户端规范、设计和实现 MUST 使用以下术语,并 MUST NOT 用同一个 `Action` 同时指代本地状态转换和跨边界业务流程:
+
+- **Store State**:Zustand Store 保存的数据,包括服务端确认实体、运行态、请求状态和本地 UI 状态。
+- **Store Action**:与对应 Store 或 slice 共置、通过 Zustand `set`/`setState` 执行一次受控 State Transition 的函数。Store Action MUST NOT 调用后端 API、管理路由或建立事件连接。
+- **Application Command**:位于 Store 外、代表一个用户或生命周期业务意图的可测试流程。它 MAY 调用 API、协调订阅与路由,但 MUST 通过 Store Action 提交状态变化,不得自行调用 `set`/`setState`。
+- **Selector Hook**:通过细粒度 selector 订阅 Store,并向 UI 暴露 State 或衍生 ViewModel 的读取 Hook。
+- **Command Hook**:把作用域 ID 与 Application Command 或纯本地 Store Action 绑定为 UI 事件接口的 Hook;它不得包含业务规则或直接修改 State。
+- **Lifecycle Hook**:负责 Bootstrap、按需加载、生成订阅和本地偏好持久化等 React 生命周期接入的 Hook;真正流程仍委托给 Application Command 或 coordinator。
+
+本文中的“状态变更”表示通过 `set`/`setState` 产生新状态并通知订阅者,不表示对现有 State 对象做任意原地修改。
+
+#### Scenario: API 成功后提交状态
+- **WHEN** Application Command 收到并校验合法服务端 DTO
+- **THEN** 它 MUST 调用语义明确的 Store Action 原子合并 DTO
+- **AND** Application Command、Transport 和 React 组件 MUST NOT 绕过 Store Action 直接调用 `set`/`setState`
+
+#### Scenario: 纯本地 UI 操作
+- **WHEN** 用户只改变可见列、画布位置或 overlay 等本地 UI State
+- **THEN** Command Hook MAY 直接调用对应 UI slice 的 Store Action
+- **AND** 不得为了纯本地 State Transition 建立没有跨边界职责的 Application Command
+
+### Requirement: 使用规范化客户端实体模型
+客户端 MUST 以 Project、Thread、Message 和 Artifact 的服务端 DTO 作为已确认内容事实,并 MUST 以 `assistantMessageId` 为关联键保存 AssistantRunState。客户端 MUST NOT 把整棵 Thread 拓扑、Message 列表和运行态重新组合成可整体写回的 `ThreadTreeState`。
+
+Thread 的 Root/Branch、children、depth 和 breadcrumb MUST 由 `parentThreadId` 派生;Message 默认时间线 MUST 由 `supersededAt IS NULL` 和 `sequence ASC` 派生。BaseContext MUST 保持为服务端内部事实,不得进入普通客户端实体状态或由客户端重建。
+
+#### Scenario: 合并 ProjectBootstrap
+- **WHEN** 客户端收到包含 Project、Thread topology、Root Message、AssistantRunState 和 Project Artifact Summary 的合法 ProjectBootstrap
+- **THEN** 客户端 MUST 按实体 ID 规范化合并这些 DTO 与读模型
+- **AND** 不得保存第二份整树权威快照
+- **AND** Bootstrap 中的 Message 只通过 tool result 的 `artifactId` 引用 Artifact,不得附带 Artifact 正文
+
+#### Scenario: 派生 Branch 关系
+- **WHEN** 一个 Thread 的 `parentThreadId` 指向同 Project 的另一个 Thread
+- **THEN** selector MUST 将它作为该 Parent 的 Child/Branch Thread 展示
+- **AND** 客户端不得要求服务端同时返回可独立修改的 `children` 数组
+
+### Requirement: 按生命周期划分 Zustand Store
+客户端 MUST 使用一个轻量 ProjectCatalogStore 管理 Project 列表,并 MUST 为每个打开的 `projectId` 创建独立 ThreadChatStore。ThreadChatStore MUST 以高内聚 slice 区分已确认 entities、服务端统计 read models、加载/命令状态、生成流状态和本地 workbench UI 状态;它们可以位于同一个 Zustand store,但不得互相复制权威数据。
+
+ProjectCatalogStore MUST NOT 保存 Thread 或 Message。Project-scoped Store 在离开对应 Project 页面后 MUST 可被销毁,不得让多个 Project 的 Message 长期堆积在无边界全局 Store 中。
+
+#### Scenario: 打开两个 Project
+- **WHEN** 用户先后打开 Project A 和 Project B
+- **THEN** 两个 Project 的 ThreadChatStore MUST 具有隔离的实体、生成和工作台状态
+- **AND** ProjectCatalogStore MUST 只保留两者的轻量列表信息
+
+#### Scenario: 高频流事件只更新相关订阅者
+- **WHEN** 某条 assistant Message 收到生成增量
+- **THEN** Store MUST 只改变该 `assistantMessageId` 对应的生成流状态
+- **AND** 只订阅其他 Thread 或 Project 元数据的组件 MUST NOT 因全局 version 而被强制重算
+
+### Requirement: 区分服务端事实、运行态和本地 UI 态
+Project、Thread、Message、Artifact、Project Artifact Summary 和 MessageRun 终态 MUST 以服务端响应为权威。运行中的 checkpoint 和 eventSequence MUST 保存于生成 slice;visibleThreadIds、Column Slot、列宽、画布位置、composer 草稿、overlay 和选中 Artifact MUST 保存为本地 UI 态。
+
+客户端 MAY 将设备相关 workbench UI 态持久化到 localStorage,但 MUST NOT 将它提交为 Project 内容。客户端 MUST NOT 为乐观展示伪造 Project、Thread、Message 或 MessageRun ID。
+
+#### Scenario: 发送期间展示等待态
+- **WHEN** 用户提交命令但服务端尚未返回新实体
+- **THEN** 客户端 MUST 使用本地 submitting/busy 状态展示等待
+- **AND** 不得向 entities slice 插入 `temp-*`、`main` 或客户端 UUID 形式的待创建实体
+
+#### Scenario: 恢复工作台偏好
+- **WHEN** ProjectBootstrap 成功且浏览器存在该 Project 的列布局偏好
+- **THEN** 客户端 MUST 过滤其中已经不存在的 Thread ID、重复 Slot/Thread、非法宽度和无效 Canvas pin 后恢复布局
+- **AND** 本地布局不得覆盖服务端返回的 Thread topology
+
+### Requirement: 使用稳定 Column Slot 表达物理列
+客户端 MUST 使用纯本地 `ColumnSlotId` 表示 Root 右侧物理列,并 MUST 将 Slot 当前展示的 `threadId` 与 Slot 身份分离。Root MUST 使用独立固定列身份,不得进入 Branch Slot 数组。
+
+切换一个物理列所展示的 Thread 时,客户端 MUST 保留该 Slot 的 `slotId`、物理位置、折叠态和显式列宽,只替换 `threadId`。`ColumnSlotId` 不是服务端实体 ID,MUST NOT 进入 Project、Thread、Message、Fork 或 Generation API 请求。
+
+#### Scenario: 本列切换 Thread
+- **WHEN** 用户通过现有列 Header 切换器把 Slot S 从 Thread A 切换到 Thread B
+- **THEN** Store MUST 保持 S 的 `slotId`、宽度、折叠态和位置不变,并将 S 的 `threadId` 更新为 B
+- **AND** 不得因为切换内容而重建物理列或把 A 的宽度改绑到 Thread B 实体
+
+#### Scenario: 恢复重复 Thread 的非法 Snapshot
+- **WHEN** 本地 Snapshot 中两个 Slot 指向同一个 Thread 或复用了同一个 Slot ID
+- **THEN** 客户端 MUST 按稳定顺序只保留第一个合法 Slot
+- **AND** 不得在同一工作台恢复两列相同 Thread
+
+### Requirement: 支持分栏分割线拖拽
+客户端 MUST 保留现有相邻展开列之间的分割线拖拽能力。分割线 MUST 同时调整左右两个物理列,并遵守当前 UI 的最小列宽约束;Root 宽度 MUST 归属于固定 Root 列,Branch 宽度 MUST 归属于稳定 Column Slot,而不是 Thread Entity。
+
+Pointer Move 期间的坐标、临时宽度和 Pointer Capture MUST 保持为 Resizer Hook 或组件局部瞬时状态,不得逐帧写入 Zustand。Pointer Up、键盘步进或双击复位时,客户端 MUST 通过一次 Store Action 原子提交受影响列的最终宽度;提交后的宽度 MUST 进入工作台 Snapshot,滚动位置和拖拽瞬时状态不得持久化。该操作 MUST NOT 调用服务端 API。
+
+#### Scenario: 拖拽相邻列分割线
+- **WHEN** 用户拖动两个展开列之间的分割线并释放 Pointer
+- **THEN** Store MUST 在一次 State Transition 中提交左右两列最终宽度
+- **AND** 不得在每次 Pointer Move 时修改 Zustand 或触发服务端请求
+
+#### Scenario: 切换 Thread 后保留列宽
+- **WHEN** 用户调整 Slot S 的宽度后,通过现有 Header 切换器把 S 从 Thread A 切换到 Thread B
+- **THEN** Slot S MUST 保留原宽度
+- **AND** Thread A 与 Thread B Entity 均不得保存该宽度
+
+#### Scenario: 双击分割线恢复自动宽度
+- **WHEN** 用户双击现有分割线复位
+- **THEN** 客户端 MUST 清除受影响物理列的显式宽度并恢复当前自动均分行为
+- **AND** 下一次工作台 Snapshot MUST 保存复位后的状态
+
+### Requirement: 刷新后恢复 Project 工作台视图
+客户端 MUST 按 `projectId` 将带 `schemaVersion` 的工作台视图投影防抖保存到设备 localStorage。刷新并完成 ProjectBootstrap 后,客户端 MUST 恢复合法的列槽、每列当前 Thread、折叠态、物理列宽、焦点列、列数偏好、放置模式、Columns/Canvas 模式和 Canvas pin。
+
+工作台 Snapshot MUST NOT 直接序列化整个 UI Store,也 MUST NOT 包含服务端实体、Message/Run、滚动位置、DOM 引用、弹层坐标、动画、文本选区、临时 Switcher/Help 打开态、请求状态、命令状态、流缓冲、Generation 连接或 Composer 草稿。
+
+#### Scenario: 刷新恢复多栏视图
+- **WHEN** 用户在 Project P 中打开多个 Branch、调整列宽、折叠一列并刷新页面
+- **THEN** Bootstrap 成功后客户端 MUST 恢复刷新前仍然合法的 Slot、Thread、列宽、折叠态和焦点
+- **AND** 每列滚动位置 MUST 使用当前 UI 默认行为,不得从 Snapshot 恢复
+
+#### Scenario: Snapshot 损坏或版本未知
+- **WHEN** localStorage 不可用、Snapshot 无法解析或 `schemaVersion` 不受支持
+- **THEN** 客户端 MUST 安全回退为只显示 Root 且聚焦 Root 的当前默认视图
+- **AND** 不得影响 ProjectBootstrap、Message 展示或 Generation 恢复
+
+### Requirement: 通过 selector 生成 UI ViewModel
+客户端 MUST 使用纯 selector 从 entities、服务端 read models、AssistantRunState 和 UI state 生成 ThreadColumnView、ThreadColumnHeaderView、ProjectTreeRows、MessageView、ForkAvailability 和 ProjectHeaderView 等 ViewModel。派生结果 MUST NOT 作为另一份可独立修改的领域状态持久化。
+
+Selector MUST 至少覆盖 Root Thread、Child Thread、depth、breadcrumb、有效 Message 时间线、Message 运行展示、Artifact 来源、Fork 可用性、可见列组合、物理 Slot、页面统计和列 Header 操作信息。
+
+#### Scenario: replacement 后重算消息视图
+- **WHEN** Store 合并一条 replacement Message 并将来源 Message 标记为 superseded
+- **THEN** Thread Message selector MUST 隐藏来源 Message并按 sequence 展示 replacement
+- **AND** 不得通过手动修改 UI Message 数组维持第二份时间线
+
+#### Scenario: 组合 ThreadColumnView
+- **WHEN** UI 请求一个 Thread 的列视图
+- **THEN** selector MUST 组合 Thread、有效 Messages、相关 AssistantRunState、Message 中的 Artifact 引用、breadcrumb 和操作可用性
+- **AND** 该 ViewModel 不得成为 API 写入对象
+
+#### Scenario: 打开 Artifact Drawer
+- **WHEN** 用户打开 Message tool result 中 `artifactId` 指向的 Artifact
+- **THEN** Lifecycle/Command Hook MUST 按 ID 调用 Artifact Query,并将成功结果缓存到当前 Project Store
+- **AND** Message 组件不得自行请求或复制 Artifact 正文
+
+#### Scenario: 组合每列 Header ViewModel
+- **WHEN** UI 请求 Root 或某个 Branch Slot 的 Header
+- **THEN** selector MUST 派生标题、Root/Branch、depth、breadcrumbs、直接 Child 数量与列表、Fork 来源可用性以及 switch/collapse 能力
+- **AND** `directChildren` MUST 只包含直接 Child;不得把它作为可修改 children 数组写回 Thread Entity
+
+#### Scenario: 组合页面统计
+- **WHEN** UI 请求 ProjectHeaderView
+- **THEN** `threadCount` 与 `branchCount` MUST 从完整 Thread topology 派生,Artifact 与 Markdown 总数 MUST 从服务端 Artifact Summary 派生
+- **AND** 不得使用局部 `artifactsById` 数量冒充 Project Artifact 总数
+
+#### Scenario: 多个 Run 的 Artifact Summary 乱序到达
+- **WHEN** 当前 Summary 的 `changeSequence=8`,随后收到另一个 Run 携带的 `changeSequence=7`
+- **THEN** Store MUST 忽略该旧 Summary 并继续展示 sequence 8 的统计
+- **AND** `changeSequence` MUST NOT 被提交给任何 Project、Thread、Message 或 Generation Command
+
+### Requirement: 使用 Application Command 执行业务意图
+UI 组件和 React Hook MUST NOT 直接调用 `fetch`、构造 Prompt、创建实体 ID或修改服务端实体。跨越 API、事件连接或路由的用户意图 MUST 进入可独立测试的 Application Command,由 Command 调用 API capability、校验结果并通过 Store Action 原子合并 Store。
+
+Application Command MUST 至少覆盖:加载/创建 Project、加载 Thread、发送首条消息、发送后续消息、Fork、Edit、Regenerate、Stop、feedback、更新 Project 元数据以及生成恢复。
+
+#### Scenario: Fork Command 成功
+- **WHEN** UI 调用 Fork Application Command 并传入已有 sourceThreadId、sourceMessageId 和选区信息
+- **THEN** Command MUST 调用服务端 Fork API 并通过 Store Action 合并服务端返回的 Child Thread
+- **AND** UI 层 MAY 在成功后打开新 Thread,但不得自行计算 BaseContext 或 Child Thread ID
+
+#### Scenario: Application Command 失败
+- **WHEN** 服务端返回结构化领域错误
+- **THEN** Command MUST 保持既有已确认 entities 不变并通过 Store Action 更新相应命令错误状态
+- **AND** Hook MUST 向 UI 暴露可展示的失败结果
+
+### Requirement: 使用 Hooks 连接 Store、Command 与 UI
+客户端 MUST 将 Hook 分为 Selector Hook、Command Hook 和 Lifecycle Hook。Selector Hook MUST 通过细粒度 selector 订阅 Store;Command Hook MUST 调用 Application Command 或纯本地 Store Action;Lifecycle Hook MUST 只负责把 React 生命周期接入 Bootstrap、按需加载、生成订阅和本地偏好持久化流程。
+
+普通衍生数据 MUST NOT 通过 `useEffect + setState` 镜像 Store。单个 Message 组件 MUST NOT 自行发起 Message 或 Generation 查询。
+
+#### Scenario: Thread 组件读取数据
+- **WHEN** ThreadColumn 渲染指定 `threadId`
+- **THEN** 它 MUST 通过 Selector Hook 获得 ThreadColumnView
+- **AND** 不得接收整棵 Project 状态后自行遍历并维护副本
+
+#### Scenario: 页面恢复生成订阅
+- **WHEN** 生命周期 Hook 发现已加载 Message 中存在 queued/running AssistantRunState
+- **THEN** 它 MUST 交给统一 generation coordinator 从 eventSequence 恢复订阅
+- **AND** 每个 Message 组件不得建立重复订阅
+
+### Requirement: 支持无实体 ID 的新 Project 入口
+`/thread-chat/new` MUST 表示本地新 Project 草稿入口,不得把 `new` 当作 Project ID。页面 MUST 在没有 Project/Thread 实体的情况下复用空白聊天 UI;用户第一次发送前不得写入 Project 列表或创建假的 Root Thread。
+
+第一次发送成功后,客户端 MUST 合并服务端原子返回的 Project、Root Thread、user Message、assistant Message 和 AssistantRunState,并 MUST 使用集中式路由构造器根据 `project.id` 得到目标 Project URL,再通过 `router.replace` 进入该页面。失败时 MUST 留在 `/thread-chat/new` 并保留草稿。
+
+#### Scenario: 打开后直接离开 new 页面
+- **WHEN** 用户打开 `/thread-chat/new` 但未发送任何内容便离开
+- **THEN** 系统 MUST NOT 创建 Project、Thread、Message 或 MessageRun
+
+#### Scenario: 首次发送成功
+- **WHEN** 用户在 `/thread-chat/new` 发送第一条有效 Message
+- **THEN** 客户端 MUST 调用创建 Project 的 Application Command,并通过 Store Action 使用服务端返回的实体初始化 Project Store
+- **AND** URL MUST 被替换为客户端路由构造器根据服务端 `project.id` 生成的目标 Project URL
+
+#### Scenario: CreationBundle 无空白帧交接
+- **WHEN** `/new` 创建命令返回合法 CreationBundle
+- **THEN** 客户端 MUST 先用 CreationBundle 建立 bootstrap-ready 的 seeded ProjectRuntime,再执行 `router.replace(threadChatRoutes.project(bundle.project.id))`
+- **AND** 目标 ProjectProvider MUST acquire 同一个 seeded Runtime 并跳过第二次 ProjectBootstrap
+- **AND** `/new` 当前页面 MUST 保持渲染到目标 Project Route 可提交,不得插入空白 Project、第二次 Bootstrap Loading 或清空后等待的 Composer 帧
+- **AND** 客户端 MUST NOT 从 CreationBundle 读取页面 URL
+
+#### Scenario: AI 事件早于目标 ProjectProvider 挂载
+- **WHEN** seeded Runtime 在路由交接完成前收到 A1 的 snapshot、delta 或 terminal 事件
+- **THEN** GenerationCoordinator MUST 把事件合并到该 seeded Runtime
+- **AND** 目标 ProjectProvider 接管后 MUST 直接展示同一 Store 的最新状态,不得重新创建 Run 或丢弃已到达事件
+
+#### Scenario: 首次发送失败
+- **WHEN** 创建命令在服务端提交前失败
+- **THEN** 客户端 MUST 保留本地输入并允许重试
+- **AND** 不得向 ProjectCatalogStore 增加未确认 Project
+
+### Requirement: 采用轻量 Bootstrap 与按 Thread 加载
+进入持久化 Project 时,客户端 MUST 首先加载 ProjectBootstrap。Bootstrap MUST 包含 Project、全量轻量 Thread topology、Project Artifact Summary、Root Thread 的有效 Message 和相关 AssistantRunState,但 MUST NOT 包含所有 Branch Message、BaseContext 或 Artifact 正文。
+
+其他 Thread 的 Message MUST 在首次打开时按 Thread 加载。MVP 客户端 MUST 支持一次合并最多 200 条按 sequence 升序排列的有效 Message,并 MUST 保留服务端返回的 `hasOlderMessages` 信息,但不要求实现复杂的树内自动分页替换。
+
+P0 的 Markdown tool result MUST 只向客户端提供 `artifactId` 引用。只有用户打开 Artifact Drawer 时,客户端才 MUST 通过 Artifact Query 按 ID 加载正文;加载 ProjectBootstrap、ThreadMessageBundle 或 topology 不得提前下载 Markdown 正文。
+
+客户端 MUST 使用 ProjectRuntime 级 `ThreadMessageLoader` 管理 `threadId → in-flight Promise/AbortController`。Promise、AbortController 和 in-flight Map MUST NOT 进入 Zustand;Store 只保存每个 Thread 的 `LoadState`、窗口边界和已合并实体。同一 Thread 的并发 ensure MUST 复用同一个 Promise,不同 Thread MUST 可以并行加载。
+
+#### Scenario: 初次进入已有 Project
+- **WHEN** 用户打开 `/thread-chat/{projectId}`
+- **THEN** 客户端 MUST 先合并 ProjectBootstrap 并展示 Root Thread
+- **AND** 不得为了渲染 topology 下载全部 Branch Message
+
+#### Scenario: 刷新恢复多个 Branch Column
+- **WHEN** Bootstrap 后恢复出的工作台 Snapshot 包含多个尚未 ready 的 Branch Thread
+- **THEN** Lifecycle MUST 非阻塞地并行调用每个 Branch 的 `ensureThreadMessages`
+- **AND** Root MUST 立即渲染;不得等待所有 Branch 请求完成后才展示页面
+- **AND** 每个 Branch MUST 独立进入 loading、ready 或 error,一个失败不得阻塞其他列
+
+#### Scenario: Workbench Snapshot 不是 Message Cache
+- **WHEN** 页面刷新后 localStorage 存在多个 Branch Slot,但新的 ProjectRuntime 只有 Root MessageBundle
+- **THEN** 客户端 MUST 使用 Snapshot 恢复列布局,并分别 ensure 每个 Branch MessageBundle
+- **AND** 不得从 Snapshot 构造 Message、Run 或 ready 状态
+
+#### Scenario: 没有 Workbench Snapshot
+- **WHEN** ProjectBootstrap 成功但当前设备没有合法 Workbench Snapshot
+- **THEN** 客户端 MUST 使用当前默认视图,只展示且聚焦 Root
+- **AND** 不得主动加载任何未打开 Branch 的 Message
+
+#### Scenario: ProjectRuntime 中已有 Message Cache
+- **WHEN** 同一 ProjectRuntime 内某个 Branch 的 ThreadMessageWindow 已是 ready 后再次打开该 Thread
+- **THEN** 客户端 MUST 直接复用已合并实体和窗口状态
+- **AND** 不得重复请求该 Thread 的 MessageBundle
+
+#### Scenario: 首次打开 Branch Thread
+- **WHEN** 用户打开尚未加载 Message 的 Branch Thread
+- **THEN** 客户端 MUST 只请求该 Thread 的 Message bundle 并合并结果
+- **AND** 重复打开已加载 Thread 不得自动重复请求
+
+#### Scenario: 同一 Thread 并发 ensure
+- **WHEN** 两个 Lifecycle/Command Hook 在第一个请求完成前同时 ensure 同一 threadId
+- **THEN** ThreadMessageLoader MUST 返回同一个 in-flight Promise
+- **AND** 服务端只能收到一个对应 Message Query
+
+#### Scenario: Slot 切换后旧 Thread 响应迟到
+- **WHEN** Slot S 在 Thread A 请求期间切换到 Thread B,随后 A 的 Bundle 到达
+- **THEN** Bundle MUST 只按 threadId 合并到 A 的实体索引和请求状态
+- **AND** S MUST 继续展示 B;不得让迟到响应把 Slot 切回 A
+
+#### Scenario: ProjectRuntime 销毁
+- **WHEN** ProjectProvider 卸载并销毁 ProjectRuntime
+- **THEN** ThreadMessageLoader MUST Abort 该 Runtime 的全部在飞 Message Query
+- **AND** Abort MUST NOT 写入可重试 error,也不得影响服务端 MessageRun
+
+#### Scenario: 关闭 Column 时请求仍在进行
+- **WHEN** 用户关闭或切换正在加载的 Branch Column,但 ProjectRuntime 仍存活
+- **THEN** Loader MUST 允许请求完成并按 threadId 缓存合法结果
+- **AND** Column 操作不得把请求结果合并到当前 Slot 的新 Thread
+
+### Requirement: 恢复服务端 MessageRun
+客户端 MUST 使用 `assistantMessageId + eventSequence` 识别和恢复生成。queued/running 的展示 MUST 先使用服务端 checkpoint,再从最后 eventSequence 之后订阅;completed MUST 使用 finalized Message.parts;failed/stopped MUST 展示终态且不得自动重启。
+
+刷新、路由切换或取消订阅 MUST NOT 发送 Stop。只有明确的用户 Stop Application Command 可以请求服务端停止 MessageRun。
+
+#### Scenario: 刷新后恢复 running Message
+- **WHEN** ProjectBootstrap 或 Thread bundle 返回 running AssistantRunState
+- **THEN** 客户端 MUST 立即展示 checkpoint 并从返回的 eventSequence 之后恢复订阅
+- **AND** 不得创建新的 assistant Message 或 MessageRun
+
+#### Scenario: 收到 completed 事件
+- **WHEN** generation coordinator 收到合法 completed 终态
+- **THEN** 客户端 MUST 合并服务端给出的 finalized Message 和终态 Run
+- **AND** 必须清除对应的临时流缓冲
diff --git a/openspec/specs/thread-chat-command-api/api-contracts.md b/openspec/specs/thread-chat-command-api/api-contracts.md
new file mode 100644
index 00000000..eb78cf63
--- /dev/null
+++ b/openspec/specs/thread-chat-command-api/api-contracts.md
@@ -0,0 +1,57 @@
+# Thread Chat `/api/v1` 合同
+
+本文是已落地 Route Handler 与共享 Zod Schema 的稳定索引。规范性行为以同目录 [spec.md](./spec.md) 为准;精确字段以 `lib/thread-chat/api/contracts.ts` 的 strict schemas 为可执行合同。
+
+## 通用规则
+
+- 所有领域 ID 是服务端 UUID、不透明字符串;客户端只构造页面 URL,不构造实体 ID,DTO 不返回 `canonicalUrl`。
+- JSON request/response 使用 strict Zod Schema,未知字段拒绝。actor 只来自 Session;所有 Query/Command 都执行 owner scope 与归档/业务资格校验。
+- user parts 只接受允许的 AI SDK v7 user part;Markdown tool output 只保存 `{ artifactId }`,不嵌入正文。
+- Command 在单事务内提交完整原子结果;错误为 `{ error: { code, message, details? } }`,使用一致 HTTP 映射。
+
+## Query API
+
+| Method | Path | Request | Success |
+|---|---|---|---|
+| GET | `/api/v1/projects` | `status=active|archived|all`、`limit`、opaque `cursor` | `ListProjectsResult` |
+| GET | `/api/v1/projects/{projectId}/bootstrap` | path ID | Project、全量轻量 topology、Artifact Summary、唯一 Root bundle |
+| GET | `/api/v1/threads/{threadId}/messages` | `limit<=200`、`beforeSequence` | `ThreadMessageBundle` 与窗口边界 |
+| GET | `/api/v1/artifacts/{artifactId}` | path ID | 按 ID 返回完整 `Artifact` |
+
+Bootstrap/MessageBundle/SSE 不返回 BaseContext、Prompt History、未打开 Branch 消息或 Artifact 正文。Artifact Summary 的 `changeSequence` 和 totals 由服务端计算。
+
+## Command API
+
+| Method | Path | Body | Success |
+|---|---|---|---|
+| POST | `/api/v1/projects` | `{ parts, requestedModelId? }` | CreationBundle:Project、Root、user、assistant、queued Run |
+| PATCH/DELETE | `/api/v1/projects/{projectId}` | metadata patch / none | Project / 204 |
+| POST | `/api/v1/projects/{projectId}/archive|unarchive` | empty | Project |
+| PATCH | `/api/v1/threads/{threadId}` | `{ customTitle }` | Branch Thread;Root metadata 被拒绝 |
+| POST | `/api/v1/threads/{threadId}/archive|unarchive` | empty | Branch Thread |
+| POST | `/api/v1/threads/{threadId}/messages` | `{ parts, requestedModelId? }` | user + assistant + queued Run |
+| POST | `/api/v1/threads/{threadId}/forks` | source Message、anchor/quote;不含 Child ID/BaseContext | 新 Child Thread |
+| POST | `/api/v1/messages/{messageId}/edits` | `{ parts, requestedModelId? }` | ReplacementBundle |
+| POST | `/api/v1/messages/{messageId}/regenerations` | `{ requestedModelId? }` | ReplacementBundle |
+| PUT | `/api/v1/messages/{messageId}/feedback` | `{ value: positive|negative|null }` | Feedback DTO |
+| POST | `/api/v1/assistant-messages/{id}/stop` | empty | 最新 AssistantRunState |
+
+P0 不提供 user-only append、客户端 BaseContext、通用 Idempotency-Key、双写或 feature flag。
+
+## SSE
+
+`GET /api/v1/assistant-messages/{assistantMessageId}/events?afterEventSequence=N` 返回 `text/event-stream`。每次连接首个业务事件固定为持久化 `run.snapshot`;其后只接受严格递增且大于恢复游标的事件:
+
+- `run.delta`:最新 checkpoint 增量/视图;
+- `run.completed`:finalized Message、terminal Run、最新 Artifact Summary;
+- `run.failed`:结构化错误与 terminal Run;
+- `run.stopped`:terminal Run;
+- snapshot/completed 不复制 Artifact 正文。
+
+断开 SSE、刷新或切换页面不停止后台执行。queued/running 刷新后复用同一 `assistantMessageId` 与 MessageRun;显式 Stop API 才写入 stop request。
+
+## 错误与维护
+
+共享错误码覆盖认证、权限、not found、strict validation、归档状态、领域资格、冲突和内部错误;服务端错误堆栈不进入响应。`ThreadChatApiCapabilities` 是 Web UI 与未来 adapter 的 transport-neutral 边界。
+
+后续增删任何字段、Route、事件或错误码时,必须同时更新正式 spec、本文、`contracts.ts`、`capabilities.ts`、`routes.ts`、Route Handler、JSON Transport、合同/权限/原子性/SSE 测试与 OpenSpec strict validation。
diff --git a/openspec/specs/thread-chat-command-api/spec.md b/openspec/specs/thread-chat-command-api/spec.md
new file mode 100644
index 00000000..ddc13aba
--- /dev/null
+++ b/openspec/specs/thread-chat-command-api/spec.md
@@ -0,0 +1,215 @@
+# thread-chat-command-api Specification
+
+## Purpose
+
+定义 Thread Chat `/api/v1` 的共享契约、查询、命令、SSE、权限与错误语义。
+
+稳定说明见 [api-contracts.md](./api-contracts.md)。若说明文档与本规范冲突,以本规范中的可验证 Requirement 与 Scenario 为准;任何实现变更都 MUST 同步校验正式规范、说明文档、共享 Schema、Route/Runtime 实现与验收测试。
+
+## Requirements
+
+### Requirement: 使用版本化 API 和服务端实体身份
+ThreadChat 后端 MUST 在 `/api/v1` 下提供版本化 Query、Command 和事件接口。客户端 MUST 只提交正在操作的既有资源 ID;Project、Thread、Message 和 MessageRun 的新 ID MUST 由服务端生成并在成功响应中返回。
+
+服务端 MUST 从认证 Session 确定 actor,MUST 对每个 Project、Thread 和 Message 操作执行归属或访问授权校验。普通 JSON 成功响应 MUST 返回 `{ data: T }` 中的权威 DTO,错误 MUST 返回 `{ error: { code, message, details? } }`;204 和事件流除外。请求 Object MUST 严格校验未声明字段。MVP 普通追加、Fork 和生成命令 MUST NOT 要求 Project/Thread revision 或 If-Match。
+
+#### Scenario: 客户端提交待创建实体 ID
+- **WHEN** 创建或 Fork 请求包含 newProjectId、newThreadId、newMessageId 或 newMessageRunId
+- **THEN** 服务端 MUST 忽略或拒绝这些字段
+- **AND** 成功结果中的实体 ID MUST 来自服务端
+
+#### Scenario: 访问其他用户的 Project
+- **WHEN** actor 对目标 Project 没有访问权
+- **THEN** 服务端 MUST 返回 403 或不泄漏存在性的 404
+- **AND** 不得返回 Project topology、Message 或运行状态
+
+### Requirement: 提供 Project 列表与元数据命令
+服务端 MUST 提供列出、更新、归档和永久删除 Project 的能力。列表 MUST 为轻量 ProjectSummary,按 `updatedAt DESC, id DESC` 稳定排序,不得包含 Thread topology 或 Message 正文。
+
+Project metadata 更新 MUST 允许独立修改 customTitle、Target 和 Instruction;未提供字段 MUST 保持不变,显式 null MUST 按字段契约清空。MVP 对 metadata 使用服务端最后写入生效,不引入通用 revision。
+
+#### Scenario: 列出 Project
+- **WHEN** 客户端请求 `GET /api/v1/projects`
+- **THEN** 服务端 MUST 只返回 actor 可访问的 ProjectSummary
+- **AND** 每项 MUST 至少包含 id、展示标题、archivedAt、updatedAt 和轻量统计
+
+#### Scenario: 更新 Project Target
+- **WHEN** 客户端请求 `PATCH /api/v1/projects/{projectId}` 并提交合法 Target
+- **THEN** 服务端 MUST 更新该 Project 的终极、短期和中期目标集合
+- **AND** 不得修改 Thread、Message 或其他 Project
+
+#### Scenario: 永久删除 Project
+- **WHEN** 已授权 actor 明确请求永久删除 Project
+- **THEN** 服务端 MUST 按领域规范清理其 Thread、Message、MessageRun 和附属资源
+- **AND** 后续 Bootstrap MUST 返回 not_found
+
+### Requirement: 原子创建首个 Project 对话
+服务端 MUST 允许客户端用一条命令提交首条 user Message,并在同一数据库事务创建 Project、唯一 Root Thread、user Message、assistant Message 和 queued MessageRun。事务成功前不得唤醒模型执行;任一实体写入失败 MUST 回滚全部创建。
+
+请求 MUST 包含符合 AI SDK v7 UIMessage.parts 的 `initialMessage.parts`,并 MAY 包含 `requestedModelId`。请求不得包含新实体 ID。响应 MUST 返回全部创建实体、初始 ProjectArtifactSummary 和 AssistantRunState。服务端 MUST NOT 返回或构造 Web 页面 URL;页面路由由客户端根据响应中的 `project.id` 决定。
+
+#### Scenario: 首次发送成功
+- **WHEN** 客户端请求 `POST /api/v1/projects` 并提交合法 initialMessage
+- **THEN** 服务端 MUST 返回 201 及 Project、Root Thread、U1、A1 和 queued Run
+- **AND** 初始 ProjectArtifactSummary MUST 是 `{ changeSequence: 0, total: 0, byKind: {} }`
+- **AND** 数据库中不得存在缺少 Root Thread 或缺少 A1 Run 的部分 Project
+
+#### Scenario: 创建响应与 Web 路由解耦
+- **WHEN** 服务端成功创建首个 Project 对话
+- **THEN** CreationBundle MUST 包含可作为资源身份的 `project.id`
+- **AND** CreationBundle MUST NOT 包含 `canonicalUrl`、`pageUrl` 或其他客户端页面路径
+- **AND** Web 客户端 MUST 使用集中式路由构造器决定导航目标
+
+#### Scenario: 首次发送校验失败
+- **WHEN** initialMessage.parts 为空、非法或不符合允许的 UIMessage part 协议
+- **THEN** 服务端 MUST 返回 validation_error
+- **AND** 不得创建任何 Project 数据
+
+### Requirement: 提供 ProjectBootstrap Query
+服务端 MUST 提供 `GET /api/v1/projects/{projectId}/bootstrap`。响应 MUST 包含 Project DTO、全量轻量 ThreadTopologyItem、ProjectArtifactSummary、唯一 Root Thread 的 MessageBundle,以及恢复这些 Message 所需的 AssistantRunState。Message 中可以包含 Artifact ID 引用,但 Bootstrap 不得返回 Artifact 正文。
+
+Bootstrap MUST NOT 返回 BaseContext、所有 Branch Message、全部 Project File/Artifact 正文或服务端 Prompt。Thread topology MUST 足以由客户端派生 Root、Child、depth 和 breadcrumb。
+
+ProjectArtifactSummary MUST 统计该 Project 的全部 Artifact,并 MUST 至少返回服务端单调 `changeSequence`、总数和按稳定 `kind` 聚合的数量。它不得退化为客户端已经按 ID 加载的 Artifact 数量;`total` 必须等于各 kind 计数之和。`changeSequence` 只用于拒绝乱序旧统计,不得成为 Command 请求参数或写入前置条件。
+
+#### Scenario: 加载现有 Project
+- **WHEN** actor 请求可访问 Project 的 Bootstrap
+- **THEN** 服务端 MUST 返回且只返回一个 Root Thread,并返回全部轻量 topology
+- **AND** Root MessageBundle MUST 按 sequence 升序排列有效 Message
+- **AND** ProjectArtifactSummary MUST 覆盖该 Project 全量 Artifact
+
+#### Scenario: Project 不存在
+- **WHEN** projectId 不存在或不可访问
+- **THEN** 服务端 MUST 返回 not_found
+- **AND** 不得创建空 Project 作为降级结果
+
+#### Scenario: 未加载 Branch Artifact 仍计入统计
+- **WHEN** Project 的 Branch Thread 中存在 3 个 Markdown Artifact,但 Bootstrap 不返回该 Branch 的 Message
+- **THEN** `artifactSummary.byKind.markdown` MUST 仍然等于 3
+- **AND** Root bundle MUST NOT 因此返回这 3 个 Artifact 的正文
+
+### Requirement: 按 Thread 提供 MessageBundle
+服务端 MUST 提供 `GET /api/v1/threads/{threadId}/messages`,并 MUST 通过 Thread 所属 Project 校验访问权。响应 MUST 返回有效 Message 与相关 AssistantRunState;默认最多返回最新 200 条,再按 sequence 升序输出。
+
+响应 MUST 包含 `hasOlderMessages` 和可供未来向前加载的边界 sequence。MVP 客户端可以不请求更早页面,但 API 不得让调用方通过下载整棵 Project 才能读取一个 Thread。
+
+P0 中,Markdown tool result MUST 在符合 AI SDK v7 的 Message part 中保存 `artifactId`,不得复制 Markdown 正文。客户端需要展示正文时 MUST 通过独立 Artifact Query 按 ID 加载。
+
+#### Scenario: Thread 少于 200 条有效 Message
+- **WHEN** 客户端读取包含 80 条有效 Message 的 Thread
+- **THEN** 服务端 MUST 返回全部 80 条并设置 `hasOlderMessages=false`
+
+#### Scenario: Thread 超过 200 条有效 Message
+- **WHEN** 客户端未指定边界读取包含超过 200 条有效 Message 的 Thread
+- **THEN** 服务端 MUST 返回最新 200 条并按 sequence 升序排列
+- **AND** 必须设置 `hasOlderMessages=true` 和更早页面边界
+
+### Requirement: 提供 Thread 元数据命令
+服务端 MUST 允许授权 actor 更新 Branch Thread 的 customTitle,并 MUST 提供归档和取消归档 Thread 的显式命令。Root Thread 的展示标题 MUST 继续来自 Project,客户端不得通过 Thread metadata 命令为 Root 建立第二套标题权威。
+
+归档 Thread MUST 保留其 Message、Child Thread、Fork 来源和 BaseContext;它只改变默认导航可见性,不得等同于永久删除。
+
+#### Scenario: 重命名 Branch Thread
+- **WHEN** 客户端请求 `PATCH /api/v1/threads/{threadId}` 并提交合法 customTitle
+- **THEN** 服务端 MUST 更新该 Branch Thread 标题并返回最新 Thread DTO
+- **AND** 不得修改 Project 标题
+
+#### Scenario: 归档 Thread
+- **WHEN** 客户端请求 `POST /api/v1/threads/{threadId}/archive`
+- **THEN** 服务端 MUST 设置 archivedAt 并保留完整 Thread 内容和后代关系
+
+### Requirement: 原子发送后续消息并启动生成
+服务端 MUST 提供在既有 Thread 中发送 user Message 的常用原子命令。该命令 MUST 在同一事务创建 user Message、assistant Message 和 queued MessageRun,并返回三者;模型执行只能在事务提交后启动。
+
+Path MUST 包含已有 `threadId`;Body MUST 只包含合法 user `parts` 和可选 `requestedModelId`。服务端 MUST 根据当前有效历史构造 Prompt,客户端不得提交 Prompt History、BaseContext、待创建 ID 或整棵 Project 状态。
+
+P0 MUST NOT 额外提供“只创建 user Message、不创建 assistant Message 与 MessageRun”的发送命令。该限制只是当前 API 能力边界,不得被实现为 user/assistant 必须角色交替的数据库约束。
+
+P0 MUST NOT 额外提供“只创建 user Message、不创建 assistant Message 与 MessageRun”的发送命令。该限制只是当前 API 能力边界,不得被实现为 user/assistant 必须角色交替的数据库约束。
+
+#### Scenario: 在 Root Thread 发送消息
+- **WHEN** 客户端请求 `POST /api/v1/threads/{threadId}/messages` 并提交合法 user parts
+- **THEN** 服务端 MUST 分配新的 sequence 并返回 user Message、assistant Message 和 queued Run
+- **AND** 响应中的所有新 ID MUST 由服务端生成
+
+#### Scenario: 同 Thread 已有运行中生成
+- **WHEN** 当前产品策略不允许同一 Thread 并发生成且已有 queued/running Run
+- **THEN** 服务端 MUST 返回 `thread_generation_in_progress`
+- **AND** 不得依赖客户端 busy 状态作为唯一保护
+
+### Requirement: 原子创建 Fork Thread
+服务端 MUST 提供从已有 sourceThreadId 和 sourceMessageId 创建 Child Thread 的命令。请求 MAY 包含用户选区 anchor/quote,但 MUST NOT 包含 BaseContext、ForkSourceSnapshot 的权威字段或 Child Thread ID。
+
+服务端 MUST 验证来源资格、同 Project 关系和无环约束,在一个事务中计算并持久化 BaseContext、ForkSourceSnapshot 和 Child Thread,然后返回新 Thread DTO。
+
+#### Scenario: 从 completed assistant Message Fork
+- **WHEN** 客户端请求 `POST /api/v1/threads/{sourceThreadId}/forks` 且 sourceMessage 合格
+- **THEN** 服务端 MUST 返回 201 和服务端创建的 Child Thread
+- **AND** Child Thread 必须属于同一 Project并冻结到来源 Message 的 BaseContext
+
+#### Scenario: 从 running assistant Message Fork
+- **WHEN** sourceMessage 的 Run 为 queued 或 running
+- **THEN** 服务端 MUST 返回 `fork_source_not_finalized`
+- **AND** 不得创建部分 Child Thread
+
+### Requirement: 使用 replacement 命令实现 Edit 和 Regenerate
+服务端 MUST 分别提供 Edit 最后一条有效 user Message 和 Regenerate 当前可重新生成 assistant Message 的命令。两种命令 MUST 创建服务端 ID 的 replacement Message;Regenerate 以及 Edit 后的新回答 MUST 同时创建新的 queued MessageRun,且不得覆盖旧 Message.parts 或 sequence。
+
+Edit 请求 MUST 只提交 sourceUserMessageId、新 parts 和可选 requestedModelId。Regenerate 请求 MUST 只提交 sourceAssistantMessageId 和可选 requestedModelId。响应 MUST 返回被 superseded 的 Message ID、全部新增 Message 和新的 AssistantRunState。
+
+#### Scenario: Regenerate 当前 assistant Message
+- **WHEN** 客户端请求 `POST /api/v1/messages/{assistantMessageId}/regenerations` 且来源可重新生成
+- **THEN** 服务端 MUST 返回 replacement assistant Message 和新的 queued Run
+- **AND** 来源 Message 的内容与 sequence 必须保持不变
+
+#### Scenario: Edit 最后一条 user Message
+- **WHEN** 客户端请求 `POST /api/v1/messages/{userMessageId}/edits` 并提交新 parts
+- **THEN** 服务端 MUST 返回 replacement user Message、replacement assistant Message、queued Run 和被 superseded 的后缀 ID
+
+#### Scenario: Edit 历史 user Message
+- **WHEN** sourceUserMessageId 不是当前 Thread 最后一条有效 user Message
+- **THEN** 服务端 MUST 返回 `fork_required`
+- **AND** 不得修改任何既有 Message
+
+### Requirement: 通过 assistantMessageId 管理生成生命周期
+服务端 MUST 允许客户端使用 assistantMessageId 查询、通过 SSE 订阅并停止对应 MessageRun,而不要求客户端理解内部 MessageRun ID。SSE 事件流 MUST 使用严格递增的 eventSequence,并 MUST 支持 `afterEventSequence` 恢复。每次连接 MUST 先返回当前持久化 checkpoint snapshot 及其 cursor;若仍在运行,后续 live event MUST 严格大于该 cursor。旧 token delta MAY 不逐条重放,但恢复结果不得丢失已经持久化的生成内容。
+
+Stop MUST 是显式 Command;连接关闭、页面刷新或取消订阅不得自动停止 Run。终态事件 MUST 携带或允许随后取得 finalized Message 和终态 AssistantRunState。`run.snapshot` 与 `run.completed` MUST 携带当前 ProjectArtifactSummary,使客户端能够在刷新、重连和 Artifact 生成完成后校正 Project 页面统计;客户端不得依赖事件次数自行累加。
+
+#### Scenario: 恢复 running 事件流
+- **WHEN** 客户端请求 assistant Message 事件且提交 `afterEventSequence=42`
+- **THEN** 服务端 MUST 先发送不旧于该游标的当前 checkpoint snapshot
+- **AND** 后续 live event MUST 大于 snapshot cursor
+- **AND** 不得重新启动第二个 MessageRun
+
+#### Scenario: 显式停止生成
+- **WHEN** 客户端请求 `POST /api/v1/assistant-messages/{assistantMessageId}/stop`
+- **THEN** 服务端 MUST 对 queued/running Run 记录 stop 请求并返回最新 AssistantRunState
+- **AND** 重复 Stop MUST 返回相同终态或当前状态而不创建新 Run
+
+#### Scenario: 仅断开订阅
+- **WHEN** 浏览器关闭事件连接但未发送 Stop
+- **THEN** MessageRun MUST 继续由服务端执行
+
+### Requirement: 按 ID 加载 Artifact
+服务端 MUST 提供 `GET /api/v1/artifacts/{artifactId}`,并 MUST 通过 Artifact 所属 Project 校验访问权。Message、ThreadMessageBundle、ProjectBootstrap 和生成事件 MUST 只通过 `artifactId` 引用 Artifact,不得复制 Markdown 正文。
+
+#### Scenario: 打开 Markdown Artifact
+- **WHEN** 用户打开 Message tool result 引用的 Markdown Artifact
+- **THEN** 客户端 MUST 使用 `artifactId` 请求 Artifact Query
+- **AND** 服务端 MUST 返回对应 Artifact 的完整内容
+
+#### Scenario: 未打开 Artifact
+- **WHEN** 客户端只加载 ProjectBootstrap 或 ThreadMessageBundle,但用户没有打开 Artifact
+- **THEN** 服务端 MUST NOT 返回 Artifact 正文
+
+### Requirement: 提供 Message feedback 命令
+服务端 MUST 允许客户端按 assistantMessageId 设置 positive、negative 或 null feedback。只有 finalized 且允许评价的 assistant Message 可以接受 feedback;命令不得修改 Message 内容或 MessageRun。
+
+#### Scenario: 设置正向反馈
+- **WHEN** 客户端请求 `PUT /api/v1/messages/{assistantMessageId}/feedback` 并提交 `positive`
+- **THEN** 服务端 MUST 返回该 Message 最新 feedback
+
+#### Scenario: 评价 user Message
+- **WHEN** 目标 Message 不是 assistant Message
+- **THEN** 服务端 MUST 返回 `message_not_feedback_eligible`
diff --git a/package.json b/package.json
index dd4472f5..4ffaab04 100644
--- a/package.json
+++ b/package.json
@@ -12,12 +12,20 @@
     "start": "next start",
     "lint": "eslint",
     "format": "prettier --write \"**/*.{ts,tsx}\"",
+    "test": "pnpm test:unit && pnpm test:client && pnpm test:integration && pnpm test:api",
+    "test:unit": "vitest run --config vitest.config.ts",
+    "test:client": "vitest run --config vitest.client.config.ts",
+    "test:integration": "pnpm test:db:reset && vitest run --config vitest.integration.config.ts",
+    "test:api": "pnpm test:db:reset && vitest run --config vitest.api.config.ts",
+    "test:watch": "vitest --config vitest.config.ts",
     "typecheck": "tsc --noEmit",
     "db:generate": "drizzle-kit generate",
     "db:migrate": "drizzle-kit migrate",
     "db:reset-schema": "node scripts/reset-thread-chat-schema.mjs",
     "db:push": "drizzle-kit push",
     "db:studio": "drizzle-kit studio",
+    "test:db:create": "node scripts/create-test-database.mjs",
+    "test:db:reset": "node scripts/reset-test-schema.mjs",
     "openspec:validate": "openspec validate --all --strict"
   },
   "dependencies": {
@@ -82,6 +90,8 @@
     "@fission-ai/openspec": "1.5.0",
     "@shikijs/types": "4.3.1",
     "@tailwindcss/postcss": "^4",
+    "@testing-library/react": "^16.3.2",
+    "@testing-library/user-event": "^14.6.6",
     "@types/node": "^20",
     "@types/react": "19.2.18",
     "@types/react-dom": "19.2.4",
@@ -89,11 +99,13 @@
     "drizzle-kit": "^0.31.10",
     "eslint": "^9",
     "eslint-config-next": "16.3.1",
+    "jsdom": "^30.0.1",
     "playwright-core": "^1.61.1",
     "prettier": "^3.8.3",
     "prettier-plugin-tailwindcss": "^0.8.0",
     "tailwindcss": "^4",
-    "typescript": "^5"
+    "typescript": "^5",
+    "vitest": "^4.1.11"
   },
   "pnpm": {
     "overrides": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 588ffe0c..ee98165f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -88,7 +88,7 @@ importers:
         version: 1.1.3
       better-auth:
         specifier: ^1.6.23
-        version: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.3)(postgres@3.4.9))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+        version: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.3)(postgres@3.4.9))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))
       class-variance-authority:
         specifier: ^0.7.1
         version: 0.7.1
@@ -195,6 +195,12 @@ importers:
       '@tailwindcss/postcss':
         specifier: ^4
         version: 4.3.2
+      '@testing-library/react':
+        specifier: ^16.3.2
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+      '@testing-library/user-event':
+        specifier: ^14.6.6
+        version: 14.6.6(@testing-library/dom@10.4.1)
       '@types/node':
         specifier: ^20
         version: 20.19.43
@@ -216,6 +222,9 @@ importers:
       eslint-config-next:
         specifier: 16.3.1
         version: 16.3.1(@typescript-eslint/parser@8.62.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
+      jsdom:
+        specifier: ^30.0.1
+        version: 30.0.1(@noble/hashes@2.2.0)
       playwright-core:
         specifier: ^1.61.1
         version: 1.61.1
@@ -231,6 +240,9 @@ importers:
       typescript:
         specifier: ^5
         version: 5.9.3
+      vitest:
+        specifier: ^4.1.11
+        version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
 
 packages:
 
@@ -316,6 +328,14 @@ packages:
     resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
     engines: {node: '>=10'}
 
+  '@asamuzakjp/css-color@6.0.7':
+    resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
+    engines: {node: ^22.13.0 || >=24.0.0}
+
+  '@asamuzakjp/dom-selector@8.3.2':
+    resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
+    engines: {node: ^22.13.0 || >=24.0.0}
+
   '@assistant-ui/core@0.2.19':
     resolution: {integrity: sha512-QYwIy+l21rvVsyuc19doblCJ3bfhBzqbuNa/xdts8CIDlw+5LWg1uVGCiVhTRpkysHuJyONDF4TVBAxdMXR7yw==}
     peerDependencies:
@@ -742,6 +762,46 @@ packages:
   '@better-fetch/fetch@1.3.1':
     resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==}
 
+  '@bramus/specificity@2.4.2':
+    resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+    hasBin: true
+
+  '@csstools/color-helpers@6.1.1':
+    resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
+    engines: {node: '>=20.19.0'}
+
+  '@csstools/css-calc@3.3.0':
+    resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
+    engines: {node: '>=20.19.0'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^4.0.0
+      '@csstools/css-tokenizer': ^4.0.0
+
+  '@csstools/css-color-parser@4.2.0':
+    resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==}
+    engines: {node: '>=20.19.0'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^4.0.0
+      '@csstools/css-tokenizer': ^4.0.0
+
+  '@csstools/css-parser-algorithms@4.0.0':
+    resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+    engines: {node: '>=20.19.0'}
+    peerDependencies:
+      '@csstools/css-tokenizer': ^4.0.0
+
+  '@csstools/css-syntax-patches-for-csstree@1.1.8':
+    resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==}
+    peerDependencies:
+      css-tree: ^3.2.1
+    peerDependenciesMeta:
+      css-tree:
+        optional: true
+
+  '@csstools/css-tokenizer@4.0.0':
+    resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+    engines: {node: '>=20.19.0'}
+
   '@dagrejs/dagre@3.0.0':
     resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==}
 
@@ -1263,6 +1323,15 @@ packages:
     resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
+  '@exodus/bytes@1.15.1':
+    resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+    peerDependencies:
+      '@noble/hashes': ^1.8.0 || ^2.0.0
+    peerDependenciesMeta:
+      '@noble/hashes':
+        optional: true
+
   '@fission-ai/openspec@1.5.0':
     resolution: {integrity: sha512-SLZkyF51gFYkISufZKaka0X04z4y/WCjPOcCB+EC7tALd0TC+7V76BOIzWOSIOhdhBWwh5EMIBhrgLnugIh1DA==}
     engines: {node: '>=20.19.0'}
@@ -1818,6 +1887,9 @@ packages:
     resolution: {integrity: sha512-icc5xCzndZfhuJMy5oqk5AvloWquR7jtae74qzpkKkhGp8BivK+oCcEXgGnjCdTfp8hA44l+w8gE8yYJbocJJw==}
     engines: {node: '>=14'}
 
+  '@oxc-project/types@0.146.0':
+    resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==}
+
   '@posthog/core@1.39.6':
     resolution: {integrity: sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw==}
 
@@ -2528,6 +2600,105 @@ packages:
       react-redux:
         optional: true
 
+  '@rolldown/binding-android-arm-eabi@1.2.5':
+    resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm]
+    os: [android]
+
+  '@rolldown/binding-android-arm64@1.2.5':
+    resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm64]
+    os: [android]
+
+  '@rolldown/binding-darwin-arm64@1.2.5':
+    resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rolldown/binding-darwin-x64@1.2.5':
+    resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rolldown/binding-freebsd-x64@1.2.5':
+    resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rolldown/binding-linux-arm-gnueabihf@1.2.5':
+    resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm]
+    os: [linux]
+
+  '@rolldown/binding-linux-arm64-gnu@1.2.5':
+    resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rolldown/binding-linux-arm64-musl@1.2.5':
+    resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rolldown/binding-linux-ppc64-gnu@1.2.5':
+    resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rolldown/binding-linux-s390x-gnu@1.2.5':
+    resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rolldown/binding-linux-x64-gnu@1.2.5':
+    resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rolldown/binding-linux-x64-musl@1.2.5':
+    resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rolldown/binding-openharmony-arm64@1.2.5':
+    resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rolldown/binding-win32-arm64-msvc@1.2.5':
+    resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rolldown/binding-win32-x64-msvc@1.2.5':
+    resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    cpu: [x64]
+    os: [win32]
+
+  '@rolldown/pluginutils@1.0.1':
+    resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
   '@rtsao/scc@1.1.0':
     resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
 
@@ -2712,12 +2883,43 @@ packages:
   '@tailwindcss/postcss@4.3.2':
     resolution: {integrity: sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==}
 
+  '@testing-library/dom@10.4.1':
+    resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+    engines: {node: '>=18'}
+
+  '@testing-library/react@16.3.2':
+    resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@testing-library/dom': ^10.0.0
+      '@types/react': 19.2.18
+      '@types/react-dom': 19.2.4
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
+  '@testing-library/user-event@14.6.6':
+    resolution: {integrity: sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==}
+    engines: {node: '>=12', npm: '>=6'}
+    peerDependencies:
+      '@testing-library/dom': '>=7.21.4'
+
   '@ts-morph/common@0.27.0':
     resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
 
   '@tybys/wasm-util@0.10.3':
     resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
 
+  '@types/aria-query@5.0.4':
+    resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+  '@types/chai@5.2.3':
+    resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
   '@types/d3-array@3.2.2':
     resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
 
@@ -2760,6 +2962,9 @@ packages:
   '@types/debug@4.1.13':
     resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
 
+  '@types/deep-eql@4.0.2':
+    resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
   '@types/estree-jsx@1.0.5':
     resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
 
@@ -2993,6 +3198,35 @@ packages:
     resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
     engines: {node: '>= 20'}
 
+  '@vitest/expect@4.1.11':
+    resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
+
+  '@vitest/mocker@4.1.11':
+    resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
+    peerDependencies:
+      msw: ^2.4.9
+      vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+    peerDependenciesMeta:
+      msw:
+        optional: true
+      vite:
+        optional: true
+
+  '@vitest/pretty-format@4.1.11':
+    resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
+
+  '@vitest/runner@4.1.11':
+    resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
+
+  '@vitest/snapshot@4.1.11':
+    resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
+
+  '@vitest/spy@4.1.11':
+    resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
+
+  '@vitest/utils@4.1.11':
+    resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
+
   '@workflow/serde@4.1.0':
     resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
 
@@ -3076,6 +3310,10 @@ packages:
     resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
     engines: {node: '>=8'}
 
+  ansi-styles@5.2.0:
+    resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+    engines: {node: '>=10'}
+
   argparse@2.0.1:
     resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
 
@@ -3083,6 +3321,9 @@ packages:
     resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
     engines: {node: '>=10'}
 
+  aria-query@5.3.0:
+    resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
   aria-query@5.3.2:
     resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
     engines: {node: '>= 0.4'}
@@ -3119,6 +3360,10 @@ packages:
     resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
     engines: {node: '>= 0.4'}
 
+  assertion-error@2.0.1:
+    resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+    engines: {node: '>=12'}
+
   assistant-cloud@0.1.34:
     resolution: {integrity: sha512-kmB9qJmwf1Kb3FoLvVnDV7lsT2vRwaiNX9iDoEs+3Gli8aOQ667DPUIXvEXPyV5zF4gfT0E7GFNEcYWRQTElAA==}
 
@@ -3259,6 +3504,9 @@ packages:
       zod:
         optional: true
 
+  bidi-js@1.0.3:
+    resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
   body-parser@2.3.0:
     resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
     engines: {node: '>=18'}
@@ -3315,6 +3563,10 @@ packages:
   ccount@2.0.1:
     resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
 
+  chai@6.2.2:
+    resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+    engines: {node: '>=18'}
+
   chalk@4.1.2:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
@@ -3440,6 +3692,10 @@ packages:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  css-tree@3.2.1:
+    resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+    engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
   cssesc@3.0.0:
     resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
     engines: {node: '>=4'}
@@ -3517,6 +3773,10 @@ packages:
   damerau-levenshtein@1.0.8:
     resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
 
+  data-urls@7.0.0:
+    resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
   data-view-buffer@1.0.2:
     resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
     engines: {node: '>= 0.4'}
@@ -3556,6 +3816,9 @@ packages:
   decimal.js-light@2.5.1:
     resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
 
+  decimal.js@10.6.0:
+    resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
   decode-named-character-reference@1.3.0:
     resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
 
@@ -3631,6 +3894,9 @@ packages:
     resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
     engines: {node: '>=0.10.0'}
 
+  dom-accessibility-api@0.5.16:
+    resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
   dot-prop@6.0.1:
     resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
     engines: {node: '>=10'}
@@ -3786,6 +4052,10 @@ packages:
     resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
     engines: {node: '>=0.12'}
 
+  entities@8.0.0:
+    resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+    engines: {node: '>=20.19.0'}
+
   env-paths@2.2.1:
     resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
     engines: {node: '>=6'}
@@ -3813,6 +4083,9 @@ packages:
     resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==}
     engines: {node: '>= 0.4'}
 
+  es-module-lexer@2.3.2:
+    resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
+
   es-object-atoms@1.1.2:
     resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
     engines: {node: '>= 0.4'}
@@ -3986,6 +4259,9 @@ packages:
   estree-util-is-identifier-name@3.0.0:
     resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
 
+  estree-walker@3.0.3:
+    resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
   esutils@2.0.3:
     resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
     engines: {node: '>=0.10.0'}
@@ -4013,6 +4289,10 @@ packages:
     resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
     engines: {node: ^18.19.0 || >=20.5.0}
 
+  expect-type@1.4.0:
+    resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+    engines: {node: '>=12.0.0'}
+
   express-rate-limit@8.5.2:
     resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
     engines: {node: '>= 16'}
@@ -4252,6 +4532,10 @@ packages:
     resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
     engines: {node: '>=16.9.0'}
 
+  html-encoding-sniffer@6.0.0:
+    resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
   html-url-attributes@3.0.1:
     resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
 
@@ -4449,6 +4733,9 @@ packages:
     resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
     engines: {node: '>=12'}
 
+  is-potential-custom-element-name@1.0.1:
+    resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
   is-promise@4.0.0:
     resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
 
@@ -4547,6 +4834,15 @@ packages:
     resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
     hasBin: true
 
+  jsdom@30.0.1:
+    resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
+    engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+    peerDependencies:
+      canvas: ^3.2.3
+    peerDependenciesMeta:
+      canvas:
+        optional: true
+
   jsesc@3.1.0:
     resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
     engines: {node: '>=6'}
@@ -4632,30 +4928,60 @@ packages:
     cpu: [arm64]
     os: [android]
 
+  lightningcss-android-arm64@1.33.0:
+    resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [android]
+
   lightningcss-darwin-arm64@1.32.0:
     resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [darwin]
 
+  lightningcss-darwin-arm64@1.33.0:
+    resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [darwin]
+
   lightningcss-darwin-x64@1.32.0:
     resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [darwin]
 
+  lightningcss-darwin-x64@1.33.0:
+    resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [darwin]
+
   lightningcss-freebsd-x64@1.32.0:
     resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [freebsd]
 
+  lightningcss-freebsd-x64@1.33.0:
+    resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [freebsd]
+
   lightningcss-linux-arm-gnueabihf@1.32.0:
     resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm]
     os: [linux]
 
+  lightningcss-linux-arm-gnueabihf@1.33.0:
+    resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm]
+    os: [linux]
+
   lightningcss-linux-arm64-gnu@1.32.0:
     resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
     engines: {node: '>= 12.0.0'}
@@ -4663,6 +4989,13 @@ packages:
     os: [linux]
     libc: [glibc]
 
+  lightningcss-linux-arm64-gnu@1.33.0:
+    resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
   lightningcss-linux-arm64-musl@1.32.0:
     resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
     engines: {node: '>= 12.0.0'}
@@ -4670,6 +5003,13 @@ packages:
     os: [linux]
     libc: [musl]
 
+  lightningcss-linux-arm64-musl@1.33.0:
+    resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
   lightningcss-linux-x64-gnu@1.32.0:
     resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
     engines: {node: '>= 12.0.0'}
@@ -4677,6 +5017,13 @@ packages:
     os: [linux]
     libc: [glibc]
 
+  lightningcss-linux-x64-gnu@1.33.0:
+    resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
   lightningcss-linux-x64-musl@1.32.0:
     resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
     engines: {node: '>= 12.0.0'}
@@ -4684,22 +5031,45 @@ packages:
     os: [linux]
     libc: [musl]
 
+  lightningcss-linux-x64-musl@1.33.0:
+    resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
   lightningcss-win32-arm64-msvc@1.32.0:
     resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
     engines: {node: '>= 12.0.0'}
     cpu: [arm64]
     os: [win32]
 
+  lightningcss-win32-arm64-msvc@1.33.0:
+    resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [arm64]
+    os: [win32]
+
   lightningcss-win32-x64-msvc@1.32.0:
     resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
     engines: {node: '>= 12.0.0'}
     cpu: [x64]
     os: [win32]
 
+  lightningcss-win32-x64-msvc@1.33.0:
+    resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
+    engines: {node: '>= 12.0.0'}
+    cpu: [x64]
+    os: [win32]
+
   lightningcss@1.32.0:
     resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
     engines: {node: '>= 12.0.0'}
 
+  lightningcss@1.33.0:
+    resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
+    engines: {node: '>= 12.0.0'}
+
   lines-and-columns@1.2.4:
     resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
 
@@ -4725,6 +5095,10 @@ packages:
     resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
     hasBin: true
 
+  lru-cache@11.5.2:
+    resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+    engines: {node: 20 || >=22}
+
   lru-cache@5.1.1:
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
 
@@ -4733,6 +5107,10 @@ packages:
     peerDependencies:
       react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
 
+  lz-string@1.5.0:
+    resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+    hasBin: true
+
   magic-string@0.30.21:
     resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
 
@@ -4788,6 +5166,9 @@ packages:
   mdast-util-to-string@4.0.0:
     resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
 
+  mdn-data@2.27.1:
+    resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
   media-typer@1.1.0:
     resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
     engines: {node: '>= 0.8'}
@@ -5038,6 +5419,10 @@ packages:
     resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
     engines: {node: '>= 0.4'}
 
+  obug@2.1.4:
+    resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
+    engines: {node: '>=12.20.0'}
+
   on-finished@2.4.1:
     resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
     engines: {node: '>= 0.8'}
@@ -5117,6 +5502,9 @@ packages:
     resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
     engines: {node: '>=18'}
 
+  parse5@8.0.1:
+    resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
   parseurl@1.3.3:
     resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
     engines: {node: '>= 0.8'}
@@ -5146,6 +5534,9 @@ packages:
   path-to-regexp@8.4.2:
     resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
 
+  pathe@2.0.3:
+    resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
   picocolors@1.1.1:
     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
 
@@ -5189,6 +5580,10 @@ packages:
     resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
     engines: {node: ^10 || ^12 || >=14}
 
+  postcss@8.5.26:
+    resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
+    engines: {node: ^10 || ^12 || >=14}
+
   postgres@3.4.9:
     resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
     engines: {node: '>=12'}
@@ -5270,6 +5665,10 @@ packages:
     engines: {node: '>=14'}
     hasBin: true
 
+  pretty-format@27.5.1:
+    resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+    engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
   pretty-ms@9.3.0:
     resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
     engines: {node: '>=18'}
@@ -5343,6 +5742,9 @@ packages:
   react-is@16.13.1:
     resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
 
+  react-is@17.0.2:
+    resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
   react-markdown@10.1.0:
     resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
     peerDependencies:
@@ -5495,6 +5897,11 @@ packages:
     resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
     engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
 
+  rolldown@1.2.5:
+    resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    hasBin: true
+
   rou3@0.7.12:
     resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
 
@@ -5527,6 +5934,10 @@ packages:
   safer-buffer@2.1.2:
     resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
 
+  saxes@6.0.0:
+    resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+    engines: {node: '>=v12.22.7'}
+
   scheduler@0.27.0:
     resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
 
@@ -5610,6 +6021,9 @@ packages:
     resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
     engines: {node: '>= 0.4'}
 
+  siginfo@2.0.0:
+    resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
   signal-exit@3.0.7:
     resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
 
@@ -5643,6 +6057,9 @@ packages:
   stable-hash@0.0.5:
     resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
 
+  stackback@0.0.2:
+    resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
   standardwebhooks@1.0.0:
     resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
 
@@ -5650,6 +6067,9 @@ packages:
     resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
     engines: {node: '>= 0.8'}
 
+  std-env@4.2.0:
+    resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
+
   stdin-discarder@0.2.2:
     resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
     engines: {node: '>=18'}
@@ -5752,6 +6172,9 @@ packages:
     peerDependencies:
       react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
 
+  symbol-tree@3.2.4:
+    resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
   systeminformation@5.31.11:
     resolution: {integrity: sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w==}
     engines: {node: '>=8.0.0'}
@@ -5778,10 +6201,28 @@ packages:
   tiny-invariant@1.3.3:
     resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
 
+  tinybench@2.9.0:
+    resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+  tinyexec@1.3.0:
+    resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
+    engines: {node: '>=18'}
+
   tinyglobby@0.2.17:
     resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
     engines: {node: '>=12.0.0'}
 
+  tinyrainbow@3.1.1:
+    resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
+    engines: {node: '>=14.0.0'}
+
+  tldts-core@7.4.11:
+    resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==}
+
+  tldts@7.4.11:
+    resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==}
+    hasBin: true
+
   to-regex-range@5.0.1:
     resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
     engines: {node: '>=8.0'}
@@ -5790,6 +6231,14 @@ packages:
     resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
     engines: {node: '>=0.6'}
 
+  tough-cookie@6.0.2:
+    resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
+    engines: {node: '>=16'}
+
+  tr46@6.0.0:
+    resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+    engines: {node: '>=20'}
+
   trim-lines@3.0.1:
     resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
 
@@ -5875,6 +6324,10 @@ packages:
     resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
     engines: {node: '>=20.18.1'}
 
+  undici@8.10.0:
+    resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
+    engines: {node: '>=22.19.0'}
+
   unicorn-magic@0.3.0:
     resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
     engines: {node: '>=18'}
@@ -6002,6 +6455,110 @@ packages:
   victory-vendor@37.3.6:
     resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
 
+  vite@8.2.2:
+    resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
+    engines: {node: ^20.19.0 || >=22.12.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^20.19.0 || >=22.12.0
+      '@vitejs/devtools': ^0.4.0 || ^0.5.0
+      esbuild: ^0.27.0 || ^0.28.0
+      jiti: '>=1.21.0'
+      less: ^4.0.0
+      sass: ^1.70.0
+      sass-embedded: ^1.70.0
+      stylus: '>=0.54.8'
+      sugarss: ^5.0.0
+      terser: ^5.16.0
+      tsx: ^4.8.1
+      yaml: ^2.4.2
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      '@vitejs/devtools':
+        optional: true
+      esbuild:
+        optional: true
+      jiti:
+        optional: true
+      less:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+      tsx:
+        optional: true
+      yaml:
+        optional: true
+
+  vitest@4.1.11:
+    resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
+    engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+    hasBin: true
+    peerDependencies:
+      '@edge-runtime/vm': '*'
+      '@opentelemetry/api': ^1.9.0
+      '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+      '@vitest/browser-playwright': 4.1.11
+      '@vitest/browser-preview': 4.1.11
+      '@vitest/browser-webdriverio': 4.1.11
+      '@vitest/coverage-istanbul': 4.1.11
+      '@vitest/coverage-v8': 4.1.11
+      '@vitest/ui': 4.1.11
+      happy-dom: '*'
+      jsdom: '*'
+      vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+    peerDependenciesMeta:
+      '@edge-runtime/vm':
+        optional: true
+      '@opentelemetry/api':
+        optional: true
+      '@types/node':
+        optional: true
+      '@vitest/browser-playwright':
+        optional: true
+      '@vitest/browser-preview':
+        optional: true
+      '@vitest/browser-webdriverio':
+        optional: true
+      '@vitest/coverage-istanbul':
+        optional: true
+      '@vitest/coverage-v8':
+        optional: true
+      '@vitest/ui':
+        optional: true
+      happy-dom:
+        optional: true
+      jsdom:
+        optional: true
+
+  w3c-xmlserializer@5.0.0:
+    resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+    engines: {node: '>=18'}
+
+  webidl-conversions@8.0.1:
+    resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+    engines: {node: '>=20'}
+
+  whatwg-mimetype@5.0.0:
+    resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+    engines: {node: '>=20'}
+
+  whatwg-url@16.0.1:
+    resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+  whatwg-url@17.1.0:
+    resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
+    engines: {node: ^22.14.0 || >=24.0.0}
+
   which-boxed-primitive@1.1.1:
     resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
     engines: {node: '>= 0.4'}
@@ -6028,6 +6585,11 @@ packages:
     engines: {node: ^16.13.0 || >=18.0.0}
     hasBin: true
 
+  why-is-node-running@2.3.0:
+    resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+    engines: {node: '>=8'}
+    hasBin: true
+
   word-wrap@1.2.5:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
@@ -6043,6 +6605,13 @@ packages:
     resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
     engines: {node: '>=20'}
 
+  xml-name-validator@5.0.0:
+    resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+    engines: {node: '>=18'}
+
+  xmlchars@2.2.0:
+    resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
   yallist@3.1.1:
     resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
 
@@ -6226,6 +6795,21 @@ snapshots:
 
   '@alloc/quick-lru@5.2.0': {}
 
+  '@asamuzakjp/css-color@6.0.7':
+    dependencies:
+      '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+      '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+      '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+      '@csstools/css-tokenizer': 4.0.0
+      lru-cache: 11.5.2
+
+  '@asamuzakjp/dom-selector@8.3.2':
+    dependencies:
+      bidi-js: 1.0.3
+      css-tree: 3.2.1
+      is-potential-custom-element-name: 1.0.1
+      lru-cache: 11.5.2
+
   '@assistant-ui/core@0.2.19(@assistant-ui/store@0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8))(@assistant-ui/tap@0.9.3(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(assistant-cloud@0.1.34)(react@19.2.8)(zustand@5.0.14(@types/react@19.2.18)(immer@11.1.9)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)))':
     dependencies:
       '@assistant-ui/store': 0.2.19(@assistant-ui/tap@0.9.3(@types/react@19.2.18)(react@19.2.8))(@types/react@19.2.18)(react@19.2.8)
@@ -6815,6 +7399,34 @@ snapshots:
 
   '@better-fetch/fetch@1.3.1': {}
 
+  '@bramus/specificity@2.4.2':
+    dependencies:
+      css-tree: 3.2.1
+
+  '@csstools/color-helpers@6.1.1': {}
+
+  '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+    dependencies:
+      '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+      '@csstools/css-tokenizer': 4.0.0
+
+  '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+    dependencies:
+      '@csstools/color-helpers': 6.1.1
+      '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+      '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+      '@csstools/css-tokenizer': 4.0.0
+
+  '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+    dependencies:
+      '@csstools/css-tokenizer': 4.0.0
+
+  '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)':
+    optionalDependencies:
+      css-tree: 3.2.1
+
+  '@csstools/css-tokenizer@4.0.0': {}
+
   '@dagrejs/dagre@3.0.0':
     dependencies:
       '@dagrejs/graphlib': 4.0.1
@@ -7145,6 +7757,10 @@ snapshots:
       '@eslint/core': 0.17.0
       levn: 0.4.1
 
+  '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)':
+    optionalDependencies:
+      '@noble/hashes': 2.2.0
+
   '@fission-ai/openspec@1.5.0(@types/node@20.19.43)':
     dependencies:
       '@inquirer/core': 10.3.2(@types/node@20.19.43)
@@ -7746,6 +8362,8 @@ snapshots:
 
   '@opentelemetry/semantic-conventions@1.42.0': {}
 
+  '@oxc-project/types@0.146.0': {}
+
   '@posthog/core@1.39.6':
     dependencies:
       '@posthog/types': 1.392.1
@@ -8510,6 +9128,53 @@ snapshots:
       react: 19.2.8
       react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
 
+  '@rolldown/binding-android-arm-eabi@1.2.5':
+    optional: true
+
+  '@rolldown/binding-android-arm64@1.2.5':
+    optional: true
+
+  '@rolldown/binding-darwin-arm64@1.2.5':
+    optional: true
+
+  '@rolldown/binding-darwin-x64@1.2.5':
+    optional: true
+
+  '@rolldown/binding-freebsd-x64@1.2.5':
+    optional: true
+
+  '@rolldown/binding-linux-arm-gnueabihf@1.2.5':
+    optional: true
+
+  '@rolldown/binding-linux-arm64-gnu@1.2.5':
+    optional: true
+
+  '@rolldown/binding-linux-arm64-musl@1.2.5':
+    optional: true
+
+  '@rolldown/binding-linux-ppc64-gnu@1.2.5':
+    optional: true
+
+  '@rolldown/binding-linux-s390x-gnu@1.2.5':
+    optional: true
+
+  '@rolldown/binding-linux-x64-gnu@1.2.5':
+    optional: true
+
+  '@rolldown/binding-linux-x64-musl@1.2.5':
+    optional: true
+
+  '@rolldown/binding-openharmony-arm64@1.2.5':
+    optional: true
+
+  '@rolldown/binding-win32-arm64-msvc@1.2.5':
+    optional: true
+
+  '@rolldown/binding-win32-x64-msvc@1.2.5':
+    optional: true
+
+  '@rolldown/pluginutils@1.0.1': {}
+
   '@rtsao/scc@1.1.0': {}
 
   '@sec-ant/readable-stream@0.4.1': {}
@@ -8678,6 +9343,31 @@ snapshots:
       postcss: 8.5.16
       tailwindcss: 4.3.2
 
+  '@testing-library/dom@10.4.1':
+    dependencies:
+      '@babel/code-frame': 7.29.7
+      '@babel/runtime': 7.29.7
+      '@types/aria-query': 5.0.4
+      aria-query: 5.3.0
+      dom-accessibility-api: 0.5.16
+      lz-string: 1.5.0
+      picocolors: 1.1.1
+      pretty-format: 27.5.1
+
+  '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+    dependencies:
+      '@babel/runtime': 7.29.7
+      '@testing-library/dom': 10.4.1
+      react: 19.2.8
+      react-dom: 19.2.8(react@19.2.8)
+    optionalDependencies:
+      '@types/react': 19.2.18
+      '@types/react-dom': 19.2.4(@types/react@19.2.18)
+
+  '@testing-library/user-event@14.6.6(@testing-library/dom@10.4.1)':
+    dependencies:
+      '@testing-library/dom': 10.4.1
+
   '@ts-morph/common@0.27.0':
     dependencies:
       fast-glob: 3.3.3
@@ -8689,6 +9379,13 @@ snapshots:
       tslib: 2.8.1
     optional: true
 
+  '@types/aria-query@5.0.4': {}
+
+  '@types/chai@5.2.3':
+    dependencies:
+      '@types/deep-eql': 4.0.2
+      assertion-error: 2.0.1
+
   '@types/d3-array@3.2.2': {}
 
   '@types/d3-color@3.1.3': {}
@@ -8732,6 +9429,8 @@ snapshots:
     dependencies:
       '@types/ms': 2.1.0
 
+  '@types/deep-eql@4.0.2': {}
+
   '@types/estree-jsx@1.0.5':
     dependencies:
       '@types/estree': 1.0.9
@@ -8939,6 +9638,47 @@ snapshots:
 
   '@vercel/oidc@3.2.0': {}
 
+  '@vitest/expect@4.1.11':
+    dependencies:
+      '@standard-schema/spec': 1.1.0
+      '@types/chai': 5.2.3
+      '@vitest/spy': 4.1.11
+      '@vitest/utils': 4.1.11
+      chai: 6.2.2
+      tinyrainbow: 3.1.1
+
+  '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))':
+    dependencies:
+      '@vitest/spy': 4.1.11
+      estree-walker: 3.0.3
+      magic-string: 0.30.21
+    optionalDependencies:
+      vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)
+
+  '@vitest/pretty-format@4.1.11':
+    dependencies:
+      tinyrainbow: 3.1.1
+
+  '@vitest/runner@4.1.11':
+    dependencies:
+      '@vitest/utils': 4.1.11
+      pathe: 2.0.3
+
+  '@vitest/snapshot@4.1.11':
+    dependencies:
+      '@vitest/pretty-format': 4.1.11
+      '@vitest/utils': 4.1.11
+      magic-string: 0.30.21
+      pathe: 2.0.3
+
+  '@vitest/spy@4.1.11': {}
+
+  '@vitest/utils@4.1.11':
+    dependencies:
+      '@vitest/pretty-format': 4.1.11
+      convert-source-map: 2.0.0
+      tinyrainbow: 3.1.1
+
   '@workflow/serde@4.1.0': {}
 
   '@xyflow/react@12.11.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.9)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
@@ -9024,12 +9764,18 @@ snapshots:
     dependencies:
       color-convert: 2.0.1
 
+  ansi-styles@5.2.0: {}
+
   argparse@2.0.1: {}
 
   aria-hidden@1.2.6:
     dependencies:
       tslib: 2.8.1
 
+  aria-query@5.3.0:
+    dependencies:
+      dequal: 2.0.3
+
   aria-query@5.3.2: {}
 
   array-buffer-byte-length@1.0.2:
@@ -9099,6 +9845,8 @@ snapshots:
       get-intrinsic: 1.3.0
       is-array-buffer: 3.0.5
 
+  assertion-error@2.0.1: {}
+
   assistant-cloud@0.1.34:
     dependencies:
       assistant-stream: 0.3.25
@@ -9149,7 +9897,7 @@ snapshots:
       elkjs: 0.11.1
       entities: 7.0.1
 
-  better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.3)(postgres@3.4.9))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+  better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.3)(postgres@3.4.9))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))):
     dependencies:
       '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0)
       '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.3)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.3)(postgres@3.4.9))
@@ -9174,6 +9922,7 @@ snapshots:
       next: 16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
       react: 19.2.8
       react-dom: 19.2.8(react@19.2.8)
+      vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
     transitivePeerDependencies:
       - '@cloudflare/workers-types'
       - '@opentelemetry/api'
@@ -9187,6 +9936,10 @@ snapshots:
     optionalDependencies:
       zod: 4.4.3
 
+  bidi-js@1.0.3:
+    dependencies:
+      require-from-string: 2.0.2
+
   body-parser@2.3.0:
     dependencies:
       bytes: 3.1.2
@@ -9255,6 +10008,8 @@ snapshots:
 
   ccount@2.0.1: {}
 
+  chai@6.2.2: {}
+
   chalk@4.1.2:
     dependencies:
       ansi-styles: 4.3.0
@@ -9365,6 +10120,11 @@ snapshots:
       shebang-command: 2.0.0
       which: 2.0.2
 
+  css-tree@3.2.1:
+    dependencies:
+      mdn-data: 2.27.1
+      source-map-js: 1.2.1
+
   cssesc@3.0.0: {}
 
   csstype@3.2.3: {}
@@ -9435,6 +10195,13 @@ snapshots:
 
   damerau-levenshtein@1.0.8: {}
 
+  data-urls@7.0.0(@noble/hashes@2.2.0):
+    dependencies:
+      whatwg-mimetype: 5.0.0
+      whatwg-url: 16.0.1(@noble/hashes@2.2.0)
+    transitivePeerDependencies:
+      - '@noble/hashes'
+
   data-view-buffer@1.0.2:
     dependencies:
       call-bound: 1.0.4
@@ -9469,6 +10236,8 @@ snapshots:
 
   decimal.js-light@2.5.1: {}
 
+  decimal.js@10.6.0: {}
+
   decode-named-character-reference@1.3.0:
     dependencies:
       character-entities: 2.0.2
@@ -9524,6 +10293,8 @@ snapshots:
     dependencies:
       esutils: 2.0.3
 
+  dom-accessibility-api@0.5.16: {}
+
   dot-prop@6.0.1:
     dependencies:
       is-obj: 2.0.0
@@ -9587,6 +10358,8 @@ snapshots:
 
   entities@7.0.1: {}
 
+  entities@8.0.0: {}
+
   env-paths@2.2.1: {}
 
   error-ex@1.3.4:
@@ -9680,6 +10453,8 @@ snapshots:
       iterator.prototype: 1.1.5
       math-intrinsics: 1.1.0
 
+  es-module-lexer@2.3.2: {}
+
   es-object-atoms@1.1.2:
     dependencies:
       es-errors: 1.3.0
@@ -10004,6 +10779,10 @@ snapshots:
 
   estree-util-is-identifier-name@3.0.0: {}
 
+  estree-walker@3.0.3:
+    dependencies:
+      '@types/estree': 1.0.9
+
   esutils@2.0.3: {}
 
   etag@1.8.1: {}
@@ -10043,6 +10822,8 @@ snapshots:
       strip-final-newline: 4.0.0
       yoctocolors: 2.1.2
 
+  expect-type@1.4.0: {}
+
   express-rate-limit@8.5.2(express@5.2.1):
     dependencies:
       express: 5.2.1
@@ -10335,6 +11116,12 @@ snapshots:
 
   hono@4.12.27: {}
 
+  html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0):
+    dependencies:
+      '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0)
+    transitivePeerDependencies:
+      - '@noble/hashes'
+
   html-url-attributes@3.0.1: {}
 
   html-void-elements@3.0.0: {}
@@ -10501,6 +11288,8 @@ snapshots:
 
   is-plain-obj@4.1.0: {}
 
+  is-potential-custom-element-name@1.0.1: {}
+
   is-promise@4.0.0: {}
 
   is-regex@1.2.1:
@@ -10587,6 +11376,32 @@ snapshots:
     dependencies:
       argparse: 2.0.1
 
+  jsdom@30.0.1(@noble/hashes@2.2.0):
+    dependencies:
+      '@asamuzakjp/css-color': 6.0.7
+      '@asamuzakjp/dom-selector': 8.3.2
+      '@bramus/specificity': 2.4.2
+      '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1)
+      '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0)
+      css-tree: 3.2.1
+      data-urls: 7.0.0(@noble/hashes@2.2.0)
+      decimal.js: 10.6.0
+      html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0)
+      is-potential-custom-element-name: 1.0.1
+      lru-cache: 11.5.2
+      parse5: 8.0.1
+      saxes: 6.0.0
+      symbol-tree: 3.2.4
+      tough-cookie: 6.0.2
+      undici: 8.10.0
+      w3c-xmlserializer: 5.0.0
+      webidl-conversions: 8.0.1
+      whatwg-mimetype: 5.0.0
+      whatwg-url: 17.1.0(@noble/hashes@2.2.0)
+      xml-name-validator: 5.0.0
+    transitivePeerDependencies:
+      - '@noble/hashes'
+
   jsesc@3.1.0: {}
 
   json-buffer@3.0.1: {}
@@ -10656,36 +11471,69 @@ snapshots:
   lightningcss-android-arm64@1.32.0:
     optional: true
 
+  lightningcss-android-arm64@1.33.0:
+    optional: true
+
   lightningcss-darwin-arm64@1.32.0:
     optional: true
 
+  lightningcss-darwin-arm64@1.33.0:
+    optional: true
+
   lightningcss-darwin-x64@1.32.0:
     optional: true
 
+  lightningcss-darwin-x64@1.33.0:
+    optional: true
+
   lightningcss-freebsd-x64@1.32.0:
     optional: true
 
+  lightningcss-freebsd-x64@1.33.0:
+    optional: true
+
   lightningcss-linux-arm-gnueabihf@1.32.0:
     optional: true
 
+  lightningcss-linux-arm-gnueabihf@1.33.0:
+    optional: true
+
   lightningcss-linux-arm64-gnu@1.32.0:
     optional: true
 
+  lightningcss-linux-arm64-gnu@1.33.0:
+    optional: true
+
   lightningcss-linux-arm64-musl@1.32.0:
     optional: true
 
+  lightningcss-linux-arm64-musl@1.33.0:
+    optional: true
+
   lightningcss-linux-x64-gnu@1.32.0:
     optional: true
 
+  lightningcss-linux-x64-gnu@1.33.0:
+    optional: true
+
   lightningcss-linux-x64-musl@1.32.0:
     optional: true
 
+  lightningcss-linux-x64-musl@1.33.0:
+    optional: true
+
   lightningcss-win32-arm64-msvc@1.32.0:
     optional: true
 
+  lightningcss-win32-arm64-msvc@1.33.0:
+    optional: true
+
   lightningcss-win32-x64-msvc@1.32.0:
     optional: true
 
+  lightningcss-win32-x64-msvc@1.33.0:
+    optional: true
+
   lightningcss@1.32.0:
     dependencies:
       detect-libc: 2.1.2
@@ -10702,6 +11550,22 @@ snapshots:
       lightningcss-win32-arm64-msvc: 1.32.0
       lightningcss-win32-x64-msvc: 1.32.0
 
+  lightningcss@1.33.0:
+    dependencies:
+      detect-libc: 2.1.2
+    optionalDependencies:
+      lightningcss-android-arm64: 1.33.0
+      lightningcss-darwin-arm64: 1.33.0
+      lightningcss-darwin-x64: 1.33.0
+      lightningcss-freebsd-x64: 1.33.0
+      lightningcss-linux-arm-gnueabihf: 1.33.0
+      lightningcss-linux-arm64-gnu: 1.33.0
+      lightningcss-linux-arm64-musl: 1.33.0
+      lightningcss-linux-x64-gnu: 1.33.0
+      lightningcss-linux-x64-musl: 1.33.0
+      lightningcss-win32-arm64-msvc: 1.33.0
+      lightningcss-win32-x64-msvc: 1.33.0
+
   lines-and-columns@1.2.4: {}
 
   locate-path@3.0.0:
@@ -10726,6 +11590,8 @@ snapshots:
     dependencies:
       js-tokens: 4.0.0
 
+  lru-cache@11.5.2: {}
+
   lru-cache@5.1.1:
     dependencies:
       yallist: 3.1.1
@@ -10734,6 +11600,8 @@ snapshots:
     dependencies:
       react: 19.2.8
 
+  lz-string@1.5.0: {}
+
   magic-string@0.30.21:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.5
@@ -10895,6 +11763,8 @@ snapshots:
     dependencies:
       '@types/mdast': 4.0.4
 
+  mdn-data@2.27.1: {}
+
   media-typer@1.1.0: {}
 
   merge-descriptors@2.0.0: {}
@@ -11232,6 +12102,8 @@ snapshots:
       define-properties: 1.2.1
       es-object-atoms: 1.1.2
 
+  obug@2.1.4: {}
+
   on-finished@2.4.1:
     dependencies:
       ee-first: 1.1.1
@@ -11341,6 +12213,10 @@ snapshots:
 
   parse-ms@4.0.0: {}
 
+  parse5@8.0.1:
+    dependencies:
+      entities: 8.0.0
+
   parseurl@1.3.3: {}
 
   path-browserify@1.0.1: {}
@@ -11357,6 +12233,8 @@ snapshots:
 
   path-to-regexp@8.4.2: {}
 
+  pathe@2.0.3: {}
+
   picocolors@1.1.1: {}
 
   picomatch@2.3.2: {}
@@ -11392,6 +12270,12 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  postcss@8.5.26:
+    dependencies:
+      nanoid: 3.3.18
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
   postgres@3.4.9: {}
 
   posthog-node@5.39.4:
@@ -11408,6 +12292,12 @@ snapshots:
 
   prettier@3.9.4: {}
 
+  pretty-format@27.5.1:
+    dependencies:
+      ansi-regex: 5.0.1
+      ansi-styles: 5.2.0
+      react-is: 17.0.2
+
   pretty-ms@9.3.0:
     dependencies:
       parse-ms: 4.0.0
@@ -11530,6 +12420,8 @@ snapshots:
 
   react-is@16.13.1: {}
 
+  react-is@17.0.2: {}
+
   react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8):
     dependencies:
       '@types/hast': 3.0.4
@@ -11729,6 +12621,27 @@ snapshots:
 
   reusify@1.1.0: {}
 
+  rolldown@1.2.5:
+    dependencies:
+      '@oxc-project/types': 0.146.0
+      '@rolldown/pluginutils': 1.0.1
+    optionalDependencies:
+      '@rolldown/binding-android-arm-eabi': 1.2.5
+      '@rolldown/binding-android-arm64': 1.2.5
+      '@rolldown/binding-darwin-arm64': 1.2.5
+      '@rolldown/binding-darwin-x64': 1.2.5
+      '@rolldown/binding-freebsd-x64': 1.2.5
+      '@rolldown/binding-linux-arm-gnueabihf': 1.2.5
+      '@rolldown/binding-linux-arm64-gnu': 1.2.5
+      '@rolldown/binding-linux-arm64-musl': 1.2.5
+      '@rolldown/binding-linux-ppc64-gnu': 1.2.5
+      '@rolldown/binding-linux-s390x-gnu': 1.2.5
+      '@rolldown/binding-linux-x64-gnu': 1.2.5
+      '@rolldown/binding-linux-x64-musl': 1.2.5
+      '@rolldown/binding-openharmony-arm64': 1.2.5
+      '@rolldown/binding-win32-arm64-msvc': 1.2.5
+      '@rolldown/binding-win32-x64-msvc': 1.2.5
+
   rou3@0.7.12: {}
 
   router@2.2.0:
@@ -11770,6 +12683,10 @@ snapshots:
 
   safer-buffer@2.1.2: {}
 
+  saxes@6.0.0:
+    dependencies:
+      xmlchars: 2.2.0
+
   scheduler@0.27.0: {}
 
   secure-json-parse@4.1.0: {}
@@ -11948,6 +12865,8 @@ snapshots:
       side-channel-map: 1.0.1
       side-channel-weakmap: 1.0.2
 
+  siginfo@2.0.0: {}
+
   signal-exit@3.0.7: {}
 
   signal-exit@4.1.0: {}
@@ -11972,6 +12891,8 @@ snapshots:
 
   stable-hash@0.0.5: {}
 
+  stackback@0.0.2: {}
+
   standardwebhooks@1.0.0:
     dependencies:
       '@stablelib/base64': 1.0.1
@@ -11979,6 +12900,8 @@ snapshots:
 
   statuses@2.0.2: {}
 
+  std-env@4.2.0: {}
+
   stdin-discarder@0.2.2: {}
 
   stop-iteration-iterator@1.1.0:
@@ -12103,6 +13026,8 @@ snapshots:
       react: 19.2.8
       use-sync-external-store: 1.6.0(react@19.2.8)
 
+  symbol-tree@3.2.4: {}
+
   systeminformation@5.31.11: {}
 
   tabbable@6.5.0: {}
@@ -12117,17 +13042,37 @@ snapshots:
 
   tiny-invariant@1.3.3: {}
 
+  tinybench@2.9.0: {}
+
+  tinyexec@1.3.0: {}
+
   tinyglobby@0.2.17:
     dependencies:
       fdir: 6.5.0(picomatch@4.0.5)
       picomatch: 4.0.5
 
+  tinyrainbow@3.1.1: {}
+
+  tldts-core@7.4.11: {}
+
+  tldts@7.4.11:
+    dependencies:
+      tldts-core: 7.4.11
+
   to-regex-range@5.0.1:
     dependencies:
       is-number: 7.0.0
 
   toidentifier@1.0.1: {}
 
+  tough-cookie@6.0.2:
+    dependencies:
+      tldts: 7.4.11
+
+  tr46@6.0.0:
+    dependencies:
+      punycode: 2.3.1
+
   trim-lines@3.0.1: {}
 
   trough@2.2.0: {}
@@ -12235,6 +13180,8 @@ snapshots:
 
   undici@7.28.0: {}
 
+  undici@8.10.0: {}
+
   unicorn-magic@0.3.0: {}
 
   unified@11.0.5:
@@ -12388,6 +13335,74 @@ snapshots:
       d3-time: 3.1.0
       d3-timer: 3.0.1
 
+  vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0):
+    dependencies:
+      lightningcss: 1.33.0
+      picomatch: 4.0.5
+      postcss: 8.5.26
+      rolldown: 1.2.5
+      tinyglobby: 0.2.17
+    optionalDependencies:
+      '@types/node': 20.19.43
+      esbuild: 0.28.1
+      fsevents: 2.3.3
+      jiti: 2.7.0
+      tsx: 4.23.0
+      yaml: 2.9.0
+
+  vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)):
+    dependencies:
+      '@vitest/expect': 4.1.11
+      '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
+      '@vitest/pretty-format': 4.1.11
+      '@vitest/runner': 4.1.11
+      '@vitest/snapshot': 4.1.11
+      '@vitest/spy': 4.1.11
+      '@vitest/utils': 4.1.11
+      es-module-lexer: 2.3.2
+      expect-type: 1.4.0
+      magic-string: 0.30.21
+      obug: 2.1.4
+      pathe: 2.0.3
+      picomatch: 4.0.5
+      std-env: 4.2.0
+      tinybench: 2.9.0
+      tinyexec: 1.3.0
+      tinyglobby: 0.2.17
+      tinyrainbow: 3.1.1
+      vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)
+      why-is-node-running: 2.3.0
+    optionalDependencies:
+      '@opentelemetry/api': 1.9.1
+      '@types/node': 20.19.43
+      jsdom: 30.0.1(@noble/hashes@2.2.0)
+    transitivePeerDependencies:
+      - msw
+
+  w3c-xmlserializer@5.0.0:
+    dependencies:
+      xml-name-validator: 5.0.0
+
+  webidl-conversions@8.0.1: {}
+
+  whatwg-mimetype@5.0.0: {}
+
+  whatwg-url@16.0.1(@noble/hashes@2.2.0):
+    dependencies:
+      '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0)
+      tr46: 6.0.0
+      webidl-conversions: 8.0.1
+    transitivePeerDependencies:
+      - '@noble/hashes'
+
+  whatwg-url@17.1.0(@noble/hashes@2.2.0):
+    dependencies:
+      '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0)
+      tr46: 6.0.0
+      webidl-conversions: 8.0.1
+    transitivePeerDependencies:
+      - '@noble/hashes'
+
   which-boxed-primitive@1.1.1:
     dependencies:
       is-bigint: 1.1.0
@@ -12437,6 +13452,11 @@ snapshots:
     dependencies:
       isexe: 3.1.5
 
+  why-is-node-running@2.3.0:
+    dependencies:
+      siginfo: 2.0.0
+      stackback: 0.0.2
+
   word-wrap@1.2.5: {}
 
   wrap-ansi@6.2.0:
@@ -12452,6 +13472,10 @@ snapshots:
       is-wsl: 3.1.1
       powershell-utils: 0.1.0
 
+  xml-name-validator@5.0.0: {}
+
+  xmlchars@2.2.0: {}
+
   yallist@3.1.1: {}
 
   yaml@2.9.0: {}
diff --git a/scripts/create-test-database.mjs b/scripts/create-test-database.mjs
new file mode 100644
index 00000000..35f9e257
--- /dev/null
+++ b/scripts/create-test-database.mjs
@@ -0,0 +1,32 @@
+import postgres from "postgres"
+import {
+  assertSafeTestDatabaseUrl,
+  loadTestDatabaseEnvironment,
+  TEST_DATABASE_NAME,
+} from "./lib/test-database-safety.mjs"
+
+loadTestDatabaseEnvironment()
+const testDatabaseUrl = new URL(
+  assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL)
+)
+const adminUrl = new URL(testDatabaseUrl)
+adminUrl.pathname = "/postgres"
+
+const sql = postgres(adminUrl.toString(), { max: 1 })
+
+try {
+  const existing = await sql`
+    select 1
+    from pg_database
+    where datname = ${TEST_DATABASE_NAME}
+  `
+
+  if (existing.length === 0) {
+    await sql.unsafe(`CREATE DATABASE "${TEST_DATABASE_NAME}"`)
+    console.log(`[test:db:create] 已创建 ${TEST_DATABASE_NAME}。`)
+  } else {
+    console.log(`[test:db:create] ${TEST_DATABASE_NAME} 已存在。`)
+  }
+} finally {
+  await sql.end()
+}
diff --git a/scripts/lib/test-database-safety.mjs b/scripts/lib/test-database-safety.mjs
new file mode 100644
index 00000000..c077d554
--- /dev/null
+++ b/scripts/lib/test-database-safety.mjs
@@ -0,0 +1,42 @@
+import { config } from "dotenv"
+
+export const TEST_DATABASE_NAME = "thread-chat-test"
+
+/** 只加载测试连接变量;数据库配置禁止回退到开发库连接。 */
+export function loadTestDatabaseEnvironment() {
+  config({ path: [".env.test.local", ".env.local"], quiet: true })
+}
+
+/**
+ * 返回经过 allowlist 校验的测试数据库 URL。任何其他数据库名都会立即终止测试操作。
+ *
+ * @param {string | undefined} rawUrl
+ */
+export function assertSafeTestDatabaseUrl(rawUrl) {
+  if (!rawUrl) {
+    throw new Error(
+      "未配置 TEST_DATABASE_URL;测试数据库操作已停止,且不会回退到 DATABASE_URL。"
+    )
+  }
+
+  const normalized = rawUrl.trim().replace(/^(['"])(.*)\1$/, "$2")
+  let url
+  try {
+    url = new URL(normalized)
+  } catch {
+    throw new Error("TEST_DATABASE_URL 不是合法的 PostgreSQL URL。")
+  }
+
+  if (!url.protocol.startsWith("postgres")) {
+    throw new Error("TEST_DATABASE_URL 必须使用 postgres 协议。")
+  }
+
+  const databaseName = decodeURIComponent(url.pathname.slice(1))
+  if (databaseName !== TEST_DATABASE_NAME) {
+    throw new Error(
+      `拒绝操作数据库 ${JSON.stringify(databaseName)};测试 allowlist 仅包含 ${TEST_DATABASE_NAME}。`
+    )
+  }
+
+  return url.toString()
+}
diff --git a/scripts/reset-test-schema.mjs b/scripts/reset-test-schema.mjs
new file mode 100644
index 00000000..88bd6b2f
--- /dev/null
+++ b/scripts/reset-test-schema.mjs
@@ -0,0 +1,76 @@
+import { spawnSync } from "node:child_process"
+import postgres from "postgres"
+import {
+  assertSafeTestDatabaseUrl,
+  loadTestDatabaseEnvironment,
+} from "./lib/test-database-safety.mjs"
+
+const TEST_SCHEMA = "thread_chat"
+
+loadTestDatabaseEnvironment()
+const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL)
+const sql = postgres(testDatabaseUrl, { max: 1 })
+
+try {
+  // vector 是当前 Schema 的数据库级扩展;只确保存在,不删除或修改其他 schema。
+  await sql`CREATE EXTENSION IF NOT EXISTS vector`
+  await sql`DROP SCHEMA IF EXISTS ${sql(TEST_SCHEMA)} CASCADE`
+  // drizzle-kit 在 schemaFilter 指向不存在的 schema 时会在 introspection 阶段失败;
+  // 先建立空 namespace,再由 push 从零创建其中的全部表与约束。
+  await sql`CREATE SCHEMA ${sql(TEST_SCHEMA)}`
+  console.log(`[test:db:reset] 已将测试库中的 ${TEST_SCHEMA} 重置为空 schema。`)
+} finally {
+  await sql.end()
+}
+
+const push = spawnSync(
+  "pnpm",
+  [
+    "exec",
+    "drizzle-kit",
+    "push",
+    "--config",
+    "drizzle.test.config.ts",
+    "--force",
+  ],
+  {
+    env: { ...process.env, TEST_DATABASE_URL: testDatabaseUrl },
+    encoding: "utf8",
+  }
+)
+
+process.stdout.write(push.stdout ?? "")
+process.stderr.write(push.stderr ?? "")
+if (push.error) throw push.error
+if (push.status !== 0) process.exit(push.status ?? 1)
+if (
+  /PostgresError|\bError:/.test(`${push.stdout ?? ""}\n${push.stderr ?? ""}`)
+) {
+  throw new Error("drizzle-kit push 输出了数据库错误。")
+}
+
+const verificationSql = postgres(testDatabaseUrl, { max: 1 })
+try {
+  const [schema] = await verificationSql`
+    select
+      exists(
+        select 1
+        from information_schema.schemata
+        where schema_name = ${TEST_SCHEMA}
+      ) as exists,
+      (
+        select count(*)::integer
+        from information_schema.tables
+        where table_schema = ${TEST_SCHEMA}
+      ) as table_count
+  `
+  if (!schema?.exists || schema.table_count === 0) {
+    throw new Error(
+      `drizzle-kit push 返回成功,但 ${TEST_SCHEMA} schema 未完整建立。`
+    )
+  }
+} finally {
+  await verificationSql.end()
+}
+
+console.log("[test:db:reset] 测试 Schema 已从空状态重建。")
diff --git a/tests/api/contracts.test.ts b/tests/api/contracts.test.ts
new file mode 100644
index 00000000..75b987b1
--- /dev/null
+++ b/tests/api/contracts.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest"
+import {
+  apiErrorResponseSchema,
+  creationBundleSchema,
+  createProjectRequestSchema,
+  markdownArtifactToolOutputSchema,
+  userMessagePartsSchema,
+} from "@/lib/thread-chat/api/contracts"
+import {
+  apiErrorFixture,
+  creationBundleDTOFixture,
+  invalidUserPartFixture,
+  ownershipMismatchFixture,
+} from "../fixtures/thread-chat-api-fixtures"
+
+describe("ThreadChat V1 contracts", () => {
+  it("接受权威 Creation Bundle 与结构化错误", () => {
+    expect(creationBundleSchema.parse(creationBundleDTOFixture)).toEqual(
+      creationBundleDTOFixture
+    )
+    expect(apiErrorResponseSchema.parse(apiErrorFixture)).toEqual(
+      apiErrorFixture
+    )
+  })
+
+  it("拒绝未知请求字段、错误实体归属与非法 user part", () => {
+    expect(() =>
+      createProjectRequestSchema.parse({
+        initialMessage: { parts: [{ type: "text", text: "hello" }] },
+        clientProjectId: crypto.randomUUID(),
+      })
+    ).toThrow()
+    expect(() =>
+      creationBundleSchema.parse({
+        ...creationBundleDTOFixture,
+        assistantMessage: ownershipMismatchFixture,
+      })
+    ).toThrow(/ownership/)
+    expect(() =>
+      userMessagePartsSchema.parse([invalidUserPartFixture])
+    ).toThrow()
+  })
+
+  it("Markdown tool output 只能携带 artifactId", () => {
+    const artifactId = crypto.randomUUID()
+    expect(markdownArtifactToolOutputSchema.parse({ artifactId })).toEqual({
+      artifactId,
+    })
+    expect(() =>
+      markdownArtifactToolOutputSchema.parse({
+        artifactId,
+        content: "# duplicated",
+      })
+    ).toThrow()
+  })
+})
diff --git a/tests/api/errors.test.ts b/tests/api/errors.test.ts
new file mode 100644
index 00000000..ee607eea
--- /dev/null
+++ b/tests/api/errors.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it, vi } from "vitest"
+import { ZodError } from "zod"
+import { ThreadChatDomainError } from "@/lib/thread-chat/domain/domain-error"
+import {
+  errorResponse,
+  ThreadChatApiError,
+} from "@/lib/thread-chat/api/server/errors"
+
+async function readError(response: Response) {
+  return {
+    status: response.status,
+    body: await response.json(),
+  }
+}
+
+describe("ThreadChat API error mapping", () => {
+  it.each([
+    ["project_owner_mismatch", "forbidden", 403],
+    ["thread_archived", "thread_archived", 409],
+    ["thread_generation_in_progress", "thread_generation_in_progress", 409],
+    [
+      "root_thread_title_owned_by_project",
+      "root_thread_title_owned_by_project",
+      422,
+    ],
+    [
+      "root_thread_archive_owned_by_project",
+      "root_thread_archive_owned_by_project",
+      422,
+    ],
+    ["message_not_editable", "message_not_editable", 422],
+    ["message_not_regeneratable", "message_not_regeneratable", 422],
+    ["feedback_not_eligible", "message_not_feedback_eligible", 422],
+    ["fork_required", "fork_required", 422],
+    ["fork_anchor_mismatch", "fork_anchor_mismatch", 422],
+    ["message_not_fork_eligible", "fork_source_not_finalized", 422],
+    ["message_not_finalized", "fork_source_not_finalized", 422],
+    ["message_superseded", "fork_source_superseded", 422],
+    ["thread_source_invalid", "fork_source_thread_mismatch", 422],
+    ["thread_not_found", "thread_not_found", 404],
+    ["message_not_found", "message_not_found", 404],
+    ["source_message_not_found", "source_message_not_found", 404],
+    ["assistant_message_not_found", "assistant_message_not_found", 404],
+    ["message_run_not_found", "message_run_not_found", 404],
+  ] as const)("映射 %s", async (domainCode, apiCode, status) => {
+    expect(
+      await readError(
+        errorResponse(new ThreadChatDomainError(domainCode, "mapped"))
+      )
+    ).toEqual({
+      status,
+      body: { error: { code: apiCode, message: "mapped" } },
+    })
+  })
+
+  it("按 Route fallback 映射 entity_not_found", async () => {
+    expect(
+      await readError(
+        errorResponse(
+          new ThreadChatDomainError("entity_not_found", "missing"),
+          "artifact_not_found"
+        )
+      )
+    ).toEqual({
+      status: 404,
+      body: { error: { code: "artifact_not_found", message: "missing" } },
+    })
+  })
+
+  it("保留显式 API 错误与 Zod details,隐藏未知异常", async () => {
+    expect(
+      await readError(
+        errorResponse(
+          new ThreadChatApiError("project_delete_conflict", 409, "busy", {
+            projectId: "project",
+          })
+        )
+      )
+    ).toEqual({
+      status: 409,
+      body: {
+        error: {
+          code: "project_delete_conflict",
+          message: "busy",
+          details: { projectId: "project" },
+        },
+      },
+    })
+
+    const zodError = new ZodError([
+      {
+        code: "custom",
+        path: ["field"],
+        message: "invalid",
+      },
+    ])
+    const validation = await readError(errorResponse(zodError))
+    expect(validation.status).toBe(400)
+    expect(validation.body.error).toMatchObject({ code: "validation_error" })
+
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
+    expect(
+      await readError(errorResponse(new Error("database secret")))
+    ).toEqual({
+      status: 500,
+      body: {
+        error: {
+          code: "internal_error",
+          message: "Internal server error.",
+        },
+      },
+    })
+    consoleError.mockRestore()
+  })
+})
diff --git a/tests/api/handlers.test.ts b/tests/api/handlers.test.ts
new file mode 100644
index 00000000..fd9e8485
--- /dev/null
+++ b/tests/api/handlers.test.ts
@@ -0,0 +1,558 @@
+import { randomUUID } from "node:crypto"
+import { describe, expect, it, vi } from "vitest"
+
+vi.mock("next/server", () => ({ after: vi.fn() }))
+
+import { dbClient } from "@/lib/db"
+import {
+  creationBundleSchema,
+  messageCreationBundleSchema,
+  projectBootstrapSchema,
+  replacementBundleSchema,
+  threadMessageBundleSchema,
+} from "@/lib/thread-chat/api/contracts"
+import {
+  bootstrapProject,
+  createProject,
+  deleteProject,
+  editMessage,
+  forkThread,
+  listProjects,
+  loadArtifact,
+  loadThreadMessages,
+  patchProject,
+  patchThread,
+  regenerateMessage,
+  sendMessage,
+  setFeedback,
+  setProjectArchived,
+  setThreadArchived,
+  stopAssistant,
+} from "@/lib/thread-chat/api/server/handlers"
+import { withActor } from "@/lib/thread-chat/api/server/http"
+import {
+  createThreadChatRepositories,
+  ThreadChatUnitOfWork,
+} from "@/lib/thread-chat/infrastructure/repositories"
+
+const unitOfWork = new ThreadChatUnitOfWork(dbClient)
+
+async function createUser(): Promise {
+  const id = randomUUID()
+  await dbClient`
+    insert into thread_chat."user" (
+      id, name, email, email_verified, created_at, updated_at
+    ) values (
+      ${id}, 'API Test', ${`${id}@thread-chat.test`}, true, now(), now()
+    )
+  `
+  return id
+}
+
+async function deleteUser(id: string): Promise {
+  await dbClient`delete from thread_chat."user" where id = ${id}`
+}
+
+function jsonRequest(url: string, method: string, body?: unknown) {
+  return new Request(url, {
+    method,
+    headers:
+      body === undefined ? undefined : { "Content-Type": "application/json" },
+    body: body === undefined ? undefined : JSON.stringify(body),
+  })
+}
+
+async function call(
+  actorId: string | null,
+  action: (actorId: string) => Promise,
+  fallback: Parameters[1] = "internal_error"
+) {
+  return withActor(action, fallback, async () => actorId)
+}
+
+async function data(response: Response) {
+  return (await response.json()).data
+}
+
+async function errorCode(response: Response) {
+  return (await response.json()).error.code as string
+}
+
+async function aggregateCounts(actorId: string) {
+  const [row] = await dbClient<
+    { projects: number; threads: number; messages: number; runs: number }[]
+  >`
+    select
+      count(distinct p.id)::integer as projects,
+      count(distinct t.id)::integer as threads,
+      count(distinct m.id)::integer as messages,
+      count(distinct r.id)::integer as runs
+    from thread_chat.projects p
+    left join thread_chat.threads t on t.project_id = p.id
+    left join thread_chat.messages m on m.thread_id = t.id
+    left join thread_chat.message_runs r on r.assistant_message_id = m.id
+    where p.owner_user_id = ${actorId}
+  `
+  return row
+}
+
+async function completeAssistant(
+  actorId: string,
+  assistantMessageId: string,
+  parts: Array> = [{ type: "text", text: "completed" }]
+) {
+  const repositories = createThreadChatRepositories(dbClient)
+  const run = await repositories.messageRuns.findOwnedByAssistantMessageId(
+    actorId,
+    assistantMessageId
+  )
+  await repositories.messageRuns.transition({
+    actorId,
+    messageRunId: run!.id,
+    expectedStatus: "queued",
+    nextStatus: "running",
+  })
+  await repositories.messages.finalizeAssistantOnce({
+    actorId,
+    messageId: assistantMessageId,
+    parts: parts as never,
+    finalizedAt: new Date(),
+  })
+  await repositories.messageRuns.transition({
+    actorId,
+    messageRunId: run!.id,
+    expectedStatus: "running",
+    nextStatus: "completed",
+    finishedAt: new Date(),
+    incrementEventSequence: true,
+  })
+}
+
+describe("ThreadChat API handlers", () => {
+  it("Session、严格输入、Project cursor 与 owner scope 使用统一合同", async () => {
+    const actorId = await createUser()
+    const otherId = await createUser()
+    try {
+      const unauthorized = await call(null, (actor) =>
+        listProjects(actor, new Request("http://test/api/v1/projects"))
+      )
+      expect(unauthorized.status).toBe(401)
+
+      const invalid = await call(actorId, (actor) =>
+        createProject(
+          actor,
+          jsonRequest("http://test/api/v1/projects", "POST", {
+            initialMessage: { parts: [{ type: "text", text: "valid" }] },
+            clientProjectId: randomUUID(),
+          })
+        )
+      )
+      expect(invalid.status).toBe(400)
+      expect((await invalid.json()).error.code).toBe("validation_error")
+
+      const creations: Array> = []
+      for (const text of ["one", "two", "three"]) {
+        const response = await call(actorId, (actor) =>
+          createProject(
+            actor,
+            jsonRequest("http://test/api/v1/projects", "POST", {
+              initialMessage: { parts: [{ type: "text", text }] },
+            })
+          )
+        )
+        expect(response.status).toBe(201)
+        const creation = creationBundleSchema.parse(await data(response))
+        expect(JSON.stringify(creation)).not.toContain("canonicalUrl")
+        creations.push(creation)
+      }
+
+      const firstPage = await data(
+        await call(actorId, (actor) =>
+          listProjects(
+            actor,
+            new Request("http://test/api/v1/projects?limit=2&status=active")
+          )
+        )
+      )
+      expect(firstPage.items).toHaveLength(2)
+      expect(firstPage.nextCursor).toEqual(expect.any(String))
+      const secondPage = await data(
+        await call(actorId, (actor) =>
+          listProjects(
+            actor,
+            new Request(
+              `http://test/api/v1/projects?limit=2&status=active&cursor=${encodeURIComponent(firstPage.nextCursor)}`
+            )
+          )
+        )
+      )
+      expect(secondPage.items).toHaveLength(1)
+      expect(
+        new Set(
+          [...firstPage.items, ...secondPage.items].map((item) => item.id)
+        ).size
+      ).toBe(3)
+      const rebound = await call(actorId, (actor) =>
+        listProjects(
+          actor,
+          new Request(
+            `http://test/api/v1/projects?status=archived&cursor=${encodeURIComponent(firstPage.nextCursor)}`
+          )
+        )
+      )
+      expect(rebound.status).toBe(400)
+      expect((await rebound.json()).error.code).toBe("invalid_cursor")
+
+      const forbidden = await call(
+        otherId,
+        (actor) => bootstrapProject(actor, creations[0].project.id),
+        "project_not_found"
+      )
+      expect(forbidden.status).toBe(404)
+      expect((await forbidden.json()).error.code).toBe("project_not_found")
+    } finally {
+      await deleteUser(actorId)
+      await deleteUser(otherId)
+    }
+  })
+
+  it("Query 边界与失败 Command 返回合同错误且不留下半成品", async () => {
+    const actorId = await createUser()
+    try {
+      expect(
+        await data(
+          await call(actorId, (actor) =>
+            listProjects(actor, new Request("http://test/api/v1/projects"))
+          )
+        )
+      ).toEqual({ items: [], nextCursor: null })
+
+      const duplicateQuery = await call(actorId, (actor) =>
+        listProjects(
+          actor,
+          new Request("http://test/api/v1/projects?limit=2&limit=3")
+        )
+      )
+      expect(duplicateQuery.status).toBe(400)
+      expect(await errorCode(duplicateQuery)).toBe("invalid_query")
+
+      const invalidModel = await call(actorId, (actor) =>
+        createProject(
+          actor,
+          jsonRequest("http://test/api/v1/projects", "POST", {
+            initialMessage: {
+              parts: [{ type: "text", text: "invalid model" }],
+            },
+            requestedModelId: "not/available",
+          })
+        )
+      )
+      expect(invalidModel.status).toBe(422)
+      expect(await errorCode(invalidModel)).toBe("model_not_available")
+      expect(await aggregateCounts(actorId)).toEqual({
+        projects: 0,
+        threads: 0,
+        messages: 0,
+        runs: 0,
+      })
+
+      const creation = creationBundleSchema.parse(
+        await data(
+          await call(actorId, (actor) =>
+            createProject(
+              actor,
+              jsonRequest("http://test/api/v1/projects", "POST", {
+                initialMessage: { parts: [{ type: "text", text: "queued" }] },
+              })
+            )
+          )
+        )
+      )
+      const before = await aggregateCounts(actorId)
+
+      const sendConflict = await call(actorId, (actor) =>
+        sendMessage(
+          actor,
+          creation.rootThread.id,
+          jsonRequest("http://test/messages", "POST", {
+            parts: [{ type: "text", text: "must rollback" }],
+          })
+        )
+      )
+      expect(sendConflict.status).toBe(409)
+      expect(await errorCode(sendConflict)).toBe(
+        "thread_generation_in_progress"
+      )
+
+      const forkConflict = await call(actorId, (actor) =>
+        forkThread(
+          actor,
+          creation.rootThread.id,
+          jsonRequest("http://test/forks", "POST", {
+            sourceMessageId: creation.assistantMessage.id,
+          })
+        )
+      )
+      expect(forkConflict.status).toBe(422)
+      expect(await errorCode(forkConflict)).toBe("fork_source_not_finalized")
+
+      const rootTitle = await call(actorId, (actor) =>
+        patchThread(
+          actor,
+          creation.rootThread.id,
+          jsonRequest("http://test/thread", "PATCH", { customTitle: "Root" })
+        )
+      )
+      expect(rootTitle.status).toBe(422)
+      expect(await errorCode(rootTitle)).toBe(
+        "root_thread_title_owned_by_project"
+      )
+      const rootArchive = await call(actorId, (actor) =>
+        setThreadArchived(actor, creation.rootThread.id, true)
+      )
+      expect(rootArchive.status).toBe(422)
+      expect(await errorCode(rootArchive)).toBe(
+        "root_thread_archive_owned_by_project"
+      )
+
+      const feedbackConflict = await call(actorId, (actor) =>
+        setFeedback(
+          actor,
+          creation.userMessage.id,
+          jsonRequest("http://test/feedback", "PUT", { value: "negative" })
+        )
+      )
+      expect(feedbackConflict.status).toBe(422)
+      expect(await errorCode(feedbackConflict)).toBe(
+        "message_not_feedback_eligible"
+      )
+
+      expect(await aggregateCounts(actorId)).toEqual(before)
+    } finally {
+      await deleteUser(actorId)
+    }
+  })
+
+  it("Query、metadata、Artifact 与 command 响应保持原子关系", async () => {
+    const actorId = await createUser()
+    const otherId = await createUser()
+    try {
+      const creationResponse = await call(actorId, (actor) =>
+        createProject(
+          actor,
+          jsonRequest("http://test/api/v1/projects", "POST", {
+            initialMessage: {
+              parts: [{ type: "text", text: "create artifact" }],
+            },
+          })
+        )
+      )
+      const creation = creationBundleSchema.parse(await data(creationResponse))
+      const artifact = await unitOfWork.transaction((repositories) =>
+        repositories.artifacts.insert({
+          actorId,
+          id: randomUUID(),
+          projectId: creation.project.id,
+          sourceMessageId: creation.assistantMessage.id,
+          kind: "markdown",
+          title: "API Artifact",
+          content: "# API Artifact body",
+        })
+      )
+      await completeAssistant(actorId, creation.assistantMessage.id, [
+        { type: "text", text: "done" },
+        {
+          type: "dynamic-tool",
+          toolName: "createMarkdownArtifact",
+          toolCallId: "tool-api",
+          state: "output-available",
+          input: { title: "API Artifact" },
+          output: { artifactId: artifact.id },
+        },
+      ])
+
+      const bootstrapResponse = await call(actorId, (actor) =>
+        bootstrapProject(actor, creation.project.id)
+      )
+      const bootstrap = projectBootstrapSchema.parse(
+        await data(bootstrapResponse)
+      )
+      expect(bootstrap.artifactSummary).toEqual({
+        changeSequence: 1,
+        total: 1,
+        byKind: { markdown: 1 },
+      })
+      expect(JSON.stringify(bootstrap)).not.toContain("# API Artifact body")
+      const artifactResponse = await call(actorId, (actor) =>
+        loadArtifact(actor, artifact.id)
+      )
+      expect(await data(artifactResponse)).toMatchObject({
+        id: artifact.id,
+        content: "# API Artifact body",
+      })
+      const hiddenArtifact = await call(
+        otherId,
+        (actor) => loadArtifact(actor, artifact.id),
+        "artifact_not_found"
+      )
+      expect(hiddenArtifact.status).toBe(404)
+
+      const patchResponse = await call(actorId, (actor) =>
+        patchProject(
+          actor,
+          creation.project.id,
+          jsonRequest("http://test/project", "PATCH", {
+            customTitle: "Patched",
+          })
+        )
+      )
+      expect(await data(patchResponse)).toMatchObject({
+        customTitle: "Patched",
+        target: null,
+      })
+      expect(
+        (
+          await data(
+            await call(actorId, (actor) =>
+              setProjectArchived(actor, creation.project.id, true)
+            )
+          )
+        ).archivedAt
+      ).not.toBeNull()
+      await call(actorId, (actor) =>
+        setProjectArchived(actor, creation.project.id, false)
+      )
+
+      const feedback = await call(actorId, (actor) =>
+        setFeedback(
+          actor,
+          creation.assistantMessage.id,
+          jsonRequest("http://test/feedback", "PUT", { value: "positive" })
+        )
+      )
+      expect(await data(feedback)).toMatchObject({ value: "positive" })
+
+      const sendResponse = await call(actorId, (actor) =>
+        sendMessage(
+          actor,
+          creation.rootThread.id,
+          jsonRequest("http://test/messages", "POST", {
+            parts: [{ type: "text", text: "second" }],
+          })
+        )
+      )
+      const sent = messageCreationBundleSchema.parse(await data(sendResponse))
+      expect(sent.assistantRun.assistantMessageId).toBe(
+        sent.assistantMessage.id
+      )
+      await completeAssistant(actorId, sent.assistantMessage.id)
+
+      const forkResponse = await call(actorId, (actor) =>
+        forkThread(
+          actor,
+          creation.rootThread.id,
+          jsonRequest("http://test/forks", "POST", {
+            sourceMessageId: sent.assistantMessage.id,
+          })
+        )
+      )
+      const branch = (await data(forkResponse)).thread
+      expect(branch).toMatchObject({
+        parentThreadId: creation.rootThread.id,
+        sourceMessageId: sent.assistantMessage.id,
+      })
+      expect(
+        await data(
+          await call(actorId, (actor) =>
+            patchThread(
+              actor,
+              branch.id,
+              jsonRequest("http://test/thread", "PATCH", {
+                customTitle: "Branch",
+              })
+            )
+          )
+        )
+      ).toMatchObject({ customTitle: "Branch" })
+      expect(
+        (
+          await data(
+            await call(actorId, (actor) =>
+              setThreadArchived(actor, branch.id, true)
+            )
+          )
+        ).archivedAt
+      ).not.toBeNull()
+
+      const invalidFork = await call(actorId, (actor) =>
+        forkThread(
+          actor,
+          creation.rootThread.id,
+          jsonRequest("http://test/forks", "POST", {
+            sourceMessageId: sent.assistantMessage.id,
+            baseContext: { schemaVersion: 1, messageIds: [] },
+          })
+        )
+      )
+      expect(invalidFork.status).toBe(400)
+
+      const editResponse = await call(actorId, (actor) =>
+        editMessage(
+          actor,
+          sent.userMessage.id,
+          jsonRequest("http://test/edit", "POST", {
+            parts: [{ type: "text", text: "second edited" }],
+          })
+        )
+      )
+      const edited = replacementBundleSchema.parse(await data(editResponse))
+      expect(new Set(edited.supersededMessageIds)).toEqual(
+        new Set([sent.userMessage.id, sent.assistantMessage.id])
+      )
+      await completeAssistant(actorId, edited.createdMessages[1].id)
+
+      const regenerateResponse = await call(actorId, (actor) =>
+        regenerateMessage(
+          actor,
+          edited.createdMessages[1].id,
+          jsonRequest("http://test/regenerate", "POST", {})
+        )
+      )
+      const regenerated = replacementBundleSchema.parse(
+        await data(regenerateResponse)
+      )
+      expect(regenerated.createdMessages[0].replacesMessageId).toBe(
+        edited.createdMessages[1].id
+      )
+      const stopped = await call(actorId, (actor) =>
+        stopAssistant(actor, regenerated.createdMessages[0].id)
+      )
+      expect(await data(stopped)).toMatchObject({ status: "stopped" })
+
+      const window = threadMessageBundleSchema.parse(
+        await data(
+          await call(actorId, (actor) =>
+            loadThreadMessages(
+              actor,
+              creation.rootThread.id,
+              new Request("http://test/messages?limit=2")
+            )
+          )
+        )
+      )
+      expect(window.messages).toHaveLength(2)
+      expect(window.hasOlderMessages).toBe(true)
+      expect(window.messages[0].sequence).toBeLessThan(
+        window.messages[1].sequence
+      )
+
+      const deleted = await call(actorId, (actor) =>
+        deleteProject(actor, creation.project.id)
+      )
+      expect(deleted.status).toBe(204)
+    } finally {
+      await deleteUser(actorId)
+      await deleteUser(otherId)
+    }
+  })
+})
diff --git a/tests/api/sse.test.ts b/tests/api/sse.test.ts
new file mode 100644
index 00000000..9e6f55eb
--- /dev/null
+++ b/tests/api/sse.test.ts
@@ -0,0 +1,238 @@
+import { randomUUID } from "node:crypto"
+import { describe, expect, it, vi } from "vitest"
+
+vi.mock("next/server", () => ({ after: vi.fn() }))
+
+import { dbClient } from "@/lib/db"
+import { assistantMessageEventSchema } from "@/lib/thread-chat/api/contracts"
+import {
+  assistantEvents,
+  createProject,
+  stopAssistant,
+} from "@/lib/thread-chat/api/server/handlers"
+import { withActor } from "@/lib/thread-chat/api/server/http"
+import { createThreadChatRepositories } from "@/lib/thread-chat/infrastructure/repositories"
+
+async function createUser(): Promise {
+  const id = randomUUID()
+  await dbClient`
+    insert into thread_chat."user" (
+      id, name, email, email_verified, created_at, updated_at
+    ) values (
+      ${id}, 'SSE Test', ${`${id}@thread-chat.test`}, true, now(), now()
+    )
+  `
+  return id
+}
+
+async function deleteUser(id: string): Promise {
+  await dbClient`delete from thread_chat."user" where id = ${id}`
+}
+
+async function createQueued(actorId: string) {
+  const response = await withActor(
+    (actor) =>
+      createProject(
+        actor,
+        new Request("http://test/api/v1/projects", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            initialMessage: { parts: [{ type: "text", text: "SSE" }] },
+          }),
+        })
+      ),
+    "internal_error",
+    async () => actorId
+  )
+  return (await response.json()).data
+}
+
+async function readEvent(reader: ReadableStreamDefaultReader) {
+  const { value, done } = await reader.read()
+  expect(done).toBe(false)
+  const block = new TextDecoder().decode(value)
+  const line = block.split("\n").find((entry) => entry.startsWith("data:"))
+  return assistantMessageEventSchema.parse(JSON.parse(line!.slice(5).trim()))
+}
+
+describe("ThreadChat SSE", () => {
+  it("snapshot、delta cursor、completed、重连与重复连接复用同一 Run", async () => {
+    const actorId = await createUser()
+    try {
+      const creation = await createQueued(actorId)
+      const repositories = createThreadChatRepositories(dbClient)
+      const queued =
+        await repositories.messageRuns.findOwnedByAssistantMessageId(
+          actorId,
+          creation.assistantMessage.id
+        )
+      await repositories.messageRuns.transition({
+        actorId,
+        messageRunId: queued!.id,
+        expectedStatus: "queued",
+        nextStatus: "running",
+      })
+      await repositories.messageRuns.checkpoint({
+        actorId,
+        messageRunId: queued!.id,
+        expectedEventSequence: 0,
+        checkpointParts: [{ type: "text", text: "partial" }],
+        heartbeatAt: new Date(),
+      })
+
+      const firstResponse = await assistantEvents(
+        actorId,
+        creation.assistantMessage.id,
+        new Request("http://test/events?afterEventSequence=0")
+      )
+      const firstReader = firstResponse.body!.getReader()
+      expect(await readEvent(firstReader)).toMatchObject({
+        type: "run.snapshot",
+        cursor: 1,
+        run: {
+          assistantMessageId: creation.assistantMessage.id,
+          checkpointParts: [{ type: "text", text: "partial" }],
+        },
+      })
+      await firstReader.cancel()
+      expect(
+        await repositories.messageRuns.findOwnedByAssistantMessageId(
+          actorId,
+          creation.assistantMessage.id
+        )
+      ).toMatchObject({ status: "running" })
+
+      const responseA = await assistantEvents(
+        actorId,
+        creation.assistantMessage.id,
+        new Request("http://test/events?afterEventSequence=1")
+      )
+      const responseB = await assistantEvents(
+        actorId,
+        creation.assistantMessage.id,
+        new Request("http://test/events?afterEventSequence=1")
+      )
+      const readerA = responseA.body!.getReader()
+      const readerB = responseB.body!.getReader()
+      expect((await readEvent(readerA)).type).toBe("run.snapshot")
+      expect((await readEvent(readerB)).type).toBe("run.snapshot")
+
+      await repositories.messages.finalizeAssistantOnce({
+        actorId,
+        messageId: creation.assistantMessage.id,
+        parts: [{ type: "text", text: "final" }],
+        finalizedAt: new Date(),
+      })
+      await repositories.messageRuns.transition({
+        actorId,
+        messageRunId: queued!.id,
+        expectedStatus: "running",
+        nextStatus: "completed",
+        finishedAt: new Date(),
+        incrementEventSequence: true,
+      })
+      expect(await readEvent(readerA)).toMatchObject({
+        type: "run.completed",
+        eventSequence: 2,
+        message: { parts: [{ type: "text", text: "final" }] },
+      })
+      expect(await readEvent(readerB)).toMatchObject({
+        type: "run.completed",
+        eventSequence: 2,
+      })
+
+      const reconnect = await assistantEvents(
+        actorId,
+        creation.assistantMessage.id,
+        new Request("http://test/events?afterEventSequence=1")
+      )
+      const reconnectReader = reconnect.body!.getReader()
+      expect(await readEvent(reconnectReader)).toMatchObject({
+        type: "run.snapshot",
+        cursor: 2,
+        run: { status: "completed" },
+      })
+      expect((await reconnectReader.read()).done).toBe(true)
+
+      const [count] = await dbClient<{ count: number }[]>`
+        select count(*)::integer as count
+        from thread_chat.message_runs
+        where assistant_message_id = ${creation.assistantMessage.id}
+      `
+      expect(count.count).toBe(1)
+    } finally {
+      await deleteUser(actorId)
+    }
+  })
+
+  it("拒绝超前 cursor,并发送 live failed 与 stopped 终态", async () => {
+    const actorId = await createUser()
+    try {
+      const failedCreation = await createQueued(actorId)
+      const repositories = createThreadChatRepositories(dbClient)
+      const failedRun =
+        await repositories.messageRuns.findOwnedByAssistantMessageId(
+          actorId,
+          failedCreation.assistantMessage.id
+        )
+      const invalid = await withActor(
+        (actor) =>
+          assistantEvents(
+            actor,
+            failedCreation.assistantMessage.id,
+            new Request("http://test/events?afterEventSequence=99")
+          ),
+        "assistant_message_not_found",
+        async () => actorId
+      )
+      expect(invalid.status).toBe(409)
+      expect((await invalid.json()).error.code).toBe("invalid_event_cursor")
+
+      await repositories.messageRuns.transition({
+        actorId,
+        messageRunId: failedRun!.id,
+        expectedStatus: "queued",
+        nextStatus: "running",
+      })
+      const failedResponse = await assistantEvents(
+        actorId,
+        failedCreation.assistantMessage.id,
+        new Request("http://test/events")
+      )
+      const failedReader = failedResponse.body!.getReader()
+      expect((await readEvent(failedReader)).type).toBe("run.snapshot")
+      await repositories.messageRuns.transition({
+        actorId,
+        messageRunId: failedRun!.id,
+        expectedStatus: "running",
+        nextStatus: "failed",
+        error: { code: "provider_failed", message: "failed" },
+        finishedAt: new Date(),
+        incrementEventSequence: true,
+      })
+      expect(await readEvent(failedReader)).toMatchObject({
+        type: "run.failed",
+        eventSequence: 1,
+        run: { status: "failed" },
+      })
+
+      const stoppedCreation = await createQueued(actorId)
+      const stoppedResponse = await assistantEvents(
+        actorId,
+        stoppedCreation.assistantMessage.id,
+        new Request("http://test/events")
+      )
+      const stoppedReader = stoppedResponse.body!.getReader()
+      expect((await readEvent(stoppedReader)).type).toBe("run.snapshot")
+      await stopAssistant(actorId, stoppedCreation.assistantMessage.id)
+      expect(await readEvent(stoppedReader)).toMatchObject({
+        type: "run.stopped",
+        eventSequence: 1,
+        run: { status: "stopped" },
+      })
+    } finally {
+      await deleteUser(actorId)
+    }
+  })
+})
diff --git a/tests/api/transport.test.ts b/tests/api/transport.test.ts
new file mode 100644
index 00000000..d71137f8
--- /dev/null
+++ b/tests/api/transport.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it, vi } from "vitest"
+import { JsonThreadChatTransport } from "@/lib/thread-chat/api/json-transport"
+import { ThreadChatClientError } from "@/lib/thread-chat/api/client-error"
+import {
+  assistantMessageDTOFixture,
+  assistantRunDTOFixture,
+} from "../fixtures/thread-chat-api-fixtures"
+
+describe("JsonThreadChatTransport", () => {
+  it("默认 fetch 绑定 globalThis,浏览器调用不会 Illegal invocation", async () => {
+    const nativeLikeFetch = vi.fn(function (this: unknown) {
+      if (this !== globalThis) throw new TypeError("Illegal invocation")
+      return Promise.resolve(
+        Response.json({ data: { items: [], nextCursor: null } })
+      )
+    })
+    vi.stubGlobal("fetch", nativeLikeFetch)
+    try {
+      await expect(new JsonThreadChatTransport().listProjects()).resolves.toEqual(
+        { items: [], nextCursor: null }
+      )
+    } finally {
+      vi.unstubAllGlobals()
+    }
+  })
+
+  it("编码请求并严格校验成功响应", async () => {
+    const fetcher = vi.fn().mockResolvedValue(
+      Response.json({
+        data: {
+          items: [],
+          nextCursor: null,
+        },
+      })
+    )
+    const transport = new JsonThreadChatTransport(fetcher)
+    expect(
+      await transport.listProjects({ status: "archived", limit: 10 })
+    ).toEqual({ items: [], nextCursor: null })
+    expect(fetcher).toHaveBeenCalledWith(
+      "/api/v1/projects?status=archived&limit=10",
+      expect.objectContaining({ credentials: "same-origin" })
+    )
+
+    fetcher.mockResolvedValueOnce(
+      Response.json({ data: { items: [], nextCursor: null, unknown: true } })
+    )
+    await expect(transport.listProjects()).rejects.toThrow()
+  })
+
+  it("将结构化错误映射为 ClientError", async () => {
+    const fetcher = vi.fn().mockResolvedValue(
+      Response.json(
+        {
+          error: {
+            code: "project_not_found",
+            message: "missing",
+          },
+        },
+        { status: 404 }
+      )
+    )
+    await expect(
+      new JsonThreadChatTransport(fetcher).bootstrapProject(crypto.randomUUID())
+    ).rejects.toEqual(
+      expect.objectContaining>({
+        code: "project_not_found",
+        status: 404,
+        message: "missing",
+      })
+    )
+  })
+
+  it("按 SSE data frame 解析并校验事件", async () => {
+    const events = [
+      {
+        type: "run.snapshot",
+        cursor: 0,
+        run: assistantRunDTOFixture,
+        message: assistantMessageDTOFixture,
+        artifactSummary: { changeSequence: 0, total: 0, byKind: {} },
+      },
+      {
+        type: "run.stopped",
+        eventSequence: 1,
+        run: {
+          ...assistantRunDTOFixture,
+          status: "stopped",
+          eventSequence: 1,
+          stopRequestedAt: "2026-08-25T00:00:00.000Z",
+          finishedAt: "2026-08-25T00:00:00.000Z",
+        },
+        message: assistantMessageDTOFixture,
+      },
+    ]
+    const stream = new ReadableStream({
+      start(controller) {
+        const encoder = new TextEncoder()
+        for (const event of events)
+          controller.enqueue(
+            encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
+          )
+        controller.close()
+      },
+    })
+    const fetcher = vi.fn().mockResolvedValue(
+      new Response(stream, {
+        headers: { "Content-Type": "text/event-stream" },
+      })
+    )
+    const received = []
+    for await (const event of new JsonThreadChatTransport(
+      fetcher
+    ).subscribeAssistantEvents({
+      assistantMessageId: assistantMessageDTOFixture.id,
+    })) {
+      received.push(event)
+    }
+    expect(received).toEqual(events)
+  })
+})
diff --git a/tests/client/providers.test.tsx b/tests/client/providers.test.tsx
new file mode 100644
index 00000000..9582e532
--- /dev/null
+++ b/tests/client/providers.test.tsx
@@ -0,0 +1,224 @@
+import { StrictMode, useState } from "react"
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const navigationMocks = vi.hoisted(() => ({
+  replace: vi.fn(),
+  pathname: "/thread-chat/new",
+}))
+
+vi.mock("next/navigation", () => ({
+  useRouter: () => ({ replace: navigationMocks.replace }),
+  usePathname: () => navigationMocks.pathname,
+}))
+
+vi.stubGlobal(
+  "ResizeObserver",
+  class {
+    observe() {}
+    unobserve() {}
+    disconnect() {}
+  }
+)
+HTMLElement.prototype.scrollIntoView = vi.fn()
+
+import {
+  useAppShellCommands,
+  useNewProjectDraftStore,
+  useProject,
+  useSubmitNewProjectDraft,
+} from "@/lib/thread-chat/client/hooks"
+import {
+  NewProjectDraftProvider,
+  ThreadChatAppProvider,
+  ThreadChatProjectProvider,
+  useThreadChatProjectRuntime,
+} from "@/lib/thread-chat/client/providers"
+import {
+  assistantMessageDTOFixture,
+  assistantRunDTOFixture,
+  creationBundleDTOFixture,
+  projectDTOFixture,
+  rootThreadDTOFixture,
+  userMessageDTOFixture,
+} from "../fixtures/thread-chat-api-fixtures"
+import { createTestApi } from "./test-api"
+import { ThreadChatNew } from "@/app/thread-chat/normalized/thread-chat-new"
+
+const bootstrap = {
+  project: projectDTOFixture,
+  threadTopology: [rootThreadDTOFixture],
+  artifactSummary: { changeSequence: 0, total: 0, byKind: {} },
+  initialThread: {
+    threadId: rootThreadDTOFixture.id,
+    messages: [userMessageDTOFixture, assistantMessageDTOFixture],
+    assistantRuns: [assistantRunDTOFixture],
+    hasOlderMessages: false,
+    oldestReturnedSequence: 1,
+    newestReturnedSequence: 2,
+  },
+}
+
+beforeEach(() => {
+  navigationMocks.replace.mockReset()
+  navigationMocks.pathname = "/thread-chat/new"
+})
+
+describe("ThreadChat Providers and hooks", () => {
+  it("/new 切换模型后更新选择器并把模型提交给创建命令", async () => {
+    const createProject = vi.fn().mockResolvedValue(creationBundleDTOFixture)
+    const user = userEvent.setup()
+
+    render(
+      
+        
+          
+        
+      
+    )
+
+    const selector = screen.getByRole("combobox", { name: "选择对话模型" })
+    await user.click(selector)
+    await user.click(
+      screen.getByRole("option", { name: "UMAPIS · GPT-5.6 Sol" })
+    )
+    expect(selector.textContent).toContain("UMAPIS · GPT-5.6 Sol")
+
+    await user.type(
+      screen.getByPlaceholderText("继续在主线提问…"),
+      "验证模型切换"
+    )
+    await user.click(screen.getByRole("button", { name: "发送" }))
+    await waitFor(() => expect(createProject).toHaveBeenCalledTimes(1))
+    expect(createProject).toHaveBeenCalledWith({
+      parts: [{ type: "text", text: "验证模型切换" }],
+      requestedModelId: "umapis-gpt-5.6-sol",
+    })
+  })
+
+  it("ProjectProvider 每个 projectId 复用唯一 Runtime,并由 Hook 细粒度读取", async () => {
+    const bootstrapProject = vi.fn().mockResolvedValue(bootstrap)
+    const user = userEvent.setup()
+    let firstRuntime: unknown
+
+    function Probe() {
+      const project = useProject()
+      const runtime = useThreadChatProjectRuntime()
+      const shell = useAppShellCommands()
+      const [renders, setRenders] = useState(0)
+      firstRuntime ??= runtime
+      return (
+        
+ {project?.id ?? "loading"} + + {runtime === firstRuntime ? "same" : "changed"} + + {renders} + +
+ ) + } + + render( + + + + + + + + ) + await waitFor(() => + expect(screen.getByTestId("project").textContent).toBe( + projectDTOFixture.id + ) + ) + expect(screen.getByTestId("runtime").textContent).toBe("same") + expect(bootstrapProject).toHaveBeenCalledTimes(1) + await user.click(screen.getByRole("button", { name: "toggle shell" })) + expect(screen.getByTestId("renders").textContent).toBe("1") + expect(bootstrapProject).toHaveBeenCalledTimes(1) + }) + + it("/new 先 seed Runtime、订阅生成,再根据服务端 projectId replace", async () => { + const createProject = vi.fn().mockResolvedValue(creationBundleDTOFixture) + const subscribeAssistantEvents = vi.fn(async function* () { + yield { + type: "run.failed" as const, + eventSequence: 1, + run: { + ...assistantRunDTOFixture, + status: "failed" as const, + eventSequence: 1, + error: { code: "fake_failed", message: "fake" }, + finishedAt: "2026-08-25T00:01:00.000Z", + }, + } + }) + const user = userEvent.setup() + + function DraftProbe() { + const setDraftParts = useNewProjectDraftStore( + (state) => state.setDraftParts + ) + const status = useNewProjectDraftStore((state) => state.status) + const submit = useSubmitNewProjectDraft() + return ( + <> + {status} + + + ) + } + + render( + + + + + + ) + await user.click(screen.getByRole("button", { name: "submit draft" })) + await waitFor(() => expect(createProject).toHaveBeenCalledTimes(1)) + expect(createProject).toHaveBeenCalledWith({ + parts: [{ type: "text", text: "hello" }], + requestedModelId: undefined, + }) + expect(JSON.stringify(createProject.mock.calls[0][0])).not.toContain("id") + await waitFor(() => + expect(navigationMocks.replace).toHaveBeenCalledWith( + `/thread-chat/${projectDTOFixture.id}` + ) + ) + expect(subscribeAssistantEvents).toHaveBeenCalledWith( + expect.objectContaining({ + assistantMessageId: assistantMessageDTOFixture.id, + afterEventSequence: 0, + }) + ) + }) +}) diff --git a/tests/client/runtime.test.ts b/tests/client/runtime.test.ts new file mode 100644 index 00000000..fd9793c8 --- /dev/null +++ b/tests/client/runtime.test.ts @@ -0,0 +1,540 @@ +import { describe, expect, it, vi } from "vitest" +import { createThreadChatProjectCommands } from "@/lib/thread-chat/client/commands" +import { createGenerationCoordinator } from "@/lib/thread-chat/client/generation-coordinator" +import { + createArtifactLoader, + createThreadMessageLoader, +} from "@/lib/thread-chat/client/loaders" +import { createThreadChatProjectStore } from "@/lib/thread-chat/client/project-store" +import { + createProjectRuntimeRegistry, + createThreadChatProjectRuntime, +} from "@/lib/thread-chat/client/runtime" +import type { + AssistantMessageEvent, + GenerationCoordinator, +} from "@/lib/thread-chat/client/types" +import { + assistantMessageDTOFixture, + assistantRunDTOFixture, + creationBundleDTOFixture, + projectDTOFixture, + rootThreadDTOFixture, + userMessageDTOFixture, +} from "../fixtures/thread-chat-api-fixtures" +import { createTestApi } from "./test-api" + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +const branch = { + ...rootThreadDTOFixture, + id: "00000000-0000-4000-8000-000000000301", + parentThreadId: rootThreadDTOFixture.id, + sourceMessageId: userMessageDTOFixture.id, + forkSourceSnapshot: { + schemaVersion: 1 as const, + sourceRole: "user" as const, + sourceSequence: 1, + }, +} + +describe("ThreadChat client runtime", () => { + it("Project 冷启动先落 Bootstrap,再恢复 Workbench,且 Branch 失败彼此隔离", async () => { + const secondBranch = { + ...branch, + id: "00000000-0000-4000-8000-000000000302", + } + const branchUser = { + ...userMessageDTOFixture, + id: "00000000-0000-4000-8000-000000000307", + threadId: branch.id, + sequence: 1, + } + const branchAssistant = { + ...assistantMessageDTOFixture, + id: "00000000-0000-4000-8000-000000000308", + threadId: branch.id, + sequence: 2, + } + const loadThreadMessages = vi.fn( + async ({ threadId }: { threadId: string }) => { + if (threadId === secondBranch.id) throw new Error("branch unavailable") + return { + threadId, + messages: [branchUser, branchAssistant], + assistantRuns: [ + { + ...assistantRunDTOFixture, + assistantMessageId: branchAssistant.id, + }, + ], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + } + } + ) + const runtime = createThreadChatProjectRuntime({ + projectId: projectDTOFixture.id, + api: createTestApi({ + bootstrapProject: vi.fn().mockResolvedValue({ + project: projectDTOFixture, + threadTopology: [rootThreadDTOFixture, branch, secondBranch], + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + initialThread: { + threadId: rootThreadDTOFixture.id, + messages: [userMessageDTOFixture, assistantMessageDTOFixture], + assistantRuns: [assistantRunDTOFixture], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }, + }), + loadThreadMessages, + }), + generateSlotId: () => "slot-branch", + }) + + await runtime.commands.loadProjectBootstrap() + expect(runtime.store.getState().ui).toMatchObject({ + columnSlots: [], + focusedSlotId: "root", + viewMode: "columns", + }) + runtime.store.getState().restoreWorkbenchSnapshot({ + schemaVersion: 1, + columnSlots: [ + { + slotId: "saved-branch", + threadId: branch.id, + folded: false, + widthPx: 420, + }, + ], + focusedSlotId: "saved-branch", + rootColumnWidthPx: 560, + forceColumnCount: 3, + placementMode: "replace", + viewMode: "columns", + canvasPins: {}, + }) + expect(runtime.store.getState().ui).toMatchObject({ + columnSlots: [ + { + slotId: "saved-branch", + threadId: branch.id, + folded: false, + widthPx: 420, + }, + ], + focusedSlotId: "saved-branch", + rootColumnWidthPx: 560, + forceColumnCount: 3, + }) + + await Promise.all([ + runtime.commands.ensureThreadMessages(branch.id), + runtime.commands.ensureThreadMessages(secondBranch.id), + ]) + expect( + runtime.store.getState().requests.threadMessagesById[branch.id] + .loadState.status + ).toBe("ready") + expect( + runtime.store.getState().requests.threadMessagesById[secondBranch.id] + .loadState.status + ).toBe("error") + expect( + runtime.store.getState().requests.threadMessagesById[ + rootThreadDTOFixture.id + ].loadState.status + ).toBe("ready") + runtime.destroy() + }) + + it("刷新冷启动复用 running Run,并从同一 assistantMessageId 恢复到终态", async () => { + const runningRun = { + ...assistantRunDTOFixture, + status: "running" as const, + finishedAt: null, + } + const subscribeAssistantEvents = vi.fn(async function* () { + yield { + type: "run.completed" as const, + eventSequence: 1, + run: { + ...assistantRunDTOFixture, + status: "completed" as const, + eventSequence: 1, + finishedAt: "2026-08-25T00:01:00.000Z", + }, + message: { + ...assistantMessageDTOFixture, + parts: [{ type: "text" as const, text: "recovered" }], + finalizedAt: "2026-08-25T00:01:00.000Z", + }, + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + } + }) + const runtime = createThreadChatProjectRuntime({ + projectId: projectDTOFixture.id, + api: createTestApi({ + bootstrapProject: vi.fn().mockResolvedValue({ + project: projectDTOFixture, + threadTopology: [rootThreadDTOFixture], + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + initialThread: { + threadId: rootThreadDTOFixture.id, + messages: [userMessageDTOFixture, assistantMessageDTOFixture], + assistantRuns: [runningRun], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }, + }), + subscribeAssistantEvents, + }), + }) + + await runtime.commands.loadProjectBootstrap() + await vi.waitFor(() => + expect( + runtime.store.getState().runs.byAssistantMessageId[ + assistantMessageDTOFixture.id + ].status + ).toBe("completed") + ) + expect(subscribeAssistantEvents).toHaveBeenCalledTimes(1) + expect(subscribeAssistantEvents).toHaveBeenCalledWith( + expect.objectContaining({ + assistantMessageId: assistantMessageDTOFixture.id, + afterEventSequence: 0, + }) + ) + runtime.destroy() + }) + + it("ThreadMessageLoader 同 Thread 去重、不同 Thread 并行且 destroy 统一 Abort", async () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeBootstrap({ + project: projectDTOFixture, + threadTopology: [rootThreadDTOFixture, branch], + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + initialThread: { + threadId: rootThreadDTOFixture.id, + messages: [userMessageDTOFixture, assistantMessageDTOFixture], + assistantRuns: [assistantRunDTOFixture], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }, + }) + const secondBranch = { + ...branch, + id: "00000000-0000-4000-8000-000000000302", + } + store.getState().applyThreadCreated(secondBranch) + const pendingByThread = new Map< + string, + ReturnType> + >() + const signals: AbortSignal[] = [] + const loadThreadMessages = vi.fn( + (input: { threadId: string; signal?: AbortSignal }) => { + const pending = deferred() + pendingByThread.set(input.threadId, pending) + if (input.signal) signals.push(input.signal) + return pending.promise + } + ) + const coordinator = { + resumeLoadedRuns: vi.fn(), + subscribeAssistant: vi.fn(), + unsubscribeAssistant: vi.fn(), + destroy: vi.fn(), + } satisfies GenerationCoordinator + const loader = createThreadMessageLoader({ + projectId: projectDTOFixture.id, + api: createTestApi({ loadThreadMessages }), + store, + generationCoordinator: coordinator, + }) + + const first = loader.ensure(branch.id) + const duplicate = loader.ensure(branch.id) + const parallel = loader.ensure(secondBranch.id) + expect(first).toBe(duplicate) + expect(parallel).not.toBe(first) + expect(loadThreadMessages).toHaveBeenCalledTimes(2) + loader.destroy() + expect(signals.every((signal) => signal.aborted)).toBe(true) + pendingByThread.forEach((pending) => + pending.reject(new DOMException("Aborted", "AbortError")) + ) + await Promise.all([first, duplicate, parallel]) + expect( + store.getState().requests.threadMessagesById[branch.id] + ).toMatchObject({ + loadState: { status: "loading" }, + }) + }) + + it("GenerationCoordinator 按 assistantMessageId 去重、合帧并接收终态", async () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeCreationBundle(creationBundleDTOFixture) + const terminal = deferred() + const subscribeAssistantEvents = vi.fn(async function* () { + yield { + type: "run.snapshot" as const, + cursor: 0, + run: { ...assistantRunDTOFixture, status: "running" as const }, + message: assistantMessageDTOFixture, + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + } + yield { + type: "run.delta" as const, + eventSequence: 1, + chunk: { + type: "data-run-checkpoint", + data: { checkpointParts: [{ type: "text", text: "partial" }] }, + }, + } + await terminal.promise + yield { + type: "run.completed" as const, + eventSequence: 2, + run: { + ...assistantRunDTOFixture, + status: "completed" as const, + checkpointParts: [{ type: "text", text: "final" }], + eventSequence: 2, + finishedAt: "2026-08-25T00:01:00.000Z", + }, + message: { + ...assistantMessageDTOFixture, + parts: [{ type: "text", text: "final" }], + finalizedAt: "2026-08-25T00:01:00.000Z", + }, + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + } + }) + const scheduled: Array<() => void> = [] + const coordinator = createGenerationCoordinator({ + api: createTestApi({ subscribeAssistantEvents }), + store, + scheduleFlush: (callback) => { + scheduled.push(callback) + return vi.fn() + }, + waitForReconnect: () => Promise.resolve(), + }) + coordinator.subscribeAssistant(assistantMessageDTOFixture.id) + coordinator.subscribeAssistant(assistantMessageDTOFixture.id) + await vi.waitFor(() => expect(scheduled).toHaveLength(1)) + expect(subscribeAssistantEvents).toHaveBeenCalledTimes(1) + scheduled[0]() + expect( + store.getState().runs.byAssistantMessageId[assistantMessageDTOFixture.id] + .checkpointParts + ).toEqual([{ type: "text", text: "partial" }]) + terminal.resolve() + await vi.waitFor(() => + expect( + store.getState().runs.byAssistantMessageId[ + assistantMessageDTOFixture.id + ].status + ).toBe("completed") + ) + expect( + store.getState().entities.messagesById[assistantMessageDTOFixture.id] + .parts + ).toEqual([{ type: "text", text: "final" }]) + coordinator.destroy() + }) + + it("ArtifactLoader 只按 artifactId 请求、缓存并拒绝跨 Project 响应", async () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeCreationBundle(creationBundleDTOFixture) + const artifactId = "00000000-0000-4000-8000-000000000305" + const loadArtifact = vi.fn().mockResolvedValue({ + id: artifactId, + projectId: projectDTOFixture.id, + sourceMessageId: assistantMessageDTOFixture.id, + kind: "markdown", + title: "Result", + content: "# body", + createdAt: "2026-08-25T00:01:00.000Z", + }) + const loader = createArtifactLoader({ + projectId: projectDTOFixture.id, + api: createTestApi({ loadArtifact }), + store, + }) + const first = loader.ensure(artifactId) + const duplicate = loader.ensure(artifactId) + expect(first).toBe(duplicate) + await first + expect(loadArtifact).toHaveBeenCalledTimes(1) + await loader.ensure(artifactId) + expect(loadArtifact).toHaveBeenCalledTimes(1) + expect(store.getState().entities.artifactsById[artifactId].content).toBe( + "# body" + ) + + const foreignId = "00000000-0000-4000-8000-000000000306" + loadArtifact.mockResolvedValueOnce({ + id: foreignId, + projectId: "00000000-0000-4000-8000-000000000999", + sourceMessageId: assistantMessageDTOFixture.id, + kind: "markdown", + title: "Foreign", + content: "hidden", + createdAt: "2026-08-25T00:01:00.000Z", + }) + await loader.ensure(foreignId) + expect(store.getState().requests.artifactById[foreignId]).toMatchObject({ + status: "error", + error: { code: "validation_error" }, + }) + expect(store.getState().entities.artifactsById[foreignId]).toBeUndefined() + }) + + it("GenerationCoordinator 网络失败后按持久游标重连,取消订阅不调用 Stop", async () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeCreationBundle(creationBundleDTOFixture) + let attempt = 0 + const subscribeAssistantEvents = vi.fn(async function* (_input: { + assistantMessageId: string + afterEventSequence?: number + signal?: AbortSignal + }): AsyncGenerator { + void _input + attempt++ + if (attempt === 1) throw new Error("disconnected") + yield { + type: "run.failed", + eventSequence: 1, + run: { + ...assistantRunDTOFixture, + status: "failed", + eventSequence: 1, + error: { code: "provider_failed", message: "failed" }, + finishedAt: "2026-08-25T00:01:00.000Z", + }, + } + }) + const stopAssistant = vi.fn() + const coordinator = createGenerationCoordinator({ + api: createTestApi({ subscribeAssistantEvents, stopAssistant }), + store, + waitForReconnect: () => Promise.resolve(), + }) + coordinator.subscribeAssistant(assistantMessageDTOFixture.id) + await vi.waitFor(() => + expect(subscribeAssistantEvents).toHaveBeenCalledTimes(2) + ) + expect(subscribeAssistantEvents.mock.calls[1][0]).toMatchObject({ + assistantMessageId: assistantMessageDTOFixture.id, + afterEventSequence: 0, + }) + coordinator.unsubscribeAssistant(assistantMessageDTOFixture.id) + expect(stopAssistant).not.toHaveBeenCalled() + }) + + it("Application Commands 只提交既有 ID 并用语义 Action 合并响应", async () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeCreationBundle(creationBundleDTOFixture) + const nextUser = { + ...userMessageDTOFixture, + id: "00000000-0000-4000-8000-000000000303", + sequence: 3, + parts: [{ type: "text", text: "next" }], + } + const nextAssistant = { + ...assistantMessageDTOFixture, + id: "00000000-0000-4000-8000-000000000304", + sequence: 4, + } + const sendMessage = vi.fn().mockResolvedValue({ + userMessage: nextUser, + assistantMessage: nextAssistant, + assistantRun: { + ...assistantRunDTOFixture, + assistantMessageId: nextAssistant.id, + }, + }) + const coordinator = { + resumeLoadedRuns: vi.fn(), + subscribeAssistant: vi.fn(), + unsubscribeAssistant: vi.fn(), + destroy: vi.fn(), + } satisfies GenerationCoordinator + const api = createTestApi({ sendMessage }) + const result = createThreadChatProjectCommands({ + projectId: projectDTOFixture.id, + api, + store, + messageLoader: { ensure: vi.fn(), destroy: vi.fn() }, + artifactLoader: { ensure: vi.fn(), destroy: vi.fn() }, + generationCoordinator: coordinator, + }) + await result.commands.sendMessage(rootThreadDTOFixture.id, [ + { type: "text", text: "next" }, + ]) + expect(sendMessage).toHaveBeenCalledWith({ + threadId: rootThreadDTOFixture.id, + parts: [{ type: "text", text: "next" }], + requestedModelId: undefined, + }) + expect(JSON.stringify(sendMessage.mock.calls[0][0])).not.toContain( + nextAssistant.id + ) + expect(store.getState().entities.messagesById[nextAssistant.id]).toEqual( + nextAssistant + ) + expect(coordinator.subscribeAssistant).toHaveBeenCalledWith( + nextAssistant.id + ) + }) + + it("Registry 用一次 seed handoff 交付同一 Runtime并抵抗同 tick lease 重挂载", async () => { + const api = createTestApi() + const registry = createProjectRuntimeRegistry({ + createRuntime: (projectId) => + createThreadChatProjectRuntime({ + projectId, + api, + waitForReconnect: () => new Promise(() => {}), + }), + }) + const seeded = registry.seedFromCreation(creationBundleDTOFixture) + const acquired = registry.acquire(projectDTOFixture.id) + expect(acquired).toBe(seeded) + expect(acquired.store.getState().requests.bootstrap.status).toBe("ready") + registry.release(projectDTOFixture.id) + expect(registry.acquire(projectDTOFixture.id)).toBe(seeded) + await Promise.resolve() + expect(registry.peek(projectDTOFixture.id)).toBe(seeded) + registry.release(projectDTOFixture.id) + await Promise.resolve() + expect(registry.peek(projectDTOFixture.id)).toBeNull() + }) +}) diff --git a/tests/client/store.test.ts b/tests/client/store.test.ts new file mode 100644 index 00000000..a53d916d --- /dev/null +++ b/tests/client/store.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, it } from "vitest" +import { createThreadChatAppStore } from "@/lib/thread-chat/client/app-store" +import { createThreadChatProjectStore } from "@/lib/thread-chat/client/project-store" +import { projectLegacyTreeView } from "@/app/thread-chat/normalized/project-view-model" +import { + selectForkAvailability, + selectThreadColumnView, + selectThreadMessages, +} from "@/lib/thread-chat/client/selectors" +import { + assistantMessageDTOFixture, + assistantRunDTOFixture, + creationBundleDTOFixture, + projectDTOFixture, + rootThreadDTOFixture, + userMessageDTOFixture, +} from "../fixtures/thread-chat-api-fixtures" + +const branchId = "00000000-0000-4000-8000-000000000201" + +const branch = { + ...rootThreadDTOFixture, + id: branchId, + parentThreadId: rootThreadDTOFixture.id, + sourceMessageId: userMessageDTOFixture.id, + forkSourceSnapshot: { + schemaVersion: 1 as const, + sourceRole: "user" as const, + sourceSequence: 1, + }, +} + +const branchTwo = { + ...branch, + id: "00000000-0000-4000-8000-000000000202", +} + +const branchThree = { + ...branch, + id: "00000000-0000-4000-8000-000000000205", +} + +describe("ThreadChat client stores", () => { + it("区分本页新生成与页面加载后继续生成", () => { + const runningRun = { + ...assistantRunDTOFixture, + status: "running" as const, + } + const freshStore = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + freshStore.getState().mergeCreationBundle(creationBundleDTOFixture) + freshStore.getState().applyRunEvent({ + type: "run.snapshot", + cursor: 0, + run: runningRun, + message: assistantMessageDTOFixture, + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + }) + expect( + projectLegacyTreeView(freshStore.getState()).threads[ + rootThreadDTOFixture.id + ].messages.at(-1) + ).toMatchObject({ status: "streaming" }) + expect( + projectLegacyTreeView(freshStore.getState()).threads[ + rootThreadDTOFixture.id + ].messages.at(-1)?.backgroundGeneration + ).toBeUndefined() + + const resumedStore = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + resumedStore.getState().mergeBootstrap({ + project: projectDTOFixture, + threadTopology: [rootThreadDTOFixture], + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + initialThread: { + threadId: rootThreadDTOFixture.id, + messages: [userMessageDTOFixture, assistantMessageDTOFixture], + assistantRuns: [runningRun], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }, + }) + expect( + projectLegacyTreeView(resumedStore.getState()).threads[ + rootThreadDTOFixture.id + ].messages.at(-1) + ).toMatchObject({ + status: "streaming", + backgroundGeneration: true, + }) + + resumedStore.getState().applyRunEvent({ + type: "run.completed", + eventSequence: 1, + run: { + ...runningRun, + status: "completed", + checkpointParts: [{ type: "text", text: "done" }], + eventSequence: 1, + finishedAt: "2026-08-25T00:01:00.000Z", + }, + message: { + ...assistantMessageDTOFixture, + parts: [{ type: "text", text: "done" }], + finalizedAt: "2026-08-25T00:01:00.000Z", + }, + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + }) + expect( + resumedStore.getState().runs.resumedAssistantMessageIds[ + assistantMessageDTOFixture.id + ] + ).toBeUndefined() + }) + + it("App Store 合并稳定排序的摘要且不保存 selectedProjectId", () => { + const store = createThreadChatAppStore() + expect(store.getState().catalog.loadState.status).toBe("idle") + expect("selectedProjectId" in store.getState()).toBe(false) + store.getState().mergeProjectPage({ + items: [ + { + id: projectDTOFixture.id, + displayTitle: "First", + archivedAt: null, + updatedAt: "2026-08-25T00:00:00.000Z", + threadCount: 1, + messageCount: 2, + }, + { + id: "00000000-0000-4000-8000-000000000200", + displayTitle: "Second", + archivedAt: null, + updatedAt: "2026-08-26T00:00:00.000Z", + threadCount: 1, + messageCount: 0, + }, + ], + nextCursor: "cursor", + }) + expect(store.getState().catalog.orderedProjectIds).toEqual([ + "00000000-0000-4000-8000-000000000200", + projectDTOFixture.id, + ]) + store.getState().setCatalogFilter("archived") + expect(store.getState().catalog).toMatchObject({ + activeFilter: "archived", + orderedProjectIds: [], + nextCursor: null, + loadState: { status: "idle" }, + }) + }) + + it("Creation、Message 与 replacement 由 normalizer 原子合并", () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeCreationBundle(creationBundleDTOFixture) + expect(store.getState()).toMatchObject({ + requests: { + bootstrap: { status: "ready" }, + threadMessagesById: { + [rootThreadDTOFixture.id]: { loadState: { status: "ready" } }, + }, + }, + ui: { focusedSlotId: "root" }, + }) + expect( + selectThreadMessages(store.getState(), rootThreadDTOFixture.id) + ).toEqual([userMessageDTOFixture, assistantMessageDTOFixture]) + + store.getState().applyMessageBundle({ + threadId: rootThreadDTOFixture.id, + messages: [assistantMessageDTOFixture, userMessageDTOFixture], + assistantRuns: [assistantRunDTOFixture], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }) + expect( + store.getState().entities.messageIdsByThreadId[rootThreadDTOFixture.id] + ).toEqual([userMessageDTOFixture.id, assistantMessageDTOFixture.id]) + + const replacement = { + ...assistantMessageDTOFixture, + id: "00000000-0000-4000-8000-000000000203", + sequence: 3, + replacesMessageId: assistantMessageDTOFixture.id, + } + store.getState().applyReplacementBundle({ + supersededMessageIds: [assistantMessageDTOFixture.id], + createdMessages: [replacement], + assistantRun: { + ...assistantRunDTOFixture, + assistantMessageId: replacement.id, + }, + }) + expect( + selectThreadMessages(store.getState(), rootThreadDTOFixture.id).map( + (message) => message.id + ) + ).toEqual([userMessageDTOFixture.id, replacement.id]) + }) + + it("拒绝跨 Project 归属与 duplicate sequence", () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeCreationBundle(creationBundleDTOFixture) + expect(() => + store.getState().applyThreadCreated({ + ...branch, + projectId: "00000000-0000-4000-8000-000000000999", + }) + ).toThrow(/another Project/) + expect(() => + store.getState().applyMessageBundle({ + threadId: rootThreadDTOFixture.id, + messages: [ + { + ...assistantMessageDTOFixture, + id: "00000000-0000-4000-8000-000000000204", + sequence: 1, + }, + ], + assistantRuns: [ + { + ...assistantRunDTOFixture, + assistantMessageId: "00000000-0000-4000-8000-000000000204", + }, + ], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 1, + }) + ).toThrow(/duplicate Message sequence/) + }) + + it("稳定 Column Slot、宽度、折叠和 Snapshot 过滤非法视图", () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + generateSlotId: () => "slot-1", + }) + store.getState().mergeBootstrap({ + project: projectDTOFixture, + threadTopology: [rootThreadDTOFixture, branch], + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + initialThread: { + threadId: rootThreadDTOFixture.id, + messages: [userMessageDTOFixture, assistantMessageDTOFixture], + assistantRuns: [assistantRunDTOFixture], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }, + }) + store.getState().openThread(branch.id, "root") + store.getState().commitColumnWidths({ "slot-1": 420 }) + store.getState().switchColumnThread("slot-1", branch.id) + expect(store.getState().ui.columnSlots[0]).toMatchObject({ + slotId: "slot-1", + threadId: branch.id, + widthPx: 420, + }) + store.getState().setColumnFolded("slot-1", true) + expect(store.getState().ui.focusedSlotId).toBe("root") + store.getState().restoreWorkbenchSnapshot({ + schemaVersion: 1, + columnSlots: [ + { slotId: "slot-1", threadId: branch.id, folded: false, widthPx: 360 }, + { + slotId: "duplicate", + threadId: branch.id, + folded: false, + widthPx: 360, + }, + { + slotId: "foreign", + threadId: "00000000-0000-4000-8000-000000000999", + folded: false, + widthPx: 360, + }, + ], + focusedSlotId: "duplicate", + rootColumnWidthPx: 500, + forceColumnCount: 3, + placementMode: "fold", + viewMode: "canvas", + canvasPins: { + [branch.id]: { x: 12, y: 20 }, + "00000000-0000-4000-8000-000000000999": { x: 2, y: 3 }, + }, + }) + expect(store.getState().ui).toMatchObject({ + columnSlots: [{ slotId: "slot-1", threadId: branch.id, widthPx: 360 }], + focusedSlotId: "slot-1", + rootColumnWidthPx: 500, + placementMode: "fold", + viewMode: "canvas", + canvasPins: { [branch.id]: { x: 12, y: 20 } }, + }) + }) + + it("列满时保留物理 Slot、来源替换、指定保留和重复 Thread swap", () => { + let nextSlot = 0 + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + generateSlotId: () => `slot-${++nextSlot}`, + }) + store.getState().mergeBootstrap({ + project: projectDTOFixture, + threadTopology: [ + rootThreadDTOFixture, + branch, + branchTwo, + branchThree, + ], + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + initialThread: { + threadId: rootThreadDTOFixture.id, + messages: [userMessageDTOFixture, assistantMessageDTOFixture], + assistantRuns: [assistantRunDTOFixture], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }, + }) + + store.getState().openThread(branch.id, "root", { maxExpanded: 2 }) + store.getState().openThread(branchTwo.id, "slot-1", { maxExpanded: 2 }) + store.getState().commitColumnWidths({ "slot-1": 400, "slot-2": 500 }) + store.getState().switchColumnThread("slot-1", branchTwo.id) + expect(store.getState().ui.columnSlots).toMatchObject([ + { slotId: "slot-1", threadId: branchTwo.id, widthPx: 400 }, + { slotId: "slot-2", threadId: branch.id, widthPx: 500 }, + ]) + + store.getState().openThread(branchThree.id, "slot-1", { + maxExpanded: 2, + keepSource: true, + }) + expect(store.getState().ui.columnSlots).toMatchObject([ + { slotId: "slot-1", threadId: branchTwo.id, widthPx: 400 }, + { slotId: "slot-2", threadId: branchThree.id, widthPx: 500 }, + ]) + }) + + it("fold placement 展开新列时折叠非来源 LRU Slot", () => { + let nextSlot = 0 + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + generateSlotId: () => `slot-${++nextSlot}`, + }) + store.getState().mergeBootstrap({ + project: projectDTOFixture, + threadTopology: [ + rootThreadDTOFixture, + branch, + branchTwo, + branchThree, + ], + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + initialThread: { + threadId: rootThreadDTOFixture.id, + messages: [userMessageDTOFixture, assistantMessageDTOFixture], + assistantRuns: [assistantRunDTOFixture], + hasOlderMessages: false, + oldestReturnedSequence: 1, + newestReturnedSequence: 2, + }, + }) + store.getState().setPlacementMode("fold") + store.getState().openThread(branch.id, "root", { maxExpanded: 2 }) + store.getState().openThread(branchTwo.id, "slot-1", { maxExpanded: 2 }) + store.getState().openThread(branchThree.id, "slot-2", { maxExpanded: 2 }) + + expect(store.getState().ui.columnSlots).toMatchObject([ + { slotId: "slot-1", threadId: branch.id, folded: true }, + { slotId: "slot-2", threadId: branchTwo.id, folded: false }, + { slotId: "slot-3", threadId: branchThree.id, folded: false }, + ]) + }) + + it("局部 Run、Artifact Summary 与 Column View 不依赖 Artifact 正文", () => { + const store = createThreadChatProjectStore({ + projectId: projectDTOFixture.id, + }) + store.getState().mergeCreationBundle(creationBundleDTOFixture) + store.getState().applyRunEvent({ + type: "run.snapshot", + cursor: 0, + run: assistantRunDTOFixture, + message: assistantMessageDTOFixture, + artifactSummary: { changeSequence: 2, total: 1, byKind: { markdown: 1 } }, + }) + store.getState().applyRunEvent({ + type: "run.snapshot", + cursor: 0, + run: assistantRunDTOFixture, + message: assistantMessageDTOFixture, + artifactSummary: { changeSequence: 1, total: 0, byKind: {} }, + }) + expect(store.getState().readModels.artifactSummary?.changeSequence).toBe(2) + expect(() => + store.getState().applyRunEvent({ + type: "run.snapshot", + cursor: 0, + run: assistantRunDTOFixture, + message: assistantMessageDTOFixture, + artifactSummary: { + changeSequence: 2, + total: 2, + byKind: { markdown: 2 }, + }, + }) + ).toThrow(/without advancing changeSequence/) + expect( + selectForkAvailability(store.getState(), assistantMessageDTOFixture.id) + ).toEqual({ allowed: false, reason: "message_not_finalized" }) + expect(selectThreadColumnView(store.getState(), "root")).toMatchObject({ + status: "ready", + artifactIds: [], + }) + }) +}) diff --git a/tests/client/test-api.ts b/tests/client/test-api.ts new file mode 100644 index 00000000..abc50375 --- /dev/null +++ b/tests/client/test-api.ts @@ -0,0 +1,31 @@ +import type { ThreadChatApiCapabilities } from "@/lib/thread-chat/api/capabilities" + +const notImplemented = () => + Promise.reject(new Error("Not implemented in test.")) + +export function createTestApi( + overrides: Partial = {} +): ThreadChatApiCapabilities { + return { + listProjects: notImplemented, + createProject: notImplemented, + bootstrapProject: notImplemented, + patchProject: notImplemented, + setProjectArchived: notImplemented, + deleteProject: notImplemented, + loadThreadMessages: notImplemented, + patchThread: notImplemented, + setThreadArchived: notImplemented, + sendMessage: notImplemented, + forkThread: notImplemented, + editMessage: notImplemented, + regenerateMessage: notImplemented, + setFeedback: notImplemented, + loadArtifact: notImplemented, + async *subscribeAssistantEvents() { + throw new Error("Not implemented in test.") + }, + stopAssistant: notImplemented, + ...overrides, + } +} diff --git a/tests/factories/thread-chat-factories.ts b/tests/factories/thread-chat-factories.ts new file mode 100644 index 00000000..2cf3207d --- /dev/null +++ b/tests/factories/thread-chat-factories.ts @@ -0,0 +1,141 @@ +import { randomUUID } from "node:crypto" +import type { Artifact } from "@/lib/thread-chat/domain/artifact" +import type { Message } from "@/lib/thread-chat/domain/message" +import type { MessageRun } from "@/lib/thread-chat/domain/message-run" +import type { Project } from "@/lib/thread-chat/domain/project" +import type { Thread } from "@/lib/thread-chat/domain/thread" + +const FIXTURE_TIME = new Date("2026-01-01T00:00:00.000Z") + +export type UserFixture = { + id: string + name: string + email: string + emailVerified: boolean + createdAt: Date + updatedAt: Date +} + +export type ProjectFixture = Project +export type ThreadFixture = Thread +export type MessageFixture = Message +export type ArtifactFixture = Artifact +export type MessageRunFixture = MessageRun + +export function createUserFixture( + overrides: Partial = {} +): UserFixture { + const id = overrides.id ?? randomUUID() + return { + id, + name: "测试用户", + email: `${id}@thread-chat.test`, + emailVerified: true, + createdAt: FIXTURE_TIME, + updatedAt: FIXTURE_TIME, + ...overrides, + } +} + +export function createProjectFixture( + overrides: Partial = {} +): ProjectFixture { + return { + id: randomUUID(), + ownerUserId: randomUUID(), + autoTitle: "测试 Project", + customTitle: null, + target: null, + instruction: null, + archivedAt: null, + artifactChangeSequence: 0, + createdAt: FIXTURE_TIME, + updatedAt: FIXTURE_TIME, + ...overrides, + } +} + +export function createThreadFixture( + overrides: Partial = {} +): ThreadFixture { + const isBranch = overrides.parentThreadId != null + return { + id: randomUUID(), + projectId: randomUUID(), + parentThreadId: null, + sourceMessageId: null, + forkSourceSnapshot: null, + baseContext: null, + autoTitle: null, + customTitle: null, + archivedAt: null, + createdAt: FIXTURE_TIME, + updatedAt: FIXTURE_TIME, + ...(isBranch + ? { + sourceMessageId: randomUUID(), + forkSourceSnapshot: { + schemaVersion: 1, + sourceRole: "assistant", + sourceSequence: 2, + }, + baseContext: { schemaVersion: 1, messageIds: [] }, + } + : {}), + ...overrides, + } +} + +export function createMessageFixture( + overrides: Partial = {} +): MessageFixture { + return { + id: randomUUID(), + threadId: randomUUID(), + sequence: 1, + role: "user", + parts: [{ type: "text", text: "测试消息" }], + replacesMessageId: null, + supersededAt: null, + finalizedAt: FIXTURE_TIME, + createdAt: FIXTURE_TIME, + ...overrides, + } +} + +export function createArtifactFixture( + overrides: Partial = {} +): ArtifactFixture { + return { + id: randomUUID(), + projectId: randomUUID(), + sourceMessageId: randomUUID(), + changeSequence: 1, + kind: "markdown", + title: "测试 Artifact", + content: { markdown: "# 测试" }, + createdAt: FIXTURE_TIME, + ...overrides, + } +} + +export function createMessageRunFixture( + overrides: Partial = {} +): MessageRunFixture { + return { + id: randomUUID(), + assistantMessageId: randomUUID(), + status: "queued", + modelId: "fake/test-model", + eventSequence: 0, + checkpointParts: [], + errorCode: null, + errorMessage: null, + heartbeatAt: null, + stopRequestedAt: null, + finishedAt: null, + createdAt: FIXTURE_TIME, + updatedAt: FIXTURE_TIME, + ...overrides, + } +} diff --git a/tests/fakes/fake-ai-runtime.ts b/tests/fakes/fake-ai-runtime.ts new file mode 100644 index 00000000..767900a4 --- /dev/null +++ b/tests/fakes/fake-ai-runtime.ts @@ -0,0 +1,62 @@ +import type { + AiRuntime, + AiRuntimeEvent, + AiRuntimeRequest, +} from "@/lib/thread-chat/application/ports/ai-runtime" + +export type FakeAiRuntimeScenario = { + events: AiRuntimeEvent[] +} + +export type FakeAiRuntimeRecoveryEvent = { + eventSequence: number + event: AiRuntimeEvent +} + +/** 可控且不访问网络的 AI Runtime;测试脚本决定全部 delta、工具与终态。 */ +export class FakeAiRuntime implements AiRuntime { + readonly invocations: AiRuntimeRequest[] = [] + private readonly scenarios = new Map() + + setScenario(messageRunId: string, scenario: FakeAiRuntimeScenario): void { + this.scenarios.set(messageRunId, structuredClone(scenario)) + } + + async *execute( + request: AiRuntimeRequest, + options: { signal?: AbortSignal } = {} + ): AsyncIterable { + this.invocations.push(structuredClone(request)) + const scenario = this.scenarios.get(request.messageRunId) + if (!scenario) { + yield { + type: "failed", + error: { + code: "fake_scenario_missing", + message: `未配置 MessageRun ${request.messageRunId} 的 Fake 场景。`, + }, + } + return + } + + for (const event of scenario.events) { + if (options.signal?.aborted) { + yield { type: "stopped" } + return + } + yield structuredClone(event) + } + } + + /** 为恢复流测试生成稳定游标,并只返回指定游标之后的事件。 */ + recoveryEventsAfter( + messageRunId: string, + afterEventSequence: number + ): FakeAiRuntimeRecoveryEvent[] { + const events = this.scenarios.get(messageRunId)?.events ?? [] + return events + .map((event, index) => ({ eventSequence: index + 1, event })) + .filter((entry) => entry.eventSequence > afterEventSequence) + .map((entry) => structuredClone(entry)) + } +} diff --git a/tests/fixtures/thread-chat-api-fixtures.ts b/tests/fixtures/thread-chat-api-fixtures.ts new file mode 100644 index 00000000..2d8c0250 --- /dev/null +++ b/tests/fixtures/thread-chat-api-fixtures.ts @@ -0,0 +1,95 @@ +const ids = { + project: "00000000-0000-4000-8000-000000000101", + thread: "00000000-0000-4000-8000-000000000102", + userMessage: "00000000-0000-4000-8000-000000000103", + assistantMessage: "00000000-0000-4000-8000-000000000104", + artifact: "00000000-0000-4000-8000-000000000105", +} +const timestamp = "2026-08-25T00:00:00.000Z" + +export const projectDTOFixture = { + id: ids.project, + ownerUserId: "user-fixture", + autoTitle: null, + customTitle: null, + target: null, + instruction: null, + archivedAt: null, + createdAt: timestamp, + updatedAt: timestamp, +} + +export const rootThreadDTOFixture = { + id: ids.thread, + projectId: ids.project, + parentThreadId: null, + sourceMessageId: null, + forkSourceSnapshot: null, + autoTitle: null, + customTitle: null, + archivedAt: null, + createdAt: timestamp, + updatedAt: timestamp, +} + +export const userMessageDTOFixture = { + id: ids.userMessage, + threadId: ids.thread, + sequence: 1, + role: "user" as const, + parts: [{ type: "text", text: "hello" }], + replacesMessageId: null, + supersededAt: null, + finalizedAt: timestamp, + createdAt: timestamp, +} + +export const assistantMessageDTOFixture = { + id: ids.assistantMessage, + threadId: ids.thread, + sequence: 2, + role: "assistant" as const, + parts: null, + replacesMessageId: null, + supersededAt: null, + finalizedAt: null, + createdAt: timestamp, +} + +export const assistantRunDTOFixture = { + assistantMessageId: ids.assistantMessage, + status: "queued" as const, + modelId: "fake/model", + checkpointParts: [], + eventSequence: 0, + error: null, + stopRequestedAt: null, + finishedAt: null, +} + +export const creationBundleDTOFixture = { + project: projectDTOFixture, + rootThread: rootThreadDTOFixture, + artifactSummary: { changeSequence: 0, total: 0, byKind: {} }, + userMessage: userMessageDTOFixture, + assistantMessage: assistantMessageDTOFixture, + assistantRun: assistantRunDTOFixture, +} + +export const apiErrorFixture = { + error: { + code: "validation_error" as const, + message: "Request validation failed.", + details: [{ path: ["initialMessage", "parts"] }], + }, +} + +export const ownershipMismatchFixture = { + ...assistantMessageDTOFixture, + threadId: "00000000-0000-4000-8000-000000000999", +} + +export const invalidUserPartFixture = { + type: "reasoning", + text: "client-forged", +} diff --git a/tests/integration/application.test.ts b/tests/integration/application.test.ts new file mode 100644 index 00000000..0843d695 --- /dev/null +++ b/tests/integration/application.test.ts @@ -0,0 +1,349 @@ +import { randomUUID } from "node:crypto" +import { afterAll, describe, expect, it } from "vitest" +import postgres from "postgres" +import { ThreadChatCommands } from "@/lib/thread-chat/application/thread-chat-commands" +import { ThreadChatQueries } from "@/lib/thread-chat/application/thread-chat-queries" +import { loadPromptHistory } from "@/lib/thread-chat/application/prompt-history" +import { + createThreadChatRepositories, + ThreadChatUnitOfWork, +} from "@/lib/thread-chat/infrastructure/repositories" +import { assertSafeTestDatabaseUrl } from "../../scripts/lib/test-database-safety.mjs" + +const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL) +const sql = postgres(testDatabaseUrl, { max: 10 }) +const unitOfWork = new ThreadChatUnitOfWork(sql) +const fixedNow = new Date("2026-08-25T00:00:00.000Z") +const commands = new ThreadChatCommands(unitOfWork, { + generateId: randomUUID, + now: () => fixedNow, + resolveModelId: (requested) => requested ?? "test/model", +}) +const queries = new ThreadChatQueries(sql) + +async function createUser(): Promise { + const id = randomUUID() + await sql` + insert into thread_chat."user" ( + id, name, email, email_verified, created_at, updated_at + ) values ( + ${id}, 'Application Test', ${`${id}@thread-chat.test`}, true, now(), now() + ) + ` + return id +} + +async function deleteUser(id: string): Promise { + await sql`delete from thread_chat."user" where id = ${id}` +} + +async function completeAssistant(actorId: string, messageId: string) { + const repositories = createThreadChatRepositories(sql) + const run = await repositories.messageRuns.findOwnedByAssistantMessageId( + actorId, + messageId + ) + expect(run).not.toBeNull() + const running = await repositories.messageRuns.transition({ + actorId, + messageRunId: run!.id, + expectedStatus: "queued", + nextStatus: "running", + }) + expect(running?.status).toBe("running") + await repositories.messages.finalizeAssistantOnce({ + actorId, + messageId, + parts: [{ type: "text", text: `answer:${messageId}` }], + finalizedAt: fixedNow, + }) + const completed = await repositories.messageRuns.transition({ + actorId, + messageRunId: run!.id, + expectedStatus: "running", + nextStatus: "completed", + finishedAt: fixedNow, + }) + expect(completed?.status).toBe("completed") +} + +afterAll(async () => { + await sql.end() +}) + +describe("ThreadChat Application", () => { + it("原子创建与追加 turn,并拒绝同 Thread 的并发 generation", async () => { + const actorId = await createUser() + try { + const creation = await commands.createProject({ + actorId, + parts: [{ type: "text", text: "first" }], + }) + expect(creation.userMessage.sequence).toBe(1) + expect(creation.assistantMessage.sequence).toBe(2) + expect(creation.assistantRun).toMatchObject({ + assistantMessageId: creation.assistantMessage.id, + status: "queued", + }) + await expect( + commands.sendMessage({ + actorId, + threadId: creation.rootThread.id, + parts: [{ type: "text", text: "blocked" }], + }) + ).rejects.toMatchObject({ code: "thread_generation_in_progress" }) + expect( + (await queries.threadMessages({ actorId, threadId: creation.rootThread.id })) + .messages + ).toHaveLength(2) + + await completeAssistant(actorId, creation.assistantMessage.id) + const turn = await commands.sendMessage({ + actorId, + threadId: creation.rootThread.id, + parts: [{ type: "text", text: "second" }], + requestedModelId: "test/other-model", + }) + expect([turn.userMessage.sequence, turn.assistantMessage.sequence]).toEqual([ + 3, 4, + ]) + expect(turn.assistantRun.modelId).toBe("test/other-model") + expect( + (await loadPromptHistory(sql, { actorId, threadId: creation.rootThread.id })).map( + (message) => message.id + ) + ).toEqual([ + creation.userMessage.id, + creation.assistantMessage.id, + turn.userMessage.id, + ]) + } finally { + await deleteUser(actorId) + } + }) + + it("Fork 冻结 BaseContext,Prompt History 不依赖客户端窗口", async () => { + const actorId = await createUser() + try { + const creation = await commands.createProject({ + actorId, + parts: [{ type: "text", text: "fork me" }], + }) + await completeAssistant(actorId, creation.assistantMessage.id) + await expect( + commands.forkThread({ + actorId, + sourceThreadId: creation.rootThread.id, + sourceMessageId: creation.assistantMessage.id, + anchor: { + exactQuote: "wrong", + textPosition: { start: 0, end: 5 }, + }, + }) + ).rejects.toMatchObject({ code: "fork_anchor_mismatch" }) + const branch = await commands.forkThread({ + actorId, + sourceThreadId: creation.rootThread.id, + sourceMessageId: creation.assistantMessage.id, + anchor: { exactQuote: "answer" }, + }) + expect(branch).toMatchObject({ + parentThreadId: creation.rootThread.id, + sourceMessageId: creation.assistantMessage.id, + forkSourceSnapshot: { sourceRole: "assistant", sourceSequence: 2 }, + }) + expect(branch.baseContext?.messageIds).toEqual([ + creation.userMessage.id, + creation.assistantMessage.id, + ]) + expect( + (await loadPromptHistory(sql, { actorId, threadId: branch.id })).map( + (message) => message.id + ) + ).toEqual(branch.baseContext?.messageIds) + } finally { + await deleteUser(actorId) + } + }) + + it("Edit supersede 有效后缀,Regenerate 保留旧事实并追加 replacement", async () => { + const actorId = await createUser() + try { + const creation = await commands.createProject({ + actorId, + parts: [{ type: "text", text: "u1" }], + }) + await completeAssistant(actorId, creation.assistantMessage.id) + const second = await commands.sendMessage({ + actorId, + threadId: creation.rootThread.id, + parts: [{ type: "text", text: "u2" }], + }) + await completeAssistant(actorId, second.assistantMessage.id) + + await expect( + commands.editLastUser({ + actorId, + sourceUserMessageId: creation.userMessage.id, + parts: [{ type: "text", text: "historical edit" }], + }) + ).rejects.toMatchObject({ code: "fork_required" }) + await expect( + commands.regenerate({ + actorId, + sourceAssistantMessageId: creation.assistantMessage.id, + }) + ).rejects.toMatchObject({ code: "fork_required" }) + expect( + (await queries.threadMessages({ actorId, threadId: creation.rootThread.id })) + .messages + ).toHaveLength(4) + + const edited = await commands.editLastUser({ + actorId, + sourceUserMessageId: second.userMessage.id, + parts: [{ type: "text", text: "u2 edited" }], + }) + expect(new Set(edited.supersededMessageIds)).toEqual( + new Set([second.userMessage.id, second.assistantMessage.id]) + ) + expect(edited.createdMessages.map((message) => message.role)).toEqual([ + "user", + "assistant", + ]) + expect(edited.createdMessages[0].replacesMessageId).toBe( + second.userMessage.id + ) + await completeAssistant(actorId, edited.createdMessages[1].id) + + const oldAssistant = edited.createdMessages[1] + const regenerated = await commands.regenerate({ + actorId, + sourceAssistantMessageId: oldAssistant.id, + }) + expect(regenerated.supersededMessageIds).toEqual([oldAssistant.id]) + expect(regenerated.createdMessages[0]).toMatchObject({ + role: "assistant", + replacesMessageId: oldAssistant.id, + }) + const persistedOld = await createThreadChatRepositories( + sql + ).messages.findOwnedById(actorId, oldAssistant.id) + expect(persistedOld).toMatchObject({ + sequence: oldAssistant.sequence, + parts: [{ type: "text", text: `answer:${oldAssistant.id}` }], + }) + expect(persistedOld?.supersededAt).not.toBeNull() + } finally { + await deleteUser(actorId) + } + }) + + it("metadata、archive、feedback、Bootstrap、Artifact Query 与级联删除受 owner scope 保护", async () => { + const actorId = await createUser() + const otherId = await createUser() + try { + const creation = await commands.createProject({ + actorId, + parts: [{ type: "text", text: "metadata" }], + }) + await completeAssistant(actorId, creation.assistantMessage.id) + const project = await commands.patchProject({ + actorId, + projectId: creation.project.id, + patch: { customTitle: "Custom", instruction: "instruction" }, + }) + expect(project).toMatchObject({ + customTitle: "Custom", + instruction: "instruction", + }) + await commands.setFeedback({ + actorId, + assistantMessageId: creation.assistantMessage.id, + feedback: "positive", + }) + const branch = await commands.forkThread({ + actorId, + sourceThreadId: creation.rootThread.id, + sourceMessageId: creation.assistantMessage.id, + }) + expect( + await commands.patchBranch({ + actorId, + threadId: branch.id, + customTitle: "Branch", + archived: true, + }) + ).toMatchObject({ customTitle: "Branch", archivedAt: fixedNow }) + await expect( + commands.patchBranch({ + actorId, + threadId: creation.rootThread.id, + customTitle: "Root", + }) + ).rejects.toMatchObject({ code: "root_thread_title_owned_by_project" }) + const artifact = await unitOfWork.transaction((repositories) => + repositories.artifacts.insert({ + actorId, + id: randomUUID(), + projectId: creation.project.id, + sourceMessageId: creation.assistantMessage.id, + kind: "markdown", + title: "Artifact", + content: "# body", + }) + ) + const bootstrap = await queries.projectBootstrap({ + actorId, + projectId: creation.project.id, + }) + expect(bootstrap.initialThread.threadId).toBe(creation.rootThread.id) + expect(bootstrap.artifactSummary).toEqual({ + changeSequence: 1, + total: 1, + byKind: { markdown: 1 }, + }) + expect(await queries.artifactById({ actorId, artifactId: artifact.id })).toEqual( + artifact + ) + await expect( + queries.artifactById({ actorId: otherId, artifactId: artifact.id }) + ).rejects.toMatchObject({ code: "entity_not_found" }) + expect((await queries.listProjects({ actorId }))[0].displayTitle).toBe( + "Custom" + ) + await commands.setProjectArchived({ + actorId, + projectId: creation.project.id, + archived: true, + }) + expect(await queries.listProjects({ actorId })).toEqual([]) + expect( + await queries.listProjects({ actorId, status: "archived" }) + ).toHaveLength(1) + await commands.deleteProject({ actorId, projectId: creation.project.id }) + await expect( + queries.projectBootstrap({ actorId, projectId: creation.project.id }) + ).rejects.toMatchObject({ code: "entity_not_found" }) + } finally { + await deleteUser(actorId) + await deleteUser(otherId) + } + }) + + it("任一创建失败时不留下 Project 半成品", async () => { + const missingActorId = randomUUID() + await expect( + commands.createProject({ + actorId: missingActorId, + parts: [{ type: "text", text: "rollback" }], + }) + ).rejects.toBeTruthy() + const [row] = await sql<{ count: number }[]>` + select count(*)::integer as count + from thread_chat.projects + where owner_user_id = ${missingActorId} + ` + expect(row.count).toBe(0) + }) +}) diff --git a/tests/integration/message-runner.test.ts b/tests/integration/message-runner.test.ts new file mode 100644 index 00000000..a3e5a881 --- /dev/null +++ b/tests/integration/message-runner.test.ts @@ -0,0 +1,288 @@ +import { randomUUID } from "node:crypto" +import { afterAll, describe, expect, it } from "vitest" +import postgres from "postgres" +import { MessageRunner } from "@/lib/thread-chat/application/message-runner" +import type { + AiRuntime, + AiRuntimeRequest, +} from "@/lib/thread-chat/application/ports/ai-runtime" +import { ThreadChatCommands } from "@/lib/thread-chat/application/thread-chat-commands" +import { + createThreadChatRepositories, + ThreadChatUnitOfWork, +} from "@/lib/thread-chat/infrastructure/repositories" +import { FakeAiRuntime } from "../fakes/fake-ai-runtime" +import { assertSafeTestDatabaseUrl } from "../../scripts/lib/test-database-safety.mjs" + +const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL) +const sql = postgres(testDatabaseUrl, { max: 10 }) +const unitOfWork = new ThreadChatUnitOfWork(sql) +const now = new Date("2026-08-25T01:00:00.000Z") + +function createCommands( + overrides: { + wakeRunAfterCommit?: (messageRunId: string) => void | Promise + onWakeError?: (error: unknown) => void + } = {} +) { + return new ThreadChatCommands(unitOfWork, { + generateId: randomUUID, + now: () => now, + resolveModelId: () => "fake/model", + ...overrides, + }) +} + +function createRunner(runtime: AiRuntime) { + return new MessageRunner(sql, unitOfWork, runtime, { + generateId: randomUUID, + now: () => now, + heartbeatIntervalMs: 60_000, + }) +} + +async function createUser(): Promise { + const id = randomUUID() + await sql` + insert into thread_chat."user" ( + id, name, email, email_verified, created_at, updated_at + ) values ( + ${id}, 'Runner Test', ${`${id}@thread-chat.test`}, true, now(), now() + ) + ` + return id +} + +async function deleteUser(id: string): Promise { + await sql`delete from thread_chat."user" where id = ${id}` +} + +afterAll(async () => { + await sql.end() +}) + +describe("MessageRunner", () => { + it("条件领取、checkpoint、Artifact 引用与 completed 在持久层收敛", async () => { + const actorId = await createUser() + try { + const creation = await createCommands().createProject({ + actorId, + parts: [{ type: "text", text: "生成 Markdown 文档" }], + }) + const runtime = new FakeAiRuntime() + runtime.setScenario(creation.assistantRun.id, { + events: [ + { + type: "delta", + partsDelta: [{ type: "text", text: "working" }], + }, + { + type: "artifact", + output: { + kind: "markdown", + title: "Result", + content: "# Result", + toolCallId: "tool-call-1", + }, + }, + { + type: "completed", + parts: [{ type: "text", text: "done" }], + }, + ], + }) + const runner = createRunner(runtime) + const [first, duplicate] = await Promise.all([ + runner.execute(creation.assistantRun.id), + runner.execute(creation.assistantRun.id), + ]) + expect([first.outcome, duplicate.outcome].toSorted()).toEqual([ + "completed", + "not_claimed", + ]) + expect(runtime.invocations).toHaveLength(1) + + const repositories = createThreadChatRepositories(sql) + const run = await repositories.messageRuns.findOwnedByAssistantMessageId( + actorId, + creation.assistantMessage.id + ) + expect(run).toMatchObject({ + status: "completed", + eventSequence: 3, + checkpointParts: [ + { type: "text", text: "working" }, + { + type: "dynamic-tool", + toolName: "createMarkdownArtifact", + toolCallId: "tool-call-1", + state: "output-available", + input: { title: "Result" }, + output: { artifactId: expect.any(String) }, + }, + ], + heartbeatAt: now, + finishedAt: now, + }) + const message = await repositories.messages.findOwnedById( + actorId, + creation.assistantMessage.id + ) + expect(message?.finalizedAt).toEqual(now) + const toolPart = message?.parts?.find( + (part) => part.type === "dynamic-tool" + ) + expect(toolPart).toMatchObject({ + output: { artifactId: expect.any(String) }, + }) + const artifactId = (toolPart as { output: { artifactId: string } }).output + .artifactId + expect( + (toolPart as { output: { artifactId: string } }).output + ).toEqual({ artifactId }) + expect(JSON.stringify(toolPart)).not.toContain("# Result") + expect( + await repositories.artifacts.findOwnedById(actorId, artifactId) + ).toMatchObject({ + sourceMessageId: creation.assistantMessage.id, + content: "# Result", + }) + expect(runtime.invocations[0].prompt.map((message) => message.id)).toEqual([ + creation.userMessage.id, + ]) + } finally { + await deleteUser(actorId) + } + }) + + it("queued scanner 补偿唤醒失败并保存 failed 终态", async () => { + const actorId = await createUser() + try { + const wakeErrors: unknown[] = [] + let committedAtWake = false + const runtime = new FakeAiRuntime() + const commands = createCommands({ + wakeRunAfterCommit: async (messageRunId) => { + committedAtWake = Boolean( + await createThreadChatRepositories( + sql + ).messageRuns.findExecutionContext(messageRunId) + ) + runtime.setScenario(messageRunId, { + events: [ + { + type: "failed", + error: { code: "provider_failed", message: "provider down" }, + }, + ], + }) + throw new Error("wake unavailable") + }, + onWakeError: (error) => wakeErrors.push(error), + }) + const creation = await commands.createProject({ + actorId, + parts: [{ type: "text", text: "scanner" }], + }) + expect(wakeErrors).toHaveLength(1) + expect(committedAtWake).toBe(true) + expect(creation.assistantRun.status).toBe("queued") + + const scan = await createRunner(runtime).scanQueued() + expect(scan).toHaveLength(1) + const run = await createThreadChatRepositories( + sql + ).messageRuns.findOwnedByAssistantMessageId( + actorId, + creation.assistantMessage.id + ) + expect(run).toMatchObject({ + status: "failed", + errorCode: "provider_failed", + errorMessage: "provider down", + eventSequence: 1, + finishedAt: now, + }) + } finally { + await deleteUser(actorId) + } + }) + + it("显式 Stop 终止 queued Run,且不启动 Runtime", async () => { + const actorId = await createUser() + try { + const creation = await createCommands().createProject({ + actorId, + parts: [{ type: "text", text: "stop queued" }], + }) + const runtime = new FakeAiRuntime() + const runner = createRunner(runtime) + const stopped = await runner.requestStop({ + actorId, + assistantMessageId: creation.assistantMessage.id, + }) + expect(stopped).toMatchObject({ + status: "stopped", + stopRequestedAt: now, + finishedAt: now, + eventSequence: 1, + }) + expect(await runner.execute(creation.assistantRun.id)).toEqual({ + outcome: "not_claimed", + }) + expect(runtime.invocations).toEqual([]) + } finally { + await deleteUser(actorId) + } + }) + + it("running Run 只在显式 Stop 后中止;执行器取消与浏览器订阅无关", async () => { + const actorId = await createUser() + try { + let releaseStarted!: () => void + const started = new Promise((resolve) => { + releaseStarted = resolve + }) + const blockingRuntime: AiRuntime = { + async *execute( + _request: AiRuntimeRequest, + options?: { signal?: AbortSignal } + ) { + yield { + type: "delta" as const, + partsDelta: [{ type: "text" as const, text: "partial" }], + } + releaseStarted() + await new Promise((resolve) => { + options?.signal?.addEventListener("abort", () => resolve(), { + once: true, + }) + }) + throw new Error("provider aborted") + }, + } + const creation = await createCommands().createProject({ + actorId, + parts: [{ type: "text", text: "stop running" }], + }) + const runner = createRunner(blockingRuntime) + const execution = runner.execute(creation.assistantRun.id) + await started + const accepted = await runner.requestStop({ + actorId, + assistantMessageId: creation.assistantMessage.id, + }) + expect(accepted).toMatchObject({ status: "running", stopRequestedAt: now }) + expect(await execution).toMatchObject({ outcome: "stopped" }) + const run = await createThreadChatRepositories( + sql + ).messageRuns.findOwnedByAssistantMessageId( + actorId, + creation.assistantMessage.id + ) + expect(run).toMatchObject({ status: "stopped", finishedAt: now }) + } finally { + await deleteUser(actorId) + } + }) +}) diff --git a/tests/integration/normalized-schema.test.ts b/tests/integration/normalized-schema.test.ts new file mode 100644 index 00000000..eb49a28a --- /dev/null +++ b/tests/integration/normalized-schema.test.ts @@ -0,0 +1,328 @@ +import { randomUUID } from "node:crypto" +import { afterAll, describe, expect, it } from "vitest" +import postgres from "postgres" +import { assertSafeTestDatabaseUrl } from "../../scripts/lib/test-database-safety.mjs" + +const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL) +const sql = postgres(testDatabaseUrl, { max: 1 }) + +type OwnedProject = { + userId: string + projectId: string + rootThreadId: string +} + +async function createOwnedProject(): Promise { + const userId = randomUUID() + const projectId = randomUUID() + const rootThreadId = randomUUID() + + await sql` + insert into thread_chat."user" ( + id, name, email, email_verified, created_at, updated_at + ) values ( + ${userId}, + 'Schema Test', + ${`${userId}@thread-chat.test`}, + true, + now(), + now() + ) + ` + await sql` + insert into thread_chat.projects (id, owner_user_id) + values (${projectId}, ${userId}) + ` + await sql` + insert into thread_chat.threads (id, project_id) + values (${rootThreadId}, ${projectId}) + ` + + return { userId, projectId, rootThreadId } +} + +async function deleteTestUser(userId: string): Promise { + await sql`delete from thread_chat."user" where id = ${userId}` +} + +afterAll(async () => { + await sql.end() +}) + +describe("规范化 ThreadChat Schema", () => { + it("从空 schema 一次建立六张目标表", async () => { + const rows = await sql<{ tableName: string }[]>` + select table_name as "tableName" + from information_schema.tables + where table_schema = 'thread_chat' + and table_name in ( + 'projects', + 'threads', + 'messages', + 'message_runs', + 'artifacts', + 'message_feedback' + ) + order by table_name + ` + + expect(rows.map((row) => row.tableName)).toEqual([ + "artifacts", + "message_feedback", + "message_runs", + "messages", + "projects", + "threads", + ]) + }) + + it("约束唯一 Root 与完整 Root/Branch ForkFacts", async () => { + const owned = await createOwnedProject() + try { + await expect( + sql` + insert into thread_chat.threads (id, project_id) + values (${randomUUID()}, ${owned.projectId}) + ` + ).rejects.toMatchObject({ code: "23505" }) + + await expect( + sql` + insert into thread_chat.threads ( + id, project_id, parent_thread_id + ) values ( + ${randomUUID()}, ${owned.projectId}, ${owned.rootThreadId} + ) + ` + ).rejects.toMatchObject({ code: "23514" }) + + const sourceMessageId = randomUUID() + await sql` + insert into thread_chat.messages ( + id, thread_id, sequence, role, parts, finalized_at + ) values ( + ${sourceMessageId}, + ${owned.rootThreadId}, + 1, + 'user', + '[]'::jsonb, + now() + ) + ` + await sql` + insert into thread_chat.threads ( + id, + project_id, + parent_thread_id, + source_message_id, + fork_source_snapshot, + base_context + ) values ( + ${randomUUID()}, + ${owned.projectId}, + ${owned.rootThreadId}, + ${sourceMessageId}, + ${sql.json({ + schemaVersion: 1, + sourceRole: "user", + sourceSequence: 1, + })}, + ${sql.json({ schemaVersion: 1, messageIds: [sourceMessageId] })} + ) + ` + } finally { + await deleteTestUser(owned.userId) + } + }) + + it("约束 sequence、replacement、角色、Run 状态与非负游标", async () => { + const owned = await createOwnedProject() + try { + const userMessageId = randomUUID() + const assistantMessageId = randomUUID() + await sql` + insert into thread_chat.messages ( + id, thread_id, sequence, role, parts, finalized_at + ) values + (${userMessageId}, ${owned.rootThreadId}, 1, 'user', '[]'::jsonb, now()), + (${assistantMessageId}, ${owned.rootThreadId}, 2, 'assistant', '[]'::jsonb, now()) + ` + + await expect( + sql` + insert into thread_chat.messages ( + id, thread_id, sequence, role, parts, finalized_at + ) values ( + ${randomUUID()}, ${owned.rootThreadId}, 1, 'user', '[]'::jsonb, now() + ) + ` + ).rejects.toMatchObject({ code: "23505" }) + await expect( + sql` + insert into thread_chat.messages ( + id, thread_id, sequence, role, parts, finalized_at + ) values ( + ${randomUUID()}, ${owned.rootThreadId}, 3, 'system', '[]'::jsonb, now() + ) + ` + ).rejects.toMatchObject({ code: "23514" }) + await expect( + sql` + insert into thread_chat.messages ( + id, thread_id, sequence, role, parts, finalized_at + ) values ( + ${randomUUID()}, ${owned.rootThreadId}, 0, 'user', '[]'::jsonb, now() + ) + ` + ).rejects.toMatchObject({ code: "23514" }) + + const replacementId = randomUUID() + await sql` + insert into thread_chat.messages ( + id, + thread_id, + sequence, + role, + parts, + replaces_message_id, + finalized_at + ) values ( + ${replacementId}, + ${owned.rootThreadId}, + 3, + 'assistant', + '[]'::jsonb, + ${assistantMessageId}, + now() + ) + ` + await expect( + sql` + insert into thread_chat.messages ( + id, + thread_id, + sequence, + role, + parts, + replaces_message_id, + finalized_at + ) values ( + ${randomUUID()}, + ${owned.rootThreadId}, + 4, + 'assistant', + '[]'::jsonb, + ${assistantMessageId}, + now() + ) + ` + ).rejects.toMatchObject({ code: "23505" }) + + await sql` + insert into thread_chat.message_runs ( + id, assistant_message_id, status, model_id + ) values ( + ${randomUUID()}, ${assistantMessageId}, 'queued', 'fake/test-model' + ) + ` + await expect( + sql` + insert into thread_chat.message_runs ( + id, assistant_message_id, status, model_id + ) values ( + ${randomUUID()}, ${assistantMessageId}, 'queued', 'fake/test-model' + ) + ` + ).rejects.toMatchObject({ code: "23505" }) + await expect( + sql` + insert into thread_chat.message_runs ( + id, assistant_message_id, status, model_id + ) values ( + ${randomUUID()}, ${replacementId}, 'unknown', 'fake/test-model' + ) + ` + ).rejects.toMatchObject({ code: "23514" }) + await expect( + sql` + insert into thread_chat.message_runs ( + id, assistant_message_id, status, model_id, event_sequence + ) values ( + ${randomUUID()}, ${replacementId}, 'queued', 'fake/test-model', -1 + ) + ` + ).rejects.toMatchObject({ code: "23514" }) + } finally { + await deleteTestUser(owned.userId) + } + }) + + it("永久删除 Project 级联清理 Thread、Message、Run、Artifact 与 feedback", async () => { + const owned = await createOwnedProject() + try { + const assistantMessageId = randomUUID() + const artifactId = randomUUID() + await sql` + insert into thread_chat.messages ( + id, thread_id, sequence, role, parts, finalized_at + ) values ( + ${assistantMessageId}, + ${owned.rootThreadId}, + 1, + 'assistant', + '[]'::jsonb, + now() + ) + ` + await sql` + insert into thread_chat.message_runs ( + id, assistant_message_id, status, model_id + ) values ( + ${randomUUID()}, ${assistantMessageId}, 'queued', 'fake/test-model' + ) + ` + await sql` + insert into thread_chat.artifacts ( + id, + project_id, + source_message_id, + change_sequence, + kind, + title, + content + ) values ( + ${artifactId}, + ${owned.projectId}, + ${assistantMessageId}, + 1, + 'markdown', + '测试 Artifact', + '{}'::jsonb + ) + ` + await sql` + insert into thread_chat.message_feedback ( + assistant_message_id, feedback + ) values (${assistantMessageId}, 'positive') + ` + + await sql` + delete from thread_chat.projects + where id = ${owned.projectId} + ` + + const [remaining] = await sql<{ count: number }[]>` + select ( + (select count(*) from thread_chat.threads where project_id = ${owned.projectId}) + + (select count(*) from thread_chat.messages where id = ${assistantMessageId}) + + (select count(*) from thread_chat.message_runs where assistant_message_id = ${assistantMessageId}) + + (select count(*) from thread_chat.artifacts where id = ${artifactId}) + + (select count(*) from thread_chat.message_feedback where assistant_message_id = ${assistantMessageId}) + )::integer as count + ` + expect(remaining.count).toBe(0) + } finally { + await deleteTestUser(owned.userId) + } + }) +}) diff --git a/tests/integration/repositories.test.ts b/tests/integration/repositories.test.ts new file mode 100644 index 00000000..70091b2d --- /dev/null +++ b/tests/integration/repositories.test.ts @@ -0,0 +1,386 @@ +import { randomUUID } from "node:crypto" +import { afterAll, describe, expect, it } from "vitest" +import postgres from "postgres" +import { assertSafeTestDatabaseUrl } from "../../scripts/lib/test-database-safety.mjs" +import { + createThreadChatRepositories, + ThreadChatUnitOfWork, +} from "@/lib/thread-chat/infrastructure/repositories" + +const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL) +const sql = postgres(testDatabaseUrl, { max: 10 }) +const unitOfWork = new ThreadChatUnitOfWork(sql) +const now = new Date("2026-01-01T00:00:00.000Z") + +async function createUser(): Promise { + const userId = randomUUID() + await sql` + insert into thread_chat."user" ( + id, name, email, email_verified, created_at, updated_at + ) values ( + ${userId}, + 'Repository Test', + ${`${userId}@thread-chat.test`}, + true, + now(), + now() + ) + ` + return userId +} + +async function createProjectWithRoot(actorId: string) { + return unitOfWork.transaction(async (repositories) => { + const project = await repositories.projects.insert({ + id: randomUUID(), + ownerUserId: actorId, + }) + const root = await repositories.threads.insertRoot({ + actorId, + id: randomUUID(), + projectId: project.id, + }) + return { project, root } + }) +} + +async function deleteUser(userId: string): Promise { + await sql`delete from thread_chat."user" where id = ${userId}` +} + +afterAll(async () => { + await sql.end() +}) + +describe("ThreadChat Repositories", () => { + it("所有 owner-scoped Query 隔离其他 actor", async () => { + const ownerId = await createUser() + const otherId = await createUser() + try { + const { project, root } = await createProjectWithRoot(ownerId) + const repositories = createThreadChatRepositories(sql) + + expect( + await repositories.projects.findOwnedById(ownerId, project.id) + ).toMatchObject({ id: project.id, ownerUserId: ownerId }) + expect( + await repositories.projects.findOwnedById(otherId, project.id) + ).toBeNull() + expect( + await repositories.threads.findOwnedById(otherId, root.id) + ).toBeNull() + expect( + await repositories.threads.listOwnedTopology(otherId, project.id) + ).toEqual([]) + } finally { + await deleteUser(ownerId) + await deleteUser(otherId) + } + }) + + it("并发 append 通过 Thread 行锁分配唯一递增 sequence", async () => { + const ownerId = await createUser() + try { + const { root } = await createProjectWithRoot(ownerId) + const appended = await Promise.all( + Array.from({ length: 8 }, (_, index) => + unitOfWork.transaction((repositories) => + repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: root.id, + role: "user", + parts: [{ type: "text", text: `消息 ${index}` }], + finalizedAt: now, + }) + ) + ) + ) + + expect(appended.map((message) => message.sequence).toSorted()).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]) + } finally { + await deleteUser(ownerId) + } + }) + + it("finalized Message 只允许 replacement,assistant 只能封存一次", async () => { + const ownerId = await createUser() + try { + const { root } = await createProjectWithRoot(ownerId) + const result = await unitOfWork.transaction(async (repositories) => { + const source = await repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: root.id, + role: "user", + parts: [{ type: "text", text: "旧内容" }], + finalizedAt: now, + }) + const replacement = await repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: root.id, + role: "user", + parts: [{ type: "text", text: "新内容" }], + finalizedAt: now, + replacesMessageId: source.id, + }) + const assistant = await repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: root.id, + role: "assistant", + parts: null, + finalizedAt: null, + }) + const finalized = await repositories.messages.finalizeAssistantOnce({ + actorId: ownerId, + messageId: assistant.id, + parts: [{ type: "text", text: "最终内容" }], + finalizedAt: now, + }) + const duplicateFinalize = + await repositories.messages.finalizeAssistantOnce({ + actorId: ownerId, + messageId: assistant.id, + parts: [{ type: "text", text: "覆盖内容" }], + finalizedAt: now, + }) + return { source, replacement, finalized, duplicateFinalize } + }) + + const sourceAfter = await createThreadChatRepositories( + sql + ).messages.findOwnedById(ownerId, result.source.id) + expect(sourceAfter).toMatchObject({ + parts: [{ type: "text", text: "旧内容" }], + sequence: result.source.sequence, + }) + expect(sourceAfter?.supersededAt).toBeInstanceOf(Date) + expect(result.replacement.replacesMessageId).toBe(result.source.id) + expect(result.finalized?.parts).toEqual([ + { type: "text", text: "最终内容" }, + ]) + expect(result.duplicateFinalize).toBeNull() + } finally { + await deleteUser(ownerId) + } + }) + + it("事务内拒绝跨 Project Fork 与 Artifact provenance", async () => { + const ownerId = await createUser() + try { + const first = await createProjectWithRoot(ownerId) + const second = await createProjectWithRoot(ownerId) + const source = await unitOfWork.transaction((repositories) => + repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: first.root.id, + role: "user", + parts: [{ type: "text", text: "来源" }], + finalizedAt: now, + }) + ) + + await expect( + unitOfWork.transaction((repositories) => + repositories.threads.insertBranch({ + actorId: ownerId, + id: randomUUID(), + projectId: second.project.id, + parentThreadId: second.root.id, + sourceMessageId: source.id, + forkSourceSnapshot: { + schemaVersion: 1, + sourceRole: "user", + sourceSequence: source.sequence, + }, + baseContext: { schemaVersion: 1, messageIds: [source.id] }, + }) + ) + ).rejects.toMatchObject({ code: "thread_source_invalid" }) + + await expect( + unitOfWork.transaction((repositories) => + repositories.artifacts.insert({ + actorId: ownerId, + id: randomUUID(), + projectId: second.project.id, + sourceMessageId: source.id, + kind: "markdown", + title: "非法跨 Project Artifact", + content: "# invalid", + }) + ) + ).rejects.toMatchObject({ code: "artifact_provenance_invalid" }) + + const childCount = await sql<{ count: number }[]>` + select count(*)::integer as count + from thread_chat.threads + where project_id = ${second.project.id} + ` + expect(childCount[0].count).toBe(1) + } finally { + await deleteUser(ownerId) + } + }) + + it("assistant Message 只有一个 Run,并持久化状态机、checkpoint 与 Stop", async () => { + const ownerId = await createUser() + try { + const { root } = await createProjectWithRoot(ownerId) + const result = await unitOfWork.transaction(async (repositories) => { + const assistant = await repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: root.id, + role: "assistant", + parts: null, + finalizedAt: null, + }) + const queued = await repositories.messageRuns.insertQueued({ + actorId: ownerId, + id: randomUUID(), + assistantMessageId: assistant.id, + modelId: "fake/test-model", + }) + return { assistant, queued } + }) + + await expect( + unitOfWork.transaction((repositories) => + repositories.messageRuns.insertQueued({ + actorId: ownerId, + id: randomUUID(), + assistantMessageId: result.assistant.id, + modelId: "fake/test-model", + }) + ) + ).rejects.toMatchObject({ code: "23505" }) + + const running = await unitOfWork.transaction((repositories) => + repositories.messageRuns.transition({ + actorId: ownerId, + messageRunId: result.queued.id, + expectedStatus: "queued", + nextStatus: "running", + }) + ) + const checkpoint = await unitOfWork.transaction((repositories) => + repositories.messageRuns.checkpoint({ + actorId: ownerId, + messageRunId: result.queued.id, + expectedEventSequence: 0, + checkpointParts: [{ type: "text", text: "部分内容" }], + heartbeatAt: now, + }) + ) + const stopped = await unitOfWork.transaction((repositories) => + repositories.messageRuns.requestStop(ownerId, result.assistant.id, now) + ) + + expect(running?.status).toBe("running") + expect(checkpoint).toMatchObject({ + eventSequence: 1, + checkpointParts: [{ type: "text", text: "部分内容" }], + }) + expect(stopped?.stopRequestedAt).toEqual(now) + } finally { + await deleteUser(ownerId) + } + }) + + it("Artifact 分配单调 changeSequence,feedback 只接受合格 assistant", async () => { + const ownerId = await createUser() + try { + const { project, root } = await createProjectWithRoot(ownerId) + const { assistant, user } = await unitOfWork.transaction( + async (repositories) => { + const user = await repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: root.id, + role: "user", + parts: [{ type: "text", text: "用户" }], + finalizedAt: now, + }) + const assistant = await repositories.messages.append({ + actorId: ownerId, + id: randomUUID(), + threadId: root.id, + role: "assistant", + parts: [{ type: "text", text: "回答" }], + finalizedAt: now, + }) + const queued = await repositories.messageRuns.insertQueued({ + actorId: ownerId, + id: randomUUID(), + assistantMessageId: assistant.id, + modelId: "fake/test-model", + }) + await repositories.messageRuns.transition({ + actorId: ownerId, + messageRunId: queued.id, + expectedStatus: "queued", + nextStatus: "running", + }) + await repositories.messageRuns.transition({ + actorId: ownerId, + messageRunId: queued.id, + expectedStatus: "running", + nextStatus: "completed", + finishedAt: now, + }) + return { assistant, user } + } + ) + + const artifacts = await unitOfWork.transaction(async (repositories) => [ + await repositories.artifacts.insert({ + actorId: ownerId, + id: randomUUID(), + projectId: project.id, + sourceMessageId: assistant.id, + kind: "markdown", + title: "A", + content: "# A", + }), + await repositories.artifacts.insert({ + actorId: ownerId, + id: randomUUID(), + projectId: project.id, + sourceMessageId: assistant.id, + kind: "markdown", + title: "B", + content: "# B", + }), + ]) + expect(artifacts.map((artifact) => artifact.changeSequence)).toEqual([ + 1, 2, + ]) + expect( + await unitOfWork.transaction((repositories) => + repositories.feedback.set({ + actorId: ownerId, + assistantMessageId: assistant.id, + feedback: "positive", + }) + ) + ).toBe("positive") + await expect( + unitOfWork.transaction((repositories) => + repositories.feedback.set({ + actorId: ownerId, + assistantMessageId: user.id, + feedback: "positive", + }) + ) + ).rejects.toMatchObject({ code: "feedback_not_eligible" }) + } finally { + await deleteUser(ownerId) + } + }) +}) diff --git a/tests/integration/test-database-isolation.test.ts b/tests/integration/test-database-isolation.test.ts new file mode 100644 index 00000000..4f636fa6 --- /dev/null +++ b/tests/integration/test-database-isolation.test.ts @@ -0,0 +1,31 @@ +import { afterAll, describe, expect, it } from "vitest" +import postgres from "postgres" +import { + assertSafeTestDatabaseUrl, + TEST_DATABASE_NAME, +} from "../../scripts/lib/test-database-safety.mjs" + +const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL) +const sql = postgres(testDatabaseUrl, { max: 1 }) + +afterAll(async () => { + await sql.end() +}) + +describe("PostgreSQL 测试库隔离", () => { + it("实际连接 thread-chat-test,且 thread_chat schema 已由 db:push 建立", async () => { + const [database] = await sql<{ name: string }[]>` + select current_database() as name + ` + const [schema] = await sql<{ exists: boolean }[]>` + select exists( + select 1 + from information_schema.schemata + where schema_name = 'thread_chat' + ) as exists + ` + + expect(database.name).toBe(TEST_DATABASE_NAME) + expect(schema.exists).toBe(true) + }) +}) diff --git a/tests/setup/require-test-database.ts b/tests/setup/require-test-database.ts new file mode 100644 index 00000000..13f0e96c --- /dev/null +++ b/tests/setup/require-test-database.ts @@ -0,0 +1,8 @@ +import { + assertSafeTestDatabaseUrl, + loadTestDatabaseEnvironment, +} from "../../scripts/lib/test-database-safety.mjs" + +loadTestDatabaseEnvironment() +const testDatabaseUrl = assertSafeTestDatabaseUrl(process.env.TEST_DATABASE_URL) +process.env.DATABASE_URL = testDatabaseUrl diff --git a/tests/unit/domain-model.test.ts b/tests/unit/domain-model.test.ts new file mode 100644 index 00000000..ba8ca62d --- /dev/null +++ b/tests/unit/domain-model.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest" +import { + resolveBaseContextMessages, + validateBaseContext, +} from "@/lib/thread-chat/domain/base-context" +import { + assertMessageCanBeReplaced, + assertMessageForkEligible, + selectEffectiveMessages, + type Message, +} from "@/lib/thread-chat/domain/message" +import { + assertMessageRunTransition, + nextEventSequence, + type MessageRun, +} from "@/lib/thread-chat/domain/message-run" +import { + validateThreadTopology, + type Thread, +} from "@/lib/thread-chat/domain/thread" +import { + assertArtifactProvenance, + toMarkdownArtifactToolOutput, +} from "@/lib/thread-chat/domain/artifact" +import { buildPromptHistory } from "@/lib/thread-chat/domain/prompt-history" + +const now = new Date("2026-01-01T00:00:00.000Z") + +function message(overrides: Partial = {}): Message { + return { + id: "message-1", + threadId: "root", + sequence: 1, + role: "user", + parts: [{ type: "text", text: "测试" }], + replacesMessageId: null, + supersededAt: null, + finalizedAt: now, + createdAt: now, + ...overrides, + } +} + +function run(overrides: Partial = {}): MessageRun { + return { + id: "run-1", + assistantMessageId: "message-1", + status: "completed", + modelId: "fake/test-model", + eventSequence: 0, + checkpointParts: [], + errorCode: null, + errorMessage: null, + heartbeatAt: null, + stopRequestedAt: null, + finishedAt: now, + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +function thread(overrides: Partial = {}): Thread { + return { + id: "root", + projectId: "project-1", + parentThreadId: null, + sourceMessageId: null, + forkSourceSnapshot: null, + baseContext: null, + autoTitle: null, + customTitle: null, + archivedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + } +} + +describe("BaseContext", () => { + it("验证版本、去重并按 messageIds 顺序解析", () => { + const first = message({ id: "first", sequence: 1 }) + const second = message({ id: "second", sequence: 2 }) + const context = validateBaseContext({ + schemaVersion: 1, + messageIds: ["second", "first"], + }) + + expect( + resolveBaseContextMessages( + context, + new Map([ + [first.id, first], + [second.id, second], + ]) + ).map((entry) => entry.id) + ).toEqual(["second", "first"]) + expect(() => + validateBaseContext({ schemaVersion: 1, messageIds: ["first", "first"] }) + ).toThrow(/不得重复/) + }) +}) + +describe("Thread topology", () => { + it("接受唯一 Root 与同 Project 的嵌套 Branch", () => { + const source = message({ id: "source", threadId: "root" }) + const child = thread({ + id: "child", + parentThreadId: "root", + sourceMessageId: source.id, + forkSourceSnapshot: { + schemaVersion: 1, + sourceRole: "user", + sourceSequence: 1, + }, + baseContext: { schemaVersion: 1, messageIds: [source.id] }, + }) + + expect(() => + validateThreadTopology( + "project-1", + [thread(), child], + new Map([[source.id, source]]) + ) + ).not.toThrow() + }) + + it("拒绝跨 Project Parent/source 与环", () => { + const invalidChild = thread({ + id: "child", + parentThreadId: "root", + sourceMessageId: "source", + forkSourceSnapshot: { + schemaVersion: 1, + sourceRole: "user", + sourceSequence: 1, + }, + baseContext: { schemaVersion: 1, messageIds: [] }, + }) + expect(() => + validateThreadTopology( + "project-1", + [thread(), invalidChild], + new Map([["source", message({ id: "source", threadId: "other" })]]) + ) + ).toThrow(/Parent Thread/) + + const branchA = thread({ + id: "a", + parentThreadId: "b", + sourceMessageId: "source-b", + forkSourceSnapshot: { + schemaVersion: 1, + sourceRole: "user", + sourceSequence: 1, + }, + baseContext: { schemaVersion: 1, messageIds: [] }, + }) + const branchB = thread({ + id: "b", + parentThreadId: "a", + sourceMessageId: "source-a", + forkSourceSnapshot: { + schemaVersion: 1, + sourceRole: "user", + sourceSequence: 1, + }, + baseContext: { schemaVersion: 1, messageIds: [] }, + }) + expect(() => + validateThreadTopology( + "project-1", + [thread(), branchA, branchB], + new Map([ + ["source-a", message({ id: "source-a", threadId: "a" })], + ["source-b", message({ id: "source-b", threadId: "b" })], + ]) + ) + ).toThrow(/不得形成环/) + }) +}) + +describe("Message replacement 与 Fork", () => { + it("replacement 保持来源不可变并追加到有效时间线", () => { + const source = message({ id: "source", sequence: 1, supersededAt: now }) + const replacement = message({ + id: "replacement", + sequence: 3, + replacesMessageId: source.id, + }) + expect( + selectEffectiveMessages([replacement, source]).map((item) => item.id) + ).toEqual(["replacement"]) + + const activeSource = { ...source, supersededAt: null } + expect(() => + assertMessageCanBeReplaced(activeSource, replacement) + ).not.toThrow() + expect(() => + assertMessageCanBeReplaced(activeSource, { + ...replacement, + threadId: "other", + }) + ).toThrow(/同 Thread/) + }) + + it("只允许 completed assistant 或 finalized user 作为 Fork source", () => { + const assistant = message({ role: "assistant" }) + expect(() => assertMessageForkEligible(assistant, run())).not.toThrow() + expect(() => + assertMessageForkEligible(assistant, run({ status: "running" })) + ).toThrow(/completed/) + expect(() => assertMessageForkEligible(message(), null)).not.toThrow() + }) +}) + +describe("MessageRun 状态机", () => { + it("只允许条件状态转换和非负 eventSequence", () => { + expect(() => assertMessageRunTransition("queued", "running")).not.toThrow() + expect(() => + assertMessageRunTransition("running", "completed") + ).not.toThrow() + expect(() => assertMessageRunTransition("completed", "running")).toThrow( + /不允许/ + ) + expect(nextEventSequence(7)).toBe(8) + expect(() => nextEventSequence(-1)).toThrow(/非负/) + }) +}) + +describe("Prompt History", () => { + it("保留 BaseContext 顺序并排除非 completed assistant", () => { + const baseAssistant = message({ + id: "base-assistant", + threadId: "parent", + role: "assistant", + supersededAt: now, + }) + const currentUser = message({ id: "current-user", sequence: 1 }) + const queuedAssistant = message({ + id: "queued-assistant", + sequence: 2, + role: "assistant", + finalizedAt: null, + }) + + expect( + buildPromptHistory({ + baseMessageIds: [baseAssistant.id], + baseMessages: [baseAssistant], + currentMessages: [currentUser, queuedAssistant], + assistantRuns: [ + run({ assistantMessageId: baseAssistant.id }), + run({ + assistantMessageId: queuedAssistant.id, + status: "queued", + finishedAt: null, + }), + ], + }).map((entry) => entry.id) + ).toEqual([baseAssistant.id, currentUser.id]) + }) +}) + +describe("Artifact provenance", () => { + it("只投影 artifactId,并拒绝跨 Project 来源", () => { + const source = message() + const root = thread() + const artifact = { + id: "artifact-1", + projectId: root.projectId, + sourceMessageId: source.id, + } + expect(() => assertArtifactProvenance(artifact, source, root)).not.toThrow() + expect(toMarkdownArtifactToolOutput(artifact)).toEqual({ + artifactId: "artifact-1", + }) + expect(() => + assertArtifactProvenance( + { ...artifact, projectId: "other-project" }, + source, + root + ) + ).toThrow(/同一 Project/) + }) +}) diff --git a/tests/unit/fake-ai-runtime.test.ts b/tests/unit/fake-ai-runtime.test.ts new file mode 100644 index 00000000..54669cc1 --- /dev/null +++ b/tests/unit/fake-ai-runtime.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" +import { FakeAiRuntime } from "../fakes/fake-ai-runtime" + +const request = { + messageRunId: "run-1", + assistantMessageId: "assistant-1", + modelId: "fake/test-model", + prompt: [], +} + +async function collect(source: AsyncIterable): Promise { + const values: T[] = [] + for await (const value of source) values.push(value) + return values +} + +describe("FakeAiRuntime", () => { + it("按脚本输出 delta、Artifact 与 completed", async () => { + const runtime = new FakeAiRuntime() + runtime.setScenario(request.messageRunId, { + events: [ + { + type: "delta", + partsDelta: [{ type: "text", text: "第一段" }], + }, + { + type: "artifact", + output: { kind: "markdown", title: "文档", content: "# 文档" }, + }, + { + type: "completed", + parts: [{ type: "text", text: "完成" }], + }, + ], + }) + + const events = await collect(runtime.execute(request)) + + expect(events.map((event) => event.type)).toEqual([ + "delta", + "artifact", + "completed", + ]) + expect(runtime.invocations).toEqual([request]) + }) + + it("支持 failed、stopped 和按 eventSequence 恢复事件", async () => { + const runtime = new FakeAiRuntime() + runtime.setScenario(request.messageRunId, { + events: [ + { type: "delta", partsDelta: [] }, + { type: "failed", error: { code: "provider_error", message: "失败" } }, + ], + }) + + expect(runtime.recoveryEventsAfter(request.messageRunId, 1)).toEqual([ + { + eventSequence: 2, + event: { + type: "failed", + error: { code: "provider_error", message: "失败" }, + }, + }, + ]) + + const controller = new AbortController() + controller.abort() + expect( + await collect(runtime.execute(request, { signal: controller.signal })) + ).toEqual([{ type: "stopped" }]) + }) +}) diff --git a/tests/unit/isolated-test-ai-runtime.test.ts b/tests/unit/isolated-test-ai-runtime.test.ts new file mode 100644 index 00000000..6270645a --- /dev/null +++ b/tests/unit/isolated-test-ai-runtime.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" +import { + IsolatedTestAiRuntime, + usesIsolatedTestAiRuntime, +} from "@/lib/thread-chat/infrastructure/isolated-test-ai-runtime" +import type { AiRuntimeRequest } from "@/lib/thread-chat/application/ports/ai-runtime" + +function request(text: string): AiRuntimeRequest { + return { + messageRunId: "00000000-0000-4000-8000-000000000001", + assistantMessageId: "00000000-0000-4000-8000-000000000002", + modelId: "fake/model", + prompt: [ + { + id: "00000000-0000-4000-8000-000000000003", + role: "user", + parts: [{ type: "text", text }], + }, + ], + } +} + +describe("IsolatedTestAiRuntime", () => { + it("仅允许非 production 的 thread-chat-test 数据库启用", () => { + expect( + usesIsolatedTestAiRuntime({ + databaseUrl: "postgres://localhost:5432/thread-chat-test", + nodeEnv: "development", + }) + ).toBe(true) + expect( + usesIsolatedTestAiRuntime({ + databaseUrl: "postgres://localhost:5432/thread-chat-test", + nodeEnv: "production", + }) + ).toBe(false) + expect( + usesIsolatedTestAiRuntime({ + databaseUrl: "postgres://localhost:5432/thread-chat", + nodeEnv: "development", + }) + ).toBe(false) + }) + + it("稳定产生 delta、Artifact 与 completed", async () => { + const events = [] + for await (const event of new IsolatedTestAiRuntime({ + normalMs: 0, + slowMs: 0, + stopTimeoutMs: 0, + }).execute(request("请生成 Markdown 文档"))) + events.push(event) + + expect(events.map((event) => event.type)).toEqual([ + "delta", + "artifact", + "completed", + ]) + expect(events[1]).toMatchObject({ + type: "artifact", + output: { title: "E2E Markdown" }, + }) + }) + + it("显式 Stop 会终止等待中的运行", async () => { + const controller = new AbortController() + const iterator = new IsolatedTestAiRuntime({ + normalMs: 0, + slowMs: 0, + stopTimeoutMs: 30_000, + }) + .execute(request("请持续生成,直到我停止"), { + signal: controller.signal, + }) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: "delta" }, + }) + const terminal = iterator.next() + controller.abort() + await expect(terminal).resolves.toMatchObject({ + value: { type: "stopped" }, + done: false, + }) + }) +}) diff --git a/tests/unit/test-database-safety.test.ts b/tests/unit/test-database-safety.test.ts new file mode 100644 index 00000000..b421fabe --- /dev/null +++ b/tests/unit/test-database-safety.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" +import { + assertSafeTestDatabaseUrl, + TEST_DATABASE_NAME, +} from "../../scripts/lib/test-database-safety.mjs" + +describe("测试数据库安全检查", () => { + it("只接受 allowlist 中的物理测试数据库", () => { + const result = assertSafeTestDatabaseUrl( + `postgres://postgres:postgres@localhost:5432/${TEST_DATABASE_NAME}` + ) + + expect(new URL(result).pathname).toBe(`/${TEST_DATABASE_NAME}`) + }) + + it("拒绝开发数据库并且不回退", () => { + expect(() => + assertSafeTestDatabaseUrl( + "postgres://postgres:postgres@localhost:5432/thread-chat" + ) + ).toThrow(/拒绝操作数据库/) + expect(() => assertSafeTestDatabaseUrl(undefined)).toThrow( + /不会回退到 DATABASE_URL/ + ) + }) +}) diff --git a/tests/unit/thread-chat-factories.test.ts b/tests/unit/thread-chat-factories.test.ts new file mode 100644 index 00000000..5aa0db1c --- /dev/null +++ b/tests/unit/thread-chat-factories.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest" +import { + createArtifactFixture, + createMessageFixture, + createMessageRunFixture, + createProjectFixture, + createThreadFixture, + createUserFixture, +} from "../factories/thread-chat-factories" + +describe("ThreadChat fixture factories", () => { + it("由 factory 生成服务端侧测试 ID 并允许显式关联", () => { + const user = createUserFixture() + const project = createProjectFixture({ ownerUserId: user.id }) + const root = createThreadFixture({ projectId: project.id }) + const message = createMessageFixture({ + threadId: root.id, + role: "assistant", + }) + const artifact = createArtifactFixture({ + projectId: project.id, + sourceMessageId: message.id, + }) + const run = createMessageRunFixture({ assistantMessageId: message.id }) + + expect( + new Set([user.id, project.id, root.id, message.id, artifact.id]).size + ).toBe(5) + expect(project.ownerUserId).toBe(user.id) + expect(artifact.sourceMessageId).toBe(message.id) + expect(run.assistantMessageId).toBe(message.id) + }) + + it("Branch fixture 自动补齐 ForkFacts", () => { + const branch = createThreadFixture({ parentThreadId: "parent-thread" }) + + expect(branch.sourceMessageId).not.toBeNull() + expect(branch.forkSourceSnapshot).not.toBeNull() + expect(branch.baseContext).toEqual({ schemaVersion: 1, messageIds: [] }) + }) +}) diff --git a/vitest.api.config.ts b/vitest.api.config.ts new file mode 100644 index 00000000..414d3364 --- /dev/null +++ b/vitest.api.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + test: { + environment: "node", + include: ["tests/api/**/*.test.ts"], + setupFiles: ["./tests/setup/require-test-database.ts"], + fileParallelism: false, + maxWorkers: 1, + passWithNoTests: true, + clearMocks: true, + restoreMocks: true, + }, +}) diff --git a/vitest.client.config.ts b/vitest.client.config.ts new file mode 100644 index 00000000..9442b0e8 --- /dev/null +++ b/vitest.client.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + test: { + environment: "jsdom", + include: ["tests/client/**/*.test.{ts,tsx}"], + clearMocks: true, + restoreMocks: true, + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..e52170d9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + test: { + environment: "node", + include: ["tests/unit/**/*.test.ts"], + clearMocks: true, + restoreMocks: true, + }, +}) diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 00000000..549daae0 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + test: { + environment: "node", + include: ["tests/integration/**/*.test.ts"], + setupFiles: ["./tests/setup/require-test-database.ts"], + fileParallelism: false, + maxWorkers: 1, + passWithNoTests: true, + clearMocks: true, + restoreMocks: true, + }, +})