feat: 接入 CLI 对话工作流

This commit is contained in:
李岩岩 2026-07-30 17:45:51 +08:00 committed by liyy
parent 13d2494208
commit 8283ba604d
5 changed files with 331 additions and 87 deletions

View File

@ -1,43 +1,31 @@
---
name: write-agent-blog
description: 为 llm-to-agent 的《Agent进阶专题》系列创作中文文章,并在项目 blog 目录输出单个 Markdown 文件。当用户要求为某次提交、代码变更、小功能点、路线图事项、里程碑或实现过程撰写、生成、修改博客或文章时使用,包括“为本次提交写篇文章”“给这个功能写博客”“记录这次实现”等表达
description: 为 llm-to-agent 的《Agent进阶专题》系列创作中文文章。触发:"为本次提交写篇文章""给这个功能写博客""记录这次实现"。不触发:修改 README、写代码注释、写 commit message
---
# 创作 Agent 系列博客
为《Agent进阶专题》系列创作一篇以项目事实为依据的 Hexo 文章,并将成果保存为 `<项目根目录>/blog/` 下的单个 Markdown 文件。
为《Agent进阶专题》输出一篇 Hexo Markdown 文章到 `blog/` 下。**源码是唯一事实来源,只写已实现的行为。**
## 工作流程
---
1. 使用 `git rev-parse --show-toplevel` 定位项目根目录。
2. 确定用户要求的写作范围。对于“本次提交”等表达,使用 `git show` 检查 `HEAD`;用户明确指向尚未提交的工作时,还要检查工作区变更。
3. 阅读相关实现、测试、包脚本、路线图事项和功能文档。阅读已有的 `blog/*.md`,延续编号、术语和内容深度,并避免重复。
4. 确定文章对应的单个小功能点、所属阶段(如 `P1``P2`)、阶段内篇号、标题和仅含 ASCII 小写字符的短横线式别名。
5. 以 `assets/article-template.md` 为写作骨架。替换所有占位内容并删除模板说明。
6. 如果 `blog/` 不存在则创建它。除非用户明确要求修改已有文章,否则只新建一个 `.md` 文件。
7. 检查元信息、文件名、永久链接、`<!-- more -->`、事实陈述、代码片段和 Git 差异。除非用户明确要求,否则不要创建提交。
## 必须(违反即错误)
## 事实依据
### 工作流程
- 将源代码、测试和实际差异视为事实依据;使用文档和提交信息理解设计意图与背景。
- 只描述选定变更已经体现的行为。明确标注计划和未来工作,不得将其写成已经实现的能力。
- 不得虚构调试经历、性能数据、设计争论、执行命令、命令输出或用户反馈。
- 优先选用能够揭示核心思路的短代码片段。确保片段与仓库内容一致,并在附近正文中注明仓库相对路径。
- 如果目标提交主要是脚手架、配置或文档,应解释这项基础工作的目的和后续价值,但不得夸大现有能力。
1. `git rev-parse --show-toplevel` 定位项目根目录。
2. 用 `git show` / `git diff` 读取目标变更;读已有 `blog/*.md` 确定编号、术语、避免重复。
3. 确定阶段P0/P1/P2、阶段内序号、中文标题、英文 slug。
4. 只新建一个 `blog/2026-PX-0X-slug.md`,不覆盖已有文件。
## 文件名与元信息
### 文件名与元信息
文件名使用 `2026-PX-0X-xxx.md` 格式:
```
2026-PX-0X-slug.md
```
- 除非用户修改约定,否则本系列固定使用四位年份 `2026`
- 将 `PX` 替换为路线图阶段,例如 `P1`
- 将 `0X` 替换为该阶段内的两位文章序号,从 `01` 开始。
- 将 `xxx` 替换为描述该功能的简短英文别名,只使用小写 ASCII 字母、数字和短横线。
- 示例:`2026-P1-03-streaming-chat.md`
- 根据已有文章推断下一个未使用的序号。如果无法可靠判断所属阶段,应暂停并询问用户,不要自行猜测。
- 不得覆盖已有文件。如果目标名称已经存在,先判断它是否对应同一功能;否则递增序号。
严格使用以下元信息结构:
- `PX` = 路线图阶段,`0X` = 阶段内序号01 起),`slug` = 小写 ASCII + 短横线。
- 如果无法判断阶段或序号,暂停询问用户。
```yaml
---
@ -46,80 +34,142 @@ tags: [AI, Agent]
categories:
- AI
date: YYYY-MM-DD HH:mm:ss
permalink: /agent/YYYY/MM/slug/
permalink: /YYYY/agent/slug/
---
```
- 新建文件时使用当前本地日期和时间。
- 标题应具体并体现结果,不使用章节编号,也不要使用“一些思考”之类含糊标题。
- 文件名和永久链接使用相同的英文别名。
- 永久链接中的 `YYYY/MM``date` 推导。
- date 用当前本地时间适当加工避开工作时间permalink的YYYY从date中提取。
- 标题具体、体现结果,不用"一些思考"等模糊标题。
## 文章固定结构
### 事实约束
保持以下三部分顺序:
- 只写代码中已存在的行为。未实现的能力标注"计划中",不写成已实现。
- 禁止虚构:调试经历、性能数据、设计争论、命令输出、用户反馈。
- 代码片段必须与仓库一致,附近标注仓库相对路径。
- 脚手架/配置类提交:解释目的和后续价值,不夸大现有能力。
1. Hexo 元信息。
2. 位于 `<!-- more -->` 之前的简洁简介,用于列表页摘要。
3. 位于 `<!-- more -->` 之后的完整正文。
### 文章结构
使用自然且能够说明内容的标题,不要直接使用“简介部分”或“正文部分”等模板化标题。简介由一至两个短段落组成,需要说明问题、此次交付的能力及其价值。
```
元信息
摘要1-2 句)
<!-- more -->
正文
```
正文应围绕具体功能组织,不要机械套用完全相同的标题。根据主题只选择必要内容,不要求每篇全部覆盖:
- `<!-- more -->` 恰好出现一次,前后各空一行。
- 摘要说明"做了什么、为什么有价值"。
- 正文标题从 `##` 开始,不重复一级标题。
- 变更前存在的问题或限制;
- 当前小功能点的目标与边界;
- 核心设计或理解模型;
- 配合精选代码片段说明实现路径;
- 验证方式与可观察结果;
- 取舍、当前限制和下一项自然演进能力;
- 用简短结语将本篇内容连接到整个系列的演进主线。
---
- 导读、概念介绍和设计点题类文章建议控制在 1000 个中文字符左右;这是保持简洁的参考值,不是硬性上限。根据章节范围和必要信息调整篇幅,优先保证主题讲清且没有无关扩展。
- 标题给出的主题就是内容边界。只讲清当前主题,不借机扩展相关协议、实现细节或远期能力。
## 应该(尽量做到)
## 读者视角与系列衔接
### 内容边界
- 面向第一次接触本项目的外部读者写作,不要把作者已经掌握的项目背景当作读者常识。
- 阶段编号和文章编号用于组织系列,不是正文的叙事前提。首次出现 `P0``P1` 等编号时必须用自然语言解释其含义;如果编号对理解当前内容没有帮助,就不要在正文中使用。
- 每篇文章先从读者能够理解的场景、问题或一次自然的需求变化切入,再逐步引出项目术语和设计结论。
- 用“原本有什么—遇到什么问题—做出什么选择”推进文章,但不要为了讲故事虚构场景或增加文学化铺垫。
- 不得使用“如前所述”“大家已经知道”等措辞假设读者读过其他文章。即使文章位于系列中间,也应提供理解当前主题所需的最少背景。
- 相邻文章应各守边界,避免提前讲完后续主题。结尾只需自然提出下一篇的问题,不要罗列尚未解释的阶段和术语。
- 当前导读部分暂定为:`P0-00` 项目介绍与目录导航、`P0-01` 架构设计、`P0-02` 功能划分、`P0-03` 阶段规划、`P0-04` 目录规划。创作其中一篇时,不要侵占其他篇目的主要内容。
- 标题就是边界。不讲无关协议、远期能力、实现细节。
- 导读/概念类 ~1000 中文字符;实现类按需,讲清即止。
- 2-4 个短章节即可,不为完整而堆章节。
## 系列文风
### 叙事方式
- 使用清晰的简体中文,面向理解 TypeScript 和基本 LLM 概念、但可能刚接触 Agent 工程的开发者。
- 直接、口语化、克制。先给结论,再补必要原因;能用一句话说清的内容不要扩成一段。
- 删除不推动观点的过渡句、重复总结、设问和修饰语。每个段落只表达一个重点,通常不超过三句话。
- 优先使用具体动词和短句,避免“我们需要先回答一个不那么显眼、却会影响整个项目的问题”一类绕弯表达。
- 适度使用“我们”营造共同实践感,重点仍应放在技术推理和可复现的工程过程上。
- 按照“动机—原理—实现—验证”的顺序展开。在进入密集实现细节前,先解释为什么这样做。
- 保持段落简短、标题明确,并与项目文档使用一致的术语。
- 技术术语首次出现时给出简要解释。代码中的英文标识符保持原样;不要为同一概念反复更换译名。
- 避免营销语言、泛泛的 AI 背景介绍、夸张结论、填充式总结和流水账式叙述。
- 每篇文章都应能够独立阅读,同时在概念上承接前一项能力并引出下一项能力。
- 用最短的具体情境引出矛盾和选择,避免从项目内部术语或文档摘要直接起笔,也避免把技术文章写成散文。
- 不为追求完整而增加章节。点题类文章通常使用两至四个短章节即可。
- 从读者能理解的场景切入,不假定读者读过前文。
- "原本有什么 → 遇到什么问题 → 做出什么选择"推进。
- 读者已知的概念直接引用跳过,如"和 Express 中间件机制一致,略过"。
- 结尾自然引出下一篇的问题,不罗列阶段和术语。
- 首次出现 P0/P1 等编号时用自然语言解释。
## Markdown 与代码
### 文风
- 使用标准 Markdown围栏代码块必须标注语言。
- 正文标题从 `##` 开始;页面标题由 Hexo 元信息提供,不要在正文中重复一级标题。
- 标题、列表、代码块和 `<!-- more -->` 前后保留空行。
- 代码片段应尽量短且与主题直接相关。只有在省略范围明显且不会造成误解时才使用 `...`
- 展示命令时,应区分命令和示例输出。只有命令确实存在或实际执行过,才能将其作为验证命令写入文章。
- 除非用户明确要求,否则不要添加目录。
面向懂 TS 和基础 LLM、刚接触 Agent 工程的开发者。**笔记体**:结论先行,解释从简。
- ❌ "我们需要先回答一个不那么显眼、却会影响整个项目的问题"
- ✅ "先确定一件事Kernel 不认业务概念。"
- ❌ "经过反复思考和持续调整,我们最终选择了一种简洁优雅的方案"
- ✅ "架构收缩为两个核心概念Kernel + Extensions。"
- 每段一个重点,通常不超过三句话。
- 删除过渡句、重复总结、设问、修饰语。
- 可用"我们",重心在技术推理。
- 不用营销语言、泛泛 AI 介绍、夸张结论。
- 不写散文,不用文学化铺垫。
### 格式
- 围栏代码块标注语言;标题/列表/代码块/`<!-- more -->` 前后空行。
- 代码尽量短,展示核心思路即可。省略处用 `...` 且确保不会误解。
- 不自行添加目录。
---
## 避免
- 假设读者读过系列其他文章("如前所述""大家已经知道")。
- 提前讲完后续文章的主题。
- 把计划/路线图写成已实现的能力。
- 流水账式叙述或填充式总结。
- 为同一概念反复更换中文译名。
---
## 最终检查
回复用户前确认:
- [ ] 只新增/修改了一篇 `.md`
- [ ] 文件名匹配 `^2026-P[0-9]+-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$`
- [ ] 元信息字段完整,`<!-- more -->` 恰好一次
- [ ] 无占位内容残留
- [ ] 代码与仓库一致
- [ ] 回复含文件链接 + 一句话主题说明
- 只新增或修改了一篇目标 Markdown 文章。
- 文件名符合 `^2026-P[0-9]+-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$`
- 元信息结构有效;除非用户要求,否则只包含本系列规定的字段。
- 有意义的简介之后恰好出现一次 `<!-- more -->`
- 没有遗留任何占位内容。
- 事实陈述和代码片段符合当前仓库状态。
- 最终回复包含文章文件链接,并用一句话说明文章主题。
---
## 参考:本作者已上线系列的共性特征
以下从 Java 专题和 AI 基础专题中提取,是已被验证有效的写作习惯。
### 开头
一句话交代本篇做什么,不做铺垫:
- "本篇简单记录常量和变量。"
- "开始之前,我们需要搭建一个基本的项目环境,基于 TypeScript 和 Node.js且尽量简洁。"
### 系列衔接
可引用前文建立上下文,但只提直接相关的一篇:
- "上一节我们已经可以和大模型进行连续对话了,这节我们通过简单的例子来演示一下提示词管理的能力。"
### 代码呈现
- 代码是正文主体,文字只是必要说明。
- 按实现步骤拆分代码块,每块配一行解释。
- 可在代码块之间穿插截图验证运行结果。
### 结尾
两种方式,按主题选择:
- **引出下一篇**"到此,我们的项目环境就搭建完成了,下一节我们将进行一个简单的对话。"
- **发散点列表**:列出 3-5 个后续可探索的方向,标注为"发散点"而非"计划"。
```md
发散点:
* 上下文长度管理 - 监控上下文的长度,并在超过限制时进行裁剪。
* 持久化 - 将上下文保存到文件或者数据库中。
```
### 跨知识引用
读者已知的概念直接跳过,不重复解释:
- "`if` 语句和 `javascript` 完全一致,略过。"
- "和 Express 中间件机制一致,略过。"
### 语气
- 用"我们"但不煽情,重心始终在技术本身。
- 笔记语气:记录过程、防止遗忘,不假装客观权威。
- [ ] 无占位内容残留
- [ ] 代码与仓库一致
- [ ] 回复含文件链接 + 一句话主题说明

103
src/extensions/chat.test.ts Normal file
View File

@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { Kernel } from "../kernel";
import { Hook } from "./catalog";
import {
createAgentExtension,
type AgentService,
} from "./shared/agent";
import { createDeepSeekExtension } from "./shared/deepseek";
import {
createWorkspaceExtension,
type WorkspaceService,
} from "./shared/workspace";
test("streams a reply, saves it, and restores the conversation after restart", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
await mkdir(projectPath);
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
const requests: Array<{
model: string;
messages: Array<{ role: string; content: string }>;
stream: boolean;
}> = [];
const answers = ["第一次回答", "第二次回答"];
const request = async (url: string, init: RequestInit) => {
assert.equal(url, "https://api.deepseek.com/chat/completions");
assert.equal(init.method, "POST");
const body = JSON.parse(String(init.body));
requests.push(body);
const answer = answers[requests.length - 1] as string;
const middle = Math.ceil(answer.length / 2);
const stream = [
`data: ${JSON.stringify({ choices: [{ delta: { content: answer.slice(0, middle) } }] })}`,
`data: ${JSON.stringify({ choices: [{ delta: { content: answer.slice(middle) } }] })}`,
"data: [DONE]",
"",
].join("\n\n");
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
};
const firstKernel = new Kernel().use(
createWorkspaceExtension({ home, projectPath }),
createDeepSeekExtension({ apiKey: "test-key", request }),
createAgentExtension(),
);
await firstKernel.start();
let firstAnswer = "";
for await (const chunk of firstKernel.get<AgentService>(Hook.Agent).chat("第一次问题")) {
firstAnswer += chunk;
}
assert.equal(firstAnswer, "第一次回答");
await firstKernel.stop();
const secondKernel = new Kernel().use(
createWorkspaceExtension({ home, projectPath }),
createDeepSeekExtension({ apiKey: "test-key", request }),
createAgentExtension(),
);
await secondKernel.start();
let secondAnswer = "";
for await (const chunk of secondKernel.get<AgentService>(Hook.Agent).chat("第二次问题")) {
secondAnswer += chunk;
}
assert.equal(secondAnswer, "第二次回答");
assert.deepEqual(requests[1]?.messages, [
{ role: "user", content: "第一次问题" },
{ role: "assistant", content: "第一次回答" },
{ role: "user", content: "第二次问题" },
]);
const workspace = secondKernel.get<WorkspaceService>(Hook.Workspace);
assert.deepEqual(
(await workspace.messages()).map(({ role, content }) => ({ role, content })),
[
{ role: "user", content: "第一次问题" },
{ role: "assistant", content: "第一次回答" },
{ role: "user", content: "第二次问题" },
{ role: "assistant", content: "第二次回答" },
],
);
assert.deepEqual(await readdir(projectPath), []);
await secondKernel.stop();
});

View File

@ -0,0 +1,84 @@
import {
createInterface,
type Interface as ReadlineInterface,
} from "node:readline/promises";
import type { Extension } from "../../kernel";
import { Event, ExtensionId, Hook } from "../catalog";
import type { AgentService } from "../shared/agent";
export function createCliExtension(): Extension {
let terminal: ReadlineInterface | undefined;
let loop: Promise<void> | undefined;
let currentRequest: AbortController | undefined;
return {
id: ExtensionId.Cli,
setup() {},
start(context) {
const agent = context.get<AgentService>(Hook.Agent);
terminal = createInterface({
input: process.stdin,
output: process.stdout,
});
terminal.on("SIGINT", () => {
if (currentRequest) {
currentRequest.abort();
} else {
terminal?.close();
}
});
loop = (async () => {
try {
process.stdout.write(
`llm-to-agent\nWorkspace: ${process.cwd()}\n输入 /exit 退出Ctrl+C 取消当前回复。\n\n`,
);
terminal?.setPrompt("> ");
terminal?.prompt();
for await (const line of terminal!) {
const input = line.trim();
if (input === "/exit") break;
if (!input) {
terminal?.prompt();
continue;
}
currentRequest = new AbortController();
try {
for await (const chunk of agent.chat(input, currentRequest.signal)) {
process.stdout.write(chunk);
}
process.stdout.write("\n\n");
} catch (error) {
if (currentRequest.signal.aborted) {
process.stdout.write("\n[已取消]\n\n");
} else {
process.stderr.write(`\n${String(error)}\n\n`);
}
} finally {
currentRequest = undefined;
}
terminal?.prompt();
}
} finally {
terminal?.close();
await context.emit(Event.RuntimeStopRequested, { source: "cli" });
}
})();
},
async stop() {
currentRequest?.abort();
terminal?.close();
await loop;
},
};
}

View File

@ -1,6 +1,7 @@
import { createCliExtension } from "../extensions/cli";
import type { ProductDefinition } from "./product";
export const cliProduct: ProductDefinition = {
id: "cli",
extensions: [],
extensions: [createCliExtension],
};

View File

@ -1,4 +1,10 @@
import type { ExtensionFactory } from "../kernel";
import { createAgentExtension } from "../extensions/shared/agent";
import { createDeepSeekExtension } from "../extensions/shared/deepseek";
import { createWorkspaceExtension } from "../extensions/shared/workspace";
// 公共扩展在实现真实能力时加入这里。
export const sharedExtensions: ExtensionFactory[] = [];
export const sharedExtensions: ExtensionFactory[] = [
createWorkspaceExtension,
createDeepSeekExtension,
createAgentExtension,
];