Compare commits
No commits in common. "85441ca85576e64f76315a7816052bc00e86dfa3" and "a90db184fca21f24a483452c714971cd08c2a8ee" have entirely different histories.
85441ca855
...
a90db184fc
@ -1,31 +1,43 @@
|
||||
---
|
||||
name: write-agent-blog
|
||||
description: 为 llm-to-agent 的《Agent进阶专题》系列创作中文文章。触发:"为本次提交写篇文章""给这个功能写博客""记录这次实现"。不触发:修改 README、写代码注释、写 commit message。
|
||||
description: 为 llm-to-agent 的《Agent进阶专题》系列创作中文文章,并在项目 blog 目录输出单个 Markdown 文件。当用户要求为某次提交、代码变更、小功能点、路线图事项、里程碑或实现过程撰写、生成、修改博客或文章时使用,包括“为本次提交写篇文章”“给这个功能写博客”“记录这次实现”等表达。
|
||||
---
|
||||
|
||||
# 创作 Agent 系列博客
|
||||
|
||||
为《Agent进阶专题》输出一篇 Hexo Markdown 文章到 `blog/` 下。**源码是唯一事实来源,只写已实现的行为。**
|
||||
为《Agent进阶专题》系列创作一篇以项目事实为依据的 Hexo 文章,并将成果保存为 `<项目根目录>/blog/` 下的单个 Markdown 文件。
|
||||
|
||||
---
|
||||
## 工作流程
|
||||
|
||||
## 必须(违反即错误)
|
||||
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-slug.md
|
||||
```
|
||||
文件名使用 `2026-PX-0X-xxx.md` 格式:
|
||||
|
||||
- `PX` = 路线图阶段,`0X` = 阶段内序号(01 起),`slug` = 小写 ASCII + 短横线。
|
||||
- 如果无法判断阶段或序号,暂停询问用户。
|
||||
- 除非用户修改约定,否则本系列固定使用四位年份 `2026`。
|
||||
- 将 `PX` 替换为路线图阶段,例如 `P1`。
|
||||
- 将 `0X` 替换为该阶段内的两位文章序号,从 `01` 开始。
|
||||
- 将 `xxx` 替换为描述该功能的简短英文别名,只使用小写 ASCII 字母、数字和短横线。
|
||||
- 示例:`2026-P1-03-streaming-chat.md`。
|
||||
- 根据已有文章推断下一个未使用的序号。如果无法可靠判断所属阶段,应暂停并询问用户,不要自行猜测。
|
||||
- 不得覆盖已有文件。如果目标名称已经存在,先判断它是否对应同一功能;否则递增序号。
|
||||
|
||||
严格使用以下元信息结构:
|
||||
|
||||
```yaml
|
||||
---
|
||||
@ -34,142 +46,80 @@ tags: [AI, Agent]
|
||||
categories:
|
||||
- AI
|
||||
date: YYYY-MM-DD HH:mm:ss
|
||||
permalink: /YYYY/agent/slug/
|
||||
permalink: /agent/YYYY/MM/slug/
|
||||
---
|
||||
```
|
||||
|
||||
- date 用当前本地时间,适当加工避开工作时间,permalink的YYYY从date中提取。
|
||||
- 标题具体、体现结果,不用"一些思考"等模糊标题。
|
||||
- 新建文件时使用当前本地日期和时间。
|
||||
- 标题应具体并体现结果,不使用章节编号,也不要使用“一些思考”之类含糊标题。
|
||||
- 文件名和永久链接使用相同的英文别名。
|
||||
- 永久链接中的 `YYYY/MM` 从 `date` 推导。
|
||||
|
||||
### 事实约束
|
||||
## 文章固定结构
|
||||
|
||||
- 只写代码中已存在的行为。未实现的能力标注"计划中",不写成已实现。
|
||||
- 禁止虚构:调试经历、性能数据、设计争论、命令输出、用户反馈。
|
||||
- 代码片段必须与仓库一致,附近标注仓库相对路径。
|
||||
- 脚手架/配置类提交:解释目的和后续价值,不夸大现有能力。
|
||||
保持以下三部分顺序:
|
||||
|
||||
### 文章结构
|
||||
1. Hexo 元信息。
|
||||
2. 位于 `<!-- more -->` 之前的简洁简介,用于列表页摘要。
|
||||
3. 位于 `<!-- more -->` 之后的完整正文。
|
||||
|
||||
```
|
||||
元信息
|
||||
摘要(1-2 句)
|
||||
<!-- more -->
|
||||
正文
|
||||
```
|
||||
使用自然且能够说明内容的标题,不要直接使用“简介部分”或“正文部分”等模板化标题。简介由一至两个短段落组成,需要说明问题、此次交付的能力及其价值。
|
||||
|
||||
- `<!-- more -->` 恰好出现一次,前后各空一行。
|
||||
- 摘要说明"做了什么、为什么有价值"。
|
||||
- 正文标题从 `##` 开始,不重复一级标题。
|
||||
正文应围绕具体功能组织,不要机械套用完全相同的标题。根据主题只选择必要内容,不要求每篇全部覆盖:
|
||||
|
||||
---
|
||||
- 变更前存在的问题或限制;
|
||||
- 当前小功能点的目标与边界;
|
||||
- 核心设计或理解模型;
|
||||
- 配合精选代码片段说明实现路径;
|
||||
- 验证方式与可观察结果;
|
||||
- 取舍、当前限制和下一项自然演进能力;
|
||||
- 用简短结语将本篇内容连接到整个系列的演进主线。
|
||||
|
||||
## 应该(尽量做到)
|
||||
- 导读、概念介绍和设计点题类文章建议控制在 1000 个中文字符左右;这是保持简洁的参考值,不是硬性上限。根据章节范围和必要信息调整篇幅,优先保证主题讲清且没有无关扩展。
|
||||
- 标题给出的主题就是内容边界。只讲清当前主题,不借机扩展相关协议、实现细节或远期能力。
|
||||
|
||||
### 内容边界
|
||||
## 读者视角与系列衔接
|
||||
|
||||
- 标题就是边界。不讲无关协议、远期能力、实现细节。
|
||||
- 导读/概念类 ~1000 中文字符;实现类按需,讲清即止。
|
||||
- 2-4 个短章节即可,不为完整而堆章节。
|
||||
- 面向第一次接触本项目的外部读者写作,不要把作者已经掌握的项目背景当作读者常识。
|
||||
- 阶段编号和文章编号用于组织系列,不是正文的叙事前提。首次出现 `P0`、`P1` 等编号时必须用自然语言解释其含义;如果编号对理解当前内容没有帮助,就不要在正文中使用。
|
||||
- 每篇文章先从读者能够理解的场景、问题或一次自然的需求变化切入,再逐步引出项目术语和设计结论。
|
||||
- 用“原本有什么—遇到什么问题—做出什么选择”推进文章,但不要为了讲故事虚构场景或增加文学化铺垫。
|
||||
- 不得使用“如前所述”“大家已经知道”等措辞假设读者读过其他文章。即使文章位于系列中间,也应提供理解当前主题所需的最少背景。
|
||||
- 相邻文章应各守边界,避免提前讲完后续主题。结尾只需自然提出下一篇的问题,不要罗列尚未解释的阶段和术语。
|
||||
- 当前导读部分暂定为:`P0-00` 项目介绍与目录导航、`P0-01` 架构设计、`P0-02` 功能划分、`P0-03` 阶段规划、`P0-04` 目录规划。创作其中一篇时,不要侵占其他篇目的主要内容。
|
||||
|
||||
### 叙事方式
|
||||
## 系列文风
|
||||
|
||||
- 从读者能理解的场景切入,不假定读者读过前文。
|
||||
- "原本有什么 → 遇到什么问题 → 做出什么选择"推进。
|
||||
- 读者已知的概念直接引用跳过,如"和 Express 中间件机制一致,略过"。
|
||||
- 结尾自然引出下一篇的问题,不罗列阶段和术语。
|
||||
- 首次出现 P0/P1 等编号时用自然语言解释。
|
||||
- 使用清晰的简体中文,面向理解 TypeScript 和基本 LLM 概念、但可能刚接触 Agent 工程的开发者。
|
||||
- 直接、口语化、克制。先给结论,再补必要原因;能用一句话说清的内容不要扩成一段。
|
||||
- 删除不推动观点的过渡句、重复总结、设问和修饰语。每个段落只表达一个重点,通常不超过三句话。
|
||||
- 优先使用具体动词和短句,避免“我们需要先回答一个不那么显眼、却会影响整个项目的问题”一类绕弯表达。
|
||||
- 适度使用“我们”营造共同实践感,重点仍应放在技术推理和可复现的工程过程上。
|
||||
- 按照“动机—原理—实现—验证”的顺序展开。在进入密集实现细节前,先解释为什么这样做。
|
||||
- 保持段落简短、标题明确,并与项目文档使用一致的术语。
|
||||
- 技术术语首次出现时给出简要解释。代码中的英文标识符保持原样;不要为同一概念反复更换译名。
|
||||
- 避免营销语言、泛泛的 AI 背景介绍、夸张结论、填充式总结和流水账式叙述。
|
||||
- 每篇文章都应能够独立阅读,同时在概念上承接前一项能力并引出下一项能力。
|
||||
- 用最短的具体情境引出矛盾和选择,避免从项目内部术语或文档摘要直接起笔,也避免把技术文章写成散文。
|
||||
- 不为追求完整而增加章节。点题类文章通常使用两至四个短章节即可。
|
||||
|
||||
### 文风
|
||||
## Markdown 与代码
|
||||
|
||||
面向懂 TS 和基础 LLM、刚接触 Agent 工程的开发者。**笔记体**:结论先行,解释从简。
|
||||
|
||||
- ❌ "我们需要先回答一个不那么显眼、却会影响整个项目的问题"
|
||||
- ✅ "先确定一件事:Kernel 不认业务概念。"
|
||||
|
||||
- ❌ "经过反复思考和持续调整,我们最终选择了一种简洁优雅的方案"
|
||||
- ✅ "架构收缩为两个核心概念:Kernel + Extensions。"
|
||||
|
||||
- 每段一个重点,通常不超过三句话。
|
||||
- 删除过渡句、重复总结、设问、修饰语。
|
||||
- 可用"我们",重心在技术推理。
|
||||
- 不用营销语言、泛泛 AI 介绍、夸张结论。
|
||||
- 不写散文,不用文学化铺垫。
|
||||
|
||||
### 格式
|
||||
|
||||
- 围栏代码块标注语言;标题/列表/代码块/`<!-- more -->` 前后空行。
|
||||
- 代码尽量短,展示核心思路即可。省略处用 `...` 且确保不会误解。
|
||||
- 不自行添加目录。
|
||||
|
||||
---
|
||||
|
||||
## 避免
|
||||
|
||||
- 假设读者读过系列其他文章("如前所述""大家已经知道")。
|
||||
- 提前讲完后续文章的主题。
|
||||
- 把计划/路线图写成已实现的能力。
|
||||
- 流水账式叙述或填充式总结。
|
||||
- 为同一概念反复更换中文译名。
|
||||
|
||||
---
|
||||
- 使用标准 Markdown,围栏代码块必须标注语言。
|
||||
- 正文标题从 `##` 开始;页面标题由 Hexo 元信息提供,不要在正文中重复一级标题。
|
||||
- 标题、列表、代码块和 `<!-- more -->` 前后保留空行。
|
||||
- 代码片段应尽量短且与主题直接相关。只有在省略范围明显且不会造成误解时才使用 `...`。
|
||||
- 展示命令时,应区分命令和示例输出。只有命令确实存在或实际执行过,才能将其作为验证命令写入文章。
|
||||
- 除非用户明确要求,否则不要添加目录。
|
||||
|
||||
## 最终检查
|
||||
|
||||
- [ ] 只新增/修改了一篇 `.md`
|
||||
- [ ] 文件名匹配 `^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 中间件机制一致,略过。"
|
||||
|
||||
### 语气
|
||||
|
||||
- 用"我们"但不煽情,重心始终在技术本身。
|
||||
- 笔记语气:记录过程、防止遗忘,不假装客观权威。
|
||||
- [ ] 无占位内容残留
|
||||
- [ ] 代码与仓库一致
|
||||
- [ ] 回复含文件链接 + 一句话主题说明
|
||||
- 只新增或修改了一篇目标 Markdown 文章。
|
||||
- 文件名符合 `^2026-P[0-9]+-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$`。
|
||||
- 元信息结构有效;除非用户要求,否则只包含本系列规定的字段。
|
||||
- 有意义的简介之后恰好出现一次 `<!-- more -->`。
|
||||
- 没有遗留任何占位内容。
|
||||
- 事实陈述和代码片段符合当前仓库状态。
|
||||
- 最终回复包含文章文件链接,并用一句话说明文章主题。
|
||||
|
||||
@ -12,9 +12,6 @@ pnpm dev
|
||||
|
||||
启动时自动加载项目根目录的 `.env`。
|
||||
|
||||
其中设置 `DEEPSEEK_API_KEY`;
|
||||
也可用 `LLM_TO_AGENT_HOME` 修改数据根目录,用 `DEEPSEEK_MODEL` 修改模型。
|
||||
|
||||
文档:
|
||||
|
||||
- [架构总览](docs/architecture.md)
|
||||
|
||||
@ -1,16 +1,3 @@
|
||||
export const ExtensionId = {
|
||||
Workspace: "workspace",
|
||||
DeepSeek: "deepseek",
|
||||
Agent: "agent",
|
||||
Cli: "cli",
|
||||
};
|
||||
|
||||
export const Hook = {
|
||||
Workspace: "workspace",
|
||||
Agent: "agent",
|
||||
ModelProviders: "model.providers",
|
||||
};
|
||||
|
||||
export const Event = {
|
||||
RuntimeStopRequested: "runtime.stop.requested",
|
||||
RunStarted: "run.started",
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@ -1,84 +0,0 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionRuntimeContext,
|
||||
} from "../../../kernel";
|
||||
import { Event, ExtensionId, Hook } from "../../catalog";
|
||||
import {
|
||||
type ChatMessage,
|
||||
type WorkspaceService,
|
||||
} from "../workspace";
|
||||
|
||||
export interface ModelProvider {
|
||||
id: string;
|
||||
chat(messages: ChatMessage[], signal?: AbortSignal): AsyncIterable<string>;
|
||||
}
|
||||
|
||||
export interface AgentService {
|
||||
chat(input: string, signal?: AbortSignal): AsyncIterable<string>;
|
||||
}
|
||||
|
||||
export function createAgentExtension(): Extension {
|
||||
let runtime: ExtensionRuntimeContext | undefined;
|
||||
let workspace: WorkspaceService | undefined;
|
||||
let model: ModelProvider | undefined;
|
||||
|
||||
const agent: AgentService = {
|
||||
async *chat(input, signal) {
|
||||
if (!runtime || !workspace || !model) {
|
||||
throw new Error("Agent is not running.");
|
||||
}
|
||||
|
||||
const activeRuntime = runtime;
|
||||
const activeWorkspace = workspace;
|
||||
const activeModel = model;
|
||||
const runId = randomUUID();
|
||||
|
||||
const userMessage = await activeWorkspace.append("user", input);
|
||||
await activeRuntime.emit(Event.MessageAdded, {
|
||||
workspaceId: activeWorkspace.workspaceId,
|
||||
conversationId: activeWorkspace.conversationId,
|
||||
message: userMessage,
|
||||
});
|
||||
await activeRuntime.emit(Event.RunStarted, { runId, input });
|
||||
|
||||
let answer = "";
|
||||
|
||||
try {
|
||||
const messages = await activeWorkspace.messages();
|
||||
|
||||
for await (const chunk of activeModel.chat(messages, signal)) {
|
||||
answer += chunk;
|
||||
yield chunk;
|
||||
}
|
||||
|
||||
const assistantMessage = await activeWorkspace.append("assistant", answer);
|
||||
await activeRuntime.emit(Event.MessageAdded, {
|
||||
workspaceId: activeWorkspace.workspaceId,
|
||||
conversationId: activeWorkspace.conversationId,
|
||||
message: assistantMessage,
|
||||
});
|
||||
await activeRuntime.emit(Event.RunCompleted, { runId });
|
||||
} catch (error) {
|
||||
await activeRuntime.emit(Event.RunFailed, { runId, error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id: ExtensionId.Agent,
|
||||
|
||||
setup(context) {
|
||||
context.add(Hook.Agent, agent);
|
||||
},
|
||||
|
||||
start(context) {
|
||||
const providers = context.all<ModelProvider>(Hook.ModelProviders);
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new Error("Agent needs at least one model provider.");
|
||||
}
|
||||
|
||||
runtime = context;
|
||||
workspace = context.get<WorkspaceService>(Hook.Workspace);
|
||||
model = providers[0];
|
||||
},
|
||||
|
||||
stop() {
|
||||
runtime = undefined;
|
||||
workspace = undefined;
|
||||
model = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
import type { Extension } from "../../../kernel";
|
||||
import { ExtensionId, Hook } from "../../catalog";
|
||||
import type { ModelProvider } from "../agent";
|
||||
|
||||
export interface DeepSeekOptions {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
request?: (url: string, init: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
export function createDeepSeekExtension(options: DeepSeekOptions = {}): Extension {
|
||||
const apiKey = options.apiKey ?? process.env.DEEPSEEK_API_KEY;
|
||||
const baseUrl = (
|
||||
options.baseUrl ??
|
||||
process.env.DEEPSEEK_BASE_URL ??
|
||||
"https://api.deepseek.com"
|
||||
).replace(/\/+$/, "");
|
||||
const model = options.model ?? process.env.DEEPSEEK_MODEL ?? "deepseek-v4-flash";
|
||||
const request = options.request ?? fetch;
|
||||
|
||||
const provider: ModelProvider = {
|
||||
id: "deepseek",
|
||||
|
||||
async *chat(messages, signal) {
|
||||
const response = await request(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: messages.map(({ role, content }) => ({ role, content })),
|
||||
thinking: { type: "disabled" },
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`DeepSeek request failed (${response.status}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("DeepSeek returned an empty response body.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
|
||||
const events = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = events.pop() ?? "";
|
||||
|
||||
for (const entry of events) {
|
||||
for (const line of entry.split(/\r?\n/)) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") return;
|
||||
if (!data) continue;
|
||||
|
||||
const chunk = JSON.parse(data);
|
||||
const content = chunk.choices?.[0]?.delta?.content;
|
||||
if (content) yield content;
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id: ExtensionId.DeepSeek,
|
||||
|
||||
setup(context) {
|
||||
if (!apiKey) {
|
||||
throw new Error("DEEPSEEK_API_KEY is required.");
|
||||
}
|
||||
|
||||
context.add(Hook.ModelProviders, provider);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import type { Extension } from "../../../kernel";
|
||||
import { ExtensionId, Hook } from "../../catalog";
|
||||
|
||||
export type MessageRole = "system" | "user" | "assistant";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceService {
|
||||
home: string;
|
||||
projectPath: string;
|
||||
workspaceId: string;
|
||||
conversationId: string;
|
||||
messages(): Promise<ChatMessage[]>;
|
||||
append(role: MessageRole, content: string): Promise<ChatMessage>;
|
||||
}
|
||||
|
||||
export interface WorkspaceOptions {
|
||||
home?: string;
|
||||
projectPath?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extension {
|
||||
const home = resolve(
|
||||
options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
|
||||
);
|
||||
const projectPath = resolve(options.projectPath ?? process.cwd());
|
||||
const workspaceId = createHash("sha256").update(projectPath).digest("hex").slice(0, 16);
|
||||
const conversationId = options.conversationId ?? "default";
|
||||
const workspaceDirectory = join(home, "workspaces", workspaceId);
|
||||
const conversationDirectory = join(workspaceDirectory, "conversations", conversationId);
|
||||
const messagesFile = join(conversationDirectory, "messages.jsonl");
|
||||
|
||||
const workspace: WorkspaceService = {
|
||||
home,
|
||||
projectPath,
|
||||
workspaceId,
|
||||
conversationId,
|
||||
|
||||
async messages() {
|
||||
try {
|
||||
const content = await readFile(messagesFile, "utf8");
|
||||
return content
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ChatMessage);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async append(role, content) {
|
||||
const message = {
|
||||
role,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await appendFile(messagesFile, `${JSON.stringify(message)}\n`, "utf8");
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id: ExtensionId.Workspace,
|
||||
|
||||
async setup(context) {
|
||||
await mkdir(conversationDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
join(workspaceDirectory, "workspace.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: workspaceId,
|
||||
kind: "linked",
|
||||
projectPath,
|
||||
activeConversationId: conversationId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
context.add(Hook.Workspace, workspace);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import { createCliExtension } from "../extensions/cli";
|
||||
import type { ProductDefinition } from "./product";
|
||||
|
||||
export const cliProduct: ProductDefinition = {
|
||||
id: "cli",
|
||||
extensions: [createCliExtension],
|
||||
extensions: [],
|
||||
};
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
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[] = [
|
||||
createWorkspaceExtension,
|
||||
createDeepSeekExtension,
|
||||
createAgentExtension,
|
||||
];
|
||||
// 公共扩展在实现真实能力时加入这里。
|
||||
export const sharedExtensions: ExtensionFactory[] = [];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user