diff --git a/README.md b/README.md index bdbf8b3..d173b6d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ pnpm dev 启动时自动加载项目根目录的 `.env`。 +其中设置 `DEEPSEEK_API_KEY`; +也可用 `LLM_TO_AGENT_HOME` 修改数据根目录,用 `DEEPSEEK_MODEL` 修改模型。 + 文档: - [架构总览](docs/architecture.md) diff --git a/src/extensions/shared/agent/index.ts b/src/extensions/shared/agent/index.ts new file mode 100644 index 0000000..ed244b4 --- /dev/null +++ b/src/extensions/shared/agent/index.ts @@ -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; +} + +export interface AgentService { + chat(input: string, signal?: AbortSignal): AsyncIterable; +} + +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(Hook.ModelProviders); + + if (providers.length === 0) { + throw new Error("Agent needs at least one model provider."); + } + + runtime = context; + workspace = context.get(Hook.Workspace); + model = providers[0]; + }, + + stop() { + runtime = undefined; + workspace = undefined; + model = undefined; + }, + }; +} diff --git a/src/extensions/shared/deepseek/index.ts b/src/extensions/shared/deepseek/index.ts new file mode 100644 index 0000000..4447489 --- /dev/null +++ b/src/extensions/shared/deepseek/index.ts @@ -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; +} + +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); + }, + }; +}