From 612135f9112e5cafb83915497f3cc2900e81cf84 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 24 Aug 2026 00:34:03 +0800 Subject: [PATCH] feat: implement PI core chat timeline --- .../20260823-pi-chat-timeline-c8f31a62.md | 176 ++++++ README.md | 18 +- electron/api/host-api-transport.ts | 2 + electron/api/route-handlers.ts | 2 + electron/api/routes/coding-attachments.ts | 109 ++++ electron/api/routes/coding-conversations.ts | 2 +- .../coding-projects/conversation-store.ts | 10 +- electron/coding-projects/project-config.ts | 29 +- electron/coding-projects/project-store.ts | 8 +- .../coding-runtime/conversation-service.ts | 106 +++- .../coding-runtime/pi/session-registry.ts | 7 +- electron/main/ipc/host-api-proxy.ts | 22 +- shared/coding-conversation-contracts.ts | 16 + shared/coding-project-contracts.ts | 54 ++ src/lib/coding-attachments.ts | 43 ++ src/lib/coding-conversations.ts | 9 + src/lib/coding-projects.ts | 48 ++ src/lib/host-api.ts | 63 ++ src/pages/Chat/CodingChatPanel.tsx | 574 ++++++++++++++++++ src/pages/Chat/CodingComposer.tsx | 173 ++++++ src/pages/Chat/CodingConversationTimeline.tsx | 370 +++++++++++ src/pages/Chat/coding-chat-snapshot.ts | 28 + src/pages/Chat/index.tsx | 5 +- src/stores/coding-conversations.ts | 288 +++++++-- src/stores/coding-workspace.ts | 212 +++++++ src/types/coding-conversation.ts | 10 +- src/types/coding-project.ts | 7 + tests/e2e/pi-coding-first-chat.spec.ts | 286 +++++++++ tests/unit/ai-hardware-page.test.tsx | 5 +- tests/unit/coding-attachments-facade.test.ts | 45 ++ tests/unit/coding-attachments-routes.test.ts | 137 +++++ tests/unit/coding-chat-panel.test.tsx | 201 ++++++ .../coding-conversation-timeline.test.tsx | 162 +++++ .../unit/coding-conversations-store.test.tsx | 297 ++++++++- tests/unit/coding-core-routes.test.ts | 162 ++++- tests/unit/coding-projects-facade.test.ts | 42 ++ tests/unit/coding-workspace-store.test.ts | 124 ++++ tests/unit/host-api-proxy.test.ts | 44 ++ tests/unit/host-api.test.ts | 27 + tests/unit/pi-session-registry.test.ts | 77 +++ 40 files changed, 3881 insertions(+), 119 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md create mode 100644 electron/api/routes/coding-attachments.ts create mode 100644 shared/coding-project-contracts.ts create mode 100644 src/lib/coding-attachments.ts create mode 100644 src/lib/coding-projects.ts create mode 100644 src/pages/Chat/CodingChatPanel.tsx create mode 100644 src/pages/Chat/CodingComposer.tsx create mode 100644 src/pages/Chat/CodingConversationTimeline.tsx create mode 100644 src/pages/Chat/coding-chat-snapshot.ts create mode 100644 src/stores/coding-workspace.ts create mode 100644 src/types/coding-project.ts create mode 100644 tests/e2e/pi-coding-first-chat.spec.ts create mode 100644 tests/unit/coding-attachments-facade.test.ts create mode 100644 tests/unit/coding-attachments-routes.test.ts create mode 100644 tests/unit/coding-chat-panel.test.tsx create mode 100644 tests/unit/coding-conversation-timeline.test.tsx create mode 100644 tests/unit/coding-projects-facade.test.ts create mode 100644 tests/unit/coding-workspace-store.test.ts diff --git a/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md b/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md new file mode 100644 index 0000000..69e2df0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md @@ -0,0 +1,176 @@ +# Task: Implement PI-120 core chat and first-conversation UX + +## Identity + +- Task ID: 20260823-pi-chat-timeline-c8f31a62 +- Mode: Feature +- Branch: codex/20260823-pi-chat-timeline-c8f31a62-pi-chat-timeline +- Worktree: D:\Datas\OthersProjects\makelore-pi-chat-timeline-c8f31a62 +- Base commit: 54d8bb2e4a6f1a7dd857827659e60d1b4a1fb6f5 +- Owner: codex-root +- Status: In Progress + +## Scope + +- Implement `PI-120 — Core Chat and first-Conversation UX` on cumulative + PI-110 delivery HEAD `54d8bb2`. +- Add a vendor-neutral `CodingChatPanel` composition, first-Conversation + metadata selection/creation, core Composer, and memoized timeline renderers + for message/thinking/tool/compaction/boundary nodes. +- Bind the page exclusively to `/api/coding/*` Renderer facades and the + PI-110 Conversation store, including optimistic prompt acceptance, + preparing/queued/running/error/recovery states, draft continuity, and + hidden-Conversation render isolation. +- Add focused component/performance coverage and the first-prompt Electron E2E. + Do not implement PI-130 feature-complete controls or PI-140 legacy removal. + +## Intent And Constraints + +- The exact Ready Frontier is `{PI-120}`. PI-110 is Done at audit HEAD + `934ba02` and cumulative delivery HEAD `54d8bb2`; PI-130 remains locked until + this ticket is accepted. +- Textarea editability depends only on an active project, an enabled Agent, + and the absence of a project destruction/migration transition. It never + depends on worker/runtime/metadata loading, provider latency, diagnostics, + another Conversation, or the current Conversation run state. +- Conversation creation is local metadata only. Snapshot/prewarm is lazy and + Conversation-scoped; preparation beyond ten seconds becomes a recoverable + error while the draft stays editable. +- A send immediately creates one stable optimistic user node. HTTP 202 is only + acceptance; later provider/tool failure never removes the accepted user + message, and uncertain delivery is never silently resent. +- Renderer components must use granular PI-110 selectors and memoized blocks. + Hidden Conversation streaming cannot rerender the selected timeline; + cumulative tool output replaces rather than appends duplicate content. +- Do not read `src/stores/opencode.ts`, call `/api/opencode`, import Pi/OpenCode + wire types, or mechanically rename the legacy 4k-line panel. The old panel + remains only as PI-140 removal residue after the new page composition takes + ownership. +- PI-130 still owns model/thinking controls, steer/follow-up queue controls, + compaction/retry/context actions, interactions, subagent graph, changes/files/ + browser/skills/commands, and Conversation archive/fork/title/unread UI. +- The planner amended the attachment contract: `POST /api/coding/attachments` + accepts one bounded image binary and returns only `{attachmentId,mime,byteLength}`; + authenticated `GET /api/coding/attachments/:id/content` returns raw bytes. + Upload happens only on Send, each local variant has one in-flight/result, and + Renderer state/protocol keep references rather than base64. +- The planner selected public `patch-batch` delivery only. Main retains private + single-patch envelopes and original seq, batches per Conversation/generation + for 16–33 ms with bounded early flush, and cancels an old generation's pending + batch. Renderer validates the full batch and folds it in one Zustand + transaction; malformed/gapped batches recover only their target. +- Real Provider remains Explicitly Waived / Accepted Risk with + `realTurnVerified=false`. macOS x64/arm64 remains deferred to mandatory + PI-150. Neither is Pass evidence. No subagents are authorized. + +## Project Context Loaded + +- Task identity: `20260823-pi-chat-timeline-c8f31a62`, feature mode, isolated + branch/worktree above, exact base + `54d8bb2e4a6f1a7dd857827659e60d1b4a1fb6f5`. +- Concurrent Task Gate passed. The only semantically adjacent planning task, + `20260820-partner-create-model-parity-c83d`, still has undefined scope and no + claimed files. The canonical main integration task is Blocked and is not a + write target; all other planning owners are unrelated Canvas/Robot/Learning + or read-only scopes. +- Planning Gate inputs read: memory index, active task record, project + positioning/current state/decision/system/architecture/domain documents, + PI-100/105/110 completion records, exact PI-120 ticket and PI-130 boundary, + and relevant `UX-*`, `REN-*`, API, performance and recovery Spec sections. +- Canonical positioning and project-level success fields are placeholders and + the integrated memory still describes the legacy OpenCode UI. Repository + guidance plus the planner-owned Pi Spec/tickets and reviewed cumulative code + are authoritative for this feature branch. +- Confirmed seams: schema-v2 project/config/Conversation routes already provide + local-only metadata; PI-110 provides the typed Snapshot/prompt/SSE facade, + shared reducer, per-Conversation store, optimistic identity, target-only + recovery, and granular selectors. The current Chat page still imports the + legacy `OpencodeChatPanel` and is the cutover seam. +- No accepted ADR conflicts with this ticket. The attachment transport gap is + recorded above and has been escalated to the planner instead of silently + inventing a route. +- Gate result: Passed. + +## Plan + +1. Add the minimal vendor-neutral project/config/Conversation Renderer facade + and metadata store needed to load the active project, enabled Agents and + Conversations without touching the legacy OpenCode store. +2. Build a small `CodingChatPanel` composition and switch the Chat page to it; + make first-Conversation selection/creation local-only and keep Textarea + readiness independent of snapshot/runtime preparation. +3. Implement focused Composer submission/recovery UI over the PI-110 draft and + optimistic request lifecycle, including preparing/queued/running/error + status without a page-wide loading gate. +4. Implement memoized/windowed message, thinking, tool, compaction and boundary + blocks that update only the selected target and keep large/cumulative output + bounded; apply the planner's attachment ruling without expanding PI-130. +5. Add red-capable first-Conversation, core Chat, optimistic failure, + hidden-Conversation/render-count and 100 KB performance fixtures, plus the + first-prompt Electron E2E; then run focused/full validation, build, + documentation gates and planner review. + +## Outcome + +- Replaced the Chat page composition with a vendor-neutral `CodingChatPanel` + backed only by `/api/coding/*` facades. It loads active project/config/local + Conversation metadata, auto-creates the first local Conversation, and keeps + the Composer editable while runtime Snapshot preparation is held. +- Added text/image Composer behavior, stable optimistic user nodes, distinct + preparing/accepted/running/uncertain/error/recovery feedback, Enter/Shift+Enter + and IME handling, Send-time single-flight image upload, and authenticated + object-URL previews without base64 state or protocol payloads. +- Added a memoized/windowed timeline for message, Markdown, thinking, tool, + compaction, boundary and notice nodes. Hidden Conversation batches leave the + selected timeline selector stable; cumulative tool output is replaced rather + than duplicated. +- Added Main-owned attachment binary routes/IPC transport and public Snapshot + + `patch-batch` SSE delivery. Main batches at 24 ms by default with item/byte + early-flush bounds; Renderer atomically rejects malformed/gapped batches, + buffers during target recovery, trims Snapshot-covered items and replays only + a strict continuous tail. +- Fixed a supported concurrency defect exposed by the full suite: the Pi + Session Registry now reuses one Conversation store per project, serializing + simultaneous first bindings instead of racing writes to the same metadata + file. +- Synchronized `README.md` with the current Pi core Chat, first-Conversation, + batch and attachment behavior. PI-130 feature controls and PI-140 legacy + removal remain outside this task. + +## Verification + +- `pnpm exec vitest run tests/unit/coding-workspace-store.test.ts tests/unit/coding-projects-facade.test.ts tests/unit/coding-attachments-facade.test.ts tests/unit/coding-attachments-routes.test.ts tests/unit/coding-chat-panel.test.tsx tests/unit/coding-conversation-timeline.test.tsx tests/unit/coding-conversations-store.test.tsx tests/unit/coding-core-routes.test.ts tests/unit/host-api.test.ts tests/unit/host-api-proxy.test.ts`: passed, 10 files / 68 tests before the final malformed-batch and Session Registry regression additions. +- `pnpm exec vitest run tests/unit/pi-session-registry.test.ts tests/unit/pi-conversation-runtime.test.ts tests/unit/coding-conversations-store.test.tsx tests/unit/coding-chat-panel.test.tsx`: passed, 4 files / 25 tests. +- `pnpm test`: passed, 217 files / 2303 passed / 2 skipped. The final run is green after fixing the same-project parallel Session Registry write race and stabilizing the AI Hardware async query at its actual loading boundary. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with 0 errors and six unchanged warnings in `ExecutionGraphCard`, `Home`, and `Makelore`. +- `pnpm run build:vite`: passed; existing Vite dynamic-import and chunk-size warnings remain. +- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts`: passed, 1/1. +- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --repeat-each=20 --workers=1`: passed, 20/20 fresh-userData runs. Every sample asserted Composer editability below 500 ms while runtime Snapshot was held, so the measured sample p95 is below the locked 500 ms budget; the same fixture verifies optimistic prompt visibility and HTTP 202 acceptance without a real Provider. +- `git diff --check`: passed; only repository line-ending conversion notices were printed. +- Real Provider remains **Explicitly Waived / Accepted Risk** with + `realTurnVerified=false`; no Provider Account, credential isolation, + protocol-specific endpoint, rate-limit or real concurrency behavior is + claimed as Pass. +- macOS x64/arm64 execution remains user-deferred to mandatory `PI-150`; no + macOS Pass is claimed. + +## Follow-ups + +- PI-130 owns Conversation model/thinking controls, steer/follow-up queue, + compaction/retry/context controls, interactions, subagents, changes/files, + browser, skills/commands, and archive/fork/title/unread UI. +- PI-140 owns removal of legacy OpenCode runtime/routes/dependencies/UI/tests + and migration notice. +- PI-150 must run final packaged cross-platform proof, including the deferred + macOS x64/arm64 matrix. Real Provider qualification remains explicitly waived + unless the user changes that decision. + +## Promotion Candidates + +- Preserve the public Snapshot + `patch-batch` hard-cut contract and the + target-only recovery semantics in the planner-owned Pi Spec/tickets after + implementation review. +- Preserve the explicit attachment upload/read Host contract, Send-time + single-flight rule, and reference-only Renderer state in the planner-owned + Pi Spec/tickets after implementation review. diff --git a/README.md b/README.md index b1a7319..63c6c50 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 -- AI 编程运行时:Electron Main 只启动安装目录 `resources/opencode-ai/bin/` 中固定版本的 OpenCode;Renderer 不直接启动或调用运行时。OpenCode 不参与首屏启动,只在进入 Code 或发起 Code 操作时按需启动;离开 Code 且无活动任务 60 秒后由 Main 关闭 runtime、SSE 与项目监听。首选回环端口被不健康进程占用时,Main 会改用系统分配的临时端口,并把实际 URL 贯穿到所有运行时请求。 +- AI 编程核心对话运行时:Electron Main 管理固定版本的 Pi worker、会话绑定、Provider/凭证、恢复与事件投影;Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应 worker;输入框不等待运行时准备完成。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 @@ -121,17 +121,17 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode ### 项目伙伴与会话 - 新项目默认没有伙伴或 Agent;用户在项目内手动创建伙伴时必须填写名称、预设头像或本地头像、职责和精确的 `provider/model`。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;提示词与 Skill 属于后置高级设置。 -- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。伙伴模型是新会话默认值;聊天输入区的模型选择器以及 `/models`、`/model` 只切换当前 OpenCode Session 的后续轮次,不改写伙伴或 Provider 配置,也不要求重启运行时。 +- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。伙伴模型是新 Conversation 的默认值;当前核心聊天页只提供发送、图片附件与恢复,Conversation 级模型和 thinking 控件尚未出现在该页面。 - 读取旧项目时会把仍缺少模型的伙伴从兼容保留的 `defaultModel` 自动迁移到伙伴自身配置,之后运行时只认伙伴模型。 - 技能资源入口使用扳手图标;点击已安装 Skill 后先展示其目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 -- 一个伙伴可以拥有多条互相独立的 OpenCode Session;伙伴、会话、归档时间、置顶和未读数分别由项目配置与 `.niancode/conversations.json` 保存。 -- 伙伴展开后按时间展示会话,默认显示前五条,更多会话通过“更多会话”展开;每条会话只显示一行精简的最新消息预览和右侧时间,归档按钮仅在悬浮或聚焦会话卡片时出现,选中伙伴会在卡片上保持明确的展开状态反馈。 -- 会话历史按需加载:项目事件流重连只恢复当前选中或仍有活动任务的会话,旧会话在用户打开时从 OpenCode runtime 完整读取;Renderer 最多保留 8 个非活动会话的消息缓存,淘汰只影响内存副本,不删除或改写任何服务端历史。连接稳定时以事件流为准,只有断流才启用低频 REST 回退,并对同一会话的并发历史读取去重。 -- 创建伙伴后先进入伙伴对话,首条消息发送时才懒创建 OpenCode Session;新会话从干净上下文开始,标题从“新对话”在首条消息发送后自动生成,也支持手动重命名。 +- 一个伙伴可以拥有多条互相独立的 Pi Session;伙伴和 Conversation 元数据分别由项目配置与 `.niancode/conversations.json` 保存,稳定 Agent/Conversation id 保持本地历史连续。 +- 核心聊天页左侧按伙伴展示本地 Conversation,并提供新建入口;首次选择没有 Conversation 的伙伴时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。 +- Conversation 历史按需从 Main-owned Snapshot 读取。公开 SSE 只交付 Snapshot 与按 Conversation、worker generation 分组的 `patch-batch`;Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。 +- 核心时间线渲染消息、Markdown、thinking、工具、压缩、轮次边界和通知;默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。工具累计输出覆盖同一块而不重复追加。 +- Composer 支持文字、粘贴或选择 PNG/JPEG/WebP/GIF 图片。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。 - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。共享 runtime/provider 是否真正并发执行不同 Session 尚未经过自动化真实运行 smoke 验证,运行时仍可能自行串行、限流或拒绝请求。 -- 已在当前 fresh OpenCode 运行代际加载且通过 live Agent registry 校验的项目伙伴,会按 Session 独立提交和跟踪回复。若共享 runtime/provider 未确认新 Session 已开始,该 Session 会在 10 秒后单独终止并提示手动重试,不会自动重发或改写其他 Session 状态。运行期间创建唯一 ID 的新伙伴时,只要 OpenCode live Agent registry 已发现该 ID,就可在同一运行代际直接使用;已有伙伴的同 ID 内容编辑以及删除、重建仍保持“等待运行时重新加载”,用户需等当前回复完成后手动重启运行时。上述配置变更都不会自动 restart、reload 或 dispose 共享运行时,以免打断其他 Session。 -- 归档伙伴或单个会话前必须确认;归档会停止对应运行、保留历史,并可从列表底部恢复。未读按会话记录,打开一个会话只清除它自己的未读数。 -- OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩在聊天时间线实际发生的位置显示为独立事件:自动压缩使用“正在优化对话”/“已优化对话”文案,手动执行 `/compact` 使用“正在压缩上下文”/“已压缩上下文”文案。压缩进行中使用弱化动画,完成后变为静态状态并保留在原位置;触发阈值与同一 Session 的消息排队语义保持不变。 +- 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 +- 归档、分支、标题、未读、模型/thinking、队列、交互、子任务、changes/files/browser/skills/commands 等完整 Conversation 操作当前不属于核心聊天页。 ## 兼容标识 diff --git a/electron/api/host-api-transport.ts b/electron/api/host-api-transport.ts index 34151fc..16f01df 100644 --- a/electron/api/host-api-transport.ts +++ b/electron/api/host-api-transport.ts @@ -9,6 +9,8 @@ export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean if (pathname === '/api/events' || pathname === '/api/opencode/events' || pathname === '/api/coding/events') return true; + if (pathname === '/api/coding/attachments' + || pathname.startsWith('/api/coding/attachments/')) return true; if (pathname.startsWith('/api/ai-proxy/')) return true; if (pathname.includes('/events') && pathname.startsWith('/api/image-workspace/')) return true; if ( diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 6fdf374..4cb3865 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -18,6 +18,7 @@ import { handleFileRoutes } from './routes/files'; import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets'; import { handleAgentBrowserRoutes } from './routes/agent-browser'; import { handleCodingFileRoutes } from './routes/coding-files'; +import { handleCodingAttachmentRoutes } from './routes/coding-attachments'; import { handleCodingProjectRoutes } from './routes/coding-projects'; import { handleCodingConversationRoutes } from './routes/coding-conversations'; @@ -45,6 +46,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleWorksRoutes, handleAgentBrowserRoutes, handleUserSyncRoutes, + handleCodingAttachmentRoutes, handleCodingProjectRoutes, handleCodingConversationRoutes, handleCodingFileRoutes, diff --git a/electron/api/routes/coding-attachments.ts b/electron/api/routes/coding-attachments.ts new file mode 100644 index 0000000..1eeb755 --- /dev/null +++ b/electron/api/routes/coding-attachments.ts @@ -0,0 +1,109 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { HostApiContext } from '../context'; +import { sendJson } from '../route-utils'; + +const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024; +const SUPPORTED_IMAGE_MIMES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]); + +function contentType(req: IncomingMessage): string { + const value = req.headers['content-type']; + return (Array.isArray(value) ? value[0] : value)?.split(';', 1)[0]?.trim().toLowerCase() ?? ''; +} + +async function readBoundedBody(req: IncomingMessage): Promise { + const declared = Number(req.headers['content-length']); + if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_ATTACHMENT_BYTES)) { + throw Object.assign(new Error('Attachment size is invalid'), { status: 413 }); + } + const chunks: Buffer[] = []; + let byteLength = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += buffer.byteLength; + if (byteLength > MAX_ATTACHMENT_BYTES) { + throw Object.assign(new Error('Attachment size is invalid'), { status: 413 }); + } + chunks.push(buffer); + } + if (byteLength === 0) throw Object.assign(new Error('Attachment is empty'), { status: 400 }); + return Buffer.concat(chunks); +} + +function sendAttachmentError(res: ServerResponse, error: unknown): void { + const status = typeof (error as { status?: unknown })?.status === 'number' + ? (error as { status: number }).status + : (error as NodeJS.ErrnoException)?.code === 'ENOENT' + ? 404 + : 500; + const message = status === 413 + ? '图片不能超过 16 MB。' + : status === 404 + ? '图片附件不存在。' + : status === 400 + ? '图片附件无效。' + : '图片附件暂时无法读取。'; + sendJson(res, status, { + success: false, + code: status === 413 + ? 'CODING_ATTACHMENT_TOO_LARGE' + : status === 404 + ? 'CODING_ATTACHMENT_NOT_FOUND' + : status === 400 + ? 'CODING_ATTACHMENT_INVALID' + : 'CODING_ATTACHMENT_STORAGE_FAILED', + error: message, + }); +} + +export async function handleCodingAttachmentRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + const upload = url.pathname === '/api/coding/attachments' && req.method === 'POST'; + const contentMatch = url.pathname.match(/^\/api\/coding\/attachments\/([^/]+)\/content$/); + const content = Boolean(contentMatch && req.method === 'GET'); + if (!upload && !content) return false; + const attachments = ctx.codingProducts?.attachments; + if (!attachments) { + sendJson(res, 503, { + success: false, + code: 'CODING_CORE_UNAVAILABLE', + error: '本地编程服务暂时不可用。', + }); + return true; + } + + try { + if (upload) { + const mime = contentType(req); + if (!SUPPORTED_IMAGE_MIMES.has(mime)) { + throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 }); + } + const attachment = await attachments.put(await readBoundedBody(req), mime); + sendJson(res, 201, attachment); + return true; + } + + const attachmentId = decodeURIComponent(contentMatch?.[1] ?? ''); + const record = await attachments.read(attachmentId); + if (!SUPPORTED_IMAGE_MIMES.has(record.mime)) { + throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 }); + } + res.statusCode = 200; + res.setHeader('Content-Type', record.mime); + res.setHeader('Content-Length', String(record.byteLength)); + res.setHeader('Cache-Control', 'private, no-store'); + res.end(record.data); + return true; + } catch (error) { + sendAttachmentError(res, error); + return true; + } +} diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts index 7d64703..499a904 100644 --- a/electron/api/routes/coding-conversations.ts +++ b/electron/api/routes/coding-conversations.ts @@ -88,7 +88,7 @@ export async function handleCodingConversationRoutes( res, event.type, event, - `${event.conversationId}:${event.workerGeneration}:${event.seq}`, + `${event.conversationId}:${event.workerGeneration}:${event.toSeq}`, )) break; } } finally { diff --git a/electron/coding-projects/conversation-store.ts b/electron/coding-projects/conversation-store.ts index 31a7c11..633494a 100644 --- a/electron/coding-projects/conversation-store.ts +++ b/electron/coding-projects/conversation-store.ts @@ -1,21 +1,15 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; +import type { CodingConversationMetadata } from '../../shared/coding-project-contracts'; import type { ConversationModelState, ProductModelRef } from '../coding-runtime/contracts'; import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json'; import { normalizeProductModelRef } from './project-config'; export const CODING_CONVERSATIONS_PATH = '.niancode/conversations.json'; -export interface CodingConversationV2 extends ConversationModelState { - id: string; - agentId: string; - title: string; +export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState { piSessionId?: string; sessionKey?: string; - archivedAt: string | null; - unread: boolean; - createdAt: string; - updatedAt: string; } export interface CodingConversationFileV2 { diff --git a/electron/coding-projects/project-config.ts b/electron/coding-projects/project-config.ts index 2e2ce80..8386ce1 100644 --- a/electron/coding-projects/project-config.ts +++ b/electron/coding-projects/project-config.ts @@ -6,6 +6,10 @@ import { type ProjectAgentResponsibility, type ProjectType, } from '../../shared/project-config'; +import type { + CodingProjectAgent, + CodingProjectConfig, +} from '../../shared/coding-project-contracts'; import type { ConversationModelState, ConversationThinkingLevel, @@ -17,32 +21,11 @@ export const CODING_PROJECT_CONFIG_PATH = '.niancode/project.json'; export const LEGACY_CONVERSATION_NOTICE_VALUES = ['none', 'pending', 'acknowledged'] as const; export type LegacyConversationNotice = (typeof LEGACY_CONVERSATION_NOTICE_VALUES)[number]; -export interface CodingProjectAgentV2 extends ConversationModelState { - id: string; - avatarId: string; - avatarDataUrl?: string; - roleName: string; - name: string; - builtIn: boolean; - enabled: boolean; - skillIds: string[]; - responsibility: ProjectAgentResponsibility; - prompt: string; - archivedAt: string | null; - pinned: boolean; - createdAt: string; - updatedAt: string; -} +export interface CodingProjectAgentV2 extends CodingProjectAgent, ConversationModelState {} -export interface CodingProjectConfigV2 { - schemaVersion: 2; - projectType: ProjectType; - initialized: boolean; +export interface CodingProjectConfigV2 extends CodingProjectConfig { agents: CodingProjectAgentV2[]; - knowledgeDirectory: 'knowledge'; legacyConversationNotice: LegacyConversationNotice; - createdAt: string; - updatedAt: string; } export type CodingProjectConfigReadResult = diff --git a/electron/coding-projects/project-store.ts b/electron/coding-projects/project-store.ts index 278bc2c..c5a7bc2 100644 --- a/electron/coding-projects/project-store.ts +++ b/electron/coding-projects/project-store.ts @@ -1,18 +1,14 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { ProjectType } from '../../shared/project-config'; +import type { CodingProjectSummary } from '../../shared/coding-project-contracts'; import { createCodingProjectMetadata, type CodingProjectConfigV2, } from './project-config'; -export interface CodingProject { - id: string; +export interface CodingProject extends CodingProjectSummary { path: string; - name: string; - createdAt: string; - updatedAt: string; - lastOpenedAt: string; } export interface CodingProjectStoreData { diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 5959f35..93c105b 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -1,4 +1,5 @@ import type { + CodingConversationPatchBatchEvent, CodingConversationRuntime, CodingRuntimeCommand, CodingRuntimeDiagnostics, @@ -49,6 +50,9 @@ export interface CodingConversationServiceOptions { projectId: string; sessionKey: string; }): Promise; + deliveryBatchWindowMs?: number; + deliveryBatchMaxItems?: number; + deliveryBatchMaxBytes?: number; } export type CodingConversationStreamEvent = @@ -59,7 +63,7 @@ export type CodingConversationStreamEvent = seq: number; snapshot: ConversationSnapshot; } - | ({ type: 'patch' } & ConversationPatchEnvelope); + | CodingConversationPatchBatchEvent; export interface CodingConversationEventStream { snapshots: ConversationSnapshot[]; @@ -160,6 +164,100 @@ class PatchQueue implements AsyncIterable { } } +interface PendingPatchBatch { + conversationId: string; + workerGeneration: number; + items: CodingConversationPatchBatchEvent['items']; + byteLength: number; + timer: ReturnType; +} + +class PatchBatchScheduler { + private readonly pending = new Map(); + private readonly windowMs: number; + private readonly maxItems: number; + private readonly maxBytes: number; + private closed = false; + + constructor( + private readonly publish: (event: CodingConversationPatchBatchEvent) => void, + options: CodingConversationServiceOptions, + ) { + this.windowMs = Math.min(33, Math.max(16, options.deliveryBatchWindowMs ?? 24)); + this.maxItems = Math.max(1, options.deliveryBatchMaxItems ?? 128); + this.maxBytes = Math.max(1, options.deliveryBatchMaxBytes ?? 256 * 1024); + } + + push(envelope: ConversationPatchEnvelope): void { + if (this.closed) return; + const key = this.key(envelope.conversationId, envelope.workerGeneration); + const item = { + ...(envelope.runId ? { runId: envelope.runId } : {}), + seq: envelope.seq, + at: envelope.at, + patch: envelope.patch, + }; + const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8'); + let batch = this.pending.get(key); + if (batch && batch.items.length > 0 + && (batch.items.length >= this.maxItems || batch.byteLength + itemBytes > this.maxBytes)) { + this.flush(key); + batch = undefined; + } + if (!batch) { + batch = { + conversationId: envelope.conversationId, + workerGeneration: envelope.workerGeneration, + items: [], + byteLength: 0, + timer: setTimeout(() => this.flush(key), this.windowMs), + }; + this.pending.set(key, batch); + } + batch.items.push(item); + batch.byteLength += itemBytes; + if (batch.items.length >= this.maxItems || batch.byteLength >= this.maxBytes) { + this.flush(key); + } + } + + cancel(conversationId: string): void { + for (const [key, batch] of this.pending) { + if (batch.conversationId !== conversationId) continue; + clearTimeout(batch.timer); + this.pending.delete(key); + } + } + + close(): void { + this.closed = true; + for (const batch of this.pending.values()) clearTimeout(batch.timer); + this.pending.clear(); + } + + private key(conversationId: string, workerGeneration: number): string { + return `${conversationId}\u0000${workerGeneration}`; + } + + private flush(key: string): void { + const batch = this.pending.get(key); + if (!batch || this.closed) return; + this.pending.delete(key); + clearTimeout(batch.timer); + const first = batch.items[0]; + const last = batch.items.at(-1); + if (!first || !last) return; + this.publish({ + type: 'patch-batch', + conversationId: batch.conversationId, + workerGeneration: batch.workerGeneration, + fromSeq: first.seq, + toSeq: last.seq, + items: batch.items, + }); + } +} + export class CodingConversationService { private readonly prepareFlights = new Map>(); private readonly acceptances = new Map(); @@ -466,6 +564,7 @@ export class CodingConversationService { : requiredString(conversationId, 'Conversation id', 128); if (id) await this.projects.findActiveConversation(id); const queue = new PatchQueue(); + const batches = new PatchBatchScheduler((event) => queue.push(event), this.options); const cursors = new Map(); let initialize!: () => void; const initialized = new Promise((resolve) => { initialize = resolve; }); @@ -479,6 +578,7 @@ export class CodingConversationService { if (!cursor || event.workerGeneration !== cursor.workerGeneration || event.seq !== cursor.seq + 1) { + batches.cancel(event.conversationId); try { const snapshot = await this.runtime.getSnapshot(event.conversationId); cursors.set(event.conversationId, snapshot.cursor); @@ -498,7 +598,7 @@ export class CodingConversationService { workerGeneration: event.workerGeneration, seq: event.seq, }); - queue.push({ type: 'patch', ...event }); + batches.push(event); }).catch(() => undefined); }); try { @@ -516,12 +616,14 @@ export class CodingConversationService { events: queue, close: () => { unsubscribe(); + batches.close(); queue.close(); }, }; } catch (error) { initialize(); unsubscribe(); + batches.close(); queue.close(); throw error; } diff --git a/electron/coding-runtime/pi/session-registry.ts b/electron/coding-runtime/pi/session-registry.ts index 9839f9a..49eca6a 100644 --- a/electron/coding-runtime/pi/session-registry.ts +++ b/electron/coding-runtime/pi/session-registry.ts @@ -60,6 +60,7 @@ export class PiSessionRegistry { private readonly createConversationStore: typeof createCodingConversationStore; private readonly records = new Map(); private readonly prepareFlights = new Map>(); + private readonly stores = new Map>(); constructor(options: PiSessionRegistryOptions) { this.projectStore = options.projectStore; @@ -126,7 +127,11 @@ export class PiSessionRegistry { candidate.id === input.agentId && candidate.enabled && !candidate.archivedAt )); if (!agent) throw new Error('Coding Agent does not exist'); - const store = this.createConversationStore(project.path); + let store = this.stores.get(project.path); + if (!store) { + store = this.createConversationStore(project.path); + this.stores.set(project.path, store); + } const conversation = await store.get(input.conversationId); if (!conversation || conversation.agentId !== input.agentId) { throw new Error('Coding Conversation does not exist for the selected Agent'); diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts index 29a2d3b..a6e1926 100644 --- a/electron/main/ipc/host-api-proxy.ts +++ b/electron/main/ipc/host-api-proxy.ts @@ -43,11 +43,19 @@ export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): v // Inject the per-session auth token so the Host API server accepts this request. headers['Authorization'] = `Bearer ${getHostApiToken()}`; headers[RENDERER_CAPABILITY_HEADER] = getRendererCapability(); - let body: string | undefined; + let body: string | Uint8Array | undefined; if (request.body !== undefined && request.body !== null) { if (typeof request.body === 'string') { body = request.body; + } else if (request.body instanceof ArrayBuffer) { + body = new Uint8Array(request.body); + } else if (ArrayBuffer.isView(request.body)) { + body = new Uint8Array( + request.body.buffer, + request.body.byteOffset, + request.body.byteLength, + ); } else { body = JSON.stringify(request.body); } @@ -79,7 +87,14 @@ export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): v body, }); - const data: { status: number; ok: boolean; json?: unknown; text?: string } = { + const data: { + status: number; + ok: boolean; + json?: unknown; + text?: string; + bytes?: Uint8Array; + contentType?: string; + } = { status: response.status, ok: response.ok, }; @@ -88,6 +103,9 @@ export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): v const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { data.json = await response.json().catch(() => undefined); + } else if (contentType.startsWith('image/') || contentType === 'application/octet-stream') { + data.bytes = new Uint8Array(await response.arrayBuffer()); + data.contentType = contentType.split(';', 1)[0]?.trim() || 'application/octet-stream'; } else { data.text = await response.text().catch(() => ''); } diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 49cb690..84013d9 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -315,6 +315,22 @@ export interface ConversationPatchEnvelope { patch: ConversationPatch; } +export interface ConversationPatchBatchItem { + runId?: string; + seq: number; + at: number; + patch: ConversationPatch; +} + +export interface CodingConversationPatchBatchEvent { + type: 'patch-batch'; + conversationId: string; + workerGeneration: number; + fromSeq: number; + toSeq: number; + items: ConversationPatchBatchItem[]; +} + export interface PrepareConversationInput { conversationId: string; projectId: string; diff --git a/shared/coding-project-contracts.ts b/shared/coding-project-contracts.ts new file mode 100644 index 0000000..2ad514a --- /dev/null +++ b/shared/coding-project-contracts.ts @@ -0,0 +1,54 @@ +import type { ProjectAgentResponsibility, ProjectType } from './project-config'; +import type { ConversationModelState } from './coding-conversation-contracts'; + +export interface CodingProjectSummary { + id: string; + name: string; + createdAt: string; + updatedAt: string; + lastOpenedAt: string; +} + +export interface CodingProjectAgent extends ConversationModelState { + id: string; + avatarId: string; + avatarDataUrl?: string; + roleName: string; + name: string; + builtIn: boolean; + enabled: boolean; + skillIds: string[]; + responsibility: ProjectAgentResponsibility; + prompt: string; + archivedAt: string | null; + pinned: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CodingProjectConfig { + schemaVersion: 2; + projectType: ProjectType; + initialized: boolean; + agents: CodingProjectAgent[]; + knowledgeDirectory: 'knowledge'; + legacyConversationNotice: 'none' | 'pending' | 'acknowledged'; + createdAt: string; + updatedAt: string; +} + +export interface CodingConversationMetadata extends ConversationModelState { + id: string; + agentId: string; + title: string; + archivedAt: string | null; + unread: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CodingProjectConfigSnapshot { + project: CodingProjectSummary; + config: CodingProjectConfig; + knowledgeFiles: string[]; +} diff --git a/src/lib/coding-attachments.ts b/src/lib/coding-attachments.ts new file mode 100644 index 0000000..13d8631 --- /dev/null +++ b/src/lib/coding-attachments.ts @@ -0,0 +1,43 @@ +import { hostApiFetch, hostApiFetchBytes } from './host-api'; + +export const CODING_ATTACHMENT_MAX_BYTES = 16 * 1024 * 1024; +export const CODING_ATTACHMENT_MIMES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]); + +export interface CodingAttachmentRef { + attachmentId: string; + mime: string; + byteLength: number; +} + +export async function uploadCodingAttachment(file: File): Promise { + const mime = file.type.trim().toLowerCase(); + if (!CODING_ATTACHMENT_MIMES.has(mime)) throw new Error('仅支持 PNG、JPEG、WebP 或 GIF 图片。'); + if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) { + throw new Error('图片不能超过 16 MB。'); + } + return await hostApiFetch( + '/api/coding/attachments', + { + method: 'POST', + headers: { 'Content-Type': mime }, + body: await file.arrayBuffer(), + }, + ); +} + +export async function loadCodingAttachmentPreview( + attachmentId: string, +): Promise<{ bytes: Uint8Array; mime: string }> { + const response = await hostApiFetchBytes( + `/api/coding/attachments/${encodeURIComponent(attachmentId)}/content`, + ); + if (!CODING_ATTACHMENT_MIMES.has(response.contentType)) { + throw new Error('图片附件格式无效。'); + } + return { bytes: response.bytes, mime: response.contentType }; +} diff --git a/src/lib/coding-conversations.ts b/src/lib/coding-conversations.ts index 6230eb3..50cbea2 100644 --- a/src/lib/coding-conversations.ts +++ b/src/lib/coding-conversations.ts @@ -44,6 +44,15 @@ export async function submitCodingConversationPrompt( return response.acceptance; } +export async function recoverCodingConversation( + conversationId: string, +): Promise { + await hostApiFetch( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/recover`, + { method: 'POST', body: '{}' }, + ); +} + export async function openCodingConversationEvents( conversationId?: string, ): Promise { diff --git a/src/lib/coding-projects.ts b/src/lib/coding-projects.ts new file mode 100644 index 0000000..c0dbb29 --- /dev/null +++ b/src/lib/coding-projects.ts @@ -0,0 +1,48 @@ +import { hostApiFetch } from './host-api'; +import type { + CodingConversationMetadata, + CodingProjectConfigSnapshot, + CodingProjectSummary, +} from '@/types/coding-project'; + +export interface CodingProjectCatalog { + projects: CodingProjectSummary[]; + activeProjectId: string | null; +} + +export async function listCodingProjects(): Promise { + return await hostApiFetch('/api/coding/projects'); +} + +export async function getCodingProjectConfig( + projectId: string, +): Promise { + const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>( + `/api/coding/projects/config?projectId=${encodeURIComponent(projectId)}`, + ); + return response.snapshot; +} + +export async function listCodingProjectConversations( + projectId: string, +): Promise { + const response = await hostApiFetch<{ conversations: CodingConversationMetadata[] }>( + `/api/coding/projects/conversations?projectId=${encodeURIComponent(projectId)}`, + ); + return response.conversations; +} + +export async function createCodingProjectConversation(input: { + projectId: string; + agentId: string; + title: string; +}): Promise { + const response = await hostApiFetch<{ conversation: CodingConversationMetadata }>( + '/api/coding/projects/conversations', + { + method: 'POST', + body: JSON.stringify(input), + }, + ); + return response.conversation; +} diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index e675b53..e01a532 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -66,6 +66,8 @@ type HostApiProxyResponse = { ok?: boolean; json?: unknown; text?: string; + bytes?: Uint8Array; + contentType?: string; }; error?: { message?: string } | string; // backward compatibility fields @@ -80,6 +82,8 @@ type HostApiProxyData = { ok?: boolean; json?: unknown; text?: string; + bytes?: Uint8Array; + contentType?: string; transport?: 'dispatcher' | 'loopback'; }; @@ -297,6 +301,65 @@ export async function hostApiFetch(path: string, init?: RequestInit): Promise } } +export interface HostApiBinaryData { + bytes: Uint8Array; + contentType: string; +} + +export async function hostApiFetchBytes(path: string): Promise { + const startedAt = Date.now(); + try { + const response = await invokeIpc('hostapi:fetch', { + path, + method: 'GET', + headers: { Accept: 'application/octet-stream' }, + body: null, + }); + if (!response.ok) throw new Error(resolveProxyErrorMessage(response.error)); + const data = response.data; + if (!data || data.ok === false || (typeof data.status === 'number' && data.status >= 400)) { + throw createProxyHttpError(data ?? {}); + } + if (!(data.bytes instanceof Uint8Array) || !data.contentType) { + throw new Error('Host API binary response is unavailable'); + } + trackUiEvent('hostapi.fetch', { + route: telemetryRoute(path), + method: 'GET', + source: 'ipc-proxy', + durationMs: Date.now() - startedAt, + status: data.status ?? 200, + }); + return { bytes: data.bytes, contentType: data.contentType }; + } catch (error) { + const normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method: 'GET' }); + if (!shouldFallbackToBrowser(normalized.message) || !allowLocalhostFallback()) { + throw normalized; + } + } + + const [token, hostApiBase] = await Promise.all([getHostApiToken(), getHostApiBaseUrl()]); + const response = await fetch(`${hostApiBase}${path}`, { + headers: { + Accept: 'application/octet-stream', + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw normalizeAppError(new Error(`HTTP ${response.status}`), { + source: 'browser-fallback', + path, + method: 'GET', + status: response.status, + }); + } + return { + bytes: new Uint8Array(await response.arrayBuffer()), + contentType: response.headers.get('content-type')?.split(';', 1)[0]?.trim() + || 'application/octet-stream', + }; +} + export function createHostEventSource(path = '/api/events'): EventSource { // EventSource does not support custom headers, so pass the auth token // as a query parameter. The server accepts both mechanisms. diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx new file mode 100644 index 0000000..88c14a8 --- /dev/null +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -0,0 +1,574 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Bot, + CircleAlert, + LoaderCircle, + MessageSquarePlus, + RefreshCw, + Settings2, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + CODING_ATTACHMENT_MAX_BYTES, + CODING_ATTACHMENT_MIMES, + uploadCodingAttachment, + type CodingAttachmentRef, +} from '@/lib/coding-attachments'; +import { cn } from '@/lib/utils'; +import { + codingConversationStore, + selectCodingConversationDraft, + selectCodingConversationRequests, + selectCodingConversationSnapshot, + useCodingConversationStore, + type CodingConversationStoreState, +} from '@/stores/coding-conversations'; +import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; +import type { + CodingDraftAttachment, +} from '@/types/coding-conversation'; +import type { + CodingConversationMetadata, +} from '@/types/coding-project'; +import { CodingComposer } from './CodingComposer'; +import { CodingConversationTimeline } from './CodingConversationTimeline'; +import { createLocalConversationSnapshot } from './coding-chat-snapshot'; + +export interface CodingChatPanelProps { + navigationDraft?: string; + onOpenProjectSettings?(): void; +} + +interface LocalComposerAttachment { + id: string; + name: string; + file: File; + previewUrl: string; +} + +function newestConversation( + conversations: CodingConversationMetadata[], + agentId: string, +): CodingConversationMetadata | null { + return conversations + .filter((conversation) => conversation.agentId === agentId && !conversation.archivedAt) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null; +} + +function requestCount( + state: CodingConversationStoreState, + conversationId: string | null, + statuses: ReadonlySet, +): number { + if (!conversationId) return 0; + const requests = selectCodingConversationRequests(conversationId)(state); + return Object.values(requests).filter((request) => statuses.has(request.status)).length; +} + +const BLOCKING_REQUEST_STATUSES = new Set(['pending', 'accepted']); +const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']); +const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']); + +export function CodingChatPanel({ + navigationDraft, + onOpenProjectSettings, +}: CodingChatPanelProps) { + const activeProject = useCodingWorkspaceStore((state) => state.activeProject); + const config = useCodingWorkspaceStore((state) => state.config); + const conversations = useCodingWorkspaceStore((state) => state.conversations); + const selectedAgentId = useCodingWorkspaceStore((state) => state.selectedAgentId); + const workspaceLoadState = useCodingWorkspaceStore((state) => state.loadState); + const workspaceError = useCodingWorkspaceStore((state) => state.error); + const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds); + const loadWorkspace = useCodingWorkspaceStore((state) => state.load); + const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent); + const ensureConversation = useCodingWorkspaceStore((state) => state.ensureConversation); + const createConversation = useCodingWorkspaceStore((state) => state.createConversation); + + const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId); + const connectionState = useCodingConversationStore((state) => state.connectionState); + const connectionError = useCodingConversationStore((state) => state.globalError); + const primeConversation = useCodingConversationStore((state) => state.primeConversation); + const selectConversation = useCodingConversationStore((state) => state.selectConversation); + const clearConversationSelection = useCodingConversationStore((state) => state.clearConversationSelection); + const disconnectEvents = useCodingConversationStore((state) => state.disconnectEvents); + const setConversationDraft = useCodingConversationStore((state) => state.setDraft); + const submitPrompt = useCodingConversationStore((state) => state.submitPrompt); + const recoverConversation = useCodingConversationStore((state) => state.recoverConversation); + + const [provisionalDraft, setProvisionalDraft] = useState(''); + const [creatingNewConversation, setCreatingNewConversation] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submissionError, setSubmissionError] = useState(null); + const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< + Record + >({}); + const appliedNavigationDraftRef = useRef(null); + const automaticCreationKeyRef = useRef(null); + const attachmentsRef = useRef(attachmentsByDraftKey); + const uploadedAttachmentsRef = useRef(new Map()); + const uploadFlightsRef = useRef(new Map>()); + + const agents = useMemo(() => ( + config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? [] + ), [config]); + const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null; + const agentConversations = useMemo(() => ( + selectedAgent + ? conversations + .filter((conversation) => conversation.agentId === selectedAgent.id && !conversation.archivedAt) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + : [] + ), [conversations, selectedAgent]); + const selectedConversation = conversations.find((conversation) => ( + conversation.id === selectedConversationId + )) ?? null; + const provisionalDraftKey = activeProject && selectedAgent + ? `new:${activeProject.id}:${selectedAgent.id}` + : null; + const draftKey = selectedConversationId ?? provisionalDraftKey; + const localAttachments = useMemo(() => ( + draftKey ? attachmentsByDraftKey[draftKey] ?? [] : [] + ), [attachmentsByDraftKey, draftKey]); + + attachmentsRef.current = attachmentsByDraftKey; + + const selectDraft = useCallback((state: CodingConversationStoreState) => ( + selectedConversationId + ? selectCodingConversationDraft(selectedConversationId)(state) + : null + ), [selectedConversationId]); + const selectedDraft = useCodingConversationStore(selectDraft); + const selectSnapshot = useCallback((state: CodingConversationStoreState) => ( + selectedConversationId + ? selectCodingConversationSnapshot(selectedConversationId)(state) + : null + ), [selectedConversationId]); + const snapshot = useCodingConversationStore(selectSnapshot); + const entryLoadState = useCodingConversationStore((state) => ( + selectedConversationId + ? state.entriesByConversationId[selectedConversationId]?.loadState ?? 'empty' + : 'empty' + )); + const entryError = useCodingConversationStore((state) => ( + selectedConversationId + ? state.entriesByConversationId[selectedConversationId]?.error ?? null + : null + )); + const blockingRequestCount = useCodingConversationStore((state) => requestCount( + state, + selectedConversationId, + BLOCKING_REQUEST_STATUSES, + )); + const acceptedRequestCount = useCodingConversationStore((state) => requestCount( + state, + selectedConversationId, + ACCEPTED_REQUEST_STATUSES, + )); + const uncertainRequestCount = useCodingConversationStore((state) => requestCount( + state, + selectedConversationId, + UNCERTAIN_REQUEST_STATUSES, + )); + + useEffect(() => { + void loadWorkspace().catch(() => undefined); + }, [loadWorkspace]); + + useEffect(() => () => disconnectEvents(), [disconnectEvents]); + + useEffect(() => () => { + for (const attachments of Object.values(attachmentsRef.current)) { + for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl); + } + }, []); + + useEffect(() => { + if (!activeProject) return; + for (const conversation of conversations) { + primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); + } + }, [activeProject, conversations, primeConversation]); + + useEffect(() => { + if (!activeProject || !selectedAgent) { + clearConversationSelection(); + return; + } + if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return; + const next = newestConversation(conversations, selectedAgent.id); + if (next) { + void selectConversation(next.id).catch(() => undefined); + return; + } + const creationKey = `${activeProject.id}:${selectedAgent.id}`; + if (automaticCreationKeyRef.current === creationKey) return; + automaticCreationKeyRef.current = creationKey; + void ensureConversation(selectedAgent.id) + .then((conversation) => { + primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); + void selectConversation(conversation.id).catch(() => undefined); + }) + .catch(() => undefined); + }, [ + activeProject, + clearConversationSelection, + conversations, + ensureConversation, + primeConversation, + selectConversation, + selectedAgent, + selectedConversation, + ]); + + useEffect(() => { + const nextDraft = navigationDraft?.trim(); + if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return; + appliedNavigationDraftRef.current = nextDraft; + if (selectedConversationId) { + setConversationDraft(selectedConversationId, nextDraft); + } else { + setProvisionalDraft(nextDraft); + } + }, [navigationDraft, selectedConversationId, setConversationDraft]); + + useEffect(() => { + if (!selectedConversationId) return; + if (provisionalDraft.trim()) { + const current = codingConversationStore.getState().draftsByConversationId[selectedConversationId]; + if (!current?.text) setConversationDraft(selectedConversationId, provisionalDraft); + setProvisionalDraft(''); + } + if (provisionalDraftKey) { + setAttachmentsByDraftKey((currentAttachments) => { + const pending = currentAttachments[provisionalDraftKey]; + if (!pending?.length || currentAttachments[selectedConversationId]?.length) return currentAttachments; + const next = { ...currentAttachments, [selectedConversationId]: pending }; + delete next[provisionalDraftKey]; + return next; + }); + } + }, [provisionalDraft, provisionalDraftKey, selectedConversationId, setConversationDraft]); + + const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => { + if (!activeProject) return; + primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); + void selectConversation(conversation.id).catch(() => undefined); + }, [activeProject, primeConversation, selectConversation]); + + const handleCreateConversation = useCallback(async () => { + if (!activeProject || !selectedAgent || creatingNewConversation) return; + setCreatingNewConversation(true); + try { + const conversation = await createConversation(selectedAgent.id); + primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); + await selectConversation(conversation.id).catch(() => undefined); + } finally { + setCreatingNewConversation(false); + } + }, [ + activeProject, + createConversation, + creatingNewConversation, + primeConversation, + selectConversation, + selectedAgent, + ]); + + const handleAddFiles = useCallback((files: File[]) => { + if (!draftKey) return; + const accepted: LocalComposerAttachment[] = []; + for (const file of files) { + const mime = file.type.trim().toLowerCase(); + if (!CODING_ATTACHMENT_MIMES.has(mime)) { + setSubmissionError('仅支持 PNG、JPEG、WebP 或 GIF 图片。'); + continue; + } + if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) { + setSubmissionError('图片不能超过 16 MB。'); + continue; + } + accepted.push({ + id: crypto.randomUUID(), + name: file.name || '图片', + file, + previewUrl: URL.createObjectURL(file), + }); + } + if (accepted.length === 0) return; + setSubmissionError(null); + setAttachmentsByDraftKey((current) => ({ + ...current, + [draftKey]: [...(current[draftKey] ?? []), ...accepted], + })); + }, [draftKey]); + + const handleRemoveAttachment = useCallback((id: string) => { + if (!draftKey) return; + setAttachmentsByDraftKey((current) => { + const attachment = current[draftKey]?.find((item) => item.id === id); + if (attachment) URL.revokeObjectURL(attachment.previewUrl); + const nextItems = (current[draftKey] ?? []).filter((item) => item.id !== id); + const next = { ...current, [draftKey]: nextItems }; + if (nextItems.length === 0) delete next[draftKey]; + return next; + }); + uploadedAttachmentsRef.current.delete(id); + uploadFlightsRef.current.delete(id); + }, [draftKey]); + + const prepareAttachments = useCallback(async ( + attachments: LocalComposerAttachment[], + ): Promise => await Promise.all(attachments.map(async (attachment) => { + let uploaded = uploadedAttachmentsRef.current.get(attachment.id); + if (!uploaded) { + let flight = uploadFlightsRef.current.get(attachment.id); + if (!flight) { + flight = uploadCodingAttachment(attachment.file); + uploadFlightsRef.current.set(attachment.id, flight); + } + try { + uploaded = await flight; + uploadedAttachmentsRef.current.set(attachment.id, uploaded); + } finally { + if (uploadFlightsRef.current.get(attachment.id) === flight) { + uploadFlightsRef.current.delete(attachment.id); + } + } + } + return { + attachmentId: uploaded.attachmentId, + mime: uploaded.mime, + previewUrl: attachment.previewUrl, + }; + })), []); + + const handleSubmit = useCallback(() => { + if (!selectedConversationId || !draftKey || submitting) return; + const attachments = [...localAttachments]; + setSubmitting(true); + setSubmissionError(null); + void submitPrompt({ + conversationId: selectedConversationId, + mode: 'prompt', + prepareAttachments: attachments.length > 0 + ? () => prepareAttachments(attachments) + : undefined, + }).then(() => { + setAttachmentsByDraftKey((current) => { + const next = { ...current }; + delete next[draftKey]; + return next; + }); + for (const attachment of attachments) { + URL.revokeObjectURL(attachment.previewUrl); + uploadedAttachmentsRef.current.delete(attachment.id); + } + }).catch((error) => { + setSubmissionError(error instanceof Error ? error.message : String(error)); + }).finally(() => { + setSubmitting(false); + }); + }, [ + draftKey, + localAttachments, + prepareAttachments, + selectedConversationId, + submitPrompt, + submitting, + ]); + + const runStatus = snapshot?.run.status ?? 'idle'; + const workerStatus = snapshot?.worker.status ?? 'stopped'; + const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null; + const preparationError = entryError ?? runtimeError?.message ?? null; + const preparing = entryLoadState === 'loading' + || workerStatus === 'starting' + || workerStatus === 'recovering' + || runStatus === 'preparing'; + const recovering = entryLoadState === 'recovering'; + const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); + const draft = selectedDraft?.text ?? provisionalDraft; + const editable = Boolean(activeProject && selectedAgent); + const canSend = Boolean( + editable + && selectedConversationId + && (draft.trim() || localAttachments.length > 0) + && !running + && !submitting + && !preparationError + && blockingRequestCount === 0 + && uncertainRequestCount === 0, + ); + const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]); + + if (!activeProject && workspaceLoadState !== 'loading') { + return ( +
+
+
+
+

先选择一个编程项目

+

+ 项目和伙伴信息只在本地读取,不会为了打开输入框启动 Agent。 +

+ +
+
+ ); + } + + return ( +
+ + +
+
+
+

{selectedConversation?.title ?? '新对话'}

+

+ {selectedAgent?.name ?? '尚未选择伙伴'} + {connectionState === 'reconnecting' ? ' · 正在重连' : ''} +

+
+ {workspaceLoadState === 'loading' && } + +
+ + {(workspaceError || connectionError) && ( +
+
+ )} + + {selectedConversationId + ? ( + + ) + : ( +
+ )} + + ({ id, name, previewUrl }))} + submitting={submitting} + placeholder={selectedAgent ? '给当前对话发送消息' : '请选择一个伙伴后再发送'} + onChange={(value) => { + setSubmissionError(null); + if (selectedConversationId) setConversationDraft(selectedConversationId, value); + else setProvisionalDraft(value); + }} + onSubmit={handleSubmit} + onRecover={() => { + if (selectedConversationId) { + void recoverConversation(selectedConversationId).catch(() => undefined); + } + }} + onAddFiles={handleAddFiles} + onRemoveAttachment={handleRemoveAttachment} + /> +
+
+ ); +} diff --git a/src/pages/Chat/CodingComposer.tsx b/src/pages/Chat/CodingComposer.tsx new file mode 100644 index 0000000..55d9ae5 --- /dev/null +++ b/src/pages/Chat/CodingComposer.tsx @@ -0,0 +1,173 @@ +import { LoaderCircle, Plus, RotateCcw, Send, X } from 'lucide-react'; +import { useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import type { ConversationRunStatus } from '@/types/coding-conversation'; + +export interface CodingComposerProps { + value: string; + editable: boolean; + canSend: boolean; + preparing: boolean; + recovering: boolean; + runStatus: ConversationRunStatus; + error: string | null; + recoverableError: boolean; + acceptedCount: number; + attachments: Array<{ id: string; name: string; previewUrl: string }>; + submitting: boolean; + placeholder: string; + onChange(value: string): void; + onSubmit(): void; + onRecover(): void; + onAddFiles(files: File[]): void; + onRemoveAttachment(id: string): void; +} + +export function CodingComposer({ + value, + editable, + canSend, + preparing, + recovering, + runStatus, + error, + recoverableError, + acceptedCount, + attachments, + submitting, + placeholder, + onChange, + onSubmit, + onRecover, + onAddFiles, + onRemoveAttachment, +}: CodingComposerProps) { + const fileInputRef = useRef(null); + const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); + return ( +
{ + event.preventDefault(); + if (canSend) onSubmit(); + }} + > + {(preparing || recovering || submitting || error || acceptedCount > 0 || running) && ( +
+ {(preparing || recovering || submitting) &&
+ )} + { + onAddFiles(Array.from(event.target.files ?? [])); + event.target.value = ''; + }} + /> +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment) => ( +
+ {attachment.name} + +
+ ))} +
+ )} +