diff --git a/README.md b/README.md index 852b421..e50d62b 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,21 @@ ## 已实现 - 单会话聊天、SQLite 持久化与首轮 AI 主题标题 +- 首屏由 Server Component 直接读取 SQLite,客户端 hydration 前即可显示工作台 - 标题编辑、会话重置(重置后标题清空) - DeepSeek V4 Flash 流式回答 - Skill 多选、拖拽排序、标签移除与 `/` 快速提示 -- 每轮由 AI 独立判断实际使用哪些已选 Skill +- 勾选集合绑定当前需求生命周期,直到需求完成或明确放弃 +- 每轮由 AI 独立判断实际使用哪些已勾选 Skill - 候选 Skill 与实际使用 Skill 的差异化高亮反馈 -- 回答中止;中止作为本轮终态处理,并清空本轮 Skill +- Skill 编辑自动递增版本;进行中的需求固定使用开始时的完整快照 +- 回答中止采用 SQLite 原子终态;只结束 Run,不清空需求的 Skill - SSE 事件持久化、序号去重与断线续传 - Skill 新建、AI 对话编辑、删除与列表自动刷新 - 五节点严格顺序:触发条件、输入参数、执行步骤、输出格式、约束与测试 - 已完成节点可回改,未解锁节点不可跳过 - 右侧节点内容完全只读,只能通过 AI 对话修改 +- AI 先在聊天中展示待写入提案,用户确认后才更新节点 - 所有节点完成后才允许保存 - AI JSON 输出经过严格结构校验、纠错重试和服务端二次约束 - 无 API Key 时自动进入可操作的本地演示模式 @@ -59,12 +63,26 @@ SQLite 默认写入 `data/skillloom.db`,包含: - `conversations` / `messages` - `chat_runs` / `chat_run_events` +`skills.version` 记录可编辑版本;`messages.skill_snapshots_json` 保存每轮 +审计快照;`conversations.task_state` 与 +`retained_skill_snapshots` 保存进行中需求的生命周期和固定版本。 + 主聊天采用“两步式 Run”: 1. `POST /api/chat` 幂等创建回答任务。 2. `GET /api/chat/runs/:id/stream?after=:seq` 订阅 SSE。 -每个事件先写入 SQLite 并获得单调递增的 `seq`,浏览器断线后携带最后序号重连,因此不会重复拼接 token。显式中止使用独立接口,不会把普通网络断开误判为用户中止。 +每个事件先写入 SQLite 并获得单调递增的 `seq`,浏览器断线后携带最后序号重连,因此不会重复拼接 token。显式中止使用独立接口,并在同一事务中写入可见内容、用量、Skill 保留状态、终态事件和 Run 状态,避免停止请求与后台流式写入相互覆盖。 + +Skill 有三组彼此独立的状态: + +1. 用户勾选集合:当前需求允许使用哪些 Skill; +2. 当轮使用集合:路由器从勾选集合中实际选择的子集; +3. 需求状态:`active`、`awaiting_input`、`offer_pending`、 + `complete` 或 `abandoned`。 + +只有 `complete` 和 `abandoned` 会清空勾选集合。回答失败、中止或服务重启 +只结束本次 Run,需求和已固定的 Skill 快照仍保留。 结构化 AI 输出经过四层保护: @@ -88,7 +106,9 @@ npm run test:acceptance - 删除 Skill 与列表刷新 - 聊天选择和实际调用 Skill - 返回是否调用及具体 Skill -- 中止作为终态 +- 多选 Skill 的需求级保留与单轮子集路由 +- Skill 编辑版本递增与跨轮快照固定 +- 中止内容、事件与需求状态的原子终态 - 首轮 AI 主题标题、标题编辑与会话重置 - SSE 断线重连、事件去重和继续生成 diff --git a/scripts/acceptance.mjs b/scripts/acceptance.mjs index a11b160..14140cb 100644 --- a/scripts/acceptance.mjs +++ b/scripts/acceptance.mjs @@ -152,6 +152,7 @@ let nodes = definitions.map(([key, title, description]) => ({ let skillName = "未命名 Skill"; let skillDescription = ""; let builderMessages = []; +let pendingProposal = null; async function builderTurn(message) { const clientRequestId = `acceptance_builder_${crypto.randomUUID()}`; @@ -163,6 +164,7 @@ async function builderTurn(message) { message, skillName, skillDescription, + pendingProposal, nodes, messages: builderMessages, }); @@ -216,6 +218,7 @@ async function builderTurn(message) { nodes = update.evaluation.nodes; skillName = update.evaluation.skillName; skillDescription = update.evaluation.skillDescription; + pendingProposal = update.evaluation.proposal; const reply = events .filter((event) => event.type === "token") .map((event) => event.token) @@ -231,6 +234,21 @@ async function runAcceptance() { await waitForServer(); console.log("✓ 测试服务与独立 SQLite 已启动"); + const initialPageResponse = await fetch(baseUrl); + const initialPageHtml = await initialPageResponse.text(); + assert.equal(initialPageResponse.ok, true, "工作台首屏必须可以直接访问"); + assert.doesNotMatch( + initialPageHtml, + /正在打开工作台/, + "首屏不得依赖客户端 effect 才能离开加载状态", + ); + assert.match( + initialPageHtml, + /有什么我可以帮你/, + "服务端首屏必须直接输出可用的工作台内容", + ); + console.log("✓ 工作台首屏由服务端数据直接渲染,不会卡在加载状态"); + const migratedChat = await jsonRequest("/api/chat"); assert.equal(migratedChat.continuation.state, "awaiting_input"); assert.deepEqual(migratedChat.continuation.skillIds, ["skill_sql_guard"]); @@ -433,12 +451,37 @@ async function runAcceptance() { console.log("✓ Builder 拒绝脚本/API 执行能力并给出可行替代"); const incompleteTurn = await builderTurn("做一套清单"); + assert.equal( + incompleteTurn.evaluation.action, + "proposed", + "规范内容必须先作为待确认提案返回", + ); + assert.equal( + nodes.every((node) => node.content === ""), + true, + "用户确认前不得写入任何节点内容", + ); + assert.match( + incompleteTurn.reply, + /待确认|确认写入/, + "聊天回复必须明确展示待确认状态", + ); + const confirmedIncompleteTurn = await builderTurn("确认写入"); + assert.equal( + confirmedIncompleteTurn.evaluation.action, + "applied", + "用户确认后才允许写入提案", + ); assert.equal( nodes.filter((node) => node.completed).length, 0, "信息不足时节点不能提前完成", ); - assert.match(incompleteTurn.reply, /[??]/, "信息不足时 AI 必须继续追问"); + assert.match( + confirmedIncompleteTurn.reply, + /[??]/, + "确认写入的信息仍不足时 AI 必须继续追问", + ); const multiNodeTurn = await builderTurn( [ @@ -449,9 +492,46 @@ async function runAcceptance() { ].join(";"), ); assert.deepEqual( - multiNodeTurn.evaluation.updatedNodeKeys, + multiNodeTurn.evaluation.proposedNodeKeys, ["trigger", "inputs", "steps", "constraints"], - "一条连贯消息涵盖多个节点时,AI 必须一次归类全部内容", + "一条连贯消息涵盖多个节点时,AI 必须一次归类全部提案内容", + ); + assert.deepEqual( + multiNodeTurn.evaluation.updatedNodeKeys, + [], + "多节点提案确认前不得产生实际更新", + ); + assert.equal( + nodes.filter((node) => node.completed).length, + 0, + "多节点提案确认前进度不得变化", + ); + + const revisedMultiNodeTurn = await builderTurn( + "输入参数改为:必填产品需求、目标用户和验收环境,可选优先级与发布日期。", + ); + assert.equal( + revisedMultiNodeTurn.evaluation.action, + "proposed", + "修改意见必须生成新版提案,而不是直接写入", + ); + assert.match( + pendingProposal.updates.find((update) => update.nodeKey === "inputs") + ?.content ?? "", + /验收环境/, + "新版提案必须吸收用户的修改意见", + ); + assert.equal( + nodes.filter((node) => node.completed).length, + 0, + "修改待确认提案时节点仍不得变化", + ); + + const confirmedMultiNodeTurn = await builderTurn("确认写入"); + assert.deepEqual( + confirmedMultiNodeTurn.evaluation.updatedNodeKeys, + ["trigger", "inputs", "steps", "constraints"], + "确认后必须一次写入完整的多节点提案", ); assert.equal( nodes.filter((node) => node.completed).length, @@ -473,9 +553,20 @@ async function runAcceptance() { "输出格式:输出 Markdown 表格,字段为编号、前置条件、操作、预期结果。", ); assert.deepEqual( - filledGap.evaluation.updatedNodeKeys, + filledGap.evaluation.proposedNodeKeys, ["output"], - "补充缺口时 AI 应更新第 4 节点", + "补充缺口时 AI 应先提议更新第 4 节点", + ); + assert.equal( + nodes.filter((node) => node.completed).length, + 3, + "补充缺口的提案确认前不得改变进度", + ); + const confirmedGap = await builderTurn("确认写入"); + assert.deepEqual( + confirmedGap.evaluation.updatedNodeKeys, + ["output"], + "确认后才应更新第 4 节点", ); assert.equal( nodes.filter((node) => node.completed).length, @@ -483,11 +574,13 @@ async function runAcceptance() { "第 4 节点补齐后,已准备好的第 5 节点必须自动完成", ); assert.doesNotMatch( - filledGap.reply, + confirmedGap.reply, /约束与测试[^。]*[??]/, "全部节点自动完成后不得继续追问已经解锁的节点", ); - console.log("✓ 一轮可完成多个连续节点,缺口补齐后自动解锁后续节点"); + console.log( + "✓ 节点内容先展示提案,支持修改,确认后再写入并按顺序解锁", + ); assert.deepEqual( getSkillNodeQualityIssues(nodes), @@ -594,8 +687,24 @@ async function runAcceptance() { "补充规则:当需求只有一句话时,先追问目标用户再生成清单。", ); assert.ok( - editedTurn.evaluation.updatedNodeKeys.includes("trigger"), - "编辑时也必须由 AI 自动判断目标节点", + editedTurn.evaluation.proposedNodeKeys.includes("trigger"), + "编辑时也必须由 AI 自动判断待修改的目标节点", + ); + const nodesBeforeDiscard = structuredClone(nodes); + const discardedTurn = await builderTurn("放弃提案"); + assert.equal(discardedTurn.evaluation.action, "discarded"); + assert.deepEqual(nodes, nodesBeforeDiscard, "放弃提案不得改变现有节点"); + + const confirmedEditProposal = await builderTurn( + "补充规则:当需求只有一句话时,先追问目标用户再生成清单。", + ); + assert.ok( + confirmedEditProposal.evaluation.proposedNodeKeys.includes("trigger"), + ); + const confirmedEdit = await builderTurn("确认写入"); + assert.ok( + confirmedEdit.evaluation.updatedNodeKeys.includes("trigger"), + "编辑提案也必须在确认后才写入目标节点", ); await jsonRequest(`/api/skills/${skillId}`, { method: "PUT", @@ -828,7 +937,11 @@ async function runAcceptance() { const sqlReviewSkill = seededSkills.skills.find( (skill) => skill.name === "SQL 安全审阅", ); + const decisionNoteSkill = seededSkills.skills.find( + (skill) => skill.name === "决策备忘录", + ); assert.ok(sqlReviewSkill, "预置的 SQL 安全审阅 Skill 必须存在"); + assert.ok(decisionNoteSkill, "预置的决策备忘录 Skill 必须存在"); const explainSkillPayload = await jsonRequest("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -866,6 +979,79 @@ async function runAcceptance() { ); console.log("✓ 询问 Skill 用途时严格返回已选 Skill 的真实定义"); + const stagedSkillPayload = await jsonRequest("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + conversationId: chat.conversation.id, + message: "请分别介绍这些 Skill 的用途", + selectedSkillIds: [decisionNoteSkill.id, sqlReviewSkill.id], + clientRequestId: `acceptance_staged_skills_${crypto.randomUUID()}`, + }), + }); + const stagedSkillEvents = await readSse( + await fetch( + `${baseUrl}/api/chat/runs/${stagedSkillPayload.run.id}/stream?after=0`, + ), + ); + assert.deepEqual( + stagedSkillEvents + .filter( + (event) => + event.type === "skill_started" || + event.type === "skill_completed", + ) + .map((event) => [event.type, event.skillId]), + [ + ["skill_started", decisionNoteSkill.id], + ["skill_completed", decisionNoteSkill.id], + ["skill_started", sqlReviewSkill.id], + ["skill_completed", sqlReviewSkill.id], + ], + "多个 Skill 必须按用户选择顺序逐个发送开始与完成事件", + ); + const stagedStartedEvents = stagedSkillEvents.filter( + (event) => event.type === "skill_started", + ); + const stagedCompletedEvents = stagedSkillEvents.filter( + (event) => event.type === "skill_completed", + ); + assert.equal( + stagedStartedEvents[0]?.input?.request, + "请分别介绍这些 Skill 的用途", + "Skill 开始事件必须包含实际用户请求", + ); + assert.match( + stagedCompletedEvents[0]?.result ?? "", + /复杂选项整理成有依据、可复盘的决策建议/, + "Skill 完成事件必须包含真实阶段产物", + ); + assert.doesNotMatch( + stagedCompletedEvents[0]?.result ?? "", + /\"used\"\s*:\s*true/, + "Skill 结果不得再使用 used=true 占位", + ); + const chatWithStagedExecutions = await jsonRequest("/api/chat"); + const persistedStagedMessage = chatWithStagedExecutions.messages.find( + (message) => message.id === stagedSkillPayload.run.assistantMessageId, + ); + assert.equal( + persistedStagedMessage?.skillExecutions.length, + 2, + "助手消息必须持久化每个 Skill 的执行记录", + ); + assert.ok( + persistedStagedMessage?.skillExecutions.every( + (execution) => + execution.status === "completed" && + execution.input.request === "请分别介绍这些 Skill 的用途" && + typeof execution.result === "string" && + execution.result.length > 0, + ), + "刷新后仍应读取真实 Skill 输入与结果", + ); + console.log("✓ 多 Skill 按顺序执行并持久化真实输入与阶段结果"); + const followUpPayload = await jsonRequest("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -873,7 +1059,7 @@ async function runAcceptance() { conversationId: chat.conversation.id, message: "产品需求 验收清单:我还没提供具体内容,请先询问我需要补充的信息。", - selectedSkillIds: [skillId], + selectedSkillIds: [skillId, sqlReviewSkill.id], clientRequestId: `acceptance_follow_up_${crypto.randomUUID()}`, }), }); @@ -885,6 +1071,20 @@ async function runAcceptance() { const followUpRetention = followUpEvents.find( (event) => event.type === "skill_retention", ); + const followUpUsage = followUpEvents.find( + (event) => event.type === "skill_usage", + ); + assert.equal( + followUpUsage?.skillIds.length, + 1, + "路由器应只使用两个已勾选 Skill 中的相关子集", + ); + assert.ok( + followUpUsage.skillIds.every((id) => + [skillId, sqlReviewSkill.id].includes(id), + ), + "实际使用项不得超出用户勾选集合", + ); assert.equal( followUpRetention?.state, "awaiting_input", @@ -892,13 +1092,13 @@ async function runAcceptance() { ); assert.deepEqual( followUpRetention?.skillIds, - [skillId], - "未完成的多轮任务必须保留实际使用的 Skill", + [skillId, sqlReviewSkill.id], + "未完成的需求必须保留全部勾选 Skill,不能丢弃当轮未使用项", ); const chatWithRetainedSkill = await jsonRequest("/api/chat"); assert.deepEqual( chatWithRetainedSkill.retainedSkillIds, - [skillId], + [skillId, sqlReviewSkill.id], "刷新页面后仍必须恢复待继续任务的 Skill", ); assert.equal( @@ -915,7 +1115,40 @@ async function runAcceptance() { "deterministic", "明确追问应由确定性规则识别,不依赖 AI 猜测", ); - console.log("✓ AI 追问时跨轮保留 Skill,并持久化到 SQLite"); + const followUpAssistantMessage = chatWithRetainedSkill.messages.find( + (message) => message.id === followUpPayload.run.assistantMessageId, + ); + const pinnedSnapshot = followUpAssistantMessage?.skillSnapshots.find( + (snapshot) => snapshot.id === skillId, + ); + assert.ok(pinnedSnapshot?.version >= 1, "消息必须保存 Skill 版本快照"); + + const latestSkillBeforeEdit = ( + await jsonRequest(`/api/skills/${skillId}`) + ).skill; + assert.equal( + pinnedSnapshot.version, + latestSkillBeforeEdit.version, + "首次进入需求时必须固定当时的 Skill 版本", + ); + const updatedDuringTask = await jsonRequest(`/api/skills/${skillId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: latestSkillBeforeEdit.name, + description: `${latestSkillBeforeEdit.description}(新版)`, + nodes: latestSkillBeforeEdit.nodes, + status: "active", + }), + }); + assert.equal( + updatedDuringTask.skill.version, + pinnedSnapshot.version + 1, + "每次编辑 Skill 必须递增版本号", + ); + console.log( + "✓ AI 追问时保留全部勾选 Skill,并固定版本快照到 SQLite", + ); const continuedPayload = await jsonRequest("/api/chat", { method: "POST", @@ -923,7 +1156,7 @@ async function runAcceptance() { body: JSON.stringify({ conversationId: chat.conversation.id, message: "需要", - selectedSkillIds: [skillId], + selectedSkillIds: [skillId, sqlReviewSkill.id], clientRequestId: `acceptance_continued_skill_${crypto.randomUUID()}`, }), }); @@ -937,7 +1170,7 @@ async function runAcceptance() { ); assert.deepEqual( continuedUsage?.skillIds, - [skillId], + [skillId, sqlReviewSkill.id], "用户用“需要”等短回复接受上一轮提议时必须继续使用保留的 Skill", ); assert.equal( @@ -945,7 +1178,18 @@ async function runAcceptance() { "continuation", "省略回复必须通过显式续接状态恢复 Skill", ); - console.log("✓ “需要”等省略回复会继续使用上一轮保留的 Skill"); + const chatAfterContinuation = await jsonRequest("/api/chat"); + const continuedAssistantMessage = chatAfterContinuation.messages.find( + (message) => message.id === continuedPayload.run.assistantMessageId, + ); + assert.equal( + continuedAssistantMessage?.skillSnapshots.find( + (snapshot) => snapshot.id === skillId, + )?.version, + pinnedSnapshot.version, + "进行中的需求必须继续使用旧快照,不能混入刚编辑的新版本", + ); + console.log("✓ 省略回复续接所选 Skill,并沿用需求开始时的版本快照"); const stopPayload = await jsonRequest("/api/chat", { method: "POST", @@ -953,7 +1197,7 @@ async function runAcceptance() { body: JSON.stringify({ conversationId: chat.conversation.id, message: "请生成一份很长的补充清单。", - selectedSkillIds: [], + selectedSkillIds: [skillId], clientRequestId: `acceptance_stop_${crypto.randomUUID()}`, }), }); @@ -1001,6 +1245,23 @@ async function runAcceptance() { stoppedMessage?.usage?.durationMs >= 0, "中止回答也必须记录端到端用时", ); + assert.equal( + chatAfterStop.continuation.state, + "active", + "中止回答只结束 Run,不能把当前需求误判为完成", + ); + assert.deepEqual( + chatAfterStop.retainedSkillIds, + [skillId], + "中止后必须维持用户勾选的 Skill", + ); + assert.equal( + stoppedMessage?.skillSnapshots.find( + (snapshot) => snapshot.id === skillId, + )?.version, + updatedDuringTask.skill.version, + "上一个需求完成后,新需求必须获取 Skill 的最新版本快照", + ); assert.equal( chatAfterStop.messages.some( (message) => message.content === "这条并发冲突消息不应写入数据库。", @@ -1008,7 +1269,7 @@ async function runAcceptance() { false, "并发冲突事务不得留下孤立消息", ); - console.log("✓ 中止被视为一轮完成"); + console.log("✓ 中止原子落库且维持当前需求的 Skill 选择"); const renamed = await jsonRequest("/api/conversation", { method: "PATCH", @@ -1037,7 +1298,7 @@ async function runAcceptance() { try { await runAcceptance(); - console.log("\n验收通过:创建、编辑、删除、选择、调用反馈、中止、标题与重连均正常。"); + console.log("\n验收通过:生命周期、版本快照、中止竞态、创建编辑与重连均正常。"); } finally { server.kill("SIGTERM"); await new Promise((resolve) => { diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 4b2b881..800b548 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -10,6 +10,11 @@ import { listMessages, } from "@/lib/db"; import { hasDeepSeekApiKey } from "@/lib/deepseek"; +import { + pinSkillsToSnapshots, + snapshotsForSkills, +} from "@/lib/skill-snapshots"; +import { classifyContinuationReply } from "@/lib/skill-continuation"; import type { ChatMessage, Skill } from "@/lib/types"; import { createId } from "@/lib/utils"; import { chatRequestSchema } from "@/lib/validation"; @@ -44,11 +49,27 @@ export async function POST(request: Request) { const continuation = getConversationContinuation( input.conversationId, ); - const selectedSkills = [...new Set(input.selectedSkillIds)] + const currentSelectedSkills = [...new Set(input.selectedSkillIds)] .map((id) => getSkill(id)) .filter( (skill): skill is Skill => skill?.status === "active", ); + const replyKind = classifyContinuationReply(input.message); + const continuesExistingTask = + !["complete", "abandoned"].includes(continuation.state) && + replyKind !== "new_topic" && + replyKind !== "decline"; + const retainedSnapshots = continuesExistingTask + ? continuation.skillSnapshots + : []; + const selectedSkills = pinSkillsToSnapshots( + currentSelectedSkills, + retainedSnapshots, + ); + const skillSnapshots = snapshotsForSkills( + selectedSkills, + retainedSnapshots, + ); const userMessageId = createId("message"); const assistantMessageId = createId("message"); @@ -63,6 +84,8 @@ export async function POST(request: Request) { content: input.message, selectedSkillIds: selectedSkills.map((skill) => skill.id), usedSkillIds: [], + skillSnapshots, + skillExecutions: [], status: "complete", createdAt: now, }, @@ -72,6 +95,8 @@ export async function POST(request: Request) { content: "", selectedSkillIds: selectedSkills.map((skill) => skill.id), usedSkillIds: [], + skillSnapshots, + skillExecutions: [], status: "streaming", createdAt: new Date(Date.now() + 1).toISOString(), }, @@ -109,6 +134,7 @@ export async function POST(request: Request) { conversationId: input.conversationId, userMessage: input.message, selectedSkills, + skillSnapshots, continuation, history, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 13921b6..3661dbc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,30 @@ import { ChatWorkspace } from "@/components/chat-workspace"; +import { + getActiveChatRun, + getConversationContinuation, + getDefaultConversation, + listMessages, + listSkills, +} from "@/lib/db"; +import { hasDeepSeekApiKey } from "@/lib/deepseek"; +import { connection } from "next/server"; -export default function Home() { - return ; +export default async function Home() { + await connection(); + const conversation = getDefaultConversation(); + const activeRun = getActiveChatRun(conversation.id); + const continuation = getConversationContinuation(conversation.id); + + return ( + + ); } - diff --git a/src/components/assistant-builder-chat.tsx b/src/components/assistant-builder-chat.tsx index 74be785..3e72e19 100644 --- a/src/components/assistant-builder-chat.tsx +++ b/src/components/assistant-builder-chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { LoaderCircle, Sparkles } from "lucide-react"; +import { Check, LoaderCircle, Sparkles, X } from "lucide-react"; import { AssistantRuntimeProvider, MessagePrimitive, @@ -17,8 +17,10 @@ import { StreamingMarkdown } from "@/components/streaming-markdown"; import { useSmoothFollow } from "@/components/use-smooth-follow"; import { type BuilderMessage, + type BuilderProposal, type BuilderSuggestionSource, type SkillNodeKey, + SKILL_NODE_DEFINITIONS, } from "@/lib/types"; import { formatRelativeTime } from "@/lib/utils"; @@ -189,6 +191,53 @@ function BuilderSuggestions({ ); } +function BuilderProposalActions({ + proposal, + onSend, +}: { + proposal: BuilderProposal; + onSend: (message: string) => Promise; +}) { + const titles = proposal.updates + .map( + (update) => + SKILL_NODE_DEFINITIONS.find( + (definition) => definition.key === update.nodeKey, + )?.title, + ) + .filter(Boolean) + .join("、"); + + return ( +
+
+ 待确认 · {titles} +
+

+ 右侧节点尚未改变。确认后写入,或直接在输入框说明修改意见。 +

+
+ + +
+
+ ); +} + export function AssistantBuilderChat({ messages, loading, @@ -196,6 +245,7 @@ export function AssistantBuilderChat({ generationStatus, suggestions, suggestionSource, + pendingProposal, sendFailure, onSend, onStop, @@ -208,6 +258,7 @@ export function AssistantBuilderChat({ generationStatus: string; suggestions: string[]; suggestionSource: BuilderSuggestionSource; + pendingProposal: BuilderProposal | null; sendFailure: { content: string; detail: string } | null; onSend: (message: string) => Promise; onStop: () => void; @@ -264,11 +315,18 @@ export function AssistantBuilderChat({ }} /> {!streaming && ( - + pendingProposal ? ( + + ) : ( + + ) )} diff --git a/src/components/assistant-chat.tsx b/src/components/assistant-chat.tsx index 324b9d8..5ef8577 100644 --- a/src/components/assistant-chat.tsx +++ b/src/components/assistant-chat.tsx @@ -108,6 +108,7 @@ function toAssistantMessage( skills: Skill[], generationStatus: string, resolvedSkillIds: string[] | null, + completedSkillIds: string[], ): ThreadMessageLike { const usedSkills = message.usedSkillIds .map((id) => skills.find((skill) => skill.id === id)) @@ -120,22 +121,33 @@ function toAssistantMessage( const content: AssistantContentPart[] = []; for (const [position, skill] of usedSkills.entries()) { + const execution = message.skillExecutions.find( + (item) => item.skillId === skill.id, + ); + const executionCompleted = + execution?.status === "completed" || + completedSkillIds.includes(skill.id); content.push({ type: "tool-call", toolCallId: `skill_${message.id}_${skill.id}`, toolName: skill.name, args: { - skillName: skill.name, - description: skill.description, - position: position + 1, + skill: { + name: skill.name, + description: skill.description, + position: position + 1, + }, + request: + execution?.input.request ?? + "该历史记录创建时尚未保存 Skill 的实际输入。", + upstreamResults: execution?.input.upstreamResults ?? [], }, - ...(isRunning + ...(isRunning && !executionCompleted ? {} : { - result: { - used: true, - skillName: skill.name, - }, + result: + execution?.result ?? + "该历史记录创建时尚未保存 Skill 的具体执行结果。", }), }); } @@ -185,6 +197,7 @@ function convertMessage( skills: Skill[], generationStatus: string, resolvedSkillIds: string[] | null, + completedSkillIds: string[], ): ThreadMessageLike { if (message.role === "assistant") { return toAssistantMessage( @@ -192,6 +205,7 @@ function convertMessage( skills, generationStatus, resolvedSkillIds, + completedSkillIds, ); } @@ -501,7 +515,7 @@ function Welcome({ className="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent" > - 组合本轮 Skill + 组合当前需求 Skill ({ messages, - isLoading: loading, + isLoading: false, isRunning: streaming, isSendDisabled: !canSend, convertMessage: (message) => @@ -562,6 +578,7 @@ export function AssistantChat({ skills, generationStatus, resolvedSkillIds, + completedSkillIds, ), onNew: async (message) => { const text = getText(message); @@ -578,57 +595,48 @@ export function AssistantChat({ return ( - {loading ? ( -
-
- - 正在打开工作台 + + {messages.length === 0 ? ( + + ) : ( +
+
-
- ) : ( - <> - - {messages.length === 0 ? ( - - ) : ( -
- -
- )} -
+ )} + -
-
- {sendFailure && ( - - )} - -
-
- - )} +
+
+ {sendFailure && ( + + )} + +
+
); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 1e8bf87..eba01b3 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -32,6 +32,8 @@ export function ChatComposer({ skills, selectedIds, resolvedSkillIds, + activeSkillId, + completedSkillIds, onSelectedChange, onOpenSkillPicker, isStreaming, @@ -40,6 +42,8 @@ export function ChatComposer({ skills: Skill[]; selectedIds: string[]; resolvedSkillIds: string[] | null; + activeSkillId: string | null; + completedSkillIds: string[]; onSelectedChange: (ids: string[]) => void; onOpenSkillPicker: () => void; isStreaming: boolean; @@ -202,9 +206,13 @@ export function ChatComposer({ ? "selected" : !routeResolved ? "checking" - : resolvedSkillIds.includes(skill.id) - ? "used" - : "unused"; + : !resolvedSkillIds.includes(skill.id) + ? "unused" + : activeSkillId === skill.id + ? "running" + : completedSkillIds.includes(skill.id) + ? "completed" + : "queued"; return ( {index > 0 && ( @@ -216,8 +224,10 @@ export function ChatComposer({ !routeResolved && "animate-pulse text-amber-500/70", routeResolved && - resolvedSkillIds?.includes(skill.id) && + completedSkillIds.includes(skill.id) && "text-emerald-500/80", + activeSkillId === skill.id && + "animate-pulse text-blue-500/90", )} /> )} diff --git a/src/components/chat-workspace.tsx b/src/components/chat-workspace.tsx index dadac63..0aebf41 100644 --- a/src/components/chat-workspace.tsx +++ b/src/components/chat-workspace.tsx @@ -11,6 +11,7 @@ import type { ChatMessage, ChatRun, Conversation, + ConversationContinuation, Skill, SseEvent, } from "@/lib/types"; @@ -33,22 +34,71 @@ function wait(ms: number, signal: AbortSignal) { }); } -export function ChatWorkspace() { - const [skills, setSkills] = useState([]); - const [messages, setMessages] = useState([]); - const [conversation, setConversation] = useState(null); - const [selectedIds, setSelectedIds] = useState([]); +type ChatWorkspaceInitialData = { + skills: Skill[]; + messages: ChatMessage[]; + conversation: Conversation; + activeRun: ChatRun | null; + continuation: ConversationContinuation; + apiConfigured: boolean; +}; + +export function ChatWorkspace({ + initialData, +}: { + initialData: ChatWorkspaceInitialData; +}) { + const [skills, setSkills] = useState(initialData.skills); + const [messages, setMessages] = useState(() => + initialData.activeRun + ? initialData.messages.map((message) => + message.id === initialData.activeRun?.assistantMessageId + ? { + ...message, + content: "", + usedSkillIds: [], + skillExecutions: [], + status: "streaming" as const, + } + : message, + ) + : initialData.messages, + ); + const [conversation, setConversation] = useState( + initialData.conversation, + ); + const [selectedIds, setSelectedIds] = useState(() => { + const activeMessage = initialData.activeRun + ? initialData.messages.find( + (message) => + message.id === initialData.activeRun?.assistantMessageId, + ) + : null; + const selected = activeMessage + ? activeMessage.selectedSkillIds + : initialData.continuation.skillIds; + return selected.filter((id) => + initialData.skills.some((skill) => skill.id === id), + ); + }); const [pickerOpen, setPickerOpen] = useState(false); - const [loading, setLoading] = useState(true); - const [streaming, setStreaming] = useState(false); - const [generationStatus, setGenerationStatus] = useState(""); - const [apiConfigured, setApiConfigured] = useState(false); - const [activeRun, setActiveRun] = useState(null); + const [streaming, setStreaming] = useState( + Boolean(initialData.activeRun), + ); + const [generationStatus, setGenerationStatus] = useState( + initialData.activeRun ? "正在恢复本轮回答" : "", + ); + const [apiConfigured] = useState(initialData.apiConfigured); + const [activeRun, setActiveRun] = useState( + initialData.activeRun, + ); const [stopping, setStopping] = useState(false); const [resolvedSkillIds, setResolvedSkillIds] = useState( null, ); - const activeRunRef = useRef(null); + const [activeSkillId, setActiveSkillId] = useState(null); + const [completedSkillIds, setCompletedSkillIds] = useState([]); + const activeRunRef = useRef(initialData.activeRun); const stopRequestedRef = useRef(false); const visibleAssistantContentRef = useRef(""); const [sendFailure, setSendFailure] = useState<{ @@ -67,84 +117,6 @@ export function ChatWorkspace() { ); }, []); - useEffect(() => { - let cancelled = false; - - async function load() { - try { - const [skillsResponse, chatResponse] = await Promise.all([ - fetch("/api/skills", { cache: "no-store" }), - fetch("/api/chat", { cache: "no-store" }), - ]); - if (!skillsResponse.ok || !chatResponse.ok) { - throw new Error("工作台加载失败"); - } - const skillPayload = (await skillsResponse.json()) as { - skills: Skill[]; - }; - const chatPayload = (await chatResponse.json()) as { - conversation: Conversation; - messages: ChatMessage[]; - activeRun: ChatRun | null; - retainedSkillIds: string[]; - apiConfigured: boolean; - }; - if (cancelled) return; - - const hydratedMessages = chatPayload.activeRun - ? chatPayload.messages.map((message) => - message.id === chatPayload.activeRun?.assistantMessageId - ? { - ...message, - content: "", - usedSkillIds: [], - status: "streaming" as const, - } - : message, - ) - : chatPayload.messages; - - setSkills(skillPayload.skills); - setConversation(chatPayload.conversation); - setMessages(hydratedMessages); - if (chatPayload.activeRun) { - const activeMessage = chatPayload.messages.find( - (message) => - message.id === chatPayload.activeRun?.assistantMessageId, - ); - setSelectedIds( - (activeMessage?.selectedSkillIds ?? []).filter((id) => - skillPayload.skills.some((skill) => skill.id === id), - ), - ); - setResolvedSkillIds(null); - } else { - setSelectedIds( - (chatPayload.retainedSkillIds ?? []).filter((id) => - skillPayload.skills.some((skill) => skill.id === id), - ), - ); - } - setApiConfigured(chatPayload.apiConfigured); - activeRunRef.current = chatPayload.activeRun; - setActiveRun(chatPayload.activeRun); - setStreaming(Boolean(chatPayload.activeRun)); - if (chatPayload.activeRun) setGenerationStatus("正在恢复本轮回答"); - } catch (error) { - if (!cancelled) { - toast.error(error instanceof Error ? error.message : "工作台加载失败"); - } - } finally { - if (!cancelled) setLoading(false); - } - } - - void load(); - return () => { - cancelled = true; - }; - }, []); - useEffect(() => { const refresh = () => void loadSkills().catch(() => undefined); window.addEventListener("focus", refresh); @@ -201,6 +173,55 @@ export function ChatWorkspace() { usedSkillIds: event.skillIds, })); } + if (event.type === "skill_started") { + setActiveSkillId(event.skillId); + updateAssistant((message) => ({ + ...message, + skillExecutions: [ + ...message.skillExecutions.filter( + (item) => + item.skillId !== event.skillId || + item.position !== event.position, + ), + { + skillId: event.skillId, + skillName: event.skillName, + position: event.position, + total: event.total, + status: "running" as const, + input: event.input, + startedAt: event.startedAt, + }, + ].sort((a, b) => a.position - b.position), + })); + } + if (event.type === "skill_completed") { + setCompletedSkillIds((ids) => + ids.includes(event.skillId) ? ids : [...ids, event.skillId], + ); + setActiveSkillId((id) => (id === event.skillId ? null : id)); + updateAssistant((message) => ({ + ...message, + skillExecutions: message.skillExecutions + .map((item) => + item.skillId === event.skillId && + item.position === event.position + ? { + skillId: event.skillId, + skillName: event.skillName, + position: event.position, + total: event.total, + status: "completed" as const, + input: event.input, + result: event.result, + startedAt: event.startedAt, + completedAt: event.completedAt, + } + : item, + ) + .sort((a, b) => a.position - b.position), + })); + } if (event.type === "skill_retention") { retainedForNextTurn = event.skillIds; setSelectedIds(event.skillIds); @@ -294,6 +315,8 @@ export function ChatWorkspace() { setGenerationStatus(""); setSelectedIds(retainedForNextTurn); setResolvedSkillIds(null); + setActiveSkillId(null); + setCompletedSkillIds([]); setActiveRun(null); } } @@ -310,6 +333,8 @@ export function ChatWorkspace() { stopRequestedRef.current = false; visibleAssistantContentRef.current = ""; setStopping(false); + setActiveSkillId(null); + setCompletedSkillIds([]); const selectedForTurn = [...(selectedOverride ?? selectedIds)]; const userLocalId = `local_user_${crypto.randomUUID()}`; const assistantLocalId = `local_assistant_${crypto.randomUUID()}`; @@ -321,6 +346,8 @@ export function ChatWorkspace() { content, selectedSkillIds: selectedForTurn, usedSkillIds: [], + skillSnapshots: [], + skillExecutions: [], status: "complete", createdAt: now, }; @@ -330,6 +357,8 @@ export function ChatWorkspace() { content: "", selectedSkillIds: selectedForTurn, usedSkillIds: [], + skillSnapshots: [], + skillExecutions: [], status: "streaming", createdAt: new Date(Date.now() + 1).toISOString(), }; @@ -491,6 +520,8 @@ export function ChatWorkspace() { setMessages([]); setSelectedIds([]); setResolvedSkillIds(null); + setActiveSkillId(null); + setCompletedSkillIds([]); setSendFailure(null); stopRequestedRef.current = false; activeRunRef.current = null; @@ -508,15 +539,20 @@ export function ChatWorkspace() { messages={messages} selectedIds={selectedIds} resolvedSkillIds={resolvedSkillIds} - loading={loading} + activeSkillId={activeSkillId} + completedSkillIds={completedSkillIds} streaming={streaming} stopping={stopping} generationStatus={generationStatus} sendFailure={sendFailure} - canSend={Boolean(conversation) && !loading} + canSend={Boolean(conversation)} onSelectedChange={(ids) => { setSelectedIds(ids); - if (!streaming) setResolvedSkillIds(null); + if (!streaming) { + setResolvedSkillIds(null); + setActiveSkillId(null); + setCompletedSkillIds([]); + } }} onOpenSkillPicker={() => setPickerOpen(true)} onSend={sendMessage} @@ -539,6 +575,8 @@ export function ChatWorkspace() { onConfirm={(ids) => { setSelectedIds(ids); setResolvedSkillIds(null); + setActiveSkillId(null); + setCompletedSkillIds([]); }} onSkillsChange={setSkills} /> diff --git a/src/components/skill-builder.tsx b/src/components/skill-builder.tsx index fe3f5eb..5025695 100644 --- a/src/components/skill-builder.tsx +++ b/src/components/skill-builder.tsx @@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button"; import { consumeSse } from "@/lib/client-sse"; import type { BuilderMessage, + BuilderProposal, BuilderSuggestionSource, Skill, SkillNode, @@ -58,6 +59,8 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { const [description, setDescription] = useState(""); const [nodes, setNodes] = useState(createEmptyNodes); const [messages, setMessages] = useState([]); + const [pendingProposal, setPendingProposal] = + useState(null); const [loading, setLoading] = useState(Boolean(skillId)); const [streaming, setStreaming] = useState(false); const [saving, setSaving] = useState(false); @@ -164,7 +167,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { setMessages([ createBuilderMessage( "assistant", - `已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会判断应该更新哪些节点。`, + `已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会先整理成提案,等你确认后再更新节点。`, "constraints", SKILL_NODE_DEFINITIONS.map((node) => node.key), ), @@ -248,6 +251,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { message: content, skillName: name, skillDescription: description, + pendingProposal, nodes, messages: previousMessages.map((message) => ({ role: message.role, @@ -262,9 +266,12 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { } if (event.type === "status") setStatus(event.label); if (event.type === "builder_update") { - setName(event.evaluation.skillName); - setDescription(event.evaluation.skillDescription); - setNodes(event.evaluation.nodes); + setPendingProposal(event.evaluation.proposal); + if (event.evaluation.action === "applied") { + setName(event.evaluation.skillName); + setDescription(event.evaluation.skillDescription); + setNodes(event.evaluation.nodes); + } const suggestionsMatchNode = event.evaluation.suggestionNodeKey === event.evaluation.activeNode; @@ -292,16 +299,15 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { ? { ...message, nodeKey: + event.evaluation.proposedNodeKeys[0] ?? event.evaluation.updatedNodeKeys[0] ?? event.evaluation.activeNode, - updatedNodeKeys: event.evaluation.updatedNodeKeys, + updatedNodeKeys: + event.evaluation.action === "proposed" + ? event.evaluation.proposedNodeKeys + : event.evaluation.updatedNodeKeys, } : message, - ) - .filter( - (message) => - event.evaluation.activeNode === activeNode || - message.id === assistantMessage.id, ), ); } @@ -394,7 +400,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { } async function save() { - if (!allComplete || saving) return; + if (!allComplete || saving || pendingProposal) return; setSaving(true); try { const response = await fetch( @@ -462,8 +468,14 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { size="sm" className="bg-[#07C160] text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35 lg:hidden" onClick={() => void save()} - disabled={!allComplete || saving} - title={!allComplete ? "完成全部节点后才能保存" : undefined} + disabled={!allComplete || saving || Boolean(pendingProposal)} + title={ + pendingProposal + ? "请先确认或放弃当前提案" + : !allComplete + ? "完成全部节点后才能保存" + : undefined + } > {saving ? ( @@ -526,6 +538,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { generationStatus={status} suggestions={allComplete ? [] : suggestions} suggestionSource={suggestionSource} + pendingProposal={pendingProposal} sendFailure={sendFailure} onSend={sendBuilderMessage} onStop={stop} @@ -573,7 +586,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) { - 五个节点已完成。仍可继续描述修改内容,由 AI 自动更新对应节点。 + 五个节点已完成。仍可继续描述修改内容,AI 会先给出提案,确认后再更新。
) : null} @@ -590,7 +603,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {