From 44bfb8b45aed6b0056ac5a9c64e1f83964de2f41 Mon Sep 17 00:00:00 2001 From: honlnk Date: Wed, 29 Apr 2026 10:36:31 +0800 Subject: [PATCH 01/22] =?UTF-8?q?feat(app):=20=E6=8E=A5=E5=85=A5=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=B7=A5=E5=85=B7=20Agent=20=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/agent/llm.ts | 291 ++++++++++++++++++++++ src/core/agent/messages.ts | 60 +++++ src/core/agent/prompt.ts | 74 ++++++ src/core/agent/query.ts | 79 ++++++ src/core/agent/tool-execution.ts | 94 ++++++++ src/core/agent/tool-orchestration.ts | 25 ++ src/core/agent/tools.ts | 150 ++++++++++++ src/core/chat/session.ts | 348 +++++++++++++++++---------- src/core/chat/tools.ts | 154 +++--------- src/core/tools/file-tools.ts | 236 ++++++++++++++++++ src/core/tools/index.ts | 102 ++++++++ src/core/tools/path.ts | 39 +++ src/core/tools/types.ts | 80 ++++++ src/types/chat.ts | 24 +- src/views/SessionTestView.vue | 24 +- 15 files changed, 1526 insertions(+), 254 deletions(-) create mode 100644 src/core/agent/llm.ts create mode 100644 src/core/agent/messages.ts create mode 100644 src/core/agent/prompt.ts create mode 100644 src/core/agent/query.ts create mode 100644 src/core/agent/tool-execution.ts create mode 100644 src/core/agent/tool-orchestration.ts create mode 100644 src/core/agent/tools.ts create mode 100644 src/core/tools/file-tools.ts create mode 100644 src/core/tools/index.ts create mode 100644 src/core/tools/path.ts create mode 100644 src/core/tools/types.ts diff --git a/src/core/agent/llm.ts b/src/core/agent/llm.ts new file mode 100644 index 0000000..8a12db8 --- /dev/null +++ b/src/core/agent/llm.ts @@ -0,0 +1,291 @@ +import { createJsonHeaders, extractErrorMessage, normalizeBaseUrl, readJsonResponse, resolveApiUrl } from '../ai/shared' + +import { createAgentId } from './messages' +import type { + AgentAssistantResponse, + AgentMessage, + AgentToolCall, + AgentToolSchema, +} from './messages' + +export type AgentLlmInput = { + baseUrl: string + apiKey: string + model: string + messages: AgentMessage[] + tools: AgentToolSchema[] +} + +export type AgentLlmEvent = + | { type: 'start' } + | { type: 'delta'; text: string } + | { type: 'finish'; response: AgentAssistantResponse } + | { type: 'error'; message: string } + +type PendingToolCall = { + id?: string + name?: string + argumentsText: string +} + +export async function streamAgentCompletion( + input: AgentLlmInput, + onEvent: (event: AgentLlmEvent) => void, +): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl) + + if (!baseUrl || !input.apiKey.trim() || !input.model.trim()) { + const message = '请先填写 LLM 的 API 地址、API Key 和模型名称' + onEvent({ type: 'error', message }) + throw new Error(message) + } + + const response = await fetch(resolveApiUrl(baseUrl, '/chat/completions'), { + method: 'POST', + headers: createJsonHeaders(input.apiKey, baseUrl), + body: JSON.stringify({ + model: input.model.trim(), + stream: true, + messages: input.messages.map(toOpenAiMessage), + tools: input.tools, + tool_choice: 'auto', + }), + }) + + if (!response.ok) { + const payload = await readJsonResponse(response) + const message = extractErrorMessage(payload, 'Agent 调用模型失败') + onEvent({ type: 'error', message }) + throw new Error(message) + } + + if (!response.body) { + const payload = await readJsonResponse(response) + const result = extractNonStreamingResponse(payload) + onEvent({ type: 'start' }) + onEvent({ type: 'finish', response: result }) + return result + } + + onEvent({ type: 'start' }) + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + const pendingToolCalls = new Map() + let buffer = '' + let content = '' + let finishReason: string | undefined + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + const chunks = buffer.split('\n\n') + buffer = chunks.pop() ?? '' + + for (const chunk of chunks) { + for (const line of chunk.split('\n').map((item) => item.trim()).filter(Boolean)) { + if (!line.startsWith('data:')) { + continue + } + + const data = line.slice(5).trim() + + if (!data || data === '[DONE]') { + continue + } + + try { + const payload = JSON.parse(data) + const choice = readFirstChoice(payload) + + if (!choice) { + continue + } + + if (typeof choice.finish_reason === 'string') { + finishReason = choice.finish_reason + } + + const delta = isRecord(choice.delta) ? choice.delta : undefined + const deltaText = extractDeltaText(delta) + + if (deltaText) { + content += deltaText + onEvent({ type: 'delta', text: deltaText }) + } + + collectToolCallDeltas(delta, pendingToolCalls) + } catch { + continue + } + } + } + } + + const result = { + content, + toolCalls: finalizeToolCalls(pendingToolCalls), + finishReason, + } + + onEvent({ type: 'finish', response: result }) + return result +} + +function toOpenAiMessage(message: AgentMessage) { + if (message.role === 'tool') { + return { + role: 'tool', + tool_call_id: message.toolCallId, + name: message.name, + content: message.content, + } + } + + if (message.role === 'assistant') { + return { + role: 'assistant', + content: message.content || null, + tool_calls: message.toolCalls?.map((toolCall) => ({ + id: toolCall.id, + type: 'function', + function: { + name: toolCall.name, + arguments: JSON.stringify(toolCall.input), + }, + })), + } + } + + return message +} + +function extractNonStreamingResponse(payload: unknown): AgentAssistantResponse { + const choice = readFirstChoice(payload) + const message = choice && isRecord(choice.message) ? choice.message : undefined + const content = typeof message?.content === 'string' ? message.content : '' + const toolCalls = Array.isArray(message?.tool_calls) + ? message.tool_calls.map(readFullToolCall).filter((item): item is AgentToolCall => item !== null) + : [] + + return { + content, + toolCalls, + finishReason: typeof choice?.finish_reason === 'string' ? choice.finish_reason : undefined, + } +} + +function readFullToolCall(value: unknown): AgentToolCall | null { + if (!isRecord(value) || !isRecord(value.function) || typeof value.function.name !== 'string') { + return null + } + + const input = parseToolArguments( + typeof value.function.arguments === 'string' ? value.function.arguments : '{}', + ) + + return { + id: typeof value.id === 'string' ? value.id : createAgentId('tool_call'), + name: value.function.name as AgentToolCall['name'], + input, + } +} + +function collectToolCallDeltas( + delta: Record | undefined, + pendingToolCalls: Map, +) { + if (!delta || !Array.isArray(delta.tool_calls)) { + return + } + + for (const rawToolCall of delta.tool_calls) { + if (!isRecord(rawToolCall)) { + continue + } + + const index = typeof rawToolCall.index === 'number' ? rawToolCall.index : pendingToolCalls.size + const pending = pendingToolCalls.get(index) ?? { argumentsText: '' } + + if (typeof rawToolCall.id === 'string') { + pending.id = rawToolCall.id + } + + if (isRecord(rawToolCall.function)) { + if (typeof rawToolCall.function.name === 'string') { + pending.name = rawToolCall.function.name + } + + if (typeof rawToolCall.function.arguments === 'string') { + pending.argumentsText += rawToolCall.function.arguments + } + } + + pendingToolCalls.set(index, pending) + } +} + +function finalizeToolCalls(pendingToolCalls: Map): AgentToolCall[] { + return Array.from(pendingToolCalls.entries()) + .sort(([left], [right]) => left - right) + .map(([, value]) => ({ + id: value.id || createAgentId('tool_call'), + name: value.name as AgentToolCall['name'], + input: parseToolArguments(value.argumentsText), + })) + .filter((toolCall) => Boolean(toolCall.name)) +} + +function parseToolArguments(text: string): Record { + if (!text.trim()) { + return {} + } + + try { + const value = JSON.parse(text) + return isRecord(value) ? value : {} + } catch { + return {} + } +} + +function extractDeltaText(delta: Record | undefined) { + if (!delta || !('content' in delta)) { + return '' + } + + if (typeof delta.content === 'string') { + return delta.content + } + + if (Array.isArray(delta.content)) { + return delta.content + .map((item) => { + if (isRecord(item) && item.type === 'text' && typeof item.text === 'string') { + return item.text + } + + return '' + }) + .join('') + } + + return '' +} + +function readFirstChoice(payload: unknown) { + if (isRecord(payload) && Array.isArray(payload.choices) && payload.choices.length > 0) { + return isRecord(payload.choices[0]) ? payload.choices[0] : null + } + + return null +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} diff --git a/src/core/agent/messages.ts b/src/core/agent/messages.ts new file mode 100644 index 0000000..c79078d --- /dev/null +++ b/src/core/agent/messages.ts @@ -0,0 +1,60 @@ +export type AgentToolName = 'ReadFile' | 'EditFile' | 'CreateFile' + +export type AgentToolCall = { + id: string + name: AgentToolName + input: Record +} + +export type AgentSystemMessage = { + role: 'system' + content: string +} + +export type AgentUserMessage = { + role: 'user' + content: string +} + +export type AgentAssistantMessage = { + role: 'assistant' + content: string + toolCalls?: AgentToolCall[] +} + +export type AgentToolResultMessage = { + role: 'tool' + toolCallId: string + name: AgentToolName + content: string +} + +export type AgentMessage = + | AgentSystemMessage + | AgentUserMessage + | AgentAssistantMessage + | AgentToolResultMessage + +export type AgentToolSchema = { + type: 'function' + function: { + name: AgentToolName + description: string + parameters: { + type: 'object' + properties: Record + required?: string[] + additionalProperties: boolean + } + } +} + +export type AgentAssistantResponse = { + content: string + toolCalls: AgentToolCall[] + finishReason?: string +} + +export function createAgentId(prefix: string) { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} diff --git a/src/core/agent/prompt.ts b/src/core/agent/prompt.ts new file mode 100644 index 0000000..2d1cd10 --- /dev/null +++ b/src/core/agent/prompt.ts @@ -0,0 +1,74 @@ +import type { ChatTargetContext } from '../../types/chat' +import type { ProjectSnapshot } from '../../types/project' + +export function buildAgentSystemPrompt(customPrompt?: string) { + const userPrompt = customPrompt?.trim() + + return [ + userPrompt || '你是 NovAI,一个通过工具读写本地小说项目文件的写作 Agent。', + '', + '你是交互式小说创作 Agent。你的工作不是把所有正文都堆到聊天里,而是理解用户意图,然后使用工具读取、修改或创建项目文件。', + '', + '工作原则:', + '- 用户的小说、章节、设定、提示词和素材都应保存在项目文件系统中。', + '- 对已有文件动手前,先使用 ReadFile 读取相关内容。', + '- 修改已有文件时,优先使用 EditFile 做精确替换;不要在没有读过原文时盲目改写。', + '- 新建章节、设定或提示词文件时,使用 CreateFile。', + '- 聊天回复用于说明你做了什么、为什么这么做、下一步建议是什么;不要把完整长篇正文当作唯一结果留在聊天里。', + '- 如果缺少目标路径或上下文,先说明你需要什么,或先读取项目中最相关的文件。', + '- 保持中文输出,除非用户明确要求其他语言。', + '', + '工具使用规则:', + '- ReadFile 用于读取 .md、.json、.txt 文件。', + '- EditFile 用于精确替换已有文件中的片段。oldText 必须来自已读取的文件内容,尽量提供足够上下文避免误替换。', + '- CreateFile 用于创建不存在的新文件;目标已存在时会失败。', + '- 可以连续使用多个工具完成任务。完成工具调用后,继续根据工具结果判断是否还需要下一步。', + '- 完成任务后,用简短自然语言总结变更,不要重复输出整个文件。', + ].join('\n') +} + +export function buildAgentUserContext(input: { + instruction: string + project: ProjectSnapshot + target: ChatTargetContext | null +}) { + const target = input.target?.primaryPath + ? `${input.target.displayName} (${input.target.primaryPath})` + : input.target?.displayName || '当前项目' + + return [ + `用户意图:${input.instruction}`, + '', + `当前项目:${input.project.name}`, + `默认目标:${target}`, + '', + '项目文件:', + listReadableFiles(input.project).join('\n') || '- 暂无可读文本文件', + '', + '请按照系统要求,通过工具读取或修改文件。若任务已经完成,请直接总结。若需要写文件,直接调用合适的文件工具。', + ].join('\n') +} + +function listReadableFiles(project: ProjectSnapshot) { + const files: string[] = [] + const stack = [...project.tree] + + while (stack.length > 0) { + const node = stack.shift() + + if (!node) { + continue + } + + if (node.kind === 'file' && /\.(md|json|txt)$/i.test(node.name)) { + files.push(`- ${node.path}`) + continue + } + + if (node.children?.length) { + stack.unshift(...node.children) + } + } + + return files.sort((left, right) => left.localeCompare(right, 'zh-Hans-CN')) +} diff --git a/src/core/agent/query.ts b/src/core/agent/query.ts new file mode 100644 index 0000000..1fa4be9 --- /dev/null +++ b/src/core/agent/query.ts @@ -0,0 +1,79 @@ +import { streamAgentCompletion } from './llm' +import { runAgentTools } from './tool-orchestration' +import type { ToolExecutionEvent } from './tool-execution' +import type { ProjectConfig, ProjectSnapshot } from '../../types/project' +import type { + AgentAssistantMessage, + AgentMessage, +} from './messages' +import type { AgentRunnableToolMap } from './tools' + +const DEFAULT_MAX_TURNS = 8 + +export type AgentQueryEvent = + | { type: 'assistant-delta'; text: string } + | { type: 'assistant-message'; message: AgentAssistantMessage } + | ToolExecutionEvent + | { type: 'done'; messages: AgentMessage[] } + +export async function query(input: { + config: ProjectConfig + project: ProjectSnapshot + messages: AgentMessage[] + tools: AgentRunnableToolMap + maxTurns?: number + onEvent?: (event: AgentQueryEvent) => void +}): Promise { + let messages = [...input.messages] + const maxTurns = input.maxTurns ?? DEFAULT_MAX_TURNS + + for (let turn = 0; turn < maxTurns; turn += 1) { + const assistantResponse = await streamAgentCompletion( + { + baseUrl: input.config.llm.baseUrl, + apiKey: input.config.llm.apiKey, + model: input.config.llm.model, + messages, + tools: Object.values(input.tools).map((tool) => tool.schema), + }, + (event) => { + if (event.type === 'delta') { + input.onEvent?.({ type: 'assistant-delta', text: event.text }) + } + }, + ) + + const assistantMessage: AgentAssistantMessage = { + role: 'assistant', + content: assistantResponse.content, + toolCalls: assistantResponse.toolCalls, + } + + messages = [...messages, assistantMessage] + input.onEvent?.({ type: 'assistant-message', message: assistantMessage }) + + if (assistantResponse.toolCalls.length === 0) { + input.onEvent?.({ type: 'done', messages }) + return messages + } + + const toolResults = await runAgentTools({ + calls: assistantResponse.toolCalls, + project: input.project, + tools: input.tools, + onEvent: input.onEvent, + }) + + messages = [...messages, ...toolResults] + } + + const limitMessage: AgentAssistantMessage = { + role: 'assistant', + content: `已达到本轮 Agent 最大循环次数(${maxTurns})。我先停在这里,避免无限调用工具。`, + } + + messages = [...messages, limitMessage] + input.onEvent?.({ type: 'assistant-message', message: limitMessage }) + input.onEvent?.({ type: 'done', messages }) + return messages +} diff --git a/src/core/agent/tool-execution.ts b/src/core/agent/tool-execution.ts new file mode 100644 index 0000000..52df563 --- /dev/null +++ b/src/core/agent/tool-execution.ts @@ -0,0 +1,94 @@ +import type { ProjectSnapshot } from '../../types/project' +import type { AgentToolCall, AgentToolResultMessage } from './messages' +import type { AgentRunnableToolMap } from './tools' + +export type ToolExecutionEvent = + | { type: 'tool-call'; call: AgentToolCall; inputSummary: string } + | { type: 'tool-result'; call: AgentToolCall; ok: boolean; resultSummary: string } + +export async function executeAgentTool(input: { + call: AgentToolCall + project: ProjectSnapshot + tools: AgentRunnableToolMap + onEvent?: (event: ToolExecutionEvent) => void +}): Promise { + const tool = input.tools[input.call.name] + + if (!tool) { + const content = `未知工具:${input.call.name}` + input.onEvent?.({ + type: 'tool-result', + call: input.call, + ok: false, + resultSummary: content, + }) + + return { + role: 'tool', + toolCallId: input.call.id, + name: input.call.name, + content, + } + } + + let validatedInput: unknown + + try { + validatedInput = tool.core.validateInput(input.call.input) + } catch (error) { + const message = error instanceof Error ? error.message : `${tool.name} 参数校验失败` + input.onEvent?.({ + type: 'tool-result', + call: input.call, + ok: false, + resultSummary: message, + }) + + return { + role: 'tool', + toolCallId: input.call.id, + name: input.call.name, + content: message, + } + } + + input.onEvent?.({ + type: 'tool-call', + call: input.call, + inputSummary: tool.core.summarizeInput(validatedInput), + }) + + try { + const output = await tool.core.run(validatedInput, { project: input.project }) + const resultSummary = tool.core.summarizeOutput(output) + + input.onEvent?.({ + type: 'tool-result', + call: input.call, + ok: true, + resultSummary, + }) + + return { + role: 'tool', + toolCallId: input.call.id, + name: input.call.name, + content: tool.formatResult(output), + } + } catch (error) { + const message = error instanceof Error ? error.message : `${tool.name} 执行失败` + input.onEvent?.({ + type: 'tool-result', + call: input.call, + ok: false, + resultSummary: message, + }) + + return { + role: 'tool', + toolCallId: input.call.id, + name: input.call.name, + content: message, + } + } +} diff --git a/src/core/agent/tool-orchestration.ts b/src/core/agent/tool-orchestration.ts new file mode 100644 index 0000000..2cd33a7 --- /dev/null +++ b/src/core/agent/tool-orchestration.ts @@ -0,0 +1,25 @@ +import { executeAgentTool } from './tool-execution' +import type { ToolExecutionEvent } from './tool-execution' +import type { ProjectSnapshot } from '../../types/project' +import type { AgentToolCall, AgentToolResultMessage } from './messages' +import type { AgentRunnableToolMap } from './tools' + +export async function runAgentTools(input: { + calls: AgentToolCall[] + project: ProjectSnapshot + tools: AgentRunnableToolMap + onEvent?: (event: ToolExecutionEvent) => void +}): Promise { + const results: AgentToolResultMessage[] = [] + + for (const call of input.calls) { + results.push(await executeAgentTool({ + call, + project: input.project, + tools: input.tools, + onEvent: input.onEvent, + })) + } + + return results +} diff --git a/src/core/agent/tools.ts b/src/core/agent/tools.ts new file mode 100644 index 0000000..f4f87d3 --- /dev/null +++ b/src/core/agent/tools.ts @@ -0,0 +1,150 @@ +import { + createFileTool, + editFileTool, + readFileTool, +} from '../tools/file-tools' + +import type { + AgentToolName, + AgentToolSchema, +} from './messages' +import type { + CreateFileInput, + CreateFileOutput, + EditFileInput, + EditFileOutput, + ReadFileInput, + ReadFileOutput, + ToolDefinition, +} from '../tools/types' + +export type AgentRunnableTool = { + name: AgentToolName + isReadOnly: boolean + isConcurrencySafe: boolean + schema: AgentToolSchema + core: ToolDefinition + formatResult(output: TOutput): string +} + +export type AgentRunnableToolMap = Record + +export function createAgentTools(): AgentRunnableToolMap { + return { + ReadFile: { + name: 'ReadFile', + isReadOnly: true, + isConcurrencySafe: true, + schema: { + type: 'function', + function: { + name: 'ReadFile', + description: '读取当前小说项目中的文本文件,返回带行号的内容。', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: '项目内相对路径,例如 chapters/第001章.md', + }, + offset: { + type: 'integer', + minimum: 1, + description: '可选,从第几行开始读取,默认 1。', + }, + limit: { + type: 'integer', + minimum: 1, + description: '可选,最多读取多少行。', + }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + }, + core: readFileTool, + formatResult(output: ReadFileOutput) { + return [ + readFileTool.summarizeOutput(output), + '', + output.numberedContent || output.content, + ].join('\n') + }, + }, + EditFile: { + name: 'EditFile', + isReadOnly: false, + isConcurrencySafe: false, + schema: { + type: 'function', + function: { + name: 'EditFile', + description: '用精确文本替换的方式修改当前小说项目中的已有文本文件。', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: '项目内相对路径。', + }, + oldText: { + type: 'string', + description: '要替换的原文,必须与文件中内容精确匹配。', + }, + newText: { + type: 'string', + description: '替换后的新文本。', + }, + replaceAll: { + type: 'boolean', + description: '是否替换所有匹配项。默认 false。', + }, + }, + required: ['path', 'oldText', 'newText'], + additionalProperties: false, + }, + }, + }, + core: editFileTool, + formatResult(output: EditFileOutput) { + return editFileTool.summarizeOutput(output) + }, + }, + CreateFile: { + name: 'CreateFile', + isReadOnly: false, + isConcurrencySafe: false, + schema: { + type: 'function', + function: { + name: 'CreateFile', + description: '在当前小说项目中新建文本文件;如果目标已存在会失败。', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: '项目内相对路径。', + }, + content: { + type: 'string', + description: '新文件完整内容。', + }, + }, + required: ['path', 'content'], + additionalProperties: false, + }, + }, + }, + core: createFileTool, + formatResult(output: CreateFileOutput) { + return createFileTool.summarizeOutput(output) + }, + }, + } +} + +export function isAgentToolName(value: string): value is AgentToolName { + return value === 'ReadFile' || value === 'EditFile' || value === 'CreateFile' +} diff --git a/src/core/chat/session.ts b/src/core/chat/session.ts index 8338205..cae7da9 100644 --- a/src/core/chat/session.ts +++ b/src/core/chat/session.ts @@ -1,12 +1,14 @@ -import { streamChatCompletion } from '../llm/client' import { readProjectTextFile } from '../fs/project-fs' +import { buildAgentSystemPrompt, buildAgentUserContext } from '../agent/prompt' +import { query } from '../agent/query' +import { createAgentTools } from '../agent/tools' import { deriveChatTargetFromPath } from './target' import { createToolRuntimeContext, - fileEditTool, - fileReadTool, - fileWriteTool, + createFileTool, + editFileTool, + readFileTool, ragSearchTool, } from './tools' @@ -16,8 +18,10 @@ import type { ChatTargetContext, ChatTurnInput, ChatTurnResult, + PendingFileChange, ToolDefinition, } from '../../types/chat' +import type { AgentMessage } from '../agent/messages' type SessionEvent = | { type: 'message'; message: ChatMessage } @@ -29,6 +33,12 @@ type RunChatTurnOptions = { onEvent?: (event: SessionEvent) => void } +type ConfirmPendingFileChangeOptions = { + session: ChatSessionState + input: Pick + onEvent?: (event: SessionEvent) => void +} + type TurnMode = 'read-only' | 'edit-target' | 'create-chapter' export function createChatSession(projectId: string): ChatSessionState { @@ -50,12 +60,12 @@ export async function runChatTurn(options: RunChatTurnOptions): Promise 0) { - pushMessage( - session, - { - id: createId('message'), - role: 'system', - kind: 'context-summary', - summary: `已补充近期章节上下文:${recentChapters.map((item) => item.path).join('、')}`, - createdAt: new Date().toISOString(), - }, - onEvent, - ) - } - - if (shouldUseRag(input.instruction, target)) { - const ragResult = await callTool( - ragSearchTool, - { - query: input.instruction, - topK: input.config.settings.ragContextMaxItems, - }, - runtime, - session, - onEvent, - ) - session.lastRagResult = ragResult - pushMessage( - session, - { - id: createId('message'), - role: 'system', - kind: 'context-summary', - summary: summarizeRagContext(ragResult), - createdAt: new Date().toISOString(), - }, - onEvent, - ) - } else { - session.lastRagResult = null - } - - const prompt = buildUserPrompt({ + const agentMessages = buildAgentMessages({ + previousMessages: session.agentMessages, instruction: input.instruction, + systemPrompt: input.systemPrompt, + project: input.project, target, - targetFileContent, - recentChapters, - ragSummary: summarizeRag(session.lastRagResult), }) - - pushMessage(session, createAssistantText('正在根据当前目标和上下文生成本轮结果。'), onEvent) - - let generatedText = '' + const tools = createAgentTools() try { - generatedText = await streamChatCompletion( - { - baseUrl: input.config.llm.baseUrl, - apiKey: input.config.llm.apiKey, - model: input.config.llm.model, - systemPrompt: input.systemPrompt, - instruction: prompt, - }, - (event) => { - if (event.type === 'delta') { + session.agentMessages = await query({ + config: input.config, + project: input.project, + messages: agentMessages, + tools, + onEvent(event) { + if (event.type === 'assistant-delta') { session.currentDraftText += event.text onEvent?.({ type: 'draft', text: session.currentDraftText }) + return + } + + if (event.type === 'assistant-message') { + if (event.message.content.trim()) { + pushMessage(session, createAssistantText(event.message.content.trim()), onEvent) + } + return + } + + if (event.type === 'tool-call') { + pushMessage( + session, + { + id: createId('message'), + role: 'system', + kind: 'tool-call', + toolName: event.call.name, + inputSummary: event.inputSummary, + createdAt: new Date().toISOString(), + }, + onEvent, + ) + return + } + + if (event.type === 'tool-result') { + if ( + event.ok && + (event.call.name === 'EditFile' || event.call.name === 'CreateFile') && + typeof event.call.input.path === 'string' + ) { + session.lastWrittenPath = event.call.input.path + } + + pushMessage( + session, + { + id: createId('message'), + role: 'system', + kind: 'tool-result', + toolName: event.call.name, + ok: event.ok, + resultSummary: event.resultSummary, + createdAt: new Date().toISOString(), + }, + onEvent, + ) } }, - ) + }) } catch (error) { const message = error instanceof Error ? error.message : '模型生成失败' session.status = 'error' @@ -170,49 +158,110 @@ export async function runChatTurn(options: RunChatTurnOptions): Promise item.sourcePath) ?? [], - createdAt: new Date().toISOString(), - }, - onEvent, - ) + session.status = 'waiting-user' - session.status = 'waiting-user' + return { + session, + target, + writtenPath: session.lastWrittenPath, + } +} - return { - session, - target, - writtenPath: undefined, - } +function buildAgentMessages(input: { + previousMessages?: AgentMessage[] + instruction: string + systemPrompt: string + project: ChatTurnInput['project'] + target: ChatTargetContext | null +}): AgentMessage[] { + const nextUserMessage: AgentMessage = { + role: 'user', + content: buildAgentUserContext({ + instruction: input.instruction, + project: input.project, + target: input.target, + }), } - if (!targetPath) { - const message = '当前任务需要写文件,但没有可用的目标路径' + if (input.previousMessages?.length) { + return [...input.previousMessages, nextUserMessage] + } + + return [ + { + role: 'system', + content: buildAgentSystemPrompt(input.systemPrompt), + }, + nextUserMessage, + ] +} + +export async function confirmPendingFileChange( + options: ConfirmPendingFileChangeOptions, +): Promise { + const { input, onEvent } = options + const session: ChatSessionState = { + ...options.session, + status: 'running', + } + const pendingFileChange = session.pendingFileChange + + if (!pendingFileChange) { + const message = '当前没有等待确认的文件变更' session.status = 'error' - pushErrorMessage(session, message, false, onEvent) + pushErrorMessage(session, message, true, onEvent) throw new Error(message) } - if (taskType === 'edit-target' && target?.primaryPath) { - await callTool(fileEditTool, { path: targetPath, content: session.currentDraftText }, runtime, session, onEvent) + const runtime = createToolRuntimeContext({ + project: input.project, + config: input.config, + target: session.currentTarget, + session, + }) + + if (pendingFileChange.type === 'edit') { + await callTool( + editFileTool, + { + path: pendingFileChange.path, + oldText: pendingFileChange.oldText, + newText: pendingFileChange.newText, + }, + runtime, + session, + onEvent, + ) } else { - await callTool(fileWriteTool, { path: targetPath, content: session.currentDraftText }, runtime, session, onEvent) + await callTool( + createFileTool, + { + path: pendingFileChange.path, + content: pendingFileChange.content, + }, + runtime, + session, + onEvent, + ) } - session.lastWrittenPath = targetPath + session.lastWrittenPath = pendingFileChange.path + session.pendingFileChange = undefined pushMessage( session, @@ -220,8 +269,8 @@ export async function runChatTurn(options: RunChatTurnOptions): Promise item.sourcePath) ?? [], createdAt: new Date().toISOString(), }, @@ -232,8 +281,57 @@ export async function runChatTurn(options: RunChatTurnOptions): Promise + targetPath: string + targetFileContent: string + draftText: string +}): PendingFileChange { + if (input.taskType === 'edit-target') { + return { + id: createId('pending-change'), + type: 'edit', + path: input.targetPath, + oldText: input.targetFileContent, + newText: input.draftText, + createdAt: new Date().toISOString(), + } + } + + return { + id: createId('pending-change'), + type: 'create', + path: input.targetPath, + content: input.draftText, + createdAt: new Date().toISOString(), } } diff --git a/src/core/chat/tools.ts b/src/core/chat/tools.ts index 5a7fb93..56e993f 100644 --- a/src/core/chat/tools.ts +++ b/src/core/chat/tools.ts @@ -1,41 +1,23 @@ -import { readProjectTextFile, writeProjectTextFile } from '../fs/project-fs' import { searchRagCandidates } from '../rag/search' +import { + createFileTool as coreCreateFileTool, + editFileTool as coreEditFileTool, + readFileTool as coreReadFileTool, +} from '../tools/file-tools' import type { ToolDefinition, ToolRuntimeContext, } from '../../types/chat' import type { RetrievalResult } from '../../types/rag' - -export type FileReadInput = { - path: string -} - -export type FileReadOutput = { - path: string - content: string -} - -export type FileWriteInput = { - path: string - content: string -} - -export type FileWriteOutput = { - path: string - contentLength: number - created: boolean -} - -export type FileEditInput = { - path: string - content: string -} - -export type FileEditOutput = { - path: string - contentLength: number -} +import type { + CreateFileInput, + CreateFileOutput, + EditFileInput, + EditFileOutput, + ReadFileInput, + ReadFileOutput, +} from '../tools/types' export type RagSearchInput = { query: string @@ -50,105 +32,37 @@ function asObject(input: unknown) { return input as Record } -export const fileReadTool: ToolDefinition = { - name: 'FileRead', - description: '读取项目中的文本文件', - validateInput(input) { - const value = asObject(input) - - if (typeof value.path !== 'string' || !value.path.trim()) { - throw new Error('FileRead 需要有效的 path') - } - - return { - path: value.path.trim(), - } - }, +export const readFileTool: ToolDefinition = { + name: 'ReadFile', + description: coreReadFileTool.description, + validateInput: coreReadFileTool.validateInput, async call(input, context) { - const content = await readProjectTextFile(context.project.handle, input.path) - - return { - path: input.path, - content, - } - }, - summarizeInput(input) { - return `读取 ${input.path}` - }, - summarizeOutput(output) { - return `已读取 ${output.path},共 ${output.content.length} 个字符` + return coreReadFileTool.run(input, { project: context.project }) }, + summarizeInput: coreReadFileTool.summarizeInput, + summarizeOutput: coreReadFileTool.summarizeOutput, } -export const fileWriteTool: ToolDefinition = { - name: 'FileWrite', - description: '写入项目中的文本文件', - validateInput(input) { - const value = asObject(input) - - if (typeof value.path !== 'string' || !value.path.trim()) { - throw new Error('FileWrite 需要有效的 path') - } - - if (typeof value.content !== 'string') { - throw new Error('FileWrite 需要字符串 content') - } - - return { - path: value.path.trim(), - content: value.content, - } - }, +export const editFileTool: ToolDefinition = { + name: 'EditFile', + description: coreEditFileTool.description, + validateInput: coreEditFileTool.validateInput, async call(input, context) { - await writeProjectTextFile(context.project.handle, input.path, input.content) - - return { - path: input.path, - contentLength: input.content.length, - created: true, - } - }, - summarizeInput(input) { - return `写入 ${input.path}` - }, - summarizeOutput(output) { - return `已写入 ${output.path},共 ${output.contentLength} 个字符` + return coreEditFileTool.run(input, { project: context.project }) }, + summarizeInput: coreEditFileTool.summarizeInput, + summarizeOutput: coreEditFileTool.summarizeOutput, } -export const fileEditTool: ToolDefinition = { - name: 'FileEdit', - description: '覆盖修改已有文本文件', - validateInput(input) { - const value = asObject(input) - - if (typeof value.path !== 'string' || !value.path.trim()) { - throw new Error('FileEdit 需要有效的 path') - } - - if (typeof value.content !== 'string') { - throw new Error('FileEdit 需要字符串 content') - } - - return { - path: value.path.trim(), - content: value.content, - } - }, +export const createFileTool: ToolDefinition = { + name: 'CreateFile', + description: coreCreateFileTool.description, + validateInput: coreCreateFileTool.validateInput, async call(input, context) { - await writeProjectTextFile(context.project.handle, input.path, input.content) - - return { - path: input.path, - contentLength: input.content.length, - } - }, - summarizeInput(input) { - return `修改 ${input.path}` - }, - summarizeOutput(output) { - return `已更新 ${output.path},共 ${output.contentLength} 个字符` + return coreCreateFileTool.run(input, { project: context.project }) }, + summarizeInput: coreCreateFileTool.summarizeInput, + summarizeOutput: coreCreateFileTool.summarizeOutput, } export const ragSearchTool: ToolDefinition = { diff --git a/src/core/tools/file-tools.ts b/src/core/tools/file-tools.ts new file mode 100644 index 0000000..7e9059b --- /dev/null +++ b/src/core/tools/file-tools.ts @@ -0,0 +1,236 @@ +import { + readProjectTextFile, + writeProjectTextFile, +} from '../fs/project-fs' + +import { assertTextFilePath, isNotFoundError, normalizeProjectPath } from './path' +import type { + CreateFileInput, + CreateFileOutput, + EditFileInput, + EditFileOutput, + ReadFileInput, + ReadFileOutput, + ToolDefinition, +} from './types' + +const DEFAULT_READ_LIMIT = 2000 + +export const readFileTool: ToolDefinition<'ReadFile', ReadFileInput, ReadFileOutput> = { + name: 'ReadFile', + description: '读取当前小说项目中的文本文件。', + validateInput(input) { + const value = asRecord(input) + const path = normalizeProjectPath(readString(value.path, 'ReadFile.path')) + assertTextFilePath(path) + + const offset = readOptionalPositiveInteger(value.offset, 'ReadFile.offset') + const limit = readOptionalPositiveInteger(value.limit, 'ReadFile.limit') + + return { + path, + offset, + limit, + } + }, + async run(input, runtime) { + const content = await readProjectTextFile(runtime.project.handle, input.path) + const lines = splitLines(content) + const startLine = input.offset ?? 1 + const limit = input.limit ?? DEFAULT_READ_LIMIT + const startIndex = Math.max(startLine - 1, 0) + const selectedLines = lines.slice(startIndex, startIndex + limit) + const endLine = selectedLines.length > 0 ? startIndex + selectedLines.length : startLine + const numberedContent = selectedLines + .map((line, index) => `${String(startIndex + index + 1).padStart(4, ' ')} | ${line}`) + .join('\n') + + return { + path: input.path, + content: selectedLines.join('\n'), + numberedContent, + startLine, + endLine, + totalLines: lines.length, + truncated: startIndex + selectedLines.length < lines.length, + } + }, + summarizeInput(input) { + return input.offset || input.limit + ? `读取 ${input.path} 的部分内容` + : `读取 ${input.path}` + }, + summarizeOutput(output) { + return output.truncated + ? `已读取 ${output.path} 第 ${output.startLine}-${output.endLine} 行,共 ${output.totalLines} 行,结果已截断` + : `已读取 ${output.path},共 ${output.totalLines} 行` + }, +} + +export const editFileTool: ToolDefinition<'EditFile', EditFileInput, EditFileOutput> = { + name: 'EditFile', + description: '用精确文本替换的方式修改当前小说项目中的已有文本文件。', + validateInput(input) { + const value = asRecord(input) + const path = normalizeProjectPath(readString(value.path, 'EditFile.path')) + const oldText = readString(value.oldText, 'EditFile.oldText') + const newText = readString(value.newText, 'EditFile.newText') + + assertTextFilePath(path) + + if (oldText === newText) { + throw new Error('EditFile.oldText 和 EditFile.newText 完全相同,没有可修改内容') + } + + return { + path, + oldText, + newText, + replaceAll: typeof value.replaceAll === 'boolean' ? value.replaceAll : false, + } + }, + async run(input, runtime) { + const currentContent = await readProjectTextFile(runtime.project.handle, input.path) + + if (!input.oldText) { + if (currentContent.length > 0) { + throw new Error('EditFile.oldText 不能为空;新增文件请使用 CreateFile,覆盖非空文件请提供原文') + } + + await writeProjectTextFile(runtime.project.handle, input.path, input.newText) + + return { + path: input.path, + occurrences: 1, + contentLength: input.newText.length, + linesAdded: countLines(input.newText), + linesRemoved: 0, + } + } + + const occurrences = countOccurrences(currentContent, input.oldText) + + if (occurrences === 0) { + throw new Error(`在 ${input.path} 中没有找到要替换的原文`) + } + + if (occurrences > 1 && !input.replaceAll) { + throw new Error(`在 ${input.path} 中找到 ${occurrences} 处匹配;请提供更精确的 oldText,或启用 replaceAll`) + } + + const nextContent = input.replaceAll + ? currentContent.split(input.oldText).join(input.newText) + : currentContent.replace(input.oldText, input.newText) + + await writeProjectTextFile(runtime.project.handle, input.path, nextContent) + + return { + path: input.path, + occurrences: input.replaceAll ? occurrences : 1, + contentLength: nextContent.length, + linesAdded: countLines(input.newText) - countLines(input.oldText), + linesRemoved: Math.max(countLines(input.oldText) - countLines(input.newText), 0), + } + }, + summarizeInput(input) { + return input.replaceAll + ? `替换 ${input.path} 中所有匹配文本` + : `替换 ${input.path} 中一处匹配文本` + }, + summarizeOutput(output) { + return `已修改 ${output.path},替换 ${output.occurrences} 处,当前 ${output.contentLength} 个字符` + }, +} + +export const createFileTool: ToolDefinition<'CreateFile', CreateFileInput, CreateFileOutput> = { + name: 'CreateFile', + description: '在当前小说项目中新建文本文件;如果目标已存在会失败。', + validateInput(input) { + const value = asRecord(input) + const path = normalizeProjectPath(readString(value.path, 'CreateFile.path')) + const content = readString(value.content, 'CreateFile.content') + + assertTextFilePath(path) + + return { + path, + content, + } + }, + async run(input, runtime) { + try { + await readProjectTextFile(runtime.project.handle, input.path) + throw new Error(`文件已存在:${input.path}`) + } catch (error) { + if (!isNotFoundError(error)) { + throw error + } + } + + await writeProjectTextFile(runtime.project.handle, input.path, input.content) + + return { + path: input.path, + contentLength: input.content.length, + created: true, + } + }, + summarizeInput(input) { + return `新建 ${input.path}` + }, + summarizeOutput(output) { + return `已新建 ${output.path},共 ${output.contentLength} 个字符` + }, +} + +function asRecord(input: unknown) { + if (!input || typeof input !== 'object') { + throw new Error('工具输入必须是对象') + } + + return input as Record +} + +function readString(value: unknown, label: string) { + if (typeof value !== 'string') { + throw new Error(`${label} 必须是字符串`) + } + + return value +} + +function readOptionalPositiveInteger(value: unknown, label: string) { + if (value === undefined) { + return undefined + } + + if (!Number.isInteger(value) || Number(value) < 1) { + throw new Error(`${label} 必须是正整数`) + } + + return Number(value) +} + +function splitLines(content: string) { + if (!content) { + return [''] + } + + return content.replace(/\r\n/g, '\n').split('\n') +} + +function countOccurrences(source: string, needle: string) { + if (!needle) { + return 0 + } + + return source.split(needle).length - 1 +} + +function countLines(content: string) { + if (!content) { + return 0 + } + + return content.replace(/\r\n/g, '\n').split('\n').length +} diff --git a/src/core/tools/index.ts b/src/core/tools/index.ts new file mode 100644 index 0000000..c60414c --- /dev/null +++ b/src/core/tools/index.ts @@ -0,0 +1,102 @@ +import { + createFileTool, + editFileTool, + readFileTool, +} from './file-tools' +import type { + CoreToolName, + CreateFileInput, + CreateFileOutput, + EditFileInput, + EditFileOutput, + ReadFileInput, + ReadFileOutput, + ToolCall, + ToolDefinition, + ToolExecution, + ToolResult, + ToolRuntime, +} from './types' + +export type { + CoreToolName, + CreateFileInput, + CreateFileOutput, + EditFileInput, + EditFileOutput, + ReadFileInput, + ReadFileOutput, + ToolCall, + ToolExecution, + ToolResult, + ToolRuntime, +} + +type ToolOutputMap = { + ReadFile: ReadFileOutput + EditFile: EditFileOutput + CreateFile: CreateFileOutput +} + +const tools = { + ReadFile: readFileTool, + EditFile: editFileTool, + CreateFile: createFileTool, +} satisfies Record> + +export function getCoreTools() { + return tools +} + +export function getCoreTool(name: CoreToolName) { + return tools[name] +} + +export async function executeCoreTool( + name: TName, + input: unknown, + runtime: ToolRuntime, +): Promise> { + const tool = tools[name] as ToolDefinition + const call: ToolCall = { + id: createToolCallId(), + name, + input, + createdAt: new Date().toISOString(), + } + + try { + const validatedInput = tool.validateInput(input) + const output = await tool.run(validatedInput, runtime) + + return { + call, + result: { + callId: call.id, + name, + ok: true, + output, + summary: tool.summarizeOutput(output), + createdAt: new Date().toISOString(), + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : `${name} 执行失败` + + return { + call, + result: { + callId: call.id, + name, + ok: false, + error: message, + summary: message, + createdAt: new Date().toISOString(), + }, + } + } +} + +function createToolCallId() { + return `tool-${Math.random().toString(36).slice(2, 10)}` +} diff --git a/src/core/tools/path.ts b/src/core/tools/path.ts new file mode 100644 index 0000000..2668cf7 --- /dev/null +++ b/src/core/tools/path.ts @@ -0,0 +1,39 @@ +const TEXT_FILE_EXTENSIONS = ['.md', '.json', '.txt'] as const + +export function normalizeProjectPath(path: string): string { + const normalized = path.trim().replace(/\\/g, '/') + + if (!normalized) { + throw new Error('文件路径不能为空') + } + + if (normalized.startsWith('/') || /^[a-zA-Z]:\//.test(normalized)) { + throw new Error('工具只能使用小说项目内的相对路径') + } + + const segments = normalized.split('/').filter(Boolean) + + if (segments.length === 0) { + throw new Error('文件路径不能为空') + } + + if (segments.some((segment) => segment === '.' || segment === '..')) { + throw new Error('文件路径不能包含 . 或 ..') + } + + if (normalized.endsWith('/')) { + throw new Error('工具目标必须是文件,不能是目录') + } + + return segments.join('/') +} + +export function assertTextFilePath(path: string): void { + if (!TEXT_FILE_EXTENSIONS.some((extension) => path.toLowerCase().endsWith(extension))) { + throw new Error('当前工具只支持 .md、.json、.txt 文本文件') + } +} + +export function isNotFoundError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'NotFoundError' +} diff --git a/src/core/tools/types.ts b/src/core/tools/types.ts new file mode 100644 index 0000000..d05b36e --- /dev/null +++ b/src/core/tools/types.ts @@ -0,0 +1,80 @@ +import type { ProjectSnapshot } from '../../types/project' + +export type CoreToolName = 'ReadFile' | 'EditFile' | 'CreateFile' + +export type ToolRuntime = { + project: ProjectSnapshot +} + +export type ToolCall = { + id: string + name: TName + input: TInput + createdAt: string +} + +export type ToolResult = { + callId: string + name: TName + ok: boolean + output?: TOutput + error?: string + summary: string + createdAt: string +} + +export type ToolExecution = { + call: ToolCall + result: ToolResult +} + +export type ToolDefinition = { + name: TName + description: string + validateInput(input: unknown): TInput + run(input: TInput, runtime: ToolRuntime): Promise + summarizeInput(input: TInput): string + summarizeOutput(output: TOutput): string +} + +export type ReadFileInput = { + path: string + offset?: number + limit?: number +} + +export type ReadFileOutput = { + path: string + content: string + numberedContent: string + startLine: number + endLine: number + totalLines: number + truncated: boolean +} + +export type EditFileInput = { + path: string + oldText: string + newText: string + replaceAll?: boolean +} + +export type EditFileOutput = { + path: string + occurrences: number + contentLength: number + linesAdded: number + linesRemoved: number +} + +export type CreateFileInput = { + path: string + content: string +} + +export type CreateFileOutput = { + path: string + contentLength: number + created: true +} diff --git a/src/types/chat.ts b/src/types/chat.ts index b413dc6..376f3c0 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -1,7 +1,8 @@ import type { ProjectConfig, ProjectSnapshot } from './project' import type { RetrievalResult } from './rag' +import type { AgentMessage } from '../core/agent/messages' -export type ChatToolName = 'FileRead' | 'FileWrite' | 'FileEdit' | 'Bash' | 'RagSearch' +export type ChatToolName = 'ReadFile' | 'EditFile' | 'CreateFile' | 'Bash' | 'RagSearch' export type UserTextMessage = { id: string @@ -82,16 +83,35 @@ export type ChatTargetContext = { derivedFrom: 'preview' | 'selection' | 'explicit-user-intent' } -export type ChatSessionStatus = 'idle' | 'running' | 'waiting-user' | 'error' +export type ChatSessionStatus = 'idle' | 'running' | 'waiting-user' | 'awaiting-confirmation' | 'error' + +export type PendingFileChange = + | { + id: string + type: 'edit' + path: string + oldText: string + newText: string + createdAt: string + } + | { + id: string + type: 'create' + path: string + content: string + createdAt: string + } export type ChatSessionState = { sessionId: string projectId: string messages: ChatMessage[] + agentMessages?: AgentMessage[] status: ChatSessionStatus currentDraftText: string currentTarget: ChatTargetContext | null lastRagResult: RetrievalResult | null + pendingFileChange?: PendingFileChange lastWrittenPath?: string lastTaskType?: 'read-only' | 'edit-target' | 'create-chapter' } diff --git a/src/views/SessionTestView.vue b/src/views/SessionTestView.vue index 493b01f..6079f09 100644 --- a/src/views/SessionTestView.vue +++ b/src/views/SessionTestView.vue @@ -12,7 +12,9 @@ import { repairProject, rescanProject, } from '../core/fs/project-fs' -import { runChatTurn } from '../core/chat/session' +import { + runChatTurn, +} from '../core/chat/session' import { useChatStore } from '../stores/chat' import type { ProjectConfig, ProjectFileContent, ProjectInspection, ProjectSnapshot, TreeNode } from '../types/project' @@ -88,6 +90,13 @@ const errorMessages = computed(() => const contextMessages = computed(() => (chatStore.session?.messages ?? []).filter((message) => message.kind === 'context-summary'), ) +const agentMessageCount = computed(() => chatStore.session?.agentMessages?.length ?? 0) +const toolCallCount = computed(() => + (chatStore.session?.messages ?? []).filter((message) => message.kind === 'tool-call').length, +) +const toolResultCount = computed(() => + (chatStore.session?.messages ?? []).filter((message) => message.kind === 'tool-result').length, +) async function onCreateProject() { await runTask(async () => { @@ -353,18 +362,19 @@ function groupReadableFiles(files: Array<{ path: string; name: string }>) {