diff --git a/.agents/skills/ai-sdk/SKILL.md b/.agents/skills/ai-sdk/SKILL.md new file mode 100644 index 00000000..038ecd17 --- /dev/null +++ b/.agents/skills/ai-sdk/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ai-sdk +description: 'Answer questions about the AI SDK and help build AI-powered features. Use when developers: (1) Ask about AI SDK functions like generateText, streamText, ToolLoopAgent, embed, or tools, (2) Want to build AI agents, chatbots, RAG systems, or text generation features, (3) Have questions about AI providers (OpenAI, Anthropic, Google, etc.), streaming, tool calling, structured output, or embeddings, (4) Use React hooks like useChat or useCompletion. Triggers on: "AI SDK", "Vercel AI SDK", "generateText", "streamText", "add AI to my app", "build an agent", "tool calling", "structured output", "useChat".' +--- + +## What the AI SDK Is + +The AI SDK by Vercel (the `ai` package on npm) is a TypeScript toolkit for building AI applications. It provides a unified API across model providers for text generation, structured output, tool calling, agents, embeddings, and framework UI integrations. + +- Repository: https://github.com/vercel/ai +- Documentation: https://ai-sdk.dev/docs + +## Critical: Do Not Trust Your Own Memory + +Whatever you remember about the AI SDK is likely outdated. The SDK changes frequently across versions - APIs are renamed, removed, and added. Your training data almost certainly contains obsolete APIs, deprecated patterns, and model IDs that no longer exist. UI hooks like `useChat` are among the most frequently changed APIs, so be especially careful with client code. + +**Never write AI SDK code from memory.** Always verify every API, option, and pattern against the documentation and source code for the version that is actually installed in the project. + +## Use the Bundled, Version-Matched Docs + +The `ai` package ships its full documentation and source code inside `node_modules`. These always match the installed version, so trust them over anything you remember. + +1. Ensure `ai` is installed. If `node_modules/ai/` does not exist, install **only** the `ai` package using the project's package manager (e.g. `pnpm add ai`). Install provider packages (e.g. `@ai-sdk/openai`) and framework packages (e.g. `@ai-sdk/react`) later, when the task requires them. +2. Read and grep the bundled docs at `node_modules/ai/docs/` and the source at `node_modules/ai/src/`. +3. Provider and framework packages bundle their own docs at `node_modules/@ai-sdk//docs/`. +4. If something isn't in the bundled docs, search https://ai-sdk.dev/docs. You can append `.md` to any docs page URL to get its markdown, and search via `https://ai-sdk.dev/api/search-docs?q=your_query`. +5. If you cannot find support for an answer in the docs or source, say so explicitly — do not guess. + +## AI Gateway: The Fastest Way to Start + +The Vercel AI Gateway is the fastest way to get started with the AI SDK. It provides access to models from OpenAI, Anthropic, Google, and other providers through a single API, without installing provider packages or managing multiple API keys. + +To set it up: + +1. Authenticate with OIDC (for Vercel deployments) or get an AI Gateway API key. +2. Provide it to your app via the `AI_GATEWAY_API_KEY` environment variable. +3. Reference models with `provider/model` strings. + +For exact setup, authentication, and usage, read the bundled guide and the AI Gateway docs. + +### Choosing a Model + +Never use model IDs from memory — models are released and retired frequently. Fetch the current list before writing code that references a model. Do not truncate the list (e.g. with `head`) so you can find the newest models: + +```bash +# All available models +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '.data[].id' + +# Filter by provider (e.g. anthropic, openai, google) +curl -s https://ai-gateway.vercel.sh/v1/models | jq -r '[.data[] | select(.id | startswith("anthropic/")) | .id] | reverse | .[]' +``` + +When multiple versions of a model exist, prefer the one with the highest version number. + +## Building and Consuming Agents + +Use the SDK's built-in agent abstraction (such as `ToolLoopAgent`) rather than hand-rolling tool-calling loops. For end-to-end type safety, infer the UI message type from your agent definition when consuming it on the client (e.g. with `useChat`). Consuming an agent is framework-specific: check `package.json` to detect the stack, then follow the matching quickstart. + +Look up the current agent, tool, and type-safety APIs in the bundled docs (`node_modules/ai/docs/`, especially the agents section) or at https://ai-sdk.dev/docs. + +## DevTools + +AI SDK DevTools captures your AI SDK calls - requests, responses, tool calls, token usage, and multi-step runs - so you can inspect exactly what your agents do. Use it while developing to debug generations. It is a separate package and is intended for local development only. + +For setup instructions, read the bundled DevTools documentation. + +## Keep the SDK Current + +Outdated installs are the most common source of errors. Compare the installed version against the latest: + +- **Installed:** the `version` field in `node_modules/ai/package.json`. +- **Latest:** run `npm view ai version`. + +If the installed version is a major version (or more) behind the latest, tell the user they are on an old release, and recommend upgrading before continuing. Migration guides are at https://ai-sdk.dev/docs/migration-guides. + +## After Making Changes + +Run the project's type checker. Be minimal — only set options that differ from the defaults, checking docs or source for the defaults rather than over-specifying. Most type errors come from remembered, now-changed APIs; re-check the current docs and source when they occur. diff --git a/.agents/skills/debug-root/SKILL.md b/.agents/skills/debug-root/SKILL.md new file mode 100644 index 00000000..9b256894 --- /dev/null +++ b/.agents/skills/debug-root/SKILL.md @@ -0,0 +1,121 @@ +--- +name: debug-root +description: Use for complex, high-risk, or ambiguous debugging and code-fix tasks, especially when the user requires diagnosis before changes, explicit solution confirmation, preservation of an existing design, or comparison with reference implementations. 适用于复杂、高风险或边界不清的调试与修复任务。 +--- + +# 四阶段调试 + +按顺序执行:确认问题 → 诊断问题 → 确认方案 → 具体实现。除非用户已明确提供某阶段所需决定,不得跳过该阶段。 + +## 1. 确认问题 + +用简短文字复述目标、范围和已有约束。 + +- 若缺少的事实或选择会实质改变实现,先提出一个具体的澄清问题。 +- 若任务已足够明确,陈述工作理解后直接进入只读诊断。 +- 可开始诊断不代表可开始修改。 + +## 2. 诊断问题 + +只使用读取、搜索、复现和无写入验证。不编辑文件、不格式化、不安装依赖、不生成文件、不提交代码。 + +### 2a. 定位直接原因 + +在实际实现中寻找证据,定位触发当前现象的直接代码路径。将根因表达为通用不变量,而不是为某个样例、标签或组件打补丁。 + +### 2b. 横向扩散评估 + +以直接原因中涉及的 pattern / 抽象 / 接口为锚点: + +- 同一 pattern 在项目中还有哪些使用点? +- 这些使用点是否共享同一缺陷条件? +- 如果存在共性缺陷,记录受影响范围及具体位置。 + +若无扩散证据,明确写"横向扫描未发现扩散",不要强行填充。 + +### 2c. 纵向归因 + +追问:为什么这段代码会被写成这样? + +- 接口设计是否缺乏约束(类型、校验、契约)? +- 是否缺少防御机制(lint 规则、运行时断言、测试覆盖)? +- 是否属于已知的架构短板或历史 workaround? + +仅在证据充分时归因,不要强行填充。 + +### 参考实现 + +将参考实现视为诊断证据,不视为迁移或改造授权。 + +## 3. 确认方案 + +### 直接原因 + +一句话:什么代码在什么条件下产生了当前现象。 + +### 修复层级判断 + +| 层级 | 描述 | 本次是否适用 | 依据 | +|------|------|-------------|------| +| 实例修复 | 只修当前触发点 | — | — | +| 抽象修复 | 修底层 pattern / 共享模块 | — | — | +| 防御机制 | 加类型约束 / lint / 测试防止复发 | — | — | + +### 修复决策 + +基于诊断结果,从以下三条路径中选择并论证: + +**路径 A:不修复** + +- 适用条件:预期行为被误报 / 修复成本远超问题危害 / 根因不在本系统 / 模块即将下线。 +- 输出:说明为什么当前行为是合理的,或为什么修复的代价不成立。必须引用具体证据(代码注释、设计文档、产品 spec、历史 issue、数据频率),不允许仅凭推测得出"不需要修"的结论。 +- 如果根因在外部系统,说明应由谁处理。 + +**路径 B:最小修复(治标)** + +- 适用条件:问题真实存在、无扩散、无架构短板、修复成本低。 +- 输出:具体改动 + 每项改动对应的不变量 + 要维持不变的现有机制和边界。 +- 如果治标意味着不根治,说明残留风险。 + +**路径 C:系统性修复(根治)** + +- 适用条件:存在扩散、存在架构短板、问题会反复出现。 +- 输出:改动范围 + 受影响模块列表 + 每项改动对应的不变量。 +- 明确标注哪些超出当前任务边界,建议后续处理。 + +**每条路径必须附带:** + +- **成本**:改动量、风险、引入的复杂度。 +- **收益**:解决的问题范围、防止的未来故障。 +- **残留风险**:选这条路之后还剩什么没解决。 + +### 影响范围和验证方法 + +说明改动波及的模块、路径,以及验证通过的标准(测试命令、手动复现步骤等)。 + +### 决策原则 + +选择路径时以代码证据和产品逻辑为准,不以用户的初始判断为准。若诊断结论与用户预期矛盾,直接陈述矛盾及证据,不要回避或迎合。 + +--- + +除非用户已经明确确认这一准确方案,否则以 **"等待确认方案。"** 结束。不要实现。 + +## 4. 具体实现 + +只实现已确认的范围并运行约定的验证。 + +- 不得趁机换架构、扩大范围或重构无关代码。 +- 实现中若发现新的关键选择,停止修改并回到"确认方案"。 +- 仅在用户明确要求时提交、推送或部署。 + +## 用户质疑 + +将"为什么""不满意""重新考虑"等消息视为诊断阶段输入。 + +- 只回答问题或补充诊断。 +- 不得修改代码、不得换方案、不得把质疑视为授权。 + +## 快速模式 + +若用户显式表达"快速修""直接改""不用分析"等意图,跳过 2b 和 2c,仅执行 2a → 输出最小修复方案 → 确认后实现。 \ No newline at end of file diff --git a/.agents/skills/migrate-ai-sdk-v6-to-v7/SKILL.md b/.agents/skills/migrate-ai-sdk-v6-to-v7/SKILL.md new file mode 100644 index 00000000..0c10efe9 --- /dev/null +++ b/.agents/skills/migrate-ai-sdk-v6-to-v7/SKILL.md @@ -0,0 +1,108 @@ +--- +name: migrate-ai-sdk-v6-to-v7 +description: Migrate applications from AI SDK 6.x to AI SDK 7.0. Use when upgrading Vercel AI SDK packages, fixing v7 migration errors, or when the user mentions AI SDK v6, v7, upgrade, migration, breaking changes, system to instructions, fullStream, telemetry, tool context, or finalStep. +--- + +## AI SDK 6 to 7 Migration + +Use `content/docs/08-migration-guides/23-migration-guide-7-0.mdx` from the AI SDK repo as the source of truth. This skill is the working checklist; read the guide for exact examples or when behavior is unclear. + +## Migration Workflow + +1. Ensure the user has a clean backup or committed baseline before editing. +2. Inspect `package.json` and lockfiles to identify installed `ai`, `@ai-sdk/*`, provider, UI, MCP, and telemetry packages. +3. Upgrade AI SDK packages to latest versions, and add `@ai-sdk/otel` only if the project uses OpenTelemetry spans. +4. Update runtime and module assumptions: Node.js must be `>=22`, and AI SDK packages are ESM-only. Replace `require()` imports with ESM imports and add `"type": "module"` or use `.mjs` where needed. +5. Search for the v6 patterns below, migrate only the code that exists, then run typecheck and targeted tests. + +Prefer behavior-preserving changes. When v7 changes semantics, decide whether the app wants the new all-steps behavior or the previous final-step-only behavior. + +## Core API Changes + +- `experimental_customProvider` -> `customProvider`. +- `experimental_generateImage` -> `generateImage`; `Experimental_GenerateImageResult` -> `GenerateImageResult`. +- `experimental_transcribe` -> `transcribe`; `Experimental_TranscriptionResult` -> `TranscriptionResult`. +- `experimental_generateSpeech` -> `generateSpeech`; `Experimental_SpeechResult` -> `SpeechResult`. +- `experimental_output` option/result -> `output` option/result. +- `CallSettings` -> `LanguageModelCallOptions & Omit`; `prepareCallSettings` -> `prepareLanguageModelCallOptions`. +- `stepCountIs` -> `isStepCount`. + +## Prompts and Steps + +- Rename top-level `system` to `instructions` for `generateText`, `streamText`, `generateObject`, `streamObject`, and `streamUI`. +- Move `{ role: 'system' }` messages from `prompt` or `messages` into top-level `instructions`. Only use `allowSystemInMessages: true` for trusted persisted messages. +- Rename `experimental_prepareStep` to `prepareStep`. +- In `prepareStep`, rename returned `system` to `instructions`. +- In `experimental_repairToolCall`, use `{ instructions }` instead of `{ system }`. +- Audit `prepareStep` behavior: returned `instructions` and `messages` now carry forward into later steps. If code depended on one-step-only overrides, rebuild from `initialInstructions`, `initialMessages`, and `responseMessages` explicitly. + +## Lifecycle Callbacks + +- `experimental_onStart` -> `onStart`. +- `experimental_onStepStart` -> `onStepStart`. +- `onFinish` -> `onEnd`. +- `onStepFinish` -> `onStepEnd`. +- For `embed`, `embedMany`, and `rerank`, `experimental_onFinish` -> `onEnd`. +- Callback event fields use `instructions` instead of `system`. + +## Usage, Telemetry, and Include Options + +- `usage.cachedInputTokens` -> `usage.inputTokenDetails.cacheReadTokens`. +- `usage.reasoningTokens` -> `usage.outputTokenDetails.reasoningTokens`. +- OpenTelemetry moved out of `ai`; install `@ai-sdk/otel` and call `registerTelemetry(new OpenTelemetry(...))` at app startup. +- Telemetry is enabled by default once an integration is registered. Remove redundant `isEnabled: true`; use `isEnabled: false` to opt out per call. +- Move `experimental_telemetry.tracer` into the `OpenTelemetry` constructor. +- `experimental_telemetry` -> `telemetry`. +- Telemetry integration callbacks: `onRerankFinish` -> `onRerankEnd`, `onEmbedFinish` -> `onEmbedEnd`. Update tracing-channel subscribers for the same event type names. +- `experimental_include` -> `include`. +- `includeRawChunks` -> `include.rawChunks`. +- Request and response bodies are excluded by default. If code reads `request.body` or `response.body`, opt in with `include.requestBody` and, for `generateText`, `include.responseBody`. + +## Streaming, Messages, and Tools + +- `StreamTextResult.fullStream` -> `stream`. +- `streamText` `onChunk` now receives all stream parts, including lifecycle, boundary, finish, abort, and error parts. Guard by `chunk.type` before assuming text/tool/raw content. +- `step.response.messages` is no longer accumulated across previous steps. Use `result.responseMessages` for the full response message history, or flatten `result.steps`. +- Tool execution callbacks: `experimental_onToolCallStart` -> `onToolExecutionStart`, `experimental_onToolCallFinish` -> `onToolExecutionEnd`. +- Tool callback `experimental_context` -> `context`. +- Split shared runtime data from tool-specific data: use top-level `runtimeContext` for orchestration state, declare per-tool `contextSchema`, and pass per-tool values through `toolsContext`. +- Move `needsApproval` from `tool()` / `dynamicTool()` into per-call or agent `toolApproval`. +- `experimental_activeTools` -> `activeTools`. +- `ToolCallOptions` -> `ToolExecutionOptions`. +- `isToolOrDynamicToolUIPart` -> `isToolUIPart`. + +## Content Parts and Reasoning + +- Tool result `{ type: 'media' }` is removed; use `{ type: 'file-data' }`. +- Migrate `toModelOutput` `image-*`, `file-*`, `file-id`, and `image-file-id` variants to canonical `{ type: 'file', mediaType, data: { type: 'data' | 'url' | 'reference', ... } }`. +- User message `{ type: 'image', image, mediaType? }` is deprecated; use `{ type: 'file', mediaType: 'image' | 'image/*', data }`. +- Add support for the new `reasoning-file` content type in exhaustive switches, renderers, serializers, and validators. +- When adopting top-level `reasoning`, remove overlapping provider-specific reasoning settings from `providerOptions` unless provider-specific settings intentionally take precedence. + +## Multi-Step Result Shape + +- `result.usage` now includes all steps; `result.totalUsage` is deprecated. Use `result.finalStep.usage` for final-step-only usage. +- Top-level `content`, `toolCalls`, `staticToolCalls`, `dynamicToolCalls`, `toolResults`, `staticToolResults`, `dynamicToolResults`, `files`, `sources`, and `warnings` now include all steps. Use `finalStep` for previous final-step-only behavior. +- Top-level `reasoning`, `reasoningText`, `request`, `response`, and `providerMetadata` are deprecated for final-step data. Use `result.finalStep.*`; for `streamText`, await `result.finalStep`. +- Apply the same result-shape rules to `onEnd` events. + +## Stream Response Helpers + +The `streamText` result helper methods are deprecated. Replace result methods with top-level stateless helpers: + +- `result.toUIMessageStream(...)` -> `toUIMessageStream({ stream: result.stream, ... })`. +- `result.toUIMessageStreamResponse(...)` -> `toUIMessageStream(...)` plus `createUIMessageStreamResponse({ stream })`. +- `result.pipeUIMessageStreamToResponse(response, ...)` -> `toUIMessageStream(...)` plus `pipeUIMessageStreamToResponse({ response, stream })`. +- `result.toTextStreamResponse()` -> `toTextStream({ stream: result.stream })` plus `createTextStreamResponse({ stream })`. +- `result.pipeTextStreamToResponse(response)` -> `toTextStream({ stream: result.stream })` plus `pipeTextStreamToResponse({ response, stream })`. + +## Package-Specific Checks + +- MCP: `MCPTransportConfig.redirect` now defaults to `'error'`. Only set `redirect: 'follow'` for trusted MCP servers that rely on redirects. +- Vue: `@ai-sdk/vue` `Chat` class is deprecated. Prefer `useChat`, including getter/ref init for reactive chat inputs. +- Anthropic and `@ai-sdk/google-vertex/anthropic`: `providerMetadata.anthropic.cacheCreationInputTokens` was removed. Use `usage.inputTokenDetails.cacheWriteTokens`; raw Anthropic usage remains at `finalStep.providerMetadata?.anthropic?.usage`. +- Google: rename `GoogleGenerativeAI*` types, classes, and functions to `Google*`, e.g. `createGoogleGenerativeAI` -> `createGoogle`. The `google` entry point is unchanged. + +## Validation + +Run the project typecheck after edits, then the smallest relevant test suite. Also smoke-test streaming, chat UI, tool execution, telemetry, and multi-step flows if the migration touched them. If type errors remain, search the migration guide for the exact removed or renamed symbol before inventing a workaround. diff --git a/.env.example b/.env.example index bfdfee17..866919cd 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,34 @@ MINIMAX_API_KEY= MINIMAX_BASE_URL=https://api.minimaxi.com/v1 LLM_MODEL_ID=MiniMax-M2 +# === Agent 可观测性(AI SDK DevTools + Langfuse) === +# 总开关;关闭后不注册远程遥测,现有服务端摘要日志仍保留。 +AI_TELEMETRY_ENABLED=true +# 只允许本地 development 使用;production 会强制禁用。 +AI_DEVTOOLS_ENABLED=true +# 本地 DevTools 默认可查看开发输入输出;production 即使误设为 true,也只有受控 cohort 可开启。 +AI_TELEMETRY_RECORD_CONTENT=true +# 本地默认不发到 Langfuse;本地联调设 true,staging/production 设 true 或不配置。 +AI_LANGFUSE_ENABLED=false +AI_OBSERVABILITY_ENVIRONMENT=development +AI_OBSERVABILITY_RELEASE=local +# 用于把内部 user id HMAC 成稳定匿名 id;生产必须使用高熵 secret。 +AI_OBSERVABILITY_ID_SALT= +# Langfuse Cloud/OSS 都使用以下 server-only 变量;不配置 key 时安全降级为本地日志。 +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= +# 按所选 region 填写 Cloud base URL,或以后填写自托管 endpoint。 +LANGFUSE_BASE_URL= +# 评测只允许使用隔离数据库;database 名必须包含 eval/test,且需显式允许写入。 +EVAL_DATABASE_URL= +EVAL_ALLOW_DATABASE_WRITES=false +# 与 Evaluation PostgreSQL 库的 thread_chat.evaluation_guard setting 完全一致,至少 24 字符。 +EVAL_DATABASE_GUARD_TOKEN= +EVAL_ALLOW_PRIVATE_REMOTE=false +EVAL_MODEL_ID= +EVAL_CANDIDATE= +EVAL_RUN_ID= + # === OpenRouter(固定路由的 Thread Chat 模型) === OPENROUTER_API_KEY= # 可选:OpenRouter 排行榜/控制台中的应用归因;留空时不会发送对应 header。 @@ -16,6 +44,11 @@ UMAPIS_BASE_URL=https://www.umapis.com/v1 UMAPIS_API_KEY_CLAUDE= UMAPIS_API_KEY_GPT= +# === 私有模型中继(独立固定路由;服务端专用) === +# 可填写服务根地址或 /v1 API 根地址;应用会统一规范为 /v1。 +PRIVATE_RELAY_BASE_URL=https://model-relay.internal/v1 +PRIVATE_RELAY_API_KEY= + # === 火山方舟 Coding Plan(OpenAI-compatible) === # 必须使用 Coding Plan 专用的 /api/coding/v3;普通 /api/v3 会产生套餐外费用。 ARK_CODING_API_KEY= diff --git a/.github/workflows/agent-evals-scheduled.yml b/.github/workflows/agent-evals-scheduled.yml new file mode 100644 index 00000000..eb9802a3 --- /dev/null +++ b/.github/workflows/agent-evals-scheduled.yml @@ -0,0 +1,96 @@ +name: Agent Evals Scheduled + +on: + schedule: + - cron: "17 3 * * 3" + workflow_dispatch: + inputs: + run_live: + description: Run live model/Search/Langfuse suites + required: false + default: false + type: boolean + judge_model: + description: Optional registered model ID for the five-dimension judge + required: false + default: "" + type: string + +permissions: + contents: read + +env: + AI_OBSERVABILITY_ENVIRONMENT: evaluation + AI_DEVTOOLS_ENABLED: "false" + AI_TELEMETRY_RECORD_CONTENT: "false" + EVAL_CANDIDATE: scheduled-${{ github.run_id }} + AI_OBSERVABILITY_RELEASE: ${{ github.sha }} + GIT_COMMIT_SHA: ${{ github.sha }} + EVAL_RUN_ID: github-${{ github.run_id }}-${{ github.run_attempt }} + +jobs: + broad-fixture: + runs-on: ubuntu-latest + env: + AI_TELEMETRY_ENABLED: "false" + AI_LANGFUSE_ENABLED: "false" + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Search, memory, multimodal, reliability and scorer report + run: >- + pnpm eval:agent:scheduled -- + --write-snapshot=evals/agent/results/local/scheduled.json + - name: Compare fixture baseline + run: >- + pnpm eval:agent:compare -- + --baseline=evals/agent/baselines/fixture-scheduled-v1.json + --candidate=evals/agent/results/local/scheduled.json + --output=evals/agent/results/local/comparison.md + - uses: actions/upload-artifact@v4 + if: always() + with: + name: scheduled-agent-eval-${{ github.run_id }} + path: evals/agent/results/local/ + + live-content: + if: github.event_name == 'workflow_dispatch' && inputs.run_live + needs: broad-fixture + runs-on: ubuntu-latest + env: + AI_TELEMETRY_ENABLED: "true" + AI_LANGFUSE_ENABLED: "true" + LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }} + LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }} + LANGFUSE_BASE_URL: ${{ vars.LANGFUSE_BASE_URL }} + AI_OBSERVABILITY_ID_SALT: ${{ secrets.AI_OBSERVABILITY_ID_SALT }} + EVAL_MODEL_ID: ${{ vars.EVAL_MODEL_ID }} + UMAPIS_API_KEY_CLAUDE: ${{ secrets.UMAPIS_API_KEY_CLAUDE }} + UMAPIS_API_KEY_GPT: ${{ secrets.UMAPIS_API_KEY_GPT }} + ANYSEARCH_API_KEY: ${{ secrets.ANYSEARCH_API_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Live content experiment + run: >- + pnpm eval:agent:release -- --executor=declared + --suite=core-answer,search-routing,memory-context,multimodal + --judge-model=${{ inputs.judge_model }} + --langfuse-experiment + --experiment=thread-chat-scheduled-live + --write-snapshot=evals/agent/results/local/live.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: live-agent-eval-${{ github.run_id }} + path: evals/agent/results/local/ diff --git a/.github/workflows/agent-evals.yml b/.github/workflows/agent-evals.yml new file mode 100644 index 00000000..c02b3257 --- /dev/null +++ b/.github/workflows/agent-evals.yml @@ -0,0 +1,86 @@ +name: Agent Evals + +on: + pull_request: + paths: + - "constants/**" + - "evals/agent/**" + - "lib/ai/**" + - "lib/chat/**" + - "lib/observability/**" + - "lib/thread-chat/**" + - "package.json" + - "pnpm-lock.yaml" + +permissions: + contents: read + +env: + AI_OBSERVABILITY_ENVIRONMENT: evaluation + AI_TELEMETRY_ENABLED: "false" + AI_LANGFUSE_ENABLED: "false" + AI_DEVTOOLS_ENABLED: "false" + AI_TELEMETRY_RECORD_CONTENT: "false" + EVAL_CANDIDATE: pr-${{ github.event.pull_request.number }} + AI_OBSERVABILITY_RELEASE: ${{ github.sha }} + GIT_COMMIT_SHA: ${{ github.sha }} + EVAL_RUN_ID: github-${{ github.run_id }}-${{ github.run_attempt }} + +jobs: + deterministic: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Evaluation contracts + run: | + pnpm test:observability:eval-foundation + pnpm test:observability:eval-scorers + - name: Stable CI subset + run: pnpm eval:agent:ci -- --write-snapshot=evals/agent/results/local/ci.json + - name: Baseline comparison and deterministic gate + run: >- + pnpm eval:agent:compare -- + --baseline=evals/agent/baselines/fixture-ci-v1.json + --candidate=evals/agent/results/local/ci.json + --output=evals/agent/results/local/comparison.md + - uses: actions/upload-artifact@v4 + if: always() + with: + name: agent-eval-${{ github.sha }} + path: evals/agent/results/local/ + + langfuse-pr-experiment: + if: vars.LANGFUSE_PR_EVAL_ENABLED == 'true' + needs: deterministic + runs-on: ubuntu-latest + env: + AI_TELEMETRY_ENABLED: "true" + AI_LANGFUSE_ENABLED: "true" + LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }} + LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }} + LANGFUSE_BASE_URL: ${{ vars.LANGFUSE_BASE_URL }} + AI_OBSERVABILITY_ID_SALT: ${{ secrets.AI_OBSERVABILITY_ID_SALT }} + EVAL_MODEL_ID: ${{ vars.EVAL_MODEL_ID }} + UMAPIS_API_KEY_CLAUDE: ${{ secrets.UMAPIS_API_KEY_CLAUDE }} + UMAPIS_API_KEY_GPT: ${{ secrets.UMAPIS_API_KEY_GPT }} + ANYSEARCH_API_KEY: ${{ secrets.ANYSEARCH_API_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Official Langfuse client experiment on stable content cases + run: >- + pnpm eval:agent:ci -- --executor=declared + --suite=core-answer,search-routing --tag=smoke + --langfuse-experiment + --experiment=thread-chat-pr-stable diff --git a/.github/workflows/openspec.yml b/.github/workflows/openspec.yml index 59102f4c..b8a6c05f 100644 --- a/.github/workflows/openspec.yml +++ b/.github/workflows/openspec.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/project-workspace-ci.yml b/.github/workflows/project-workspace-ci.yml new file mode 100644 index 00000000..ecc8ca2f --- /dev/null +++ b/.github/workflows/project-workspace-ci.yml @@ -0,0 +1,135 @@ +name: Project Workspace CI + +on: + push: + branches: + - codex/research-project-workspace-design + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres + DIRECT_URL: postgres://postgres:postgres@localhost:5432/postgres + BETTER_AUTH_SECRET: project-workspace-ci-secret-at-least-32-characters + MINIMAX_API_KEY: project-workspace-build-placeholder + MINIMAX_BASE_URL: https://example.invalid/v1 + LLM_MODEL_ID: project-workspace-build-model + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Targeted ESLint + run: >- + pnpm exec eslint + app/thread-chat/chat/chat-view.tsx + app/thread-chat/core/projections.ts + app/thread-chat/core/store.ts + app/thread-chat/core/types.ts + app/thread-chat/gate-3-harness/mock-v1-runtime.ts + app/thread-chat/net/client.ts + app/thread-chat/net/commands/conversation-commands.ts + app/thread-chat/net/project-file-upload.ts + app/thread-chat/orchestration/artifacts/artifact-drawer.tsx + app/thread-chat/orchestration/artifacts/project-panel.tsx + app/thread-chat/orchestration/artifacts/store-bound-project-panel.tsx + app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx + app/thread-chat/thread-chat-demo.tsx + constants/project-workspace.ts + evals/agent/executors/production-harness.ts + evals/agent/schema.ts + lib/chat/attachment-content-resolver.ts + lib/chat/attachment-context-policy.ts + lib/chat/project-contract.ts + lib/chat/resolve-attachments.ts + lib/db/schema.ts + lib/thread-chat/application/compile-model-context.ts + lib/thread-chat/application/project-mutations.ts + lib/thread-chat/application/queries.ts + lib/thread-chat/contracts/commands.ts + lib/thread-chat/contracts/dto.ts + lib/thread-chat/persistence/artifact-repository.ts + lib/thread-chat/persistence/mappers.ts + lib/thread-chat/persistence/project-file-repository.ts + lib/thread-chat/server/handlers.ts + lib/thread-chat/streaming/finalize.ts + lib/thread-chat/streaming/generation-plan.ts + lib/thread-chat/streaming/run-generation.ts + + - name: Verify generated migration is in sync + run: pnpm db:generate && git diff --exit-code -- drizzle + + - name: Legacy data migration compatibility + run: node --import tsx e2e/thread-chat/project-workspace-migration-compatibility.test.mjs + + - name: Pure project context policies + run: node --import tsx e2e/thread-chat/project-workspace-context.test.mjs + + - name: Project panel UI contract + run: node e2e/thread-chat/project-panel-ui-contract.test.mjs + + - name: Project panel workspace isolation + run: node --import tsx e2e/thread-chat/project-panel-workspace-state.test.mjs + + - name: Project workspace eval harness + run: node --import tsx e2e/observability/project-workspace-eval-harness.test.mjs + + - name: Prepare normalized test database + run: pnpm db:test:reset && pnpm db:test:migrate + + - name: Project workspace schema and repository acceptance + run: node --import tsx e2e/thread-chat/project-workspace-db.test.mjs + + - name: Project workspace API integration + run: node --import tsx e2e/thread-chat/project-workspace-api-db.test.mjs + + - name: Project contract generation boundary + run: node --import tsx e2e/thread-chat/project-contract-generation-boundary.test.mjs + + - name: Project workspace history stability + run: node --import tsx e2e/thread-chat/project-workspace-history-stability.test.mjs + + - name: Project artifact context isolation + run: node --import tsx e2e/thread-chat/project-artifact-context-isolation.test.mjs + + - name: Agent eval smoke + run: pnpm eval:agent + + - name: Agent eval CI + run: pnpm eval:agent:ci + + - name: Production build + run: pnpm build + + - name: Validate OpenSpec + run: pnpm openspec:validate diff --git a/.gitignore b/.gitignore index 42b6aca8..21001d6e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ # testing /coverage +/.devtools/ +/evals/agent/.tmp/ +/evals/agent/results/local/ # next.js /.next/ @@ -40,3 +43,5 @@ next-env.d.ts # thread-chat e2e 验收脚本的截图输出 e2e/thread-chat/shots/ .vercel + +.local-backups diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/CLAUDE.md b/CLAUDE.md index 1a7af2f5..f822a773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 语言 -所有输出内容必须使用中文(代码、文件路径、命令等技术内容除外)。 +所有输出内容必须使用中文(代码、文件路径、命令等技术内容除外)。输出时减少中英文夹杂,先中文说明白。禁止说“投影、缺省”,理解不了它的意思。 ## Commands diff --git a/README.md b/README.md index 93acac15..0b7673aa 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Current directions include strengthening automated coverage, improving deploymen ### Prerequisites -- Node.js `>=20.9.0` and [pnpm](https://pnpm.io/) (this repository declares `pnpm@10.32.1`) +- Node.js `>=22`(开发、CI 与 VPS 推荐固定 Node.js 24)and [pnpm](https://pnpm.io/) (this repository declares `pnpm@10.32.1`) - A PostgreSQL database - Credentials for at least one supported model provider; the default model uses MiniMax @@ -102,10 +102,21 @@ The following features are opt-in and are not required for the quick start: - Attachments and PDF processing: Cloudflare R2 variables (`R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`) - Large-document vector retrieval: `EMBEDDINGS_BASE_URL`, `EMBEDDINGS_API_KEY`, and `EMBEDDINGS_MODEL`, plus PostgreSQL `pgvector` - Additional model providers and gateways: provider keys, Cloudflare AI Gateway, or Vercel AI Gateway variables documented in `.env.example` +- Agent observability: local AI SDK DevTools and optional Langfuse Cloud tracing use the server-only variables documented in `.env.example` - Email verification, Turnstile, Google sign-in, billing, and Creem payments: their feature-specific variables in `.env.example` Do not commit `.env.local` or credentials. +### Agent observability and evaluation + +The observability stack is opt-in and keeps production prompt/output content off by default. For the complete local DevTools, Langfuse Cloud, evaluation, acceptance, and incident-to-regression workflow, see [Agent observability operations](./docs/observability/08-operations-and-acceptance.md). The shortest safe checks are: + +```bash +pnpm test:observability +pnpm test:agent-evals +pnpm observability:check-release +``` + ## OpenRouter models Thread Chat offers fourteen fixed OpenRouter-backed internal model IDs: `openrouter-gpt-5.6-luna`, `openrouter-gpt-5.6-luna-pro`, `openrouter-gpt-5.6-terra`, `openrouter-gpt-5.6-terra-pro`, `openrouter-gpt-5.6-sol`, `openrouter-gpt-5.6-sol-pro`, `openrouter-gpt-5.5`, `openrouter-gpt-5.5-pro`, `openrouter-kimi-k3`, `openrouter-deepseek-v4-flash-0731`, `openrouter-qwen3.8-max`, `openrouter-grok-4.5`, `openrouter-grok-4.6`, and `openrouter-ox-alpha`. Configure `OPENROUTER_API_KEY`; `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE` are optional attribution values. These IDs always use the dedicated OpenRouter provider—arbitrary external slugs are rejected. Ox Alpha uses upstream ID `stealth/ox-alpha` and is offered as a free, unbilled preview; completed requests for the other models use OpenRouter's real per-step USD cost when complete, with conservative static pricing as fallback. Attachments remain on the existing text-extraction path. diff --git a/app/api/attachments/[id]/ingest/route.ts b/app/api/attachments/[id]/ingest/route.ts index c084c635..c2c1f5d1 100644 --- a/app/api/attachments/[id]/ingest/route.ts +++ b/app/api/attachments/[id]/ingest/route.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm" +import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { attachments } from "@/lib/db/schema" import { @@ -14,16 +14,22 @@ import { } from "@/lib/attachments/pdf" import { indexAttachment } from "@/lib/attachments/index-chunks" import { ATTACHMENT_POLICIES } from "@/constants/attachment" +import { getCurrentUserId } from "@/lib/auth/server" type RouteContext = { params: Promise<{ id: string }> } -async function markFailed(id: string, key: string, error: string) { +async function markFailed( + userId: string, + id: string, + key: string, + error: string +) { // 校验/解析失败的对象一并从 R2 清掉,不留不可用的孤儿文件 await deleteObject(key).catch(() => {}) await db .update(attachments) .set({ status: "failed", error }) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) return Response.json({ status: "failed", error }, { status: 422 }) } @@ -33,6 +39,8 @@ async function markFailed(id: string, key: string, error: string) { * 提取在上传阶段一次完成,对话阶段零解析开销。 */ export async function POST(_req: Request, { params }: RouteContext) { + const userId = await getCurrentUserId() + if (!userId) return Response.json({ error: "未登录" }, { status: 401 }) if (!isR2Configured()) { return Response.json({ error: "未配置 R2 存储" }, { status: 503 }) } @@ -40,7 +48,7 @@ export async function POST(_req: Request, { params }: RouteContext) { const [row] = await db .select() .from(attachments) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) .limit(1) if (!row) return Response.json({ error: "附件不存在" }, { status: 404 }) if (row.status === "ready") { @@ -56,23 +64,29 @@ export async function POST(_req: Request, { params }: RouteContext) { return Response.json({ error: "文件尚未上传完成" }, { status: 409 }) } if (policy && actualSize > policy.maxBytes) { - return markFailed(id, row.key, "文件超过大小上限") + return markFailed(userId, id, row.key, "文件超过大小上限") } if (row.mimeType === "application/pdf") { const bytes = await getObjectBytes(row.key) if (!looksLikePdf(bytes)) { - return markFailed(id, row.key, "文件内容不是有效的 PDF") + return markFailed(userId, id, row.key, "文件内容不是有效的 PDF") } let extraction try { extraction = await extractPdfPages(bytes) } catch { - return markFailed(id, row.key, "PDF 解析失败(文件可能已损坏或加密)") + return markFailed( + userId, + id, + row.key, + "PDF 解析失败(文件可能已损坏或加密)" + ) } if (!hasTextLayer(extraction)) { // 显式失败优于静默空上下文:扫描件没有文本层,注入空内容只会诱发模型幻觉 return markFailed( + userId, id, row.key, "该 PDF 没有可提取的文本层(可能是扫描件),暂不支持" @@ -86,7 +100,7 @@ export async function POST(_req: Request, { params }: RouteContext) { pageCount: extraction.pageCount, pages: extraction.pages, }) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) // 建立向量索引(配置了 embeddings 时)。失败不影响附件可用性—— // 对话时若无索引会自动回退到全文注入。 @@ -103,6 +117,6 @@ export async function POST(_req: Request, { params }: RouteContext) { await db .update(attachments) .set({ status: "ready", size: actualSize }) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) return Response.json({ status: "ready" }) } diff --git a/app/api/attachments/[id]/insights/route.ts b/app/api/attachments/[id]/insights/route.ts index 00218ab6..b0c326e7 100644 --- a/app/api/attachments/[id]/insights/route.ts +++ b/app/api/attachments/[id]/insights/route.ts @@ -1,8 +1,9 @@ -import { eq } from "drizzle-orm" +import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { attachments } from "@/lib/db/schema" import { generateInsights } from "@/lib/attachments/insights" import { isMinimaxConfigured } from "@/lib/ai/minimax" +import { getCurrentUserId } from "@/lib/auth/server" type RouteContext = { params: Promise<{ id: string }> } @@ -11,11 +12,13 @@ type RouteContext = { params: Promise<{ id: string }> } * 首次调用时按需生成并缓存进 DB,后续直接返回缓存。 */ export async function POST(_req: Request, { params }: RouteContext) { + const userId = await getCurrentUserId() + if (!userId) return Response.json({ error: "未登录" }, { status: 401 }) const { id } = await params const [row] = await db .select() .from(attachments) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) .limit(1) if (!row) return Response.json({ error: "附件不存在" }, { status: 404 }) @@ -53,7 +56,7 @@ export async function POST(_req: Request, { params }: RouteContext) { summary: insights.summary, suggestedQuestions: insights.suggestedQuestions, }) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) return Response.json(insights) } diff --git a/app/api/attachments/[id]/route.ts b/app/api/attachments/[id]/route.ts index 7c112edd..49a0c8ef 100644 --- a/app/api/attachments/[id]/route.ts +++ b/app/api/attachments/[id]/route.ts @@ -1,7 +1,8 @@ -import { eq } from "drizzle-orm" +import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { attachments } from "@/lib/db/schema" import { deleteObject, isR2Configured, presignDownload } from "@/lib/storage/r2" +import { getCurrentUserId } from "@/lib/auth/server" type RouteContext = { params: Promise<{ id: string }> } @@ -10,6 +11,8 @@ type RouteContext = { params: Promise<{ id: string }> } * 消息 parts 里持久化的是本路由的相对路径,presigned URL 每次请求现签,天然不过期。 */ export async function GET(_req: Request, { params }: RouteContext) { + const userId = await getCurrentUserId() + if (!userId) return Response.json({ error: "未登录" }, { status: 401 }) if (!isR2Configured()) { return Response.json({ error: "未配置 R2 存储" }, { status: 503 }) } @@ -17,7 +20,7 @@ export async function GET(_req: Request, { params }: RouteContext) { const [row] = await db .select() .from(attachments) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) .limit(1) if (!row) return Response.json({ error: "附件不存在" }, { status: 404 }) @@ -26,11 +29,13 @@ export async function GET(_req: Request, { params }: RouteContext) { /** composer 里移除附件时清理 R2 对象与 DB 行 */ export async function DELETE(_req: Request, { params }: RouteContext) { + const userId = await getCurrentUserId() + if (!userId) return Response.json({ error: "未登录" }, { status: 401 }) const { id } = await params const [row] = await db .select() .from(attachments) - .where(eq(attachments.id, id)) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) .limit(1) if (!row) return Response.json({ ok: true }) @@ -39,6 +44,8 @@ export async function DELETE(_req: Request, { params }: RouteContext) { // R2 清理失败不阻塞:DB 行删除后对象成为孤儿,可由后台任务兜底回收 }) } - await db.delete(attachments).where(eq(attachments.id, id)) + await db + .delete(attachments) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) return Response.json({ ok: true }) } diff --git a/app/api/attachments/route.ts b/app/api/attachments/route.ts index 6dd7f9b2..40a34f64 100644 --- a/app/api/attachments/route.ts +++ b/app/api/attachments/route.ts @@ -3,6 +3,7 @@ import { db } from "@/lib/db" import { attachments } from "@/lib/db/schema" import { isR2Configured, presignUpload } from "@/lib/storage/r2" import { ATTACHMENT_POLICIES } from "@/constants/attachment" +import { getCurrentUserId } from "@/lib/auth/server" const createSchema = z.object({ filename: z.string().min(1).max(255), @@ -15,6 +16,9 @@ const createSchema = z.object({ * 文件字节不经过本服务器,由浏览器 PUT 到 R2。 */ export async function POST(req: Request) { + const userId = await getCurrentUserId() + if (!userId) return Response.json({ error: "未登录" }, { status: 401 }) + if (!isR2Configured()) { return Response.json( { @@ -52,6 +56,7 @@ export async function POST(req: Request) { await db.insert(attachments).values({ id, + userId, key, filename, mimeType: contentType, diff --git a/app/api/branch-generations/[generationId]/route.ts b/app/api/branch-generations/[generationId]/route.ts deleted file mode 100644 index a6a455f2..00000000 --- a/app/api/branch-generations/[generationId]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { failStaleGenerationForOwner } from "@/lib/thread-chat-generation/stale-generation-repository" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" - -type RouteContext = { params: Promise<{ generationId: string }> } - -export async function GET(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const { generationId } = await params - if (!isValidTreeId(generationId)) - return Response.json( - { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, - { status: 400 } - ) - - const generation = await failStaleGenerationForOwner(userId, generationId) - if (!generation) - return Response.json( - { error: { code: "not_found", message: "generation 不存在" } }, - { status: 404 } - ) - return Response.json({ generation: toGenerationSummary(generation) }) -} diff --git a/app/api/branch-generations/[generationId]/stop/route.ts b/app/api/branch-generations/[generationId]/stop/route.ts deleted file mode 100644 index 385b63d6..00000000 --- a/app/api/branch-generations/[generationId]/stop/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { requestGenerationStop } from "@/lib/thread-chat-generation/execution-state-repository" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" -import { abortGenerationLocally } from "@/lib/thread-chat-generation/execution" - -type RouteContext = { params: Promise<{ generationId: string }> } - -export async function POST(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const { generationId } = await params - if (!isValidTreeId(generationId)) - return Response.json( - { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, - { status: 400 } - ) - - const generation = await requestGenerationStop(userId, generationId) - if (!generation) - return Response.json( - { error: { code: "not_found", message: "generation 不存在" } }, - { status: 404 } - ) - if (generation.status === "stop_requested") { - abortGenerationLocally(generationId) - } - return Response.json({ generation: toGenerationSummary(generation) }) -} diff --git a/app/api/branch-trees/[treeId]/active-leaf/route.ts b/app/api/branch-trees/[treeId]/active-leaf/route.ts deleted file mode 100644 index c7af8f3e..00000000 --- a/app/api/branch-trees/[treeId]/active-leaf/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - SWITCH_ACTIVE_LEAF_ERROR_STATUS, - SWITCH_ACTIVE_LEAF_ROUTE_ERRORS, - switchActiveLeafErrorResponseSchema, - switchActiveLeafRequestSchema, - switchActiveLeafSuccessResponseSchema, - type SwitchActiveLeafErrorCode, -} from "@/lib/thread-chat/contracts/switch-active-leaf" -import { - switchActiveLeafForOwner, - TreeCommandError, -} from "@/lib/thread-chat-generation/tree-repository" - -type RouteContext = { params: Promise<{ treeId: string }> } - -function activeLeafErrorResponse( - code: SwitchActiveLeafErrorCode, - message: string, - currentRevision?: number -) { - return Response.json( - switchActiveLeafErrorResponseSchema.parse({ - error: { - code, - message, - ...(currentRevision !== undefined ? { currentRevision } : {}), - }, - }), - { status: SWITCH_ACTIVE_LEAF_ERROR_STATUS[code] } - ) -} - -export async function PATCH(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.unauthorized - return activeLeafErrorResponse(error.code, error.message) - } - - const { treeId } = await params - if (!isValidTreeId(treeId)) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.invalid_id - return activeLeafErrorResponse(error.code, error.message) - } - const body = switchActiveLeafRequestSchema.safeParse( - await req.json().catch(() => null) - ) - if (!body.success) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.invalid_request - return activeLeafErrorResponse(error.code, error.message) - } - - try { - return Response.json( - switchActiveLeafSuccessResponseSchema.parse( - await switchActiveLeafForOwner({ userId, treeId, ...body.data }) - ) - ) - } catch (error) { - if (!(error instanceof TreeCommandError)) throw error - return activeLeafErrorResponse( - error.code, - error.message, - error.currentRevision - ) - } -} diff --git a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts b/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts deleted file mode 100644 index e1fee337..00000000 --- a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - MESSAGE_FEEDBACK_HTTP_ERRORS, - setMessageFeedbackErrorResponseSchema, - setMessageFeedbackRequestSchema, - setMessageFeedbackSuccessResponseSchema, -} from "@/lib/thread-chat/contracts/message-feedback" -import { setMessageFeedbackForOwner } from "@/lib/thread-chat-generation/message-feedback-repository" - -type RouteContext = { - params: Promise<{ treeId: string; messageId: string }> -} - -function feedbackErrorResponse(key: keyof typeof MESSAGE_FEEDBACK_HTTP_ERRORS) { - const definition = MESSAGE_FEEDBACK_HTTP_ERRORS[key] - return Response.json( - setMessageFeedbackErrorResponseSchema.parse({ error: definition.error }), - { status: definition.status } - ) -} - -export async function PUT(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return feedbackErrorResponse("unauthorized") - - const { treeId, messageId } = await params - if (!isValidTreeId(treeId) || messageId.trim() === "") - return feedbackErrorResponse("invalid_id") - - const body = setMessageFeedbackRequestSchema.safeParse( - await req.json().catch(() => null) - ) - if (!body.success) return feedbackErrorResponse("invalid_feedback") - - const result = await setMessageFeedbackForOwner({ - userId, - treeId, - threadId: body.data.threadId, - messageId, - feedback: body.data.feedback, - }) - if (!result.ok) return feedbackErrorResponse(result.reason) - - return Response.json( - setMessageFeedbackSuccessResponseSchema.parse({ - feedback: result.feedback, - }) - ) -} diff --git a/app/api/branch-trees/[treeId]/route.ts b/app/api/branch-trees/[treeId]/route.ts deleted file mode 100644 index ec3fd4d5..00000000 --- a/app/api/branch-trees/[treeId]/route.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * /api/branch-trees/[treeId] —— 分支对话树(app/thread-chat)的整树读写。 - * - * 一棵树一行(branch_trees.state = 完整 ThreadTreeState JSON): - * · GET 命中返回 { state, customTitle }(customTitle = 用户重命名过的标题,未改过为 null, - * 供主线列头副标题优先展示);未命中返回 200 + { state: null, customTitle: null }—— - * 首次访问是正常路径不是错误,客户端一个分支判断即可,无需在 fetch 层区分 - * 「404 = 正常」与「404 = 路由不存在」。 - * · PUT { state, title?, baseRevision } 严格校验 schema-v2 消息图,并按 owner/revision - * 做 CAS upsert。只写 state / 派生 title / updatedAt,不触碰 custom_title(双轨标题)。 - * · PATCH { title } 重命名:trim 后非空且 ≤ CUSTOM_TITLE_MAX_LEN,只写 custom_title 列; - * 树不存在 404——与 PUT 的派生轨互不踩踏。 - * · DELETE 删除该行,幂等(不存在也返回 { ok: true })。 - * treeId 做 UUID 形状校验(安全阀),不合法一律 400。 - */ - -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - CUSTOM_TITLE_MAX_LEN, - THREAD_TREE_SCHEMA_VERSION, -} from "@/constants/thread-chat" -import { getCurrentUserId } from "@/lib/auth/server" -import type { ThreadTreeState } from "@/lib/thread-chat/domain/types" -import { parseThreadTreeState } from "@/lib/thread-chat/domain/message-graph" -import { - assertCompletedMessageGenerationLinks, - reconcileThreadChatTurns, -} from "@/lib/thread-chat/application/reconcile-turns" -import { failStaleGenerationsForTree } from "@/lib/thread-chat-generation/stale-generation-repository" -import { - listCurrentGenerationsForTree, - toGenerationSummary, -} from "@/lib/thread-chat-generation/query-repository" -import { listMessageFeedbackForTree } from "@/lib/thread-chat-generation/message-feedback-repository" -import { - deleteOwnedTreeIfIdle, - loadOwnedOrClaimLegacyTree, - renameOwnedTree, - saveOwnedTree, -} from "@/lib/thread-chat-generation/tree-repository" -import { - SAVE_TREE_ERROR_STATUS, - SAVE_TREE_REVISION_ERRORS, - saveTreeErrorResponseSchema, - saveTreeRequestSchema, - saveTreeSuccessResponseSchema, - type SaveTreeErrorCode, -} from "@/lib/thread-chat/contracts/save-tree" - -type RouteContext = { params: Promise<{ treeId: string }> } - -function unauthorized() { - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) -} - -function notFound() { - return Response.json( - { error: { code: "not_found", message: "分支树不存在" } }, - { status: 404 } - ) -} - -function saveTreeErrorResponse( - code: SaveTreeErrorCode, - message: string, - currentRevision?: number -) { - return Response.json( - saveTreeErrorResponseSchema.parse({ - error: { - code, - message, - ...(currentRevision !== undefined ? { currentRevision } : {}), - }, - }), - { status: SAVE_TREE_ERROR_STATUS[code] } - ) -} - -export async function GET(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - const row = await loadOwnedOrClaimLegacyTree({ userId, treeId }) - if (!row) return notFound() - - await failStaleGenerationsForTree(userId, treeId) - const [generations, messageFeedbacks] = await Promise.all([ - listCurrentGenerationsForTree(userId, treeId), - listMessageFeedbackForTree(userId, treeId), - ]) - const generationSummaries = generations.map(toGenerationSummary) - let reconciled - try { - reconciled = reconcileThreadChatTurns({ - state: row.state as ThreadTreeState, - generations: generations.map((generation) => ({ - ...toGenerationSummary(generation), - turnSnapshot: generation.turnSnapshot, - })), - }) - assertCompletedMessageGenerationLinks( - reconciled.state, - generationSummaries - ) - } catch (error) { - console.error("[thread-chat] 消息图读取协调失败", { treeId, error }) - return Response.json( - { - error: { - code: "invalid_tree_state", - message: "分支树消息结构或生成关联无效", - }, - }, - { status: 500 } - ) - } - return Response.json({ - state: reconciled.state, - revision: row.revision, - customTitle: row.customTitle, - generations: generationSummaries, - messageFeedbacks, - recoverableTurns: reconciled.recoverableTurns, - }) -} - -export async function PUT(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - let body: { state?: unknown; title?: unknown; baseRevision?: unknown } - try { - body = await req.json() - } catch { - return new Response("body 必须是 JSON", { status: 400 }) - } - const { state } = body - if (typeof state !== "object" || state === null || Array.isArray(state)) - return new Response("state 缺失或不是对象", { status: 400 }) - // threads 必须是普通对象(codex review:数组/标量会让列表接口的 jsonb_object_keys - // 对这一行永久抛错,一行毒数据打挂整个 GET /api/branch-trees) - const threads = (state as Record).threads - if (typeof threads !== "object" || threads === null || Array.isArray(threads)) - return new Response("state.threads 必须是对象", { status: 400 }) - - const title = typeof body.title === "string" ? body.title : null - const incomingSchemaVersion = (state as Record).schemaVersion - if (incomingSchemaVersion !== THREAD_TREE_SCHEMA_VERSION) - return saveTreeErrorResponse( - "invalid_tree_state", - `只接受 schemaVersion=${THREAD_TREE_SCHEMA_VERSION} 的消息图` - ) - const command = saveTreeRequestSchema.safeParse(body) - if (!command.success) { - const error = SAVE_TREE_REVISION_ERRORS.revision_required - return saveTreeErrorResponse(error.code, error.message) - } - - let validatedState: ThreadTreeState - try { - validatedState = parseThreadTreeState(state) - } catch { - return saveTreeErrorResponse( - "invalid_tree_state", - "消息图包含无效的 parent、active leaf 或 Artifact source" - ) - } - const saved = await saveOwnedTree({ - userId, - treeId, - state: validatedState, - title, - baseRevision: command.data.baseRevision, - }) - if (saved.kind === "not_found") return notFound() - if (saved.kind === "conflict") { - const error = SAVE_TREE_REVISION_ERRORS.tree_revision_conflict - return saveTreeErrorResponse(error.code, error.message, saved.revision) - } - return Response.json( - saveTreeSuccessResponseSchema.parse({ - ok: true, - revision: saved.revision, - }) - ) -} - -export async function PATCH(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - let body: { title?: unknown } - try { - body = await req.json() - } catch { - return new Response("body 必须是 JSON", { status: 400 }) - } - const title = typeof body.title === "string" ? body.title.trim() : "" - if (title === "" || title.length > CUSTOM_TITLE_MAX_LEN) - return new Response( - `title 必须为 trim 后非空且不超过 ${CUSTOM_TITLE_MAX_LEN} 字的字符串`, - { status: 400 } - ) - - // 只写 custom_title(用户意志轨)——防抖 PUT 的派生 title 与之互不踩踏(design D1) - const renamed = await renameOwnedTree({ - userId, - treeId, - customTitle: title, - }) - if (!renamed) return new Response("树不存在", { status: 404 }) - return Response.json({ ok: true }) -} - -export async function DELETE(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - - const outcome = await deleteOwnedTreeIfIdle({ userId, treeId }) - if (outcome === "generation_running") { - return Response.json( - { - error: { - code: "generation_running", - message: "请先停止正在运行的生成,再删除这棵对话树", - }, - }, - { status: 409 } - ) - } - return Response.json({ ok: true }) -} diff --git a/app/api/branch-trees/route.ts b/app/api/branch-trees/route.ts deleted file mode 100644 index e794a955..00000000 --- a/app/api/branch-trees/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * /api/branch-trees —— 分支树的轻量列表(会话列表 UI 的数据源)。 - * - * GET 返回 { trees: [{ id, title, updatedAt, threadCount }] }: - * · title = coalesce(custom_title, title)(双轨标题,design D1),双空回退「未命名对话」; - * · threadCount 在 SQL 内由 state->'threads' 的顶层键数派生(design D2)—— - * 不回传整树 state(可能百 KB 级),列表只要元信息; - * · updated_at 降序,limit 100 兜底(v1 不做分页/搜索)。 - */ - -import { getCurrentUserId } from "@/lib/auth/server" -import { listOwnedTreeSummaries } from "@/lib/thread-chat-generation/tree-repository" - -export async function GET() { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const rows = await listOwnedTreeSummaries(userId) - return Response.json({ trees: rows }) -} diff --git a/app/api/chat/generation-settlement.ts b/app/api/chat/generation-settlement.ts deleted file mode 100644 index 82fa3421..00000000 --- a/app/api/chat/generation-settlement.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { UIMessage } from "ai" -import type { ResearchPlan, ResearchRoute } from "@/lib/chat/research-router" -import type { ThreadChatGenerationIdentity } from "@/lib/thread-chat/contracts/generation-identity" -import { GENERATION_ERRORS } from "@/constants/generation" -import { projectGenerationResult } from "@/lib/thread-chat/application/project-generation-result" -import { finalizeGenerationWithRetry } from "@/lib/thread-chat-generation/finalize-with-retry" -import type { StreamLifecycle } from "@/app/api/chat/stream-lifecycle" - -type SettlementDependencies = { - project: typeof projectGenerationResult - finalize: typeof finalizeGenerationWithRetry -} - -const defaultDependencies: SettlementDependencies = { - project: projectGenerationResult, - finalize: finalizeGenerationWithRetry, -} - -type GenerationSettlementInput = { - persistence: ThreadChatGenerationIdentity - researchRoute: ResearchRoute - researchPlan: ResearchPlan | null - unbilledPreview: boolean - streamLifecycle: Pick -} - -/** 将 UI stream 的结束信号投影并一次性收口到 generation 终态。 */ -export function createGenerationSettlementHandler( - { - persistence, - researchRoute, - researchPlan, - unbilledPreview, - streamLifecycle, - }: GenerationSettlementInput, - dependencies: SettlementDependencies = defaultDependencies -) { - return async ({ - responseMessage, - isAborted, - finishReason, - }: { - responseMessage: Pick - isAborted: boolean - finishReason?: string | null - }) => { - const { capturedUsage, modelStreamError, abortedUsageUnavailable } = - streamLifecycle.snapshot() - const failedWithoutFinish = - finishReason == null && modelStreamError !== undefined - const requestedTerminal = isAborted - ? "stopped" - : failedWithoutFinish - ? "failed" - : "completed" - const projected = dependencies.project({ - generationId: persistence.generationId, - threadId: persistence.threadId, - assistantMessageId: persistence.assistantMessageId, - responseMessage, - terminalStatus: requestedTerminal, - error: modelStreamError, - researchRoute, - researchPlan: researchPlan ?? undefined, - usage: capturedUsage - ? { - inputTokens: capturedUsage.inputTokens, - outputTokens: capturedUsage.outputTokens, - totalTokens: capturedUsage.inputTokens + capturedUsage.outputTokens, - } - : undefined, - }) - const outcome = - requestedTerminal === "completed" && !projected.hasDisplayableOutput - ? "failed" - : requestedTerminal - await dependencies.finalize({ - generationId: persistence.generationId, - outcome, - result: projected.result, - error: projected.result.error ?? modelStreamError, - usage: unbilledPreview ? undefined : capturedUsage, - usageUnavailable: - !unbilledPreview && (abortedUsageUnavailable || !capturedUsage), - }) - } -} - -/** stream 初始化阶段抛错时,尽力保存失败终态;结算失败不覆盖原 HTTP 错误。 */ -export async function settleGenerationInitializationFailure( - { - persistence, - usageUnavailable, - }: { - persistence: ThreadChatGenerationIdentity - error: unknown - usageUnavailable: boolean - }, - dependencies: SettlementDependencies = defaultDependencies -) { - const projected = dependencies.project({ - generationId: persistence.generationId, - threadId: persistence.threadId, - assistantMessageId: persistence.assistantMessageId, - responseMessage: { parts: [] }, - terminalStatus: "failed", - error: GENERATION_ERRORS.streamFailed, - }) - try { - await dependencies.finalize({ - generationId: persistence.generationId, - outcome: "failed", - result: projected.result, - error: projected.result.error, - usageUnavailable, - }) - } catch (finalizeError) { - console.error("[thread-chat-generation] 请求初始化失败后的终态保存失败", { - generationId: persistence.generationId, - finalizeError, - }) - } -} diff --git a/app/api/chat/generation-start-error.ts b/app/api/chat/generation-start-error.ts deleted file mode 100644 index 8de4ec82..00000000 --- a/app/api/chat/generation-start-error.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { GenerationRepositoryError } from "@/lib/thread-chat-generation/start-generation-repository" -import type { MessageActionFailureResponse } from "@/lib/thread-chat/contracts/message-action-failure" - -/** 将 generation start 事务错误映射为稳定的 HTTP 响应。 */ -export function generationStartErrorResponse(error: unknown): Response { - if (error instanceof GenerationRepositoryError) { - return Response.json( - { - error: { - code: error.code, - message: error.message, - }, - } satisfies MessageActionFailureResponse, - { - status: - error.code === "not_found" - ? 404 - : error.code === "persistence_failed" - ? 503 - : 409, - } - ) - } - console.error("[thread-chat-generation] start transaction 失败", error) - return Response.json( - { - error: { - code: "persistence_failed", - message: "无法建立生成任务,尚未调用模型", - }, - } satisfies MessageActionFailureResponse, - { status: 503 } - ) -} diff --git a/app/api/chat/request-context.ts b/app/api/chat/request-context.ts index 1c866dfc..8e7dbc43 100644 --- a/app/api/chat/request-context.ts +++ b/app/api/chat/request-context.ts @@ -6,23 +6,15 @@ 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 } @@ -31,7 +23,6 @@ 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(), }) @@ -47,7 +38,6 @@ type ChatRequestContextDependencies = { currentUserId: typeof getCurrentUserId getModel: typeof getChatModel linearModelAllowed: typeof isLinearChatModelId - threadModelAllowed: typeof isThreadChatModelId modelConfigured: typeof isModelConfigured unbilledPreview: typeof isUnbilledPreviewModel positiveBalance: typeof hasPositiveBalance @@ -57,7 +47,6 @@ const defaultDependencies: ChatRequestContextDependencies = { currentUserId: getCurrentUserId, getModel: getChatModel, linearModelAllowed: isLinearChatModelId, - threadModelAllowed: isThreadChatModelId, modelConfigured: isModelConfigured, unbilledPreview: isUnbilledPreviewModel, positiveBalance: hasPositiveBalance, @@ -85,6 +74,14 @@ export async function prepareChatRequestContext( } catch { return invalidChatRequest("请求体必须是有效 JSON。") } + if ( + typeof input === "object" && + input !== null && + Object.hasOwn(input, "threadChat") + ) + return invalidChatRequest( + "Thread Chat 已迁移到 /api/thread-chat/v1,/api/chat 不再接受该模式。" + ) const envelope = chatRequestEnvelopeSchema.safeParse(input) if (!envelope.success) return invalidChatRequest("请求体缺少有效的 messages。") @@ -117,39 +114,7 @@ export async function prepareChatRequestContext( const modelId = typeof rawModelId === "string" ? rawModelId : DEFAULT_MODEL_ID const model = dependencies.getModel(modelId)! - let threadChat: ThreadChatGenerationIdentity | undefined - if (body.threadChat != null) { - const parsedIdentity = threadChatGenerationIdentitySchema.safeParse( - body.threadChat - ) - if (!parsedIdentity.success) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "invalid_generation_identity", - message: "thread-chat 请求缺少有效的持久化身份,请刷新页面后重试", - }, - } satisfies MessageActionFailureResponse, - { status: 400 } - ), - } - if (!dependencies.threadModelAllowed(modelId)) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "invalid_thread_model", - message: "Thread Chat 不允许使用该模型,请刷新页面后重试", - }, - } satisfies MessageActionFailureResponse, - { status: 400 } - ), - } - threadChat = parsedIdentity.data - } else if (!dependencies.linearModelAllowed(modelId)) { + if (!dependencies.linearModelAllowed(modelId)) { return { kind: "response" as const, response: Response.json( @@ -187,7 +152,6 @@ export async function prepareChatRequestContext( messages: body.messages, tools: body.tools, deepResearch: body.deepResearch, - threadChat, linearThreadId: body.id, modelId, model, diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index bf256a67..c6dfedad 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -19,20 +19,17 @@ import { 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" +import { buildAiTelemetryConfig } from "@/lib/observability/ai-sdk" +import { buildLegacyChatTraceInput } from "@/lib/observability/context" +import { safeErrorMetadata } from "@/lib/observability/error" +import { runDetachedAgentTrace } from "@/lib/observability/trace" // AnySearch 搜索与网页深读可能形成多步循环,放宽单次请求时长上限。 export const maxDuration = 300 @@ -45,201 +42,175 @@ export async function POST(req: Request) { messages, tools, deepResearch, - threadChat, linearThreadId, modelId, model, isUnbilledPreview, } = requestContext - const prepared = await prepareThreadGenerationContext({ - userId, - modelId, - messages, - threadChat, - unbilledPreview: isUnbilledPreview, - }) - if (prepared.kind === "response") return prepared.response - const { - persistence, - authoritativeMessages, - authoritativeAnchorText, - preparedRevision, - generationController, - generationObserver, - } = prepared - try { // AnySearch 是当前统一联网层:所有模型都获得相同的搜索与网页深读工具。 // deepResearch 只控制研究提示强度,不再决定工具是否存在。 const research = deepResearch === true const searchReady = isSearchConfigured() - const isThreadChat = persistence != null const chatModel = resolveChatModel(modelId) const modelCallTrace: ModelCallTrace = { requestId: crypto.randomUUID(), - ...(persistence - ? { - treeId: persistence.treeId, - threadId: persistence.threadId, - generationId: persistence.generationId, - assistantMessageId: persistence.assistantMessageId, - } - : linearThreadId - ? { threadId: linearThreadId } - : {}), + ...(linearThreadId ? { threadId: linearThreadId } : {}), } - const { latestText, researchRoute, researchPlan } = - await resolveResearchContext({ - model: chatModel, - messages: authoritativeMessages, - deepResearchRequested: research, - searchReady, - modelCallTrace, - }) - const markdownArtifactRequested = - isThreadChat && isExplicitMarkdownArtifactRequest(latestText) - const { tools: allTools, webToolsEnabled } = buildChatToolSet({ - researchMode: researchRoute.mode, - 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({ + const legacyTraceInput = await buildLegacyChatTraceInput({ userId, + requestId: modelCallTrace.requestId!, + ...(linearThreadId ? { linearThreadId } : {}), 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, + return await runDetachedAgentTrace( + legacyTraceInput, + async (legacyObservation) => { + try { + const { researchRoute, researchPlan } = await resolveResearchContext({ + model: chatModel, + messages, + deepResearchRequested: research, + searchReady, + modelCallTrace, }) - } - writer.merge( - result.toUIMessageStream({ - onError: (error) => { - console.error("[chat] 流内错误:", error) - return "An error occurred." - }, - messageMetadata: ({ part }) => - part.type === "finish" - ? buildUsageMetadata(modelId, part.totalUsage) - : undefined, + const { tools: allTools, webToolsEnabled } = buildChatToolSet({ + researchMode: researchRoute.mode, + routeReason: researchRoute.reasonCode, + searchReady, + frontendToolSet: frontendTools(tools ?? {}), }) - ) - }, - onEnd: persistence - ? createGenerationSettlementHandler({ - persistence, - researchRoute, + + // MiniMax 不接受 file part:先把附件(PDF→提取文本,其余→占位说明)转换为 text part + const resolvedMessages = await resolveAttachmentParts( + messages, + userId + ) + + const system = buildChatSystemPrompt({ + researchMode: researchRoute.mode, researchPlan, + deepResearchRequested: research, + searchReady, + }) + + const streamLifecycle = createStreamLifecycle({ + userId, + modelId, + model, unbilledPreview: isUnbilledPreview, - streamLifecycle, + linearThreadId, + }) + + const result = streamText({ + ...buildAiTelemetryConfig(MODEL_CALL_PURPOSE.chatAnswer, { + ...modelCallTrace, + modelId, + entrypoint: "legacy-chat", + }), + model: withModelCallLogging( + chatModel, + MODEL_CALL_PURPOSE.chatAnswer, + modelCallTrace + ), + reasoning: reasoningForResearchRoute(researchRoute.mode, model), + system, + messages: await convertToModelMessages(resolvedMessages, { + tools: allTools, + }), + tools: allTools, + // 明确 Markdown 交付请求只强制第 0 步启动工具调用;后续步骤仍保留工具, + // 让模型在用户要求多份独立文档时,为每份文档分别创建一个 Artifact。 + prepareStep: createToolStepPolicy({ + isThreadChat: false, + markdownArtifactRequested: false, + researchMode: researchRoute.mode, + }), + maxOutputTokens: MAX_OUTPUT_TOKENS, + stopWhen: isStepCount(webToolsEnabled ? RESEARCH_MAX_STEPS : 5), + onError: streamLifecycle.onError, + onAbort: streamLifecycle.onAbort, + onEnd: streamLifecycle.onEnd, }) - : undefined, - }) - const response = createUIMessageStreamResponse({ - stream: uiStream, - consumeSseStream: ({ stream }) => { - after(async () => { - await consumeStream({ - stream, - onError: (error) => { - console.error("[chat] 服务端 UI stream 消费失败", error) + const uiStream = createUIMessageStream({ + 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, + }) + ) }, }) - 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 + + const response = createUIMessageStreamResponse({ + stream: uiStream, + consumeSseStream: ({ stream }) => { + after(async () => { + let streamFailed = false + try { + await consumeStream({ + stream, + onError: (error) => { + streamFailed = true + console.error("[chat] 服务端 UI stream 消费失败", error) + }, + }) + legacyObservation.update({ + level: streamFailed ? "ERROR" : "DEFAULT", + statusMessage: streamFailed + ? "legacy stream failed" + : "legacy stream completed", + output: { + status: streamFailed ? "failed" : "completed", + researchMode: researchRoute.mode, + }, + }) + } catch (error) { + legacyObservation.update({ + level: "ERROR", + statusMessage: "legacy stream consumer failed", + metadata: safeErrorMetadata(error), + }) + } finally { + legacyObservation.end() + } + }) + }, + }) + return response + } catch (error) { + legacyObservation.update({ + level: "ERROR", + statusMessage: "legacy request initialization failed", + metadata: safeErrorMetadata(error), + }) + legacyObservation.end() + throw error + } + } + ) } catch (error) { - generationController?.abort(error) - generationObserver?.stop() - if (persistence && generationController) { - unregisterGenerationController( - persistence.generationId, - generationController - ) - await settleGenerationInitializationFailure({ - persistence, - error, - usageUnavailable: !isUnbilledPreview, - }) - } console.error("[chat] 请求初始化失败", error) return Response.json({ error: "生成初始化失败,请重试。" }, { status: 500 }) } diff --git a/app/api/chat/stream-lifecycle.ts b/app/api/chat/stream-lifecycle.ts index 28d9e87c..1295fab3 100644 --- a/app/api/chat/stream-lifecycle.ts +++ b/app/api/chat/stream-lifecycle.ts @@ -4,7 +4,6 @@ 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: { @@ -17,7 +16,6 @@ type StreamLifecycleInput = { userId: string modelId: string model: Pick - persistentGeneration: boolean unbilledPreview: boolean linearThreadId?: string } @@ -36,15 +34,12 @@ 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 }) { @@ -52,30 +47,7 @@ export function createStreamLifecycle( 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 - }, + onAbort() {}, async onEnd({ usage, @@ -100,14 +72,6 @@ export function createStreamLifecycle( `[chat] OpenRouter 成本元数据不完整,使用静态估值:${model.id}` ) } - if (persistentGeneration) { - capturedUsage = { - inputTokens: usage.inputTokens ?? 0, - outputTokens: usage.outputTokens ?? 0, - costEvidence, - } - return - } await dependencies.charge({ userId, model: modelId, @@ -120,9 +84,7 @@ export function createStreamLifecycle( snapshot() { return { - capturedUsage, modelStreamError, - abortedUsageUnavailable, } }, } diff --git a/app/api/chat/surface-tools.ts b/app/api/chat/surface-tools.ts index 8bcb7d1e..b33fd991 100644 --- a/app/api/chat/surface-tools.ts +++ b/app/api/chat/surface-tools.ts @@ -1,11 +1,5 @@ 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.", @@ -52,19 +46,7 @@ const compareTable = tool({ 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 } - : {} +/** 线性聊天 surface 的基础工具;联网与前端工具由 route 在其上继续组合。 */ +export function surfaceTools() { + return { getWeather, compareTable } } diff --git a/app/api/chat/system-prompt.ts b/app/api/chat/system-prompt.ts index a50eedf2..dde0881e 100644 --- a/app/api/chat/system-prompt.ts +++ b/app/api/chat/system-prompt.ts @@ -3,7 +3,6 @@ import { RESEARCH_SYSTEM_PROMPT, WEB_ACCESS_SYSTEM_PROMPT, } from "@/constants/research" -import { buildThreadChatSystem } from "@/lib/chat/thread-chat-prompt" import { researchPlanExecutionPrompt, type ResearchPlan, @@ -11,9 +10,6 @@ import { } from "@/lib/chat/research-router" type ChatSystemPromptInput = { - threadChat: boolean - anchorText: string | null - markdownArtifactRequested: boolean researchMode: ResearchRoute["mode"] researchPlan: ResearchPlan | null deepResearchRequested: boolean @@ -25,20 +21,12 @@ 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 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 index 9ec60734..d7b79b19 100644 --- a/app/api/chat/tool-set.ts +++ b/app/api/chat/tool-set.ts @@ -1,19 +1,13 @@ import type { ToolSet } from "ai" -import { readUrlTool, webSearchTool } from "@/lib/chat/research-tools" +import { createResearchTools } 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"] + routeReason?: ResearchRoute["reasonCode"] searchReady: boolean - threadChat: boolean - markdownArtifactRequested: boolean /** assistant-ui 使用其内嵌 AI SDK 类型;只在最终组合出口统一适配。 */ frontendToolSet?: Record } @@ -21,20 +15,20 @@ type ChatToolSetInput = { /** 将各能力的私有工具集合组合成一次模型调用唯一可见的 ToolSet。 */ export function buildChatToolSet({ researchMode, + routeReason, searchReady, - threadChat, - markdownArtifactRequested, frontendToolSet, }: ChatToolSetInput): { tools: ToolSet; webToolsEnabled: boolean } { const webToolsEnabled = searchReady && researchMode !== "answer" + const researchTools = createResearchTools({ routeReason }) const routedWebTools = Object.fromEntries( - researchToolNames(researchMode).map((name) => [name, RESEARCH_TOOLS[name]]) + researchToolNames(researchMode).map((name) => [name, researchTools[name]]) ) as ToolSet return { webToolsEnabled, tools: { - ...surfaceTools({ threadChat, markdownArtifactRequested }), + ...surfaceTools(), ...(webToolsEnabled ? routedWebTools : {}), ...(frontendToolSet ?? {}), } as ToolSet, diff --git a/app/api/thread-chat/v1/artifacts/[artifactId]/route.ts b/app/api/thread-chat/v1/artifacts/[artifactId]/route.ts new file mode 100644 index 00000000..1265fd9a --- /dev/null +++ b/app/api/thread-chat/v1/artifacts/[artifactId]/route.ts @@ -0,0 +1,12 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleGetArtifact } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +export async function GET( + request: Request, + context: RouteContext<{ artifactId: string }> +) { + const { artifactId } = await context.params + return handleGetArtifact(request, artifactId) +} diff --git a/app/api/thread-chat/v1/messages/[messageId]/edit/route.ts b/app/api/thread-chat/v1/messages/[messageId]/edit/route.ts new file mode 100644 index 00000000..070c2844 --- /dev/null +++ b/app/api/thread-chat/v1/messages/[messageId]/edit/route.ts @@ -0,0 +1,13 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleEditMessage } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" +export const maxDuration = 300 + +export async function POST( + request: Request, + context: RouteContext<{ messageId: string }> +) { + const { messageId } = await context.params + return handleEditMessage(request, messageId) +} diff --git a/app/api/thread-chat/v1/messages/[messageId]/feedback/route.ts b/app/api/thread-chat/v1/messages/[messageId]/feedback/route.ts new file mode 100644 index 00000000..d06d2aae --- /dev/null +++ b/app/api/thread-chat/v1/messages/[messageId]/feedback/route.ts @@ -0,0 +1,12 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleSetFeedback } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +export async function PUT( + request: Request, + context: RouteContext<{ messageId: string }> +) { + const { messageId } = await context.params + return handleSetFeedback(request, messageId) +} diff --git a/app/api/thread-chat/v1/messages/[messageId]/retry/route.ts b/app/api/thread-chat/v1/messages/[messageId]/retry/route.ts new file mode 100644 index 00000000..649666d0 --- /dev/null +++ b/app/api/thread-chat/v1/messages/[messageId]/retry/route.ts @@ -0,0 +1,13 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleRetryMessage } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" +export const maxDuration = 300 + +export async function POST( + request: Request, + context: RouteContext<{ messageId: string }> +) { + const { messageId } = await context.params + return handleRetryMessage(request, messageId) +} diff --git a/app/api/thread-chat/v1/messages/[messageId]/route.ts b/app/api/thread-chat/v1/messages/[messageId]/route.ts new file mode 100644 index 00000000..dbdf0b26 --- /dev/null +++ b/app/api/thread-chat/v1/messages/[messageId]/route.ts @@ -0,0 +1,12 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleGetMessage } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +export async function GET( + request: Request, + context: RouteContext<{ messageId: string }> +) { + const { messageId } = await context.params + return handleGetMessage(request, messageId) +} diff --git a/app/api/thread-chat/v1/messages/[messageId]/stop/route.ts b/app/api/thread-chat/v1/messages/[messageId]/stop/route.ts new file mode 100644 index 00000000..ca2a14cd --- /dev/null +++ b/app/api/thread-chat/v1/messages/[messageId]/stop/route.ts @@ -0,0 +1,12 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleStopMessage } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +export async function POST( + request: Request, + context: RouteContext<{ messageId: string }> +) { + const { messageId } = await context.params + return handleStopMessage(request, messageId) +} diff --git a/app/api/thread-chat/v1/messages/[messageId]/stream/route.ts b/app/api/thread-chat/v1/messages/[messageId]/stream/route.ts new file mode 100644 index 00000000..29d83f2f --- /dev/null +++ b/app/api/thread-chat/v1/messages/[messageId]/stream/route.ts @@ -0,0 +1,13 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleMessageStream } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" +export const maxDuration = 300 + +export async function GET( + request: Request, + context: RouteContext<{ messageId: string }> +) { + const { messageId } = await context.params + return handleMessageStream(request, messageId) +} diff --git a/app/api/thread-chat/v1/projects/[projectId]/files/[attachmentId]/route.ts b/app/api/thread-chat/v1/projects/[projectId]/files/[attachmentId]/route.ts new file mode 100644 index 00000000..38f2cdd6 --- /dev/null +++ b/app/api/thread-chat/v1/projects/[projectId]/files/[attachmentId]/route.ts @@ -0,0 +1,11 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleRemoveProjectFile } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +type Context = RouteContext<{ projectId: string; attachmentId: string }> + +export async function DELETE(request: Request, context: Context) { + const { projectId, attachmentId } = await context.params + return handleRemoveProjectFile(request, projectId, attachmentId) +} diff --git a/app/api/thread-chat/v1/projects/[projectId]/files/route.ts b/app/api/thread-chat/v1/projects/[projectId]/files/route.ts new file mode 100644 index 00000000..ba7cf019 --- /dev/null +++ b/app/api/thread-chat/v1/projects/[projectId]/files/route.ts @@ -0,0 +1,11 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleAddProjectFile } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +type Context = RouteContext<{ projectId: string }> + +export async function POST(request: Request, context: Context) { + const { projectId } = await context.params + return handleAddProjectFile(request, projectId) +} diff --git a/app/api/thread-chat/v1/projects/[projectId]/route.ts b/app/api/thread-chat/v1/projects/[projectId]/route.ts new file mode 100644 index 00000000..f9b717bf --- /dev/null +++ b/app/api/thread-chat/v1/projects/[projectId]/route.ts @@ -0,0 +1,25 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { + handleDeleteProject, + handleGetProject, + handlePatchProject, +} from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +type Context = RouteContext<{ projectId: string }> + +export async function GET(request: Request, context: Context) { + const { projectId } = await context.params + return handleGetProject(request, projectId) +} + +export async function PATCH(request: Request, context: Context) { + const { projectId } = await context.params + return handlePatchProject(request, projectId) +} + +export async function DELETE(request: Request, context: Context) { + const { projectId } = await context.params + return handleDeleteProject(request, projectId) +} diff --git a/app/api/thread-chat/v1/projects/[projectId]/start/route.ts b/app/api/thread-chat/v1/projects/[projectId]/start/route.ts new file mode 100644 index 00000000..50a071ee --- /dev/null +++ b/app/api/thread-chat/v1/projects/[projectId]/start/route.ts @@ -0,0 +1,13 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleStartProject } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" +export const maxDuration = 300 + +export async function POST( + request: Request, + context: RouteContext<{ projectId: string }> +) { + const { projectId } = await context.params + return handleStartProject(request, projectId) +} diff --git a/app/api/thread-chat/v1/projects/route.ts b/app/api/thread-chat/v1/projects/route.ts new file mode 100644 index 00000000..6d45bf65 --- /dev/null +++ b/app/api/thread-chat/v1/projects/route.ts @@ -0,0 +1,7 @@ +import { handleListProjects } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +export function GET(request: Request) { + return handleListProjects(request) +} diff --git a/app/api/thread-chat/v1/threads/[threadId]/forks/route.ts b/app/api/thread-chat/v1/threads/[threadId]/forks/route.ts new file mode 100644 index 00000000..801ba0ac --- /dev/null +++ b/app/api/thread-chat/v1/threads/[threadId]/forks/route.ts @@ -0,0 +1,13 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleForkThread } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" +export const maxDuration = 300 + +export async function POST( + request: Request, + context: RouteContext<{ threadId: string }> +) { + const { threadId } = await context.params + return handleForkThread(request, threadId) +} diff --git a/app/api/thread-chat/v1/threads/[threadId]/messages/route.ts b/app/api/thread-chat/v1/threads/[threadId]/messages/route.ts new file mode 100644 index 00000000..852f713a --- /dev/null +++ b/app/api/thread-chat/v1/threads/[threadId]/messages/route.ts @@ -0,0 +1,13 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleSendMessage } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" +export const maxDuration = 300 + +export async function POST( + request: Request, + context: RouteContext<{ threadId: string }> +) { + const { threadId } = await context.params + return handleSendMessage(request, threadId) +} diff --git a/app/api/thread-chat/v1/threads/[threadId]/route.ts b/app/api/thread-chat/v1/threads/[threadId]/route.ts new file mode 100644 index 00000000..148ac715 --- /dev/null +++ b/app/api/thread-chat/v1/threads/[threadId]/route.ts @@ -0,0 +1,12 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handlePatchThread } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" + +export async function PATCH( + request: Request, + context: RouteContext<{ threadId: string }> +) { + const { threadId } = await context.params + return handlePatchThread(request, threadId) +} diff --git a/app/api/thread-chat/v1/threads/[threadId]/title/route.ts b/app/api/thread-chat/v1/threads/[threadId]/title/route.ts new file mode 100644 index 00000000..53884309 --- /dev/null +++ b/app/api/thread-chat/v1/threads/[threadId]/title/route.ts @@ -0,0 +1,13 @@ +import type { RouteContext } from "@/lib/thread-chat/server/route-utils" +import { handleGenerateThreadTitle } from "@/lib/thread-chat/server/handlers" + +export const dynamic = "force-dynamic" +export const maxDuration = 300 + +export async function POST( + request: Request, + context: RouteContext<{ threadId: string }> +) { + const { threadId } = await context.params + return handleGenerateThreadTitle(request, threadId) +} diff --git a/app/api/title/route.ts b/app/api/title/route.ts index d333b16a..ba3265fd 100644 --- a/app/api/title/route.ts +++ b/app/api/title/route.ts @@ -1,15 +1,5 @@ -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" +import { parseThreadTitleInput } from "@/lib/thread-chat/contracts/title-request" +import { generateThreadTitleText } from "@/lib/thread-chat/application/title-generator" /** * POST /api/title —— 主线与分支共用的异步语义标题生成。 @@ -22,48 +12,6 @@ import { * 客户端保留各自的回退标题。 */ -/** 喂给标题模型的首答摘录上限(字符):标题只需主旨,控制成本与延迟 */ -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 { @@ -77,23 +25,5 @@ export async function POST(req: Request) { 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 }) - } + return Response.json({ title: await generateThreadTitleText(input) }) } diff --git a/app/thread-chat-gate-3-harness/[projectId]/page.tsx b/app/thread-chat-gate-3-harness/[projectId]/page.tsx new file mode 100644 index 00000000..86cf01ad --- /dev/null +++ b/app/thread-chat-gate-3-harness/[projectId]/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from "next/navigation" +import { isValidTreeId } from "@/lib/chat/tree-id" +import { NormalizedGate3Harness } from "../../thread-chat/gate-3-harness/normalized-harness" +import "../../thread-chat/thread-chat.css" + +export default async function Gate3HarnessPage({ + params, + searchParams, +}: { + params: Promise<{ projectId: string }> + searchParams: Promise<{ background?: string }> +}) { + if (process.env.NODE_ENV !== "development") notFound() + const { projectId } = await params + if (!isValidTreeId(projectId)) notFound() + const query = await searchParams + return ( + + ) +} diff --git a/app/thread-chat/[treeId]/page.tsx b/app/thread-chat/[treeId]/page.tsx index bad9041d..998b0c90 100644 --- a/app/thread-chat/[treeId]/page.tsx +++ b/app/thread-chat/[treeId]/page.tsx @@ -6,9 +6,8 @@ import { threadChatMetadata } from "../page-metadata" export const metadata = threadChatMetadata /** - * URL 即树身份:/thread-chat/{treeId} 打开指定的分支树(直访新 UUID = 开新树)。 - * treeId 做 UUID 形状校验(安全阀),不合法 404。key={treeId} 保证切树(如「新对话」 - * 跳转)时 loader/store 整体重挂,不残留上一棵树的内存状态。 + * URL 即 Project 身份:/thread-chat/{treeId} 打开规范化分支会话;直访新 UUID + * 得到空工作台,首条消息原子创建 Project。路径仍沿用 treeId 参数名以保持 URL/UX。 */ export default async function ThreadChatTreePage({ params, diff --git a/app/thread-chat/attachment-composer-demo/page.tsx b/app/thread-chat/attachment-composer-demo/page.tsx new file mode 100644 index 00000000..2433225b --- /dev/null +++ b/app/thread-chat/attachment-composer-demo/page.tsx @@ -0,0 +1,46 @@ +"use client" + +import { useCallback, useState } from "react" + +import { AttachmentComposerDemo } from "../chat/composer/attachment-composer-demo" +import type { DemoAttachment } from "../chat/composer/attachment-composer-demo-model" + +export default function AttachmentComposerDemoPage() { + const [attachments, setAttachments] = useState([]) + + const handleChange = useCallback((nextAttachments: DemoAttachment[]) => { + setAttachments(nextAttachments) + console.log("attachments changed", nextAttachments) + }, []) + + return ( +
+
+
+

+ ThreadChat · Frontend Demo +

+

+ Attachment Composer +

+

+ 通过多选、拖拽或粘贴添加文件。粘贴的纯文本会转换为本地 .txt + 文件;所有操作仅保留在当前浏览器页面,不会上传或发送消息。 +

+
+ + + +

+ 当前附件:{attachments.length} +

+
+
+ ) +} diff --git a/app/thread-chat/branching/assistant/anchored-assistant-body.tsx b/app/thread-chat/branching/assistant/anchored-assistant-body.tsx index dc41558f..8532a382 100644 --- a/app/thread-chat/branching/assistant/anchored-assistant-body.tsx +++ b/app/thread-chat/branching/assistant/anchored-assistant-body.tsx @@ -1,9 +1,9 @@ "use client" -import type { Message, ThreadTreeState } from "../../core/types" +import type { ConversationViewMessage, ThreadTreeState } from "../../core/types" import { WebResearchPanel } from "../../orchestration/overlays/web-research-panel" import { AnchoredMarkdown } from "./anchored-markdown" -import { webResearchPlacement } from "./web-research-placement" +import { assistantPartRenderPlan } from "./assistant-part-render-plan" export function AnchoredAssistantBody({ state, @@ -11,28 +11,95 @@ export function AnchoredAssistantBody({ onOpenThread, }: { state: ThreadTreeState - message: Message + message: ConversationViewMessage onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void }) { - const research = webResearchPlacement(message) - const hasResearch = research.activities.length > 0 + const renderPlan = assistantPartRenderPlan(message) return ( - - ) : undefined - } - /> + <> + {renderPlan.map(({ kind, part, index }) => { + if (kind === "text" && part.type === "text") { + return ( + + ) + } + + if (kind === "reasoning" && part.type === "reasoning") { + return ( +
+ 思考过程 +
+

{part.text}

+
+
+ ) + } + + if (kind === "research") { + return ( + + ) + } + + if ( + kind === "file" && + (part.type === "file" || part.type === "reasoning-file") + ) { + return ( + + {part.type === "file" ? (part.filename ?? "附件") : "推理文件"} + + ) + } + + if (kind === "source-url" && part.type === "source-url") { + return ( + + {part.title ?? part.url} + + ) + } + + if (kind === "tool") { + const toolState = "state" in part ? String(part.state) : "" + return ( + + ) + } + + return null + })} + ) } diff --git a/app/thread-chat/branching/assistant/anchored-markdown.tsx b/app/thread-chat/branching/assistant/anchored-markdown.tsx index ca36cdce..25242416 100644 --- a/app/thread-chat/branching/assistant/anchored-markdown.tsx +++ b/app/thread-chat/branching/assistant/anchored-markdown.tsx @@ -22,12 +22,15 @@ export function AnchoredMarkdown({ state, msg, onOpenThread, + source, insertAt, insert, }: { state: ThreadTreeState msg: Message onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void + /** default 渲染完整消息正文;parts 渲染器可传入单个 text part 的内容。 */ + source?: string /** 在流事件记录的正文字符偏移处插入工具活动;缺省时渲染普通单段 Markdown。 */ insertAt?: number insert?: React.ReactNode @@ -38,8 +41,9 @@ export function AnchoredMarkdown({ .map((fork) => `${fork.threadId}:${fork.num}`) .join("|") const active = msg.status === "streaming" || msg.status === "pending" - const display = useSmoothText(msg.text, active) - const renderedSource = active ? display : msg.text + const markdownSource = source ?? msg.text + const display = useSmoothText(markdownSource, active) + const renderedSource = active ? display : markdownSource const [settledRevision, bumpSettledRevision] = useReducer( (revision: number) => revision + 1, 0 @@ -123,7 +127,9 @@ export function AnchoredMarkdown({ } const normalizedInsertAt = - insertAt == null ? null : Math.max(0, Math.min(insertAt, msg.text.length)) + insertAt == null + ? null + : Math.max(0, Math.min(insertAt, markdownSource.length)) const insertIsVisible = insert != null && normalizedInsertAt != null && diff --git a/app/thread-chat/branching/assistant/assistant-part-render-plan.ts b/app/thread-chat/branching/assistant/assistant-part-render-plan.ts new file mode 100644 index 00000000..53b04e61 --- /dev/null +++ b/app/thread-chat/branching/assistant/assistant-part-render-plan.ts @@ -0,0 +1,62 @@ +import type { ConversationViewMessage } from "../../core/types" +import type { ThreadChatUIMessage } from "@/lib/thread-chat/contracts/ui-message" + +export type ThreadChatUIPart = ThreadChatUIMessage["parts"][number] +export type AssistantPartRenderKind = + | "text" + | "reasoning" + | "research" + | "file" + | "source-url" + | "tool" + +export interface AssistantPartRenderPlanItem { + kind: AssistantPartRenderKind + part: ThreadChatUIPart + index: number +} + +function fallbackParts(message: ConversationViewMessage): ThreadChatUIPart[] { + return message.text + ? ([{ type: "text", text: message.text, state: "done" }] as ThreadChatUIPart[]) + : [] +} + +export function assistantPartRenderPlan( + message: ConversationViewMessage +): AssistantPartRenderPlanItem[] { + const parts = message.uiParts ?? fallbackParts(message) + const plan: AssistantPartRenderPlanItem[] = [] + let researchPanelRendered = false + + parts.forEach((part, index) => { + if (part.type === "text") { + plan.push({ kind: "text", part, index }) + return + } + if (part.type === "reasoning" && part.text.trim()) { + plan.push({ kind: "reasoning", part, index }) + return + } + if (part.type === "data-research-activity") { + if (!researchPanelRendered) { + researchPanelRendered = true + plan.push({ kind: "research", part, index }) + } + return + } + if (part.type === "file" || part.type === "reasoning-file") { + plan.push({ kind: "file", part, index }) + return + } + if (part.type === "source-url") { + plan.push({ kind: "source-url", part, index }) + return + } + if (part.type.startsWith("tool-")) { + plan.push({ kind: "tool", part, index }) + } + }) + + return plan +} diff --git a/app/thread-chat/branching/branchable-chat.tsx b/app/thread-chat/branching/branchable-chat.tsx index 59716f7d..49d85baf 100644 --- a/app/thread-chat/branching/branchable-chat.tsx +++ b/app/thread-chat/branching/branchable-chat.tsx @@ -201,29 +201,7 @@ export function BranchableChat({ {sourceProvenance && !sourceProvenance.isOnActivePath && (
- - 基于回复 - {sourceProvenance.alternativeIndex === null - ? "" - : " " + - (sourceProvenance.alternativeIndex + 1) + - "/" + - sourceProvenance.alternativeCount}{" "} - · 当前未展示 - - + 基于历史回复 · 当前时间线不展示该回复
)} @@ -268,7 +246,6 @@ export function BranchableChat({ messageCommands={messageCommands} editableUserMessageId={presentation?.latestUserMessageId} regeneratableAssistantMessageId={presentation?.latestAssistantMessageId} - turnAlternatives={presentation?.alternatives} /> ) } diff --git a/app/thread-chat/branching/selection/selection-bubble.tsx b/app/thread-chat/branching/selection/selection-bubble.tsx index 7bea8836..489e0f01 100644 --- a/app/thread-chat/branching/selection/selection-bubble.tsx +++ b/app/thread-chat/branching/selection/selection-bubble.tsx @@ -22,7 +22,14 @@ * Esc 交由壳层关闭链关气泡;Enter 有 IME 守卫(isComposing / keyCode 229)。 */ -import React, { useEffect, useLayoutEffect, useRef, useState } from "react" +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react" +import "./selection-draft-guard.css" import { GitMerge } from "lucide-react" import type { ThreadTreeState } from "../../core/types" import { threadTitle } from "../../core/selectors" @@ -74,17 +81,41 @@ export function SelectionBubble({ maxExpanded, lastActiveOf, }: SelectionBubbleProps) { + /** 可选首问(受控 textarea):留空提交 = 现有预填流;非空提交 = 带问开分支 */ + const [question, setQuestion] = useState("") + const hasQuestion = question.trim().length > 0 + /** 有草稿时新划选被忽略的轻提示(悬挂在气泡外,不扰动面板高度/定位) */ + const [draftHint, setDraftHint] = useState(false) + /** 轻提示自动消失计时器(再次忽略新划选时重置) */ + const draftHintTimer = useRef | null>(null) + const showDraftHint = useCallback(() => { + setDraftHint(true) + if (draftHintTimer.current) clearTimeout(draftHintTimer.current) + draftHintTimer.current = setTimeout(() => setDraftHint(false), 2500) + }, []) + useEffect( + () => () => { + if (draftHintTimer.current) clearTimeout(draftHintTimer.current) + }, + [] + ) useAssistantTextSelection({ state, selection: sel, onSelectionChange: onSelChange, + hasDraft: hasQuestion, + onIgnoredSelection: showDraftHint, }) + /** Esc 确认弹窗(有草稿时 Esc 不直接关,先确认清空) */ + const [confirming, setConfirming] = useState(false) + /** 气泡左右抖动中(确认弹窗弹出时触发一次,animationend 归位) */ + const [shaking, setShaking] = useState(false) + /** 入场淡入窗口:tc-pop 播完即拆 .entering 类,让稳态 animation 为 none(见 selection.css) */ + const [entering, setEntering] = useState(true) /** 迷你列条点选的让位列(override);气泡隐藏 / 换一段划选时清空 */ const [override, setOverride] = useState(null) /** ⌘/Ctrl 是否按住(实时跟踪,目标与按钮文案随之切换) */ const [metaHeld, setMetaHeld] = useState(false) - /** 可选首问(受控 textarea):留空提交 = 现有预填流;非空提交 = 带问开分支 */ - const [question, setQuestion] = useState("") const taRef = useRef(null) /** 气泡内容层(面板本体):测其高度 H 喂定位模型与轮廓 path */ const contentRef = useRef(null) @@ -97,6 +128,10 @@ export function SelectionBubble({ setOverride(null) setMetaHeld(sel?.meta ?? false) setQuestion("") + setConfirming(false) + setShaking(false) + setEntering(Boolean(sel)) // 每次新划选都重放一次 tc-pop;关闭态保持 false + setDraftHint(false) setMeasuredH(0) // 换一段划选:高度作废,等重新测量再定位(先隐藏,避免旧位闪现) } @@ -114,14 +149,16 @@ export function SelectionBubble({ return () => ro.disconnect() }, [sel]) - /* 气泡弹出即聚焦输入框(preventScroll:气泡定位刚结算完,不能再引发滚动); - 顺手清掉上一次自增高留下的行内高度(textarea 跨划选不重挂载) */ + /* 气泡测量完成、真正 visible 后再聚焦输入框:首帧 measuredH=0 时根节点 + visibility:hidden,Chromium 会拒绝聚焦其后代;只依赖 sel 会错过后续 visible 帧。 + focusReady 只发生 false → true,不会在 textarea 自增高时反复抢焦点。 */ + const focusReady = Boolean(sel && measuredH > 0) useEffect(() => { const ta = taRef.current - if (!sel || !ta) return + if (!focusReady || !ta) return ta.style.height = "" ta.focus({ preventScroll: true }) - }, [sel]) + }, [sel, focusReady]) /* 气泡打开期间跟踪 ⌘/Ctrl 起落(keydown/keyup 都带 metaKey/ctrlKey 快照) */ useEffect(() => { @@ -138,6 +175,29 @@ export function SelectionBubble({ } }, [sel]) + /* 有草稿时拦截 Esc(capture:先于壳层 use-workspace-overlays 的关闭链): + · 确认弹窗已开 → 再按 Esc 只关确认弹窗(回到编辑,内容保留); + · 未开 → 弹出「清空并关闭」确认,同时气泡左右抖动提醒内容会丢; + · 无草稿 → 不拦截,Esc 照旧直接关气泡。IME 组合态的 Esc 不拦截。 */ + useEffect(() => { + if (!sel) return + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape" || e.isComposing) return + if (!question.trim()) return + e.preventDefault() + e.stopPropagation() + if (confirming) { + setConfirming(false) + return + } + setConfirming(true) + setShaking(true) + setEntering(false) + } + document.addEventListener("keydown", onKey, true) + return () => document.removeEventListener("keydown", onKey, true) + }, [sel, question, confirming]) + if (!sel) return null /* —— 落点:floating-popup 定位模型,只用上/下两向(尾巴竖直指向选区)—— @@ -192,7 +252,6 @@ export function SelectionBubble({ : null /* —— 按钮文案四态(优先级):列条 override > ⌘ 按住 > 有输入 > 默认 —— */ - const hasQuestion = question.trim().length > 0 // 按钮只表达「动作」(两态、长度稳定);「放置后果」下沉到列条下的提示行—— // 变长的列标题在提示行里可单行省略,按钮宽度不再被撑爆(用户定的通用方案) const btnLabel = hasQuestion ? "带着问题开分支" : "开启分支讨论" @@ -224,93 +283,168 @@ export function SelectionBubble({ onFork(sel, h, q || undefined) } + /** 确认清空:关气泡并丢弃草稿(唯一能丢弃草稿的路径) */ + const discardDraft = () => { + setConfirming(false) + setQuestion("") + window.getSelection()?.removeAllRanges() + onSelChange(null) + } + return ( -
- {/* 平滑曲线轮廓(背景层):面板 + 指向选区的尾巴,一条 path。尾巴朝上时 - 整层上移 ah 让顶点探出面板上沿;朝下时留在原位向下探出。 */} + <> -
-
在新分支中讨论这段
-
{sel.text}
-
-