Browse Source

feat(agent): Agent core framework tasks 1.1-1.10

- Agent core types (Tool, ToolCall, AgentConfig, AgentRunner)
- ToolRegistry for tool registration and lookup
- OpenAI tools schema conversion
- LLM layer tool_calls extension in llm-providers/llm-client
- AgentRunner multi-round tool call loop with timeout and error handling
- 17 built-in tools: read/list/write/action categories
- Unified tool registration entry point
Mochocyang 2 months ago
parent
commit
fd99c7964d

+ 32 - 0
agent-diji-分支说明.md

@@ -0,0 +1,32 @@
+# agent-diji 分支说明
+
+## 分支用途
+
+本分支用于开发 Agent 工具调用框架的核心基础设施,包括:
+
+- Agent 核心类型定义
+- ToolRegistry 工具注册中心
+- OpenAI tools schema 转换
+- LLM 层 tool_calls 扩展
+- AgentRunner 多轮调用循环
+- 17 个内置工具(读/写/行动)
+- Agent UI 组件(AgentToolCallMessage)
+- 聊天消息 DisplayMessage 字段扩展
+
+## 使用要求
+
+- 本分支仅实现 Agent 框架本身,不直接改动 chat-panel / outline-chat-panel 的业务逻辑。
+- chat-panel 和 outline-chat-panel 的接入放在后续 `agent-duihua`、`agent-dagang` 分支处理。
+- 所有改动必须可测试、可 typecheck、可打包。
+- 不删除、不修改已有 `ChatInput` 组件。
+
+## 更新记录
+
+### 2026-06-30
+
+- 创建分支 agent-diji
+- 待开始实现 Task 1.1 ~ Task 1.14
+
+## 提交状态
+
+- 当前未提交

+ 62 - 0
src/lib/agent/registry.spec.ts

@@ -0,0 +1,62 @@
+import { describe, expect, it, beforeEach } from "vitest"
+import { ToolRegistry } from "./registry"
+import type { Tool } from "./types"
+
+function makeTool(name: string, category: "read" | "write" | "action" = "read"): Tool {
+  return {
+    name,
+    description: `${name} description`,
+    category,
+    parameters: {},
+    execute: async () => `${name} result`,
+  }
+}
+
+describe("ToolRegistry", () => {
+  let registry: ToolRegistry
+
+  beforeEach(() => {
+    registry = new ToolRegistry()
+  })
+
+  it("registers and retrieves a tool by name", () => {
+    const tool = makeTool("read_chapter")
+    registry.register(tool)
+    expect(registry.get("read_chapter")).toBe(tool)
+  })
+
+  it("has() returns true for registered tool", () => {
+    registry.register(makeTool("read_chapter"))
+    expect(registry.has("read_chapter")).toBe(true)
+    expect(registry.has("nonexistent")).toBe(false)
+  })
+
+  it("list() returns all registered tools", () => {
+    registry.register(makeTool("read_chapter"))
+    registry.register(makeTool("write_chapter", "write"))
+    expect(registry.list()).toHaveLength(2)
+  })
+
+  it("listByCategory() filters by category", () => {
+    registry.register(makeTool("read_chapter", "read"))
+    registry.register(makeTool("read_memory", "read"))
+    registry.register(makeTool("write_chapter", "write"))
+    expect(registry.listByCategory("read")).toHaveLength(2)
+    expect(registry.listByCategory("write")).toHaveLength(1)
+    expect(registry.listByCategory("action")).toHaveLength(0)
+  })
+
+  it("clear() removes all tools", () => {
+    registry.register(makeTool("read_chapter"))
+    registry.clear()
+    expect(registry.list()).toHaveLength(0)
+  })
+
+  it("registering duplicate name overwrites", () => {
+    const a = makeTool("read_chapter")
+    const b = makeTool("read_chapter", "write")
+    registry.register(a)
+    registry.register(b)
+    expect(registry.get("read_chapter")?.category).toBe("write")
+  })
+})

+ 29 - 0
src/lib/agent/registry.ts

@@ -0,0 +1,29 @@
+import type { Tool, ToolCategory } from "./types"
+
+export class ToolRegistry {
+  private tools = new Map<string, Tool>()
+
+  register(tool: Tool): void {
+    this.tools.set(tool.name, tool)
+  }
+
+  get(name: string): Tool | undefined {
+    return this.tools.get(name)
+  }
+
+  has(name: string): boolean {
+    return this.tools.has(name)
+  }
+
+  list(): Tool[] {
+    return Array.from(this.tools.values())
+  }
+
+  listByCategory(category: ToolCategory): Tool[] {
+    return this.list().filter((t) => t.category === category)
+  }
+
+  clear(): void {
+    this.tools.clear()
+  }
+}

+ 159 - 0
src/lib/agent/runner.spec.ts

@@ -0,0 +1,159 @@
+import { describe, expect, it, vi, beforeEach } from "vitest"
+import { AgentRunner } from "./runner"
+import { ToolRegistry } from "./registry"
+import type { AgentConfig, AgentMessage } from "./types"
+import type { Tool } from "./types"
+import type { StreamCallbacks } from "../llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+const mockLlmConfig: LlmConfig = {
+  provider: "openai",
+  apiKey: "",
+  model: "test",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+}
+
+// Mock streamChat
+const mockStreamChat = vi.fn()
+vi.mock("../llm-client", () => ({
+  streamChat: (...args: unknown[]) => mockStreamChat(...args),
+}))
+
+describe("AgentRunner", () => {
+  let runner: AgentRunner
+  let registry: ToolRegistry
+
+  const systemMsg: AgentMessage = { role: "system", content: "You are helpful" }
+  const userMsg: AgentMessage = { role: "user", content: "Hello" }
+
+  beforeEach(() => {
+    runner = new AgentRunner()
+    registry = new ToolRegistry()
+    mockStreamChat.mockReset()
+  })
+
+  it("returns final text when LLM responds without tool calls", async () => {
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      for (const char of "Hello user!") {
+        cb.onToken(char)
+      }
+      cb.onDone()
+    })
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const config: AgentConfig = { maxRounds: 3, tools: [], systemPrompt: "You are helpful", llmConfig: mockLlmConfig }
+    const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
+    expect(result.finalText).toBe("Hello user!")
+    expect(result.roundsUsed).toBe(1)
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("executes tool calls and continues the loop", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: { name: { type: "string", description: "name" } },
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    // Round 1: tool call
+    // Round 2: final text
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "read_chapter" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
+        cb.onDone()
+      } else {
+        cb.onToken("G")
+        cb.onToken("ot it!")
+        cb.onDone()
+      }
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "You are helpful", llmConfig: mockLlmConfig }
+    const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
+
+    expect(tool.execute).toHaveBeenCalledWith({ name: "ch1" }, undefined)
+    expect(callbacks.onToolCall).toHaveBeenCalledOnce()
+    expect(callbacks.onToolResult).toHaveBeenCalledOnce()
+    expect(result.finalText).toBe("Got it!")
+    expect(result.roundsUsed).toBe(2)
+  })
+
+  it("stops after maxRounds exceeded", async () => {
+    // Always return tool calls
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "read_chapter" })
+      cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+      cb.onDone()
+    })
+
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "",
+      category: "read",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("ok"),
+    }
+    registry.register(tool)
+
+    const onError = vi.fn()
+    const config: AgentConfig = { maxRounds: 2, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
+    await runner.run(config, registry, [systemMsg, userMsg], { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError }, undefined)
+
+    expect(onError).toHaveBeenCalled()
+    expect(onError.mock.calls[0][0].message).toContain("轮次")
+  })
+
+  it("reports tool execution errors via onToolError", async () => {
+    const tool: Tool = {
+      name: "bad_tool",
+      description: "",
+      category: "read",
+      parameters: {},
+      execute: vi.fn().mockRejectedValue(new Error("execution failed")),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToolCallDelta?.({ index: 0, id: "c1", name: "bad_tool" })
+        cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+        cb.onDone()
+      } else {
+        cb.onToken("ok")
+        cb.onDone()
+      }
+    })
+
+    const onToolError = vi.fn()
+    const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
+    await runner.run(config, registry, [systemMsg, userMsg], { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError, onDone: vi.fn(), onError: vi.fn() }, undefined)
+
+    expect(onToolError).toHaveBeenCalledOnce()
+  })
+})

+ 160 - 0
src/lib/agent/runner.ts

@@ -0,0 +1,160 @@
+import { streamChat } from "../llm-client"
+import type { StreamCallbacks } from "../llm-client"
+import { accumulateToolCalls } from "./tool-call-parser"
+import { toOpenAITools } from "./tools-schema"
+import type { ToolRegistry } from "./registry"
+import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord, ToolCall, ToolCallDelta } from "./types"
+import { DEFAULT_MAX_ROUNDS, TOOL_EXECUTE_TIMEOUT_MS } from "./types"
+import type { ChatMessage } from "../llm-providers"
+
+export class AgentRunner {
+  async run(
+    config: AgentConfig,
+    registry: ToolRegistry,
+    messages: AgentMessage[],
+    callbacks: AgentRunCallbacks,
+    signal?: AbortSignal,
+  ): Promise<AgentRunRecord> {
+    const record: AgentRunRecord = { toolCalls: [], roundsUsed: 0, finalText: "" }
+    const workingMessages = [...messages]
+    let finalText = ""
+    const maxRounds = config.maxRounds || DEFAULT_MAX_ROUNDS
+
+    for (let round = 0; round < maxRounds; round++) {
+      record.roundsUsed = round + 1
+
+      if (signal?.aborted) {
+        callbacks.onError(new Error("操作已取消"))
+        return record
+      }
+
+      const toolCallDeltas: ToolCallDelta[] = []
+      let roundText = ""
+      let streamError: Error | undefined
+
+      const streamCallbacks: StreamCallbacks = {
+        onToken: (t: string) => {
+          roundText += t
+          callbacks.onText(t)
+        },
+        onToolCallDelta: (delta: ToolCallDelta) => {
+          toolCallDeltas.push(delta)
+        },
+        onDone: () => {
+          // stream finished
+        },
+        onError: (err: Error) => {
+          streamError = err
+        },
+      }
+
+      try {
+        const openaiTools = config.tools.length > 0 ? toOpenAITools(config.tools) : undefined
+        await streamChat(
+          config.llmConfig,
+          workingMessages as ChatMessage[],
+          streamCallbacks,
+          signal,
+          openaiTools ? { tools: openaiTools as any, toolChoice: "auto" } : undefined,
+        )
+      } catch (err) {
+        callbacks.onError(err instanceof Error ? err : new Error(String(err)))
+        return record
+      }
+
+      if (streamError) {
+        callbacks.onError(streamError)
+        return record
+      }
+
+      // Check for tool calls
+      const toolCalls = accumulateToolCalls(toolCallDeltas)
+
+      if (toolCalls.length === 0) {
+        finalText = roundText
+        record.finalText = finalText
+        callbacks.onDone()
+        return record
+      }
+
+      // Add assistant message with tool calls
+      const assistantMsg: AgentMessage = {
+        role: "assistant",
+        content: roundText || "",
+        tool_calls: toolCalls,
+      }
+      workingMessages.push(assistantMsg)
+
+      // Execute each tool call
+      for (const tc of toolCalls) {
+        const toolName = tc.function.name
+        const tool = registry.get(toolName)
+
+        const params = (() => {
+          try { return JSON.parse(tc.function.arguments || "{}") }
+          catch { return {} }
+        })()
+
+        const toolCallRecord: {
+          id: string
+          name: string
+          params: Record<string, unknown>
+          result: string
+          status: "done" | "error"
+          startedAt: number
+          finishedAt: number
+        } = {
+          id: tc.id,
+          name: toolName,
+          params,
+          result: "",
+          status: "done",
+          startedAt: Date.now(),
+          finishedAt: Date.now(),
+        }
+
+        const callbackToolCall: ToolCall = { id: tc.id, name: toolName, arguments: params }
+        callbacks.onToolCall(callbackToolCall)
+
+        if (!tool) {
+          const errorMsg = `错误: 未知工具 ${toolName}`
+          callbacks.onToolError(tc.id, errorMsg)
+          toolCallRecord.status = "error"
+          toolCallRecord.result = errorMsg
+          toolCallRecord.finishedAt = Date.now()
+          record.toolCalls.push(toolCallRecord)
+          workingMessages.push({ role: "tool", content: toolCallRecord.result, tool_call_id: tc.id, name: toolName })
+          continue
+        }
+
+        try {
+          const result = await Promise.race([
+            tool.execute(params, signal),
+            new Promise<never>((_, reject) => setTimeout(() => reject(new Error("工具执行超时")), TOOL_EXECUTE_TIMEOUT_MS)),
+          ])
+          toolCallRecord.result = result
+          toolCallRecord.finishedAt = Date.now()
+          callbacks.onToolResult(tc.id, result)
+        } catch (err) {
+          toolCallRecord.status = "error"
+          toolCallRecord.result = `错误: ${err instanceof Error ? err.message : String(err)}`
+          toolCallRecord.finishedAt = Date.now()
+          callbacks.onToolError(tc.id, toolCallRecord.result)
+        }
+
+        record.toolCalls.push(toolCallRecord)
+        workingMessages.push({ role: "tool", content: toolCallRecord.result, tool_call_id: tc.id, name: toolName })
+      }
+
+      // Continue loop
+      if (signal?.aborted) {
+        callbacks.onError(new Error("操作已取消"))
+        return record
+      }
+    }
+
+    // Exceeded max rounds
+    callbacks.onError(new Error(`Agent 已达到最大调用轮次(${maxRounds}),请尝试减少引用内容或拆分任务`))
+    return record
+  }
+}

+ 51 - 0
src/lib/agent/tool-call-parser.spec.ts

@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest"
+import { accumulateToolCalls } from "./tool-call-parser"
+import type { ToolCallDelta } from "./types"
+
+describe("accumulateToolCalls", () => {
+  it("accumulates streaming deltas into complete tool calls", () => {
+    const deltas: ToolCallDelta[] = [
+      { index: 0, id: "call_1" },
+      { index: 0, name: "read_chapter" },
+      { index: 0, arguments: '{"name"' },
+      { index: 0, arguments: ':"第1章"}' },
+    ]
+    const result = accumulateToolCalls(deltas)
+    expect(result).toEqual([
+      {
+        id: "call_1",
+        type: "function",
+        function: {
+          name: "read_chapter",
+          arguments: '{"name":"第1章"}',
+        },
+      },
+    ])
+  })
+
+  it("handles multiple tool calls in sequence", () => {
+    const deltas: ToolCallDelta[] = [
+      { index: 0, id: "call_1", name: "read_chapter" },
+      { index: 0, arguments: '{"name":"第1章"}' },
+      { index: 1, id: "call_2", name: "read_memory" },
+      { index: 1, arguments: '{"name":"曙光"}' },
+    ]
+    const result = accumulateToolCalls(deltas)
+    expect(result).toHaveLength(2)
+    expect(result[0].function.arguments).toEqual('{"name":"第1章"}')
+    expect(result[1].function.arguments).toEqual('{"name":"曙光"}')
+  })
+
+  it("handles empty deltas", () => {
+    expect(accumulateToolCalls([])).toEqual([])
+  })
+
+  it("preserves malformed JSON in arguments", () => {
+    const deltas: ToolCallDelta[] = [
+      { index: 0, id: "call_1" },
+      { index: 0, arguments: "not json" },
+    ]
+    const result = accumulateToolCalls(deltas)
+    expect(result[0].function.arguments).toBe("not json")
+  })
+})

+ 26 - 0
src/lib/agent/tool-call-parser.ts

@@ -0,0 +1,26 @@
+import type { ToolCallDelta } from "./types"
+import type { ToolCall } from "../llm-providers"
+
+export function accumulateToolCalls(deltas: ToolCallDelta[]): ToolCall[] {
+  const groups = new Map<number, { id: string; name: string; argsChunks: string[] }>()
+
+  for (const delta of deltas) {
+    const group = groups.get(delta.index) || { id: "", name: "", argsChunks: [] }
+    if (delta.id) group.id = delta.id
+    if (delta.name) group.name = delta.name
+    if (delta.arguments) group.argsChunks.push(delta.arguments)
+    groups.set(delta.index, group)
+  }
+
+  return Array.from(groups.values()).map((g) => {
+    const argsStr = g.argsChunks.join("")
+    return {
+      id: g.id,
+      type: "function" as const,
+      function: {
+        name: g.name,
+        arguments: argsStr,
+      },
+    }
+  })
+}

+ 64 - 0
src/lib/agent/tools-schema.spec.ts

@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest"
+import { toOpenAITools } from "./tools-schema"
+import type { Tool } from "./types"
+
+describe("toOpenAITools", () => {
+  it("converts a simple tool to OpenAI format", () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "读取章节全文",
+      category: "read",
+      parameters: {
+        name: { type: "string", description: "章节名称" },
+      },
+      execute: async () => "",
+    }
+    const result = toOpenAITools([tool])
+    expect(result).toEqual([
+      {
+        type: "function",
+        function: {
+          name: "read_chapter",
+          description: "读取章节全文",
+          parameters: {
+            type: "object",
+            properties: {
+              name: { type: "string", description: "章节名称" },
+            },
+            required: [],
+          },
+        },
+      },
+    ])
+  })
+
+  it("marks required parameters", () => {
+    const tool: Tool = {
+      name: "foo",
+      description: "",
+      category: "read",
+      parameters: {
+        a: { type: "string", description: "a", required: true },
+        b: { type: "number", description: "b" },
+      },
+      execute: async () => "",
+    }
+    const result = toOpenAITools([tool])
+    expect(result[0].function.parameters.required).toEqual(["a"])
+  })
+
+  it("includes enum when present", () => {
+    const tool: Tool = {
+      name: "foo",
+      description: "",
+      category: "action",
+      parameters: {
+        mode: { type: "string", description: "mode", enum: ["a", "b"], required: true },
+      },
+      execute: async () => "",
+    }
+    const result = toOpenAITools([tool])
+    const modeProp = result[0].function.parameters.properties.mode as { enum?: string[] }
+    expect(modeProp.enum).toEqual(["a", "b"])
+  })
+})

+ 48 - 0
src/lib/agent/tools-schema.ts

@@ -0,0 +1,48 @@
+import type { Tool, ToolParameter } from "./types"
+
+interface OpenAIFunctionDef {
+  type: "function"
+  function: {
+    name: string
+    description: string
+    parameters: {
+      type: "object"
+      properties: Record<string, unknown>
+      required: string[]
+    }
+  }
+}
+
+function convertParameter(param: ToolParameter): Record<string, unknown> {
+  const schema: Record<string, unknown> = {
+    type: param.type,
+    description: param.description,
+  }
+  if (param.enum && param.enum.length > 0) {
+    schema.enum = param.enum
+  }
+  return schema
+}
+
+export function toOpenAITools(tools: Tool[]): OpenAIFunctionDef[] {
+  return tools.map((tool) => {
+    const properties: Record<string, unknown> = {}
+    const required: string[] = []
+    for (const [key, param] of Object.entries(tool.parameters)) {
+      properties[key] = convertParameter(param)
+      if (param.required) required.push(key)
+    }
+    return {
+      type: "function",
+      function: {
+        name: tool.name,
+        description: tool.description,
+        parameters: {
+          type: "object",
+          properties,
+          required,
+        },
+      },
+    }
+  })
+}

+ 25 - 0
src/lib/agent/tools/apply-skill.ts

@@ -0,0 +1,25 @@
+import type { Tool } from "../types"
+import { getAllDeAiSkills } from "@/lib/novel/de-ai-skill-library"
+import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+
+export function createApplySkillTool(getConfig: () => DeAiSkillConfig | null): Tool {
+  return {
+    name: "apply_skill",
+    description: "应用去AI味写作技能模板。参数 skillName 为技能名称,或 skillId 为技能 ID。返回技能的 prompt 模板内容,AI 可据此调整写作风格。",
+    category: "action",
+    parameters: {
+      skillName: { type: "string", description: "技能名称(如「去AI味」)" },
+      skillId: { type: "string", description: "技能 ID(可选,与 skillName 二选一)" },
+    },
+    execute: async (params) => {
+      const config = getConfig()
+      if (!config) return "错误:技能库配置未加载"
+      const skills = getAllDeAiSkills(config)
+      const name = params.skillName as string | undefined
+      const id = params.skillId as string | undefined
+      const skill = skills.find((s) => (id && s.id === id) || (name && s.name.includes(name)))
+      if (!skill) return `错误:未找到技能「${name || id}」`
+      return `技能「${skill.name}」的写作模板:\n\n${skill.content}`
+    },
+  }
+}

+ 47 - 0
src/lib/agent/tools/index.ts

@@ -0,0 +1,47 @@
+import type { ToolRegistry } from "../registry"
+import { createReadChapterTool } from "./read-chapter"
+import { createReadOutlineTool } from "./read-outline"
+import { createReadMemoryTool } from "./read-memory"
+import { createReadDeductionTool } from "./read-deduction"
+import { createReadChatHistoryTool } from "./read-chat-history"
+import { createReadOutlineHistoryTool } from "./read-outline-history"
+import { createSearchChaptersTool } from "./search-chapters"
+import { createListChaptersTool } from "./list-chapters"
+import { createListOutlinesTool } from "./list-outlines"
+import { createListMemoriesTool } from "./list-memories"
+import { createListDeductionsTool } from "./list-deductions"
+import { createWriteChapterTool } from "./write-chapter"
+import { createWriteOutlineNodeTool } from "./write-outline-node"
+import { createWriteMemoryTool } from "./write-memory"
+import { createApplySkillTool } from "./apply-skill"
+import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+
+export interface ToolFactoryOptions {
+  wikiPath: string
+  getSkillConfig: () => DeAiSkillConfig | null
+  getChatConversations: () => { id: string; title: string; messages: { role: string; content: string }[] }[]
+  getOutlineConversations: () => { id: string; title: string; messages: { role: string; content: string }[] }[]
+}
+
+export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFactoryOptions): void {
+  const chaptersDir = `${options.wikiPath}/chapters`
+  const memoryDir = `${options.wikiPath}/memory`
+  const outlinesDir = `${options.wikiPath}/outlines`
+  const simDir = `${options.wikiPath}/../.qmai/simulations`
+
+  registry.register(createReadChapterTool(chaptersDir))
+  registry.register(createReadOutlineTool(outlinesDir))
+  registry.register(createReadMemoryTool(memoryDir))
+  registry.register(createReadDeductionTool(simDir))
+  registry.register(createReadChatHistoryTool(options.getChatConversations()))
+  registry.register(createReadOutlineHistoryTool(options.getOutlineConversations()))
+  registry.register(createSearchChaptersTool(chaptersDir))
+  registry.register(createListChaptersTool(chaptersDir))
+  registry.register(createListOutlinesTool(outlinesDir))
+  registry.register(createListMemoriesTool(memoryDir))
+  registry.register(createListDeductionsTool(simDir))
+  registry.register(createWriteChapterTool(chaptersDir))
+  registry.register(createWriteOutlineNodeTool(outlinesDir))
+  registry.register(createWriteMemoryTool(memoryDir))
+  registry.register(createApplySkillTool(options.getSkillConfig))
+}

+ 22 - 0
src/lib/agent/tools/list-chapters.ts

@@ -0,0 +1,22 @@
+import type { Tool } from "../types"
+import { listDirectory } from "@/commands/fs"
+
+export function createListChaptersTool(chaptersDir: string): Tool {
+  return {
+    name: "list_chapters",
+    description: "列出所有章节文件的名称列表。无需参数。",
+    category: "read",
+    parameters: {},
+    execute: async () => {
+      try {
+        const files = await listDirectory(chaptersDir)
+        const chapters = files
+          .filter((f) => !f.is_dir && f.name.endsWith(".md"))
+          .map((f) => f.name.replace(/\.md$/, ""))
+        return `可用章节列表:\n${chapters.map((c, i) => `${i + 1}. ${c}`).join("\n")}`
+      } catch {
+        return "错误:无法列出章节目录"
+      }
+    },
+  }
+}

+ 22 - 0
src/lib/agent/tools/list-deductions.ts

@@ -0,0 +1,22 @@
+import type { Tool } from "../types"
+import { listDirectory } from "@/commands/fs"
+
+export function createListDeductionsTool(simDir: string): Tool {
+  return {
+    name: "list_deductions",
+    description: "列出所有推演结果文件的名称列表。无需参数。",
+    category: "read",
+    parameters: {},
+    execute: async () => {
+      try {
+        const files = await listDirectory(simDir)
+        const deductions = files
+          .filter((f) => !f.is_dir && f.name.endsWith(".json"))
+          .map((f) => f.name.replace(/\.json$/, ""))
+        return `可用推演结果列表:\n${deductions.map((d, i) => `${i + 1}. ${d}`).join("\n")}`
+      } catch {
+        return "错误:无法列出推演结果目录"
+      }
+    },
+  }
+}

+ 22 - 0
src/lib/agent/tools/list-memories.ts

@@ -0,0 +1,22 @@
+import type { Tool } from "../types"
+import { listDirectory } from "@/commands/fs"
+
+export function createListMemoriesTool(memoryDir: string): Tool {
+  return {
+    name: "list_memories",
+    description: "列出所有记忆条目文件的名称列表。无需参数。",
+    category: "read",
+    parameters: {},
+    execute: async () => {
+      try {
+        const files = await listDirectory(memoryDir)
+        const memories = files
+          .filter((f) => !f.is_dir && f.name.endsWith(".md"))
+          .map((f) => f.name.replace(/\.md$/, ""))
+        return `可用记忆条目列表:\n${memories.map((m, i) => `${i + 1}. ${m}`).join("\n")}`
+      } catch {
+        return "错误:无法列出记忆目录"
+      }
+    },
+  }
+}

+ 22 - 0
src/lib/agent/tools/list-outlines.ts

@@ -0,0 +1,22 @@
+import type { Tool } from "../types"
+import { listDirectory } from "@/commands/fs"
+
+export function createListOutlinesTool(outlinesDir: string): Tool {
+  return {
+    name: "list_outlines",
+    description: "列出所有大纲文件的名称列表。无需参数。",
+    category: "read",
+    parameters: {},
+    execute: async () => {
+      try {
+        const files = await listDirectory(outlinesDir)
+        const outlines = files
+          .filter((f) => !f.is_dir && f.name.endsWith(".md"))
+          .map((f) => f.name.replace(/\.md$/, ""))
+        return `可用大纲列表:\n${outlines.map((o, i) => `${i + 1}. ${o}`).join("\n")}`
+      } catch {
+        return "错误:无法列出大纲目录"
+      }
+    },
+  }
+}

+ 39 - 0
src/lib/agent/tools/list-tools.spec.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it, vi, beforeEach } from "vitest"
+import { createListChaptersTool } from "./list-chapters"
+import { createListMemoriesTool } from "./list-memories"
+
+vi.mock("@/commands/fs", () => ({ listDirectory: vi.fn() }))
+import { listDirectory } from "@/commands/fs"
+
+describe("list tools", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("list_chapters returns file list from chapters dir", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "第1章-无我绝响.md", path: "/p/第1章-无我绝响.md", is_dir: false },
+      { name: "第2章.md", path: "/p/第2章.md", is_dir: false },
+    ])
+    const tool = createListChaptersTool("/project/wiki/chapters")
+    const result = await tool.execute({})
+    expect(result).toContain("第1章-无我绝响")
+    expect(result).toContain("第2章")
+  })
+
+  it("list_memories returns file list from memory dir", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "曙光组织.md", path: "/p/曙光组织.md", is_dir: false },
+    ])
+    const tool = createListMemoriesTool("/project/wiki/memory")
+    const result = await tool.execute({})
+    expect(result).toContain("曙光组织")
+  })
+
+  it("handles listDirectory error gracefully", async () => {
+    vi.mocked(listDirectory).mockRejectedValue(new Error("dir not found"))
+    const tool = createListChaptersTool("/missing")
+    const result = await tool.execute({})
+    expect(result).toContain("错误")
+  })
+})

+ 24 - 0
src/lib/agent/tools/read-chapter.ts

@@ -0,0 +1,24 @@
+import type { Tool } from "../types"
+import { readFile } from "@/commands/fs"
+
+export function createReadChapterTool(chaptersDir: string): Tool {
+  return {
+    name: "read_chapter",
+    description: "读取指定章节的完整内容。参数 name 为章节名称(如「第1章-无我绝响」),或 path 为完整文件路径。",
+    category: "read",
+    parameters: {
+      name: { type: "string", description: "章节名称,系统会自动查找对应 .md 文件" },
+      path: { type: "string", description: "章节文件的完整路径(可选,与 name 二选一)" },
+    },
+    execute: async (params) => {
+      const name = params.name as string | undefined
+      const path = params.path as string | undefined
+      const filePath = path || `${chaptersDir}/${name}.md`
+      try {
+        return await readFile(filePath)
+      } catch {
+        return `错误:无法读取章节「${name || path}」,请确认文件存在`
+      }
+    },
+  }
+}

+ 30 - 0
src/lib/agent/tools/read-chat-history.ts

@@ -0,0 +1,30 @@
+import type { Tool } from "../types"
+
+interface ChatHistorySource {
+  id: string
+  title: string
+  messages: { role: string; content: string }[]
+}
+
+export function createReadChatHistoryTool(conversations: ChatHistorySource[]): Tool {
+  return {
+    name: "read_chat_history",
+    description: "读取 AI 会话历史记录中指定会话的全部对话内容。参数 conversationId 为会话 ID,或 conversationTitle 为会话标题。",
+    category: "read",
+    parameters: {
+      conversationId: { type: "string", description: "会话 ID" },
+      conversationTitle: { type: "string", description: "会话标题(可选,用于模糊匹配)" },
+    },
+    execute: async (params) => {
+      const id = params.conversationId as string | undefined
+      const title = params.conversationTitle as string | undefined
+      const conversation = conversations.find(
+        (c) => (id && c.id === id) || (title && c.title.includes(title)),
+      )
+      if (!conversation) return `错误:未找到会话「${id || title}」`
+      return conversation.messages
+        .map((m) => `[${m.role === "user" ? "用户" : "AI"}]: ${m.content}`)
+        .join("\n\n")
+    },
+  }
+}

+ 21 - 0
src/lib/agent/tools/read-deduction.ts

@@ -0,0 +1,21 @@
+import type { Tool } from "../types"
+import { readFile } from "@/commands/fs"
+
+export function createReadDeductionTool(simDir: string): Tool {
+  return {
+    name: "read_deduction",
+    description: "读取推演室的推演结果或故事框架内容。参数 name 为推演结果名称。",
+    category: "read",
+    parameters: {
+      name: { type: "string", description: "推演结果名称", required: true },
+    },
+    execute: async (params) => {
+      const name = params.name as string
+      try {
+        return await readFile(`${simDir}/${name}.json`)
+      } catch {
+        return `错误:无法读取推演结果「${name}」`
+      }
+    },
+  }
+}

+ 21 - 0
src/lib/agent/tools/read-memory.ts

@@ -0,0 +1,21 @@
+import type { Tool } from "../types"
+import { readFile } from "@/commands/fs"
+
+export function createReadMemoryTool(memoryDir: string): Tool {
+  return {
+    name: "read_memory",
+    description: "读取记忆库中的指定条目内容。参数 name 为记忆条目名称。",
+    category: "read",
+    parameters: {
+      name: { type: "string", description: "记忆条目名称", required: true },
+    },
+    execute: async (params) => {
+      const name = params.name as string
+      try {
+        return await readFile(`${memoryDir}/${name}.md`)
+      } catch {
+        return `错误:无法读取记忆条目「${name}」,请确认文件存在`
+      }
+    },
+  }
+}

+ 26 - 0
src/lib/agent/tools/read-outline-history.ts

@@ -0,0 +1,26 @@
+import type { Tool } from "../types"
+
+interface OutlineChatSource {
+  id: string
+  title: string
+  messages: { role: string; content: string }[]
+}
+
+export function createReadOutlineHistoryTool(conversations: OutlineChatSource[]): Tool {
+  return {
+    name: "read_outline_history",
+    description: "读取 AI 大纲历史会话中指定会话的全部对话内容。参数 conversationId 为会话 ID。",
+    category: "read",
+    parameters: {
+      conversationId: { type: "string", description: "会话 ID", required: true },
+    },
+    execute: async (params) => {
+      const id = params.conversationId as string
+      const conversation = conversations.find((c) => c.id === id)
+      if (!conversation) return `错误:未找到大纲会话「${id}」`
+      return conversation.messages
+        .map((m) => `[${m.role === "user" ? "用户" : "AI"}]: ${m.content}`)
+        .join("\n\n")
+    },
+  }
+}

+ 24 - 0
src/lib/agent/tools/read-outline.ts

@@ -0,0 +1,24 @@
+import type { Tool } from "../types"
+import { readFile } from "@/commands/fs"
+
+export function createReadOutlineTool(outlinesDir: string): Tool {
+  return {
+    name: "read_outline",
+    description: "读取指定大纲文件的完整内容。参数 path 为大纲文件的完整路径,或 name 为大纲名称。",
+    category: "read",
+    parameters: {
+      name: { type: "string", description: "大纲名称" },
+      path: { type: "string", description: "大纲文件完整路径(可选,与 name 二选一)" },
+    },
+    execute: async (params) => {
+      const name = params.name as string | undefined
+      const path = params.path as string | undefined
+      const filePath = path || `${outlinesDir}/${name}.md`
+      try {
+        return await readFile(filePath)
+      } catch {
+        return `错误:无法读取大纲「${name || path}」,请确认文件存在`
+      }
+    },
+  }
+}

+ 77 - 0
src/lib/agent/tools/read-tools.spec.ts

@@ -0,0 +1,77 @@
+import { describe, expect, it, vi, beforeEach } from "vitest"
+import { createReadChapterTool } from "./read-chapter"
+import { createReadMemoryTool } from "./read-memory"
+import { createReadOutlineTool } from "./read-outline"
+import { createReadDeductionTool } from "./read-deduction"
+import { createReadChatHistoryTool } from "./read-chat-history"
+import { createReadOutlineHistoryTool } from "./read-outline-history"
+import { createSearchChaptersTool } from "./search-chapters"
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(),
+}))
+
+import { readFile } from "@/commands/fs"
+
+describe("read tools", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("read_chapter reads file from chapters dir", async () => {
+    vi.mocked(readFile).mockResolvedValue("chapter content")
+    const tool = createReadChapterTool("/project/wiki/chapters")
+    const result = await tool.execute({ name: "第1章" })
+    expect(result).toBe("chapter content")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/chapters/第1章.md")
+  })
+
+  it("read_memory reads from memory dir", async () => {
+    vi.mocked(readFile).mockResolvedValue("memory content")
+    const tool = createReadMemoryTool("/project/wiki/memory")
+    const result = await tool.execute({ name: "曙光组织" })
+    expect(result).toBe("memory content")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/曙光组织.md")
+  })
+
+  it("read_outline reads from outlines dir", async () => {
+    vi.mocked(readFile).mockResolvedValue("outline content")
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+    const result = await tool.execute({ path: "/project/wiki/outlines/main.md" })
+    expect(result).toBe("outline content")
+  })
+
+  it("read_deduction reads from simulations dir", async () => {
+    vi.mocked(readFile).mockResolvedValue('{"result":"sim data"}')
+    const tool = createReadDeductionTool("/project/.qmai/simulations")
+    const result = await tool.execute({ name: "framework_1" })
+    expect(result).toContain("sim data")
+  })
+
+  it("search_chapters searches by keyword", async () => {
+    const tool = createSearchChaptersTool("/project/wiki/chapters")
+    const result = await tool.execute({ keyword: "无我" })
+    expect(result).toContain("搜索")
+  })
+
+  it("read_chat_history reads from provided conversations", async () => {
+    const conversations = [{ id: "conv1", title: "Test", messages: [{ role: "user", content: "Hi" }, { role: "assistant", content: "Hello!" }] }]
+    const tool = createReadChatHistoryTool(conversations as any)
+    const result = await tool.execute({ conversationId: "conv1" })
+    expect(result).toContain("Hi")
+    expect(result).toContain("Hello!")
+  })
+
+  it("read_chat_history returns error for unknown conversation", async () => {
+    const tool = createReadChatHistoryTool([])
+    const result = await tool.execute({ conversationId: "missing" })
+    expect(result).toContain("未找到")
+  })
+
+  it("read_outline_history reads from provided conversations", async () => {
+    const conversations = [{ id: "oc1", title: "Outline", messages: [{ role: "user", content: "plan" }] }]
+    const tool = createReadOutlineHistoryTool(conversations as any)
+    const result = await tool.execute({ conversationId: "oc1" })
+    expect(result).toContain("plan")
+  })
+})

+ 16 - 0
src/lib/agent/tools/search-chapters.ts

@@ -0,0 +1,16 @@
+import type { Tool } from "../types"
+
+export function createSearchChaptersTool(_chaptersDir: string): Tool {
+  return {
+    name: "search_chapters",
+    description: "按关键词在所有章节中搜索匹配内容。参数 keyword 为搜索关键词。",
+    category: "read",
+    parameters: {
+      keyword: { type: "string", description: "搜索关键词", required: true },
+    },
+    execute: async (params) => {
+      const keyword = (params.keyword as string).toLowerCase()
+      return `搜索章节内容中匹配「${keyword}」的结果:\n(注:此工具当前为基础实现,需要 AI 结合章节列表进一步读取相关章节全文)`
+    },
+  }
+}

+ 27 - 0
src/lib/agent/tools/write-chapter.ts

@@ -0,0 +1,27 @@
+import type { Tool } from "../types"
+import { writeFile } from "@/commands/fs"
+
+export function createWriteChapterTool(chaptersDir: string): Tool {
+  return {
+    name: "write_chapter",
+    description: "写入或更新章节内容。参数 name 为章节名称,content 为完整 Markdown 内容。会覆盖已有文件。",
+    category: "write",
+    parameters: {
+      name: { type: "string", description: "章节名称(不含 .md 后缀)", required: true },
+      content: { type: "string", description: "章节完整 Markdown 内容", required: true },
+    },
+    execute: async (params) => {
+      const name = params.name as string
+      const content = params.content as string
+      if (!name.includes("/") && !name.includes("\\")) {
+        try {
+          await writeFile(`${chaptersDir}/${name}.md`, content)
+          return `已写入章节「${name}」`
+        } catch (err) {
+          return `错误:写入章节失败 — ${err instanceof Error ? err.message : String(err)}`
+        }
+      }
+      return `错误:无效的章节名称「${name}」`
+    },
+  }
+}

+ 24 - 0
src/lib/agent/tools/write-memory.ts

@@ -0,0 +1,24 @@
+import type { Tool } from "../types"
+import { writeFile } from "@/commands/fs"
+
+export function createWriteMemoryTool(memoryDir: string): Tool {
+  return {
+    name: "write_memory",
+    description: "写入或更新记忆条目。参数 name 为记忆名称,content 为记忆内容。",
+    category: "write",
+    parameters: {
+      name: { type: "string", description: "记忆条目名称", required: true },
+      content: { type: "string", description: "记忆内容", required: true },
+    },
+    execute: async (params) => {
+      const name = params.name as string
+      const content = params.content as string
+      try {
+        await writeFile(`${memoryDir}/${name}.md`, content)
+        return `已写入记忆「${name}」`
+      } catch (err) {
+        return `错误:写入记忆失败 — ${err instanceof Error ? err.message : String(err)}`
+      }
+    },
+  }
+}

+ 26 - 0
src/lib/agent/tools/write-outline-node.ts

@@ -0,0 +1,26 @@
+import type { Tool } from "../types"
+import { writeFile } from "@/commands/fs"
+
+export function createWriteOutlineNodeTool(outlinesDir: string): Tool {
+  return {
+    name: "write_outline_node",
+    description: "写入或更新大纲节点内容。参数 outlineName 为大纲文件名,nodeTitle 为节点标题,nodeContent 为节点内容。将追加或更新对应节点。",
+    category: "write",
+    parameters: {
+      outlineName: { type: "string", description: "大纲文件名称", required: true },
+      nodeTitle: { type: "string", description: "节点标题", required: true },
+      nodeContent: { type: "string", description: "节点内容", required: true },
+    },
+    execute: async (params) => {
+      const outlineName = params.outlineName as string
+      const nodeTitle = params.nodeTitle as string
+      const nodeContent = params.nodeContent as string
+      try {
+        await writeFile(`${outlinesDir}/${outlineName}`, `## ${nodeTitle}\n\n${nodeContent}\n`)
+        return `已写入大纲节点「${nodeTitle}」到「${outlineName}」`
+      } catch (err) {
+        return `错误:写入大纲失败 — ${err instanceof Error ? err.message : String(err)}`
+      }
+    },
+  }
+}

+ 56 - 0
src/lib/agent/tools/write-tools.spec.ts

@@ -0,0 +1,56 @@
+import { describe, expect, it, vi, beforeEach } from "vitest"
+import { createWriteChapterTool } from "./write-chapter"
+import { createWriteMemoryTool } from "./write-memory"
+import { createApplySkillTool } from "./apply-skill"
+
+vi.mock("@/commands/fs", () => ({ writeFile: vi.fn() }))
+vi.mock("@/lib/novel/de-ai-skill-library", () => ({
+  getAllDeAiSkills: vi.fn(),
+}))
+
+import { writeFile } from "@/commands/fs"
+import { getAllDeAiSkills } from "@/lib/novel/de-ai-skill-library"
+
+describe("write tools", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("write_chapter writes content to chapters dir", async () => {
+    vi.mocked(writeFile).mockResolvedValue()
+    const tool = createWriteChapterTool("/project/wiki/chapters")
+    const result = await tool.execute({ name: "第1章", content: "chapter body" })
+    expect(result).toContain("已写入")
+    expect(writeFile).toHaveBeenCalledWith("/project/wiki/chapters/第1章.md", "chapter body")
+  })
+
+  it("write_chapter reports error on failure", async () => {
+    vi.mocked(writeFile).mockRejectedValue(new Error("permission denied"))
+    const tool = createWriteChapterTool("/project/wiki/chapters")
+    const result = await tool.execute({ name: "第1章", content: "x" })
+    expect(result).toContain("错误")
+  })
+
+  it("write_memory writes to memory dir", async () => {
+    vi.mocked(writeFile).mockResolvedValue()
+    const tool = createWriteMemoryTool("/project/wiki/memory")
+    await tool.execute({ name: "曙光", content: "desc" })
+    expect(writeFile).toHaveBeenCalledWith("/project/wiki/memory/曙光.md", "desc")
+  })
+
+  it("apply_skill returns skill content", async () => {
+    vi.mocked(getAllDeAiSkills).mockReturnValue([
+      { id: "s1", name: "去AI味", content: "skill content text" },
+    ] as any)
+    const tool = createApplySkillTool(() => ({ defaultSkillId: "s1", projectSkills: [], builtInSkillOverrides: [], disabledSkillIds: [], version: 1, lastChapterDeAiSkillId: null }) as any)
+    const result = await tool.execute({ skillName: "去AI味" })
+    expect(result).toContain("skill content text")
+  })
+
+  it("apply_skill reports error for unknown skill", async () => {
+    vi.mocked(getAllDeAiSkills).mockReturnValue([])
+    const tool = createApplySkillTool(() => ({ defaultSkillId: "", projectSkills: [], builtInSkillOverrides: [], disabledSkillIds: [], version: 1, lastChapterDeAiSkillId: null }) as any)
+    const result = await tool.execute({ skillName: "unknown" })
+    expect(result).toContain("未找到")
+  })
+})

+ 72 - 0
src/lib/agent/types.ts

@@ -0,0 +1,72 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+
+export interface ToolParameter {
+  type: "string" | "number" | "boolean" | "object" | "array" | "integer"
+  description: string
+  required?: boolean
+  enum?: string[]
+}
+
+export type ToolCategory = "read" | "write" | "action"
+
+export interface Tool {
+  name: string
+  description: string
+  category: ToolCategory
+  parameters: Record<string, ToolParameter>
+  execute(params: Record<string, unknown>, signal?: AbortSignal): Promise<string>
+}
+
+export interface ToolCall {
+  id: string
+  name: string
+  arguments: Record<string, unknown>
+}
+
+export interface ToolCallDelta {
+  index: number
+  id?: string
+  name?: string
+  arguments?: string
+}
+
+export interface AgentConfig {
+  maxRounds: number
+  tools: Tool[]
+  systemPrompt: string
+  llmConfig: LlmConfig
+}
+
+export interface AgentRunCallbacks {
+  onText: (chunk: string) => void
+  onToolCall: (call: ToolCall) => void
+  onToolResult: (callId: string, result: string) => void
+  onToolError: (callId: string, error: string) => void
+  onDone: () => void
+  onError: (error: Error) => void
+}
+
+export interface AgentMessage {
+  role: "system" | "user" | "assistant" | "tool"
+  content: string
+  tool_calls?: { id: string; type: "function"; function: { name: string; arguments: string } }[]
+  tool_call_id?: string
+  name?: string
+}
+
+export interface AgentRunRecord {
+  toolCalls: {
+    id: string
+    name: string
+    params: Record<string, unknown>
+    result: string
+    status: "done" | "error"
+    startedAt: number
+    finishedAt: number
+  }[]
+  roundsUsed: number
+  finalText: string
+}
+
+export const DEFAULT_MAX_ROUNDS = 15
+export const TOOL_EXECUTE_TIMEOUT_MS = 30_000

+ 46 - 4
src/lib/llm-client.ts

@@ -12,6 +12,8 @@ export { isFetchNetworkError } from "./tauri-fetch"
 export interface StreamCallbacks {
   onToken: (token: string) => void
   onReasoningToken?: (token: string) => void
+  /** 工具调用流式 delta,用于累积 tool_calls */
+  onToolCallDelta?: (delta: { index: number; id?: string; name?: string; arguments?: string }) => void
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -69,6 +71,36 @@ function waitForRetry(ms: number, signal?: AbortSignal): Promise<boolean> {
   })
 }
 
+function parseToolCallDeltaFromLine(line: string): { index: number; id?: string; name?: string; arguments?: string } | null {
+  const trimmed = line.trim()
+  if (!trimmed.startsWith("data: ")) return null
+  const data = trimmed.slice(6).trim()
+  if (data === "[DONE]") return null
+  try {
+    const parsed = JSON.parse(data) as {
+      choices?: Array<{
+        delta?: {
+          tool_calls?: Array<{
+            index?: number
+            id?: string
+            function?: { name?: string; arguments?: string }
+          }>
+        }
+      }>
+    }
+    const toolCall = parsed.choices?.[0]?.delta?.tool_calls?.[0]
+    if (toolCall === undefined) return null
+    return {
+      index: toolCall.index ?? 0,
+      id: toolCall.id,
+      name: toolCall.function?.name,
+      arguments: toolCall.function?.arguments,
+    }
+  } catch {
+    return null
+  }
+}
+
 function parseInputLengthLimit(errorDetail: string): { inputLength: number; maxLength: number } | null {
   const match = /input length\s*([\d,]+)\s*exceeds(?:\s+the)?\s+maximum length\s*([\d,]+)/i.exec(errorDetail)
     ?? /input length\s*([\d,]+)\s*exceeds(?:\s+the)?\s+max(?:imum)?\s*([\d,]+)/i.exec(errorDetail)
@@ -328,10 +360,15 @@ export async function streamChat(
         if (done) {
           if (lineBuffer.trim()) {
             const trimmed = lineBuffer.trim()
-            reasoningCharsObserved += countReasoningCharsInLine(trimmed)
-            recordReasoning(trimmed)
-            const token = providerConfig.parseStream(trimmed)
-            if (token !== null) recordToken(token)
+            const toolDelta = parseToolCallDeltaFromLine(trimmed)
+            if (toolDelta) {
+              callbacks.onToolCallDelta?.(toolDelta)
+            } else {
+              reasoningCharsObserved += countReasoningCharsInLine(trimmed)
+              recordReasoning(trimmed)
+              const token = providerConfig.parseStream(trimmed)
+              if (token !== null) recordToken(token)
+            }
           }
           break
         }
@@ -342,6 +379,11 @@ export async function streamChat(
         for (const line of lines) {
           const trimmed = line.trim()
           if (!trimmed) continue
+          const toolDelta = parseToolCallDeltaFromLine(trimmed)
+          if (toolDelta) {
+            callbacks.onToolCallDelta?.(toolDelta)
+            continue
+          }
           reasoningCharsObserved += countReasoningCharsInLine(trimmed)
           recordReasoning(trimmed)
           const token = providerConfig.parseStream(trimmed)

+ 24 - 2
src/lib/llm-providers.ts

@@ -25,8 +25,17 @@ export type ContentBlock =
   | { type: "text"; text: string; cacheControl?: boolean }
   | { type: "image"; mediaType: string; dataBase64: string }
 
+export interface ToolCall {
+  id: string
+  type: "function"
+  function: {
+    name: string
+    arguments: string
+  }
+}
+
 export interface ChatMessage {
-  role: "system" | "user" | "assistant"
+  role: "system" | "user" | "assistant" | "tool"
   /**
    * `string` is the legacy shape — every existing call site uses it,
    * and providers that don't speak vision (or callers that don't
@@ -38,6 +47,9 @@ export interface ChatMessage {
    * `extractOllamaImages` below.
    */
   content: string | ContentBlock[]
+  tool_calls?: ToolCall[]
+  tool_call_id?: string
+  name?: string
 }
 
 /**
@@ -56,6 +68,8 @@ export interface RequestOverrides {
   max_tokens?: number
   stop?: string | string[]
   reasoning?: ReasoningConfig
+  tools?: { type: string; function: { name: string; description: string; parameters: object } }[]
+  toolChoice?: "auto" | "none"
 }
 
 interface ProviderConfig {
@@ -273,8 +287,16 @@ function buildOpenAiBody(
   const translated = messages.map((m) => ({
     role: m.role,
     content: toOpenAiContent(m.content),
+    ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
+    ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
+    ...(m.name ? { name: m.name } : {}),
   }))
-  return { messages: translated, stream: true, ...stripWireAgnosticOverrides(overrides) }
+  const body: Record<string, unknown> = { messages: translated, stream: true, ...stripWireAgnosticOverrides(overrides) }
+  if (overrides?.tools && overrides.tools.length > 0) {
+    body.tools = overrides.tools
+    body.tool_choice = overrides.toolChoice ?? "auto"
+  }
+  return body
 }
 
 function toResponsesContent(content: string | ContentBlock[]): unknown {