Compare commits

..

4 Commits

Author SHA1 Message Date
李岩岩
85441ca855 feat: 接入 CLI 对话工作流 2026-07-30 17:48:17 +08:00
李岩岩
381a88eca8 feat: 实现 Agent 与 DeepSeek 对话链路 2026-07-30 17:45:11 +08:00
李岩岩
0dec9e1a36 feat: 实现 Workspace 对话持久化 2026-07-30 17:44:31 +08:00
李岩岩
3c553c4d6d refactor: 集中管理 Extension 连接点 2026-07-30 17:43:56 +08:00
10 changed files with 635 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 中间件机制一致,略过。"
### 语气
- 用"我们"但不煽情,重心始终在技术本身。
- 笔记语气:记录过程、防止遗忘,不假装客观权威。
- [ ] 无占位内容残留
- [ ] 代码与仓库一致
- [ ] 回复含文件链接 + 一句话主题说明

View File

@ -12,6 +12,9 @@ pnpm dev
启动时自动加载项目根目录的 `.env`
其中设置 `DEEPSEEK_API_KEY`
也可用 `LLM_TO_AGENT_HOME` 修改数据根目录,用 `DEEPSEEK_MODEL` 修改模型。
文档:
- [架构总览](docs/architecture.md)

View File

@ -1,3 +1,16 @@
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",

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

@ -0,0 +1,95 @@
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;
},
};
}

View File

@ -0,0 +1,96 @@
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);
},
};
}

View File

@ -0,0 +1,97 @@
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);
},
};
}

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,
];