跳到主要内容

数据截至 (上游 commit 7538cc96774b)

GUI 产品层:需求先行流水线与自动化编排

30 秒导读: 前面五章讲的是 runtime 怎么跑一个 turn。这一章讲 Electron 前端用这些 turn 拼出了什么产品——一条把"人写的需求"变成"可验收的代码改动"的流水线,外加写作工作台、工作流画布和一堆无人值守的远程入口。Kun 与普通 chat 客户端的区别,全在这一层。


1. 这是什么(零基础也能懂)

一句话定义: GUI 产品层 = 一组"围绕 agent 的工作流产品",它们都建立在同一个 runtime 之上,但各自定义了自己的落盘产物闸门规则

普通 chat 客户端的产物是聊天记录。Kun 的 GUI 产物是文件:

产品线用户看到的东西落到磁盘的产物
需求先行(SDD)需求编辑器 + 需求 AI 侧栏.kunsdd/requirements/<uuid>/ 整个目录
计划与 Todo右侧计划面板、Todo 面板.kunsdd/plan/<feature>.md
会话工作台时间线、审批气泡、变更审查无(状态在 runtime 与 localStorage)
Write 写作台Markdown 所见即所得编辑器用户自己的 .md / 导出的 HTML/PDF/图
工作流画布节点连线的自动化编辑器设置文件里的 workflow.workflows[]

给谁用: 一个人要让 agent 改一个真实项目,又不想"提一句话就让它乱改一通"。SDD 这条线的主张是:先把需求写清楚、写成结构化的验收标准,再让模型出计划,最后按计划施工、逐条验收

一句话直觉: 把它当成给 AI 用的 Jira + PR 流程——需求有编号有状态,计划的每一步都必须标注"我在实现哪条需求",施工完还要回来打勾。

用起来什么样(一条最小主线):

  1. 在 Write 视图新建一份需求,写下"帮我把 Code/Write 两个按钮改成左右等宽";
  2. 点"下一步",GUI 把需求发给模型,模型调用 create_plan 把实现计划写进一个GUI 事先预留好的文件;
  3. 计划面板打开,计划里的 - [ ] 步骤同步成当前会话的 Todo;
  4. 点"开始施工",切回 agent 模式按计划改代码;
  5. 点"验收",agent 逐条核对验收标准,把 - [ ] 改成 - [x]

本节不出现代码。下面开始拆。


2. 顶层全景(它大概怎么转)

怎么读这张图: 从上往下是一次需求的生命周期;虚线框是磁盘上的产物;所有向 runtime 的箭头都走 01 章那扇 HTTP/SSE 门,没有例外。

┌──────────────── Electron 渲染进程(产品层) ────────────────┐
│ │
用户 → │ ① 需求编辑器 ──→ ② 升级为计划 ──→ ③ 计划面板/Todo ──→ ④ 验收 │
│ │ │ │ │ │
└────────┼────────────────┼─────────────────┼───────────┼────┘
│ │ │ │
▼ ▼(带预留路径) ▼ ▼
┌────────────────────────────────────────────────────────────┐
│ Kun runtime(HTTP + SSE,见 01 章) │
│ turn 里挂 GuiPlanContext → 才放出 create_plan 工具 │
└────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌ 磁盘产物 ────────────────────────────────────────────────┐
│ .kunsdd/requirements/<uuid>/requirement.md trace.json │
│ .kunsdd/plan/sdd-<uuid>.md │
└───────────────────────────────────────────────────────────┘

旁路还有三条,它们不经过界面,但走同一扇门:

定时任务 ┐
工作流节点├─→ runPromptViaRuntime() ─→ POST /v1/threads → POST turns → 轮询结果
IM 机器人 ┘ (主进程,无人值守)

部件一句话职责:

部件干什么在哪个文件
需求草稿 store单写者持有当前 requirement.md 的内容与脏状态src/renderer/src/sdd/sdd-draft-store.ts
追踪计算把需求块 × 计划 covers × 线程 todo 合成覆盖率与状态src/renderer/src/sdd/sdd-trace-compute.ts
计划控制器预留计划路径、发计划 turn、加载计划、发验收/重规划src/renderer/src/components/workbench-plan-controller.ts
create_plan 工具runtime 侧的写闸门,只准写预留路径kun/src/adapters/tool/create-plan-tool.ts
事件消费把 SSE 事件揉进时间线,含审批气泡与看门狗src/renderer/src/store/chat-store-runtime.ts
工作流引擎25 种节点的调度、插值、cron 与人工审批src/main/workflow-runtime.ts
无头执行器给定时/工作流/IM 用的 "建线程 → 发 turn → 轮询"src/main/schedule-runtime-helpers.ts

3. 需求先行流水线(SDD):本章主菜

3.1 先看落盘形状:一个 uuid 目录装下一切

SDD 的第一条设计决策是需求即目录:一条需求的正文、追踪快照、贴图、原型、对话记录全部收在同一个 uuid 目录里,删需求就是删一个目录。

.kunsdd/
├── requirements/
│ └── <uuid>/
│ ├── requirement.md ← 需求正文(唯一真源)
│ ├── trace.json ← 出计划那一刻的需求哈希快照
│ ├── img/ ← 粘贴/生成的图片
│ ├── proto/ ← 生成的交互原型(单文件 HTML)
│ └── chat/ ← 需求 AI 的会话记录 + meta.json
└── plan/
└── sdd-<uuid>.md ← 这条需求对应的实现计划

路径规则集中在一个共享模块里,渲染进程和主进程共用同一份判定 (src/shared/sdd.ts:19 buildSddDraftRelativePath:23 isSddDraftRelativePath:44 sddRequirementUnitDir:68 sddDraftTraceRelativePath)。

反向映射也在这里:给一个计划路径,能算回它属于哪条需求 (src/shared/sdd.ts:77 sddDraftRelativePathForPlanPath)——"验收"和"增量重规划"两个按钮就是靠它判断"这个计划是不是 SDD 出身"。

诚实提示: 克隆里自带的 .kunsdd/draft/<uuid>/requirement.md已退役的旧布局。当前代码只认 .kunsdd/requirements/,旧条目在注册表归一化时直接丢弃(src/renderer/src/sdd/sdd-draft-store.ts:136)。这两份自带样本也没有 R 块和 covers 标注,只能用来看"正文和计划长什么样",不能用来看追踪闭环。

3.2 闭环的地基:R-n 需求块与 covers 标注

要解决的小问题: 怎么让"需求"和"计划步骤"之间有一条机器可读的连线,又不引入数据库?

思路: 全部塞进 Markdown 的纯文本约定里,保证人手改、agent 改、往返读写都不丢信息。

需求正文里的一条需求长这样:

### R-1: 导出为 PDF {building}
用户在写作台点导出,应能生成带样式的 PDF。
- [ ] 导出按钮出现在工具栏
- [x] 导出结果保留代码块高亮

计划文件里的一条步骤长这样:

- [ ] 实现导出 API (covers: R-1)

四条正则就是全部语法定义(src/shared/sdd-trace.ts:64-67):标题行 HEADING_RE、 花括号状态 STATUS_TOKEN_RE、任务行 TASK_LINE_RE、覆盖标注 COVERS_RE。 状态枚举只有五档,且只能前进:draft → planned → building → done → verified (src/shared/sdd-trace.ts:20-26,排序表 :219 STATUS_RANK)。

解析器有个容易忽略的细节:它认代码围栏。围栏内的行不参与标题/任务匹配,免得需求正文里贴的示例代码被当成验收标准 (src/shared/sdd-trace.ts:117,insideFence 开关)。

原理演示(示意,非源码):

// 给定需求块 + 计划步骤,算出每条需求"几步里做完了几步"
const blocks = parseSddRequirementBlocks(requirementMd) // [{id:'R-1', ...}]
const items = parseSddPlanCovers(planMd) // [{requirementIds:['R-1'], checked:false}]
const cov = computeSddCoverage(blocks, items) // R-1: 3 步里完成 1 步
// 覆盖率再折算成状态:全完=done,部分完=building,一步没完=planned

真实实现三段接力:computeSddCoverage(src/shared/sdd-trace.ts:201)算分子分母, deriveSddStatuses(:232)折算状态并只在名次前进时才返回, applySddDerivedStatuses(:251)以最小行编辑把状态 token 写回标题行。

3.3 一次"升级为计划"的全过程

这是整条流水线最值得读的一段代码:handleSddNextStep (src/renderer/src/components/workbench/useWorkbenchSddTurnController.ts:520)。它是个串行闸门链,任何一步不过就原地退出并把错误挂到草稿 store 上。

点「下一步」

├─① 草稿为空? ────────────────→ 报错退出 (:525-528)
├─② 正文里还有未完成的生成图占位符? → 报错退出 (:529-532,PENDING_INFOGRAPHIC_PROTOCOL)
├─③ 会话 busy / 还有未收尾的 runtime 工作? → 排队提示 (:533-536)
├─④ runtime 未连上? ──────────→ 报错退出 (:537-540)

├─⑤ 先把草稿存盘(计划必须基于磁盘上的真源) (:544)
├─⑥ 确保这条需求有一个"隐藏的"助手线程 (:551)
├─⑦ 收集正文里引用的本地图片 (:558)
│ └─ 模型支持图输入 → 上传成附件;否则退化成 base64 文本
├─⑧ 算出预留计划路径 .kunsdd/plan/sdd-<uuid>.md (:121)
├─⑨ 组装 plan prompt + guiPlan 上下文,发 turn (buildSddDraftToPlanPrompt)
└─⑩ 把当前需求的哈希快照写进 trace.json (:647-655)

第 ⑦ 步的图片处理有讲究:图必须落在该需求的 img/ 目录下才收, 而 proto/ 下的 HTML 原型虽然也用图片语法内嵌,却故意跳过——它是文档不是图,让施工 agent 自己去打开 (src/renderer/src/sdd/sdd-draft-images.ts:137-141)。

第 ⑩ 步是后面"漂移检测"的锚:buildSddTraceSnapshot (src/shared/sdd-trace.ts:269)给每条需求算一个内容哈希存进 trace.json, 之后 diffSddRequirementChanges(:286)一比就知道"计划生成之后,哪几条需求被人改过"。

发出去的 prompt 有一句硬要求,它是后面所有闸门的前提 (src/renderer/src/sdd/sdd-plan-prompt.ts:66):

You MUST use the `create_plan` tool exactly once to save the final plan.
- Set `operation` to `draft`.
- Set `plan_relative_path` to `.kunsdd/plan/sdd-<uuid>.md`.
- Do not edit project files directly during this planning turn.

同一段 prompt 还把 covers 规则写死:有 R 块时,每一条可执行步骤都必须挂 covers 标签,且不许漏掉任何 R-id、不许编造 R-id (src/renderer/src/sdd/sdd-plan-prompt.ts:103-107)。

3.4 追踪闭环:覆盖率、状态回写与漂移重规划

useSddTrace(src/renderer/src/sdd/use-sdd-trace.ts:45)是把上面所有片段接成环的那个 hook。它做四件事:

  1. 取三份输入——需求正文(编辑中取 store、否则取磁盘)、计划正文、trace.json 快照;
  2. 合并实时进度——computeSddTrace(src/renderer/src/sdd/sdd-trace-compute.ts:56)以计划里的复选框为基线,再用当前线程的 todo 状态升级它:todo 为 completed 视作已完成,为 in_progress 把对应需求推到 building;
  3. 回写状态——只在名次前进时改 requirement.md 的状态 token,且走"草稿激活就经 store 单写者、否则直写磁盘"两条路(use-sdd-trace.ts:149-181);
  4. 兜底轮询——每 5 秒重读一次磁盘,因为编辑器关掉之后,agent 对这两个文件的修改没有别的通知渠道(use-sdd-trace.ts:118)。

需求改了怎么办?不重新出一份计划,而是只把改动的那几块喂回去: replanChangedRequirements(src/renderer/src/components/workbench-plan-controller.ts:398)按行区间切出变化的 R 块,组一段 feedback,以 operation: 'refine' 发 turn,并要求模型"只改受影响的步骤、其余步骤和 covers 标签原样保留",发完再把 trace.json 重新盖章。

验收是另一个专用 turn:buildSddVerifyPrompt (src/renderer/src/sdd/sdd-verify-prompt.ts:11)要求 agent 逐条核对验收标准、 就地把 - [ ] 改成 - [x]、全过才把状态改成 {verified},且只准动复选框和状态 token,不准重写描述

3.5 需求 AI 侧栏:把产品经理方法论做成按钮

写需求这一段配了一个独立侧栏(src/renderer/src/components/sdd/SddAssistantPanel.tsx),它的按钮不是随手排的,而是一张PM 方法论注册表渲染出来的 (src/renderer/src/sdd/pm-skill-frameworks.ts:39 PM_SKILL_FRAMEWORKS)。

每个条目是纯数据:id、阶段、英文名、来源 skill、三个 i18n key、一段可注入模型的 guidance。阶段决定它出现在哪:

阶段出现形式例子
discover侧栏按钮clarify、research、opportunity-tree
structure侧栏按钮structure、wwa、job-stories、prd
risk侧栏按钮assumptions、pre-mortem、experiments
plan无按钮,固定注入出计划的 promptpre-mortem、prioritization-frameworks
verify无按钮,固定注入验收的 promptintended-vs-implemented、test-scenarios

composeFrameworkGuidance(src/renderer/src/sdd/pm-skill-frameworks.ts:322)把选中的若干条拼成一块带出处署名的 Markdown,三个 prompt builder 各自 spread 进去(sdd-assistant-prompt.ts:11sdd-plan-prompt.ts:109sdd-verify-prompt.ts:30)。

这里有个产品上的巧劲: 需求 AI 用的线程对用户是隐藏的sdd-thread-registry.ts 维护一张"哪些线程是需求助手线程"的表(:145 markSddAssistantThread), sddThreadIds(:191)把它们从聊天侧栏里滤掉;用户如果想"把这段对话转成正式会话",再调 releaseSddAssistantThread(:207)把它标成公开。这样一条需求可以反复问模型,而不会把聊天列表刷爆。


4. 计划与 Todo:先预留路径,再放开工具

4.1 三段式契约

这是 GUI 与 runtime 之间最值得学的一处协作设计。要解决的问题是:怎么让模型"写文件"这件事既可用又不失控?

答案不是给它一个通用写工具再事后检查,而是先把路径定死,再把工具放出来:

① GUI 预留 ② 挂在 turn 上 ③ runtime 侧闸门
buildPlanRelativePath guiPlan: { create_plan.shouldAdvertise
→ .kunsdd/plan/x.md operation, = 有 guiPlan 或 plan 模式
nextAvailablePlan… workspaceRoot, 执行时 resolveReservedTarget:
→ 撞名自动加 -2 relativePath, operation 必须一致
planId } workspace 必须一致
路径不许覆盖
planId 不许覆盖

三段各自的落点:

符号文件
预留路径buildPlanRelativePath / nextAvailablePlanRelativePathsrc/shared/gui-plan.ts:34 / :65
上下文结构GuiPlanContextSchema / GuiPlanContextkun/src/contracts/turns.ts:49 / kun/src/ports/tool-host.ts:38
工具闸门isPlanToolContextActive / resolveReservedTargetkun/src/adapters/tool/create-plan-tool.ts:235 / :342

契约里最硬的一条写在 zod schema 里:relativePath 必须通过 isGuiPlanRelativePath, 也就是必须是 .kunsdd/plan/ 直属的一个 .md 文件,不许有子目录、不许 .. (kun/src/contracts/turns.ts:55,判定实现在 kun/src/shared/gui-plan.ts:22)。

同名文件有两份,引用一律认全路径: GUI 侧预留路径用的是 src/shared/gui-plan.ts,runtime 侧校验用的是它的镜像 kun/src/shared/gui-plan.ts——函数同名、行号不同(如 isGuiPlanRelativePath 在 GUI 侧是 :44、在 runtime 侧是 :22)。本章与 04 章提到这个文件时都写全路径,别按裸文件名对行号。

执行时 resolveReservedTarget 逐项比对:operation 不符、workspace 不符、模型自带的 plan_relative_path 与预留值不一致、plan_id 不一致——四条任一不满足就返回错误结果 (create-plan-tool.ts:350 / 353 / 363 / 366)。写盘本身是"临时文件 + rename"的原子写,并在 rename 前再查一次 abort 信号 (create-plan-tool.ts:162 defaultWritePlan)。

没有 guiPlan 的自由计划模式也支持:此时工具自己在 .kunsdd/plan/ 下按标题派生一个不撞名的文件 (create-plan-tool.ts:388 resolveFreeFormTarget)。历史遗留的 .deepseekgui/plan/ 路径只允许 refine,不允许新建(:360)。

GUI 这边怎么知道"计划写好了"?不靠轮询目录,而是从工具结果块里读结构化元数据: extractPlanMetadataFromBlock(src/renderer/src/plan/plan-tool.ts:46)从 create_plan 的成功块里取出 planId / 路径 / 内容哈希,控制器再据此读文件、填面板 (workbench-plan-controller.ts:265 loadPlanFromMeta)。

面板要不要自动弹出,还有一条防串台规则:只有"这个计划是当前线程刚发起的那次计划 turn 产出的"才弹 (workbench-plan-controller.ts:109 shouldAutoOpenPlanPanel)。翻旧会话看到一个老计划不会把聊天区挤扁。

4.2 计划 → 线程 Todo 的同步

计划文件里的 - [ ] 步骤会被抽成线程 todo,让施工阶段有进度条。

抽取(src/renderer/src/plan/plan-todo-sync.ts:19 extractPlanTodos)给每条 todo 挂一个来源: { kind:'plan', planId, relativePath, ordinal, contentHash },哈希是 FNV-1a 的 36 进制 (:9 todoContentHash)。

难点在重新生成计划之后怎么保住已有进度mergePlanTodosForRenderer(:55)靠一条四级降级匹配把新旧对上 (:138 findExistingPlanTodo):

① planId + 路径 + 内容哈希 全中 ← 步骤没改,直接续
② 路径 + 内容哈希 ← 计划文件被重建,planId 变了
③ 只比内容哈希 ← 文本一样但来源丢了
④ planId + 路径 + 序号 ← 文本改了,但位置还在
命中即停;都没命中 = 新步骤

没被新计划认领的旧 plan todo 不会被删,而是摘掉 source 保留下来(:77-88),避免用户手工加的条目被计划刷新吃掉。

同步入口是 store 的 syncPlanTodosFromMarkdown (src/renderer/src/store/chat-store-maintenance-metadata-actions.ts:645),写回 runtime 前会先比对是否真有变化,避免空写。

计划本身的活动态存在一个 zustand store 里,并按 workspace 与 thread 双索引持久化到 localStorage (src/renderer/src/plan/plan-store.ts:249,注册表键 :54,读取 :237 readRememberedGuiPlan)。

还有一层用户可见的糖:/plan 斜杠命令在发送时被拦下 (src/renderer/src/plan/plan-command.ts:5 parseGuiPlanCommand,拦截点在提交控制器 useWorkbenchComposerSubmitController.ts:479); 而内部生成的那些长 prompt 会在时间线上被折叠成 "Create plan: …" 这样的短句 (src/renderer/src/plan/plan-prompts.ts:187 formatGuiPlanPromptForDisplay)。


5. 会话状态与事件消费

聊天区看着简单,难的是把一条不可靠的 SSE 流揉成一条稳定的时间线。这活儿全在 buildThreadEventSink(src/renderer/src/store/chat-store-runtime.ts:354)里,它给 01 章那条事件流装了四道保险:

保险干什么位置
流归属校验事件属于已切走的线程就直接丢弃chat-store-runtime.ts:366 isCurrentStream
单调游标lastSeq 只增不减,防止心跳/重放把光标倒回去导致重复回放:429
增量去重seq 过滤已应用的 delta(appliedDeltaSeqFloor):441-447
忙碌看门狗每个 SSE 批次(含 15 秒心跳)都重新计时,变成"静默超时"而非"总时长超时":416-422

看门狗那段注释点破了一个真实教训:如果做成绝对超时,一个跑很久但一直在心跳的工具调用会被误判成断流;做成静默超时,只有心跳真停了才提示恢复。

审批气泡是这条流上最"产品化"的一个事件。runtime 发来审批请求,handleApprovalRequestapprovalId 去重后插入一个 approval 块 (src/renderer/src/agent/kun-runtime-services.ts:567);气泡在时间线里渲染成"允许 / 拒绝"两个按钮 (src/renderer/src/components/chat/message-timeline-bubbles.tsx:260-275); 点击走 resolveApproval(src/renderer/src/store/chat-store-maintenance-interaction-actions.ts:326),它先把块置成 submitting 再提交,失败则落回 error 并把消息挂在块上——状态机在块自身,不在全局,所以同屏多个审批互不干扰。

变更审查是另一条产品线:ChangeInspector (src/renderer/src/components/ChangeInspector.tsx:19)只从时间线里挑 toolKind === 'file_change' 且能解析出统一 diff 的块(:33),列成文件清单,选中后在下方渲染补丁。 diff 的解析、加减行统计与语言角标都在 DiffView(src/renderer/src/components/DiffView.tsx:32 parseDiff)里,不依赖任何 diff 库。

时间线本身按"推理 / 执行 / 输出 / 子 agent"分段折叠 (groupProcessSections,src/renderer/src/components/chat/message-timeline-process-grouping.ts:57),这样一个动辄几十次工具调用的 turn 不会把可读的结论淹掉。


6. Write 写作工作台

Write 是 SDD 需求编辑器的宿主,但它本身是一个完整的 Markdown 写作产品。它和聊天区最大的架构差别是:行内补全不走 agent runtime,主进程直连模型

编辑器光标停顿
→ policy 判定值不值得请求(policy.ts)
→ IPC 'write:inline-completion'
(src/main/ipc/register-app-ipc-handlers.ts:1342)
→ 主进程 write-inline-completion-service.ts 直接打模型 HTTP
→ 幽灵文本

这条路径绕开 runtime,是因为它要的是毫秒级、无工具、无审批的补全,走 turn 太重。

补全的"值不值得请求"是一组硬规则,不是模型判断 (src/renderer/src/write/inline-completion/policy.ts):光标右边是单词字符就不请求、URL 尾部不请求、空行且无结构上下文不请求;长补全还额外要求光标在行尾、不在表格/标题里。

服务端那侧最有意思的是协议自防御:它定义了 <<<PREFIX>>> / <<<EDIT_SCOPE>>> 这类标记来划定输入输出边界,然后专门准备了一套正则和一张"占位符原话"黑名单,用来识别弱模型把提示词原样吐回来的情况,防止这种垃圾变成幽灵文本 (PROTOCOL_PLACEHOLDER_BODIES,src/main/services/write-inline-completion-prompt.ts,装配门面是 write-inline-completion-service.ts)。

其余三个主进程服务各管一摊:

服务干什么关键取舍
write-retrieval-service.ts为补全提供工作区内的相关片段建索引有硬预算:250ms、160 个文件、720 个 chunk(:14-25)
write-export-service.ts导出 HTML/PDF、复制富文本导出前把本地图片内联成 data URI(:287)
write-infographic-service.ts由选中文本生成信息图按图种类切换宽高比与提示词模板(:37-48)

编辑器的所见即所得是 CodeMirror 装饰实现的,代码块、表格、图片、任务复选框、HTML 内嵌各有一个 widget (src/renderer/src/write/markdown-live-preview.tsmarkdown-live-widgets.ts)。 行内改写(inline-edit)则把光标附近切出一个 scope(前 6000 / 后 4000 字符)连同最近编辑历史一起发出去 (src/renderer/src/write/inline-edit.ts:7-8)。

生成图片时会先在正文插一个 kun-pending-infographic:// 占位符 (src/renderer/src/write/infographic-pending.ts:12)——这也是 3.3 节第 ② 道闸门要拦它的原因:占位符落进计划 prompt 只会污染上下文。


7. 工作流画布

画布是 Kun 里唯一"不以对话为中心"的产品:节点连线,定时或事件触发,agent 只是其中一种节点。

引擎是主进程里的一个类(src/main/workflow-runtime.ts:57 WorkflowRuntime), 它同时持有调度器、运行中工作流集合、每个节点的实时状态、待人工审批表,以及一个本地 HTTP 服务器(既托管 webhook 触发,也托管给 agent 用的内部接口)。

25 种节点登记在一张常量表里(src/shared/app-settings-types-workflow-node.ts:56 WORKFLOW_NODE_KINDS),按职责分四类:

类别节点
触发manual-trigger、schedule-trigger、webhook-trigger
智能ai-agent、generate-image、parameter-extractor、question-classifier
控制流condition、switch、loop、merge、subworkflow、human-approval、delay
数据code、set-fields、filter、sort、limit、aggregate、http-request、template、json、output、custom

三个机制值得单独看:

① 变量插值 InterpScope(src/main/workflow-expression.ts:5)。模板里写 {{ $nodes.a.json.title }}{{ $env.KEY }}{{ $run.x }}{{ $loop.item }}{{ $input.k }},由 resolveExpr(:33)按前缀分派解析,interpolate(:62)负责整串替换。作用域是显式传下去的对象,不是全局变量。

② cron 计算 cronNextRun(src/main/workflow-runtime-helpers.ts:79)。五段标准 cron,自己实现的逐分钟扫描(上限一年)。它把标准 cron 那条最反直觉的规则实现对了:当"日"和"周"都被限定时,两者是"或"关系,不是"与"(:99-104)。 computeWorkflowNextRunAt(:140)再把一个工作流所有启用的定时触发器各算一次,取最早的那个。

③ 人工审批 human-approval(src/main/workflow-approval-node-adapter.ts:17)。执行到这里生成一个 token,把节点挂进 pendingApprovals 表并 await 一个 Promise,直到有人决策或超时;审批文案在送去 UI 之前会先做密钥脱敏(:36:43-44)。局限也很直白:待审状态在内存 Map 里(src/main/workflow-run-coordinator.ts:18),应用重启就丢——旧版代码曾把这条写进注释,拆分后注释没留下,但事实不变。

Code 节点在编辑期会做语法检查:JavaScript 用 compileFunction 编译但不执行,python/bash 调本地解释器做 parse 检查,解释器缺失时返回 unavailable 而不是报错 (src/main/workflow-code-node-adapter.ts:182 checkWorkflowCode)。

ai-agent 节点不自己发模型请求,而是调 runPromptViaRuntime——这就接到了下一节。


8. 自动化与远程入口:都走同一扇门

定时任务、工作流的 AI 节点、IM 机器人,三条完全不同的入口,最后都收敛到主进程里的同一个无头执行器 (src/main/schedule-runtime-helpers.ts:346 runPromptViaRuntime):

runPromptViaRuntime
├─ mkdir 工作目录
├─ POST /v1/threads { workspace, model, mode, providerId?, title? }
├─ POST /v1/threads/<id>/turns { prompt, mode, disableUserInput: true }
└─ 需要结果? → 每 1.5 秒 GET /v1/threads/<id> 轮询到 turn 终态
(waitForAssistantTextViaRuntime,schedule-runtime-helpers.ts:396)

注意 disableUserInput: true(:371)——注释写得很清楚:无头场景没人能回答 user_input,一个开口问问题的 turn 会一直挂到超时。这是"把交互能力按场景关掉"的典型例子。

各入口的分工:

入口干什么文件
定时任务到点跑 prompt,支持任务依赖(带环检测)src/main/schedule-runtime.ts:64,环检测 hasTaskDependencyCycle :55
工作流节点图调度、webhook 服务、人工审批src/main/workflow-runtime.ts:57
Claw / IM飞书、微信、Telegram 的会话桥接src/main/claw-runtime.ts:27
飞书流式回复把 SSE 增量喂进飞书的 Markdown 流式消息,失败退回整条发送src/main/feishu-streamer.ts:24
Telegram25 秒 getUpdates 长轮询(POLL_TIMEOUT_SECONDS,src/main/telegram-runtime-support.ts:7),零 npm 依赖,走 Electron 网络栈src/main/telegram-runtime.ts:165-167
微信桥本地起桥接进程 + HTTP 端口探测(resolveAvailableBridgePort,src/main/weixin-bridge-runtime.ts:208-214,端口常量 weixin-bridge-state.ts:3-4)+ 扫码登录长轮询同左

还有一个反向的口子:src/main/claw-schedule-mcp-server.ts--claw-schedule-mcp-server 参数启动时,把自己变成一个 stdio MCP server,对外暴露 *_schedule_list / *_schedule_create / list_workflows / run_workflow 等工具(:82:105:268:286),转手打回正在运行的 Kun 的内部 HTTP 接口。

也就是说:agent 可以通过 MCP 反过来给 GUI 排定时任务、触发工作流。这条环路是这一层最"产品化"的设计之一。


9. 配置、迁移与 IPC 契约

产品层的东西一多,配置就成了承重墙。Kun 的做法是一份类型化设置 + 一个 zod 化 IPC 面

设置拆成十来个按域划分的模块,由一个 barrel 汇总 (src/shared/app-settings.ts,分片如 app-settings-workflow.tsapp-settings-claw.tsapp-settings-write.ts)。 每个域都有 default*merge* 两个函数,读盘时先归一化再合并,所以旧版本写下的残缺 JSON 不会让新版本崩。落盘由 JsonSettingsStore (src/main/settings-store-class.ts:73)负责,写用的是 runtime 那边的原子写实现(atomicWriteFile,:384)。

迁移单独一个模块,专治"DeepSeek GUI 改名 Kun"这件事 (src/main/legacy-data-migration.ts)。它的三条设计约束写在文件头注释里,很值得抄:

  1. 整目录 rename,不逐文件拷贝——userData 里有 Chromium 的 LocalStorage/IndexedDB,半拷贝状态比不迁移更糟;
  2. 旧路径留符号链接(Windows 用 junction,免管理员权限)——残留的旧绝对路径仍可解析,用户回滚老版本还能读同一份数据,且新旧版本抢同一把单实例锁,不会两个进程同写一个 sqlite;
  3. 任何一步失败都降级成"继续用旧路径",绝不让启动失败。

IPC是渲染进程唯一能碰到 Node 能力的地方,共 117 个 ipcMain.handle (src/main/ipc/register-app-ipc-handlers.ts),每一个的入参都先过一遍 zod schema (src/main/ipc/app-ipc-schemas.ts)。文件操作再往下还有一层路径守卫:展开 ~、剥引号、拒绝越出工作区 (src/main/services/workspace-paths.ts:23 expandHomePath:64 normalizeUserPath; src/main/services/workspace-file-entries.ts:123 resolveWorkspaceFile)。

工作区支撑服务各司一摊:

服务干什么
git-service.ts / git-checkpoint-service.ts仓库发现;施工前打检查点,出事可回滚
worktree-service.tsgit worktree 池,让并行任务各占一份工作副本
skill-service.ts扫描项目级与全局 skill 目录,与 runtime 的 skill 列表合并
ui-plugin-service.tsUI 插件安装走白名单复制:只复制 manifest 与被引用的图片,脚本和可执行文件一概不进数据目录

10. 巧妙之处(可以直接抄的)

  1. 先预留路径,再放开工具。 比"给通用写工具 + 事后审计"安全得多,因为越权在工具是否可见这一层就被挡住了,模型连尝试的机会都没有(create-plan-tool.ts:223 shouldAdvertise)。

  2. 协议全在纯文本里,不引数据库。 ### R-1: … {status}(covers: R-1),四条正则就撑起了需求追踪(src/shared/sdd-trace.ts:64-67)。人能手改,agent 能手改,git 能 diff。

  3. 状态只前进。 STATUS_RANK 比较名次,verified 永远不会被自动降回 planned(src/shared/sdd-trace.ts:219:246)。自动推导最怕的"来回抖动"被一条规则消掉。

  4. 需求即目录。 删需求 = 删一个 uuid 目录,正文、贴图、原型、对话记录一次带走(sdd-draft-actions.ts:110-114)。

  5. 四级降级匹配保住进度。 计划重新生成后,todo 靠"内容哈希 → 路径 → 序号"逐级降级重认亲(plan-todo-sync.ts:138)。

  6. 看门狗做成静默超时。 心跳也算活动,于是"跑三小时的工具调用"和"断流"能被区分开(chat-store-runtime.ts:438-444,实现在 chat-store-schedulers.ts:107)。

  7. 原型不是图。 用图片语法内嵌的 HTML 原型在收图时被显式跳过,留链接给施工 agent 自己开(sdd-draft-images.ts:137)。

  8. 无头场景关掉提问能力。 disableUserInput: true 一行,消掉了"定时任务半夜卡在一个问句上"这类幽灵故障(schedule-runtime-helpers.ts:377)。


11. 边界与局限(诚实版)

  • 工作流的人工审批不持久。 待审状态在内存 Map 里(src/main/workflow-run-coordinator.ts:18),应用重启这次 run 就丢了。
  • SDD 追踪靠轮询。 编辑器关掉后,agent 对 requirement.md / 计划文件的修改没有推送通道,只能 5 秒一次重读磁盘(use-sdd-trace.ts:118)。
  • 计划路径面很窄。 只能是 .kunsdd/plan/ 直属的 .md,不支持子目录;历史 .deepseekgui/plan/ 只能 refine 不能新建(create-plan-tool.ts:360)。
  • 克隆自带的 .kunsdd/ 是旧布局。 .kunsdd/draft/ 已被代码判定为退役并在注册表里丢弃(sdd-draft-store.ts:136),两份样本也没有 R 块与 covers,不能用来验证闭环。
  • covers 覆盖率靠模型自觉。 GUI 只在 prompt 里要求"每步必须挂 covers、不许漏 R-id",没有在 create_plan 里做校验拒收——漏标只会表现为面板上的"未覆盖"提示(plan-prompts 侧无校验;提示见 src/renderer/src/components/plan/PlanPanel.tsx:286)。
  • 行内补全绕开 runtime。 好处是快,代价是这条路径不享受 runtime 的审批、缓存与用量统计(见 03 章05 章)。

本章不覆盖: GUI 与 runtime 之间的 HTTP/SSE 机制在 01 章; turn 的内部生命周期在 02 章;上下文与缓存在 03 章; 工具执行与三道闸门(沙箱 / 审批 / 钩子)的 runtime 侧实现在 04 章;模型请求怎么发出去在 05 章


12. 代码地图(导航索引)

主题文件路径关键符号
SDD 路径约定src/shared/sdd.tsbuildSddDraftRelativePathsddRequirementUnitDirsddDraftRelativePathForPlanPath
需求块与 covers 解析src/shared/sdd-trace.tsparseSddRequirementBlocksparseSddPlanCoversderiveSddStatusesbuildSddTraceSnapshotdiffSddRequirementChanges
追踪合成src/renderer/src/sdd/sdd-trace-compute.tscomputeSddTrace
追踪闭环 hooksrc/renderer/src/sdd/use-sdd-trace.tsuseSddTracesddPlanRelativePathForDraft
草稿状态src/renderer/src/sdd/sdd-draft-store.tsuseSddDraftStorecreateSddDraftrememberSddDraft
草稿存盘/删除src/renderer/src/sdd/sdd-draft-actions.tssaveActiveSddDraftToDisksyncActiveSddDraftFromDiskdeleteSddDraft
草稿恢复与历史src/renderer/src/sdd/sdd-draft-restore.ts / src/renderer/src/sdd/sdd-draft-history.tsrestoreSddDraftlistSddDraftHistory
需求贴图src/renderer/src/sdd/sdd-draft-images.tscollectSddDraftImagesresolveSddMarkdownImagePath
隐藏助手线程src/renderer/src/sdd/sdd-thread-registry.tsmarkSddAssistantThreadsddThreadIdsreleaseSddAssistantThread
SDD 提示词src/renderer/src/sdd/sdd-{assistant,plan,verify,prototype}-prompt.tsbuildSddDraftToPlanPromptbuildSddVerifyPromptbuildSddPrototypeTurnPrompt
PM 方法论注册表src/renderer/src/sdd/pm-skill-frameworks.tsPM_SKILL_FRAMEWORKScomposeFrameworkGuidance
升级为计划主流程src/renderer/src/components/workbench/useWorkbenchSddTurnController.tshandleSddNextStepsddDraftPlanRelativePathsendSddPrototypeTurn
计划控制器src/renderer/src/components/workbench-plan-controller.tsuseWorkbenchPlanControllersendPlanTurnbuildGuiPlanverifyGuiPlanreplanChangedRequirements
计划路径与契约(GUI 侧)src/shared/gui-plan.tsbuildPlanRelativePathisGuiPlanRelativePathvalidateCreatePlanToolInputGUI_PLAN_CREATE_PLAN_TOOL_NAME
计划路径校验(runtime 镜像)kun/src/shared/gui-plan.tsisGuiPlanRelativePathisGuiPlanCurrentRelativePathguiPlanWorkspaceMatches
计划上下文契约kun/src/contracts/turns.ts / kun/src/ports/tool-host.tsGuiPlanContextSchemaGuiPlanContext
create_plan 闸门kun/src/adapters/tool/create-plan-tool.tsisPlanToolContextActiveresolveReservedTargetresolveFreeFormTargetdefaultWritePlan
计划元数据提取src/renderer/src/plan/plan-tool.tsextractPlanMetadataFromBlock
计划状态src/renderer/src/plan/plan-store.tsuseGuiPlanStorereadRememberedGuiPlan
计划 Todo 同步src/renderer/src/plan/plan-todo-sync.tsextractPlanTodosmergePlanTodosForRendererfindExistingPlanTodo
计划提示词与显示src/renderer/src/plan/plan-prompts.tsbuildDraftPlanPromptgetGuiPlanPromptKindformatGuiPlanPromptForDisplay
事件消费src/renderer/src/store/chat-store-runtime.ts · chat-store-schedulers.tsbuildThreadEventSinkarmBusyWatchdogflushLiveBlocks
审批与 todo 动作src/renderer/src/store/chat-store-maintenance-interaction-actions.ts · chat-store-maintenance-metadata-actions.tsresolveApprovalsyncPlanTodosFromMarkdown
变更审查src/renderer/src/components/{ChangeInspector,DiffView}.tsxChangeInspectorparseDiff
Write 行内补全src/renderer/src/write/inline-completion/policy.tssrc/main/services/write-inline-completion-prompt.tsshouldRequestInlineCompletionPROTOCOL_PLACEHOLDER_BODIES
Write 检索/导出/图src/main/services/write-{retrieval,export,infographic}-service.tsretrieveWriteInlineCompletionContextexportWriteDocumentrequestWriteInfographic
工作流引擎src/main/workflow-runtime.ts · workflow-runtime-helpers.ts · workflow-expression.ts · workflow-run-coordinator.ts · workflow-code-node-adapter.tsWorkflowRuntimecronNextRuncomputeWorkflowNextRunAtInterpScopecheckWorkflowCode
工作流配置src/shared/app-settings-workflow.tssrc/shared/app-settings-types-workflow-node.tsnormalizeWorkflowNodeWORKFLOW_NODE_KINDS
无头执行器src/main/schedule-runtime-helpers.tsrunPromptViaRuntimewaitForAssistantTextViaRuntime
定时与远程入口src/main/{schedule-runtime,claw-runtime,telegram-runtime,feishu-streamer,weixin-bridge-runtime}.tsScheduleRuntimeClawRuntimeFeishuStreamerhasTaskDependencyCycle
反向 MCP 入口src/main/claw-schedule-mcp-server.tsrunClawScheduleMcpServerFromArgv
设置与迁移src/main/settings-store-class.tssrc/main/legacy-data-migration.tsJsonSettingsStoreLEGACY_USER_DATA_DIR_NAMES
IPC 契约src/main/ipc/{app-ipc-schemas,register-app-ipc-handlers}.tsworkspaceFileWritePayloadSchemaregisterAppIpcHandlers
工作区支撑src/main/services/{git-service,git-checkpoint-service,worktree-service,skill-service,ui-plugin-service}.tsrunGitGitCheckpointCleanupResultUiPluginInstallResult