diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ccd8220 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md + +## Purpose + +NovAI is intended to evolve toward an agentic novel-writing tool, closer to the interaction model of Claude Code / Vibe Coding tools than to a traditional chat app. + +The core loop should be: + +1. The user expresses intent in natural language. +2. The AI maintains task context. +3. The AI uses tools to read and write project files. +4. Story artifacts are saved into the local project filesystem. +5. The conversation acts as the collaboration interface, not the primary storage for story content. + +## Product Direction + +When making implementation decisions, prefer this framing: + +- The chat UI is an agent control surface. +- The AI should operate on files, not mainly emit long final text into the chat stream. +- Chapters, prompts, and elements belong in files. +- Conversation history is for collaboration, clarification, planning, and action summaries. +- Generated story content should be previewed in file/content panels and written back to the project. + +This means NovAI should gradually move away from a simple "single prompt -> single response" flow and toward a tool-using agent workflow for story creation and revision. + +## Reference Repository + +For implementation reference and comparative study, keep this external repository available next to the NovAI repo: + +- `/Users/honlnk/project/claude-code-sound` + +This repository is intentionally cloned outside the NovAI git repository so that: + +- it does not affect NovAI git status, +- it is not accidentally committed, +- it can still be read and compared during development. + +When useful, study that repository for patterns such as: + +- agent loop design, +- conversation state management, +- tool invocation structure, +- streaming interaction flow, +- file-oriented execution behavior. + +## Working Rule + +When documentation and code appear to conflict, prefer the clarified product intent above: + +- NovAI is not just a workspace with a chat box. +- NovAI should become a conversation-driven AI agent for writing stories through tools and files. diff --git a/README.md b/README.md index ccb0dab..3a433de 100644 --- a/README.md +++ b/README.md @@ -24,24 +24,29 @@ NovAI 的思路是换一条路: ## 当前状态 -项目目前处于早期开发阶段,正在验证 MVP 的最小可用创作闭环。 +项目目前处于 MVP 早期实现阶段,正在优先验证 AI 最小可用创作闭环。 当前仓库已经完成的内容主要包括: - Vue 3 + TypeScript + Vite 前端工程初始化 -- 首页、工作区、设置页的基础路由和页面骨架 - 本地小说项目的创建与打开流程 +- 不合法项目目录的检测与修复流程 - 标准项目目录初始化 -- 文件树扫描与基础文件预览链路 +- `novel.config.json` 读写 +- LLM / Embedding 配置测试连接 +- LLM 流式生成链路 +- `prompts/system.md` 读取与保存 +- 章节文件写入 `chapters/` +- 测试页中的项目文档分组与原文预览 - 项目规划、需求说明、UI 设计、技术架构等文档整理 尚未完整落地的核心能力包括: -- LLM / Embedding 真实接入 -- 章节流式生成 - 要素抽取 - Embedding 向量化与 RAG 检索 -- AI 精筛选与更完整的创作工作流 +- 近期章节上下文拼装 +- Rerank 精排与更完整的创作工作流 +- 正式工作台 UI ## MVP 目标 @@ -91,15 +96,27 @@ NovAI 采用“一个文件夹就是一个小说项目”的思路。当前默 - Vite - Pinia - Vue Router -- Sass -- Tailwind CSS v4 +- File System Access API 规划中的核心能力还包括: -- File System Access API - Orama - isomorphic-git +## 当前实现方式 + +为了优先验证 AI 主链路,当前版本暂不继续推进正式工作台界面,而是采用一个极简测试页 `/test` 作为开发入口。 + +当前测试页已经可以完成: + +- 创建 / 打开 / 修复小说项目 +- 编辑并保存项目配置 +- 测试 LLM / Embedding 连通性 +- 发起流式生成 +- 保存 SYSTEM Prompt +- 保存生成章节 +- 浏览项目中的 Markdown / JSON / 文本文档原文 + ## 本地开发 ### 环境要求 diff --git a/docs b/docs index a176ef7..16c52ec 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit a176ef7865933beaf20153471f720bcc744d1404 +Subproject commit 16c52ecb52101d784680adb7e6307e09c642d203 diff --git a/src/app/router.ts b/src/app/router.ts index 793e2f5..7bade65 100644 --- a/src/app/router.ts +++ b/src/app/router.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' +import SessionTestView from '../views/SessionTestView.vue' import TestLabView from '../views/TestLabView.vue' export const router = createRouter({ @@ -14,5 +15,10 @@ export const router = createRouter({ name: 'test', component: TestLabView, }, + { + path: '/session-test', + name: 'session-test', + component: SessionTestView, + }, ], }) diff --git a/src/core/ai/rerank-client.ts b/src/core/ai/rerank-client.ts new file mode 100644 index 0000000..db41d36 --- /dev/null +++ b/src/core/ai/rerank-client.ts @@ -0,0 +1,189 @@ +import { createJsonHeaders, extractErrorMessage, normalizeBaseUrl, readJsonResponse, resolveApiUrl } from '../ai/shared' + +import type { ModelConnectionResult } from '../../types/ai' +import type { RerankInput, RerankResult } from '../../types/rag' + +export type RerankConnectionInput = { + baseUrl: string + apiKey: string + model?: string +} + +export async function testRerankConnection( + input: RerankConnectionInput, +): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl) + + if (!baseUrl || !input.apiKey.trim()) { + return { + ok: false, + message: '请先填写 Rerank 的 API 地址和 API Key', + } + } + + try { + const response = await fetch(`${baseUrl}/models`, { + method: 'GET', + headers: createJsonHeaders(input.apiKey, baseUrl), + }) + + if (!response.ok) { + const payload = await readJsonResponse(response) + return { + ok: false, + message: extractErrorMessage(payload, 'Rerank 测试连接失败'), + } + } + + return { + ok: true, + message: 'Rerank 连接成功', + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : 'Rerank 测试连接失败', + } + } +} + +export async function rerankCandidates( + input: RerankConnectionInput & RerankInput, +): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl) + + if (!baseUrl || !input.apiKey.trim() || !input.model?.trim()) { + throw new Error('请先填写 Rerank 的 API 地址、API Key 和模型名称') + } + + const request = buildRerankRequest(baseUrl, input) + const response = await fetch(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(request.body), + }) + + if (!response.ok) { + const payload = await readJsonResponse(response) + throw new Error(extractErrorMessage(payload, 'Rerank 请求失败')) + } + + const payload = await readJsonResponse(response) + return normalizeRerankResult(payload, input) +} + +function normalizeRerankResult(payload: unknown, input: RerankInput): RerankResult { + const results = extractRerankResults(payload) + + if (results) { + const items = results + .map((item) => { + if ( + item && + typeof item === 'object' && + 'index' in item && + typeof item.index === 'number' + ) { + const candidate = input.candidates[item.index] + + if (!candidate) { + return null + } + + return { + id: candidate.id, + score: + 'relevance_score' in item && typeof item.relevance_score === 'number' + ? item.relevance_score + : 0, + } + } + + return null + }) + .filter((item): item is NonNullable => item !== null) + + return { + items, + model: + payload && + typeof payload === 'object' && + 'model' in payload && + typeof payload.model === 'string' + ? payload.model + : undefined, + } + } + + return { + items: input.candidates.slice(0, input.topN).map((candidate, index) => ({ + id: candidate.id, + score: input.candidates.length - index, + })), + } +} + +function extractRerankResults(payload: unknown) { + if ( + payload && + typeof payload === 'object' && + 'results' in payload && + Array.isArray(payload.results) + ) { + return payload.results + } + + if ( + payload && + typeof payload === 'object' && + 'output' in payload && + payload.output && + typeof payload.output === 'object' && + 'results' in payload.output && + Array.isArray(payload.output.results) + ) { + return payload.output.results + } + + return null +} + +function buildRerankRequest(baseUrl: string, input: RerankConnectionInput & RerankInput) { + const model = input.model?.trim() ?? '' + + if (isDashScopeBaseUrl(baseUrl)) { + const dashScopeOrigin = new URL(baseUrl).origin + const dashScopePath = '/api/v1/services/rerank/text-rerank/text-rerank' + + return { + url: resolveApiUrl(dashScopeOrigin, dashScopePath), + headers: createJsonHeaders(input.apiKey, dashScopeOrigin), + body: { + model, + input: { + query: input.query, + documents: input.candidates.map((candidate) => candidate.retrievalText), + }, + parameters: { + top_n: input.topN, + return_documents: false, + }, + }, + } + } + + return { + url: resolveApiUrl(baseUrl, '/rerank'), + headers: createJsonHeaders(input.apiKey, baseUrl), + body: { + model, + query: input.query, + top_n: input.topN, + documents: input.candidates.map((candidate) => candidate.retrievalText), + }, + } +} + +function isDashScopeBaseUrl(baseUrl: string) { + return /dashscope(-intl)?\.aliyuncs\.com/.test(baseUrl) +} diff --git a/src/core/ai/shared.ts b/src/core/ai/shared.ts new file mode 100644 index 0000000..4c78c76 --- /dev/null +++ b/src/core/ai/shared.ts @@ -0,0 +1,86 @@ +/** + * 统一清洗用户填写的 baseUrl,避免请求路径拼接时出现多余斜杠。 + */ +export function normalizeBaseUrl(baseUrl: string) { + return baseUrl.trim().replace(/\/+$/, '') +} + +/** + * 开发环境通过 Vite 代理转发第三方模型请求,避免浏览器直连时的 CORS 限制。 + */ +export function resolveApiUrl(baseUrl: string, path: string) { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl) + const normalizedPath = path.startsWith('/') ? path : `/${path}` + + if (shouldUseDevProxy(normalizedBaseUrl)) { + return `/api-proxy${normalizedPath}` + } + + return `${normalizedBaseUrl}${normalizedPath}` +} + +/** + * 创建默认的 JSON 请求头,并附带 Bearer 鉴权。 + */ +export function createJsonHeaders(apiKey: string, baseUrl?: string) { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey.trim()}`, + } + + const normalizedBaseUrl = normalizeBaseUrl(baseUrl ?? '') + + if (shouldUseDevProxy(normalizedBaseUrl)) { + headers['x-target-base'] = normalizedBaseUrl + } + + return headers +} + +/** + * 尝试把响应按 JSON 解析;如果服务返回的是纯文本错误页,则回退为字符串。 + */ +export async function readJsonResponse(response: Response) { + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('application/json')) { + return response.json() + } + + return response.text() +} + +/** + * 从常见的 OpenAI 兼容错误结构中提取可展示消息,不命中时回退到默认文案。 + */ +export function extractErrorMessage(payload: unknown, fallback: string) { + if (typeof payload === 'string' && payload.trim()) { + return payload + } + + if ( + payload && + typeof payload === 'object' && + 'error' in payload && + payload.error && + typeof payload.error === 'object' && + 'message' in payload.error && + typeof payload.error.message === 'string' + ) { + return payload.error.message + } + + if (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string') { + return payload.message + } + + return fallback +} + +function shouldUseDevProxy(baseUrl: string) { + if (!baseUrl) { + return false + } + + return typeof window !== 'undefined' && import.meta.env.DEV +} diff --git a/src/core/chat/session.ts b/src/core/chat/session.ts new file mode 100644 index 0000000..8338205 --- /dev/null +++ b/src/core/chat/session.ts @@ -0,0 +1,552 @@ +import { streamChatCompletion } from '../llm/client' +import { readProjectTextFile } from '../fs/project-fs' + +import { deriveChatTargetFromPath } from './target' +import { + createToolRuntimeContext, + fileEditTool, + fileReadTool, + fileWriteTool, + ragSearchTool, +} from './tools' + +import type { + ChatMessage, + ChatSessionState, + ChatTargetContext, + ChatTurnInput, + ChatTurnResult, + ToolDefinition, +} from '../../types/chat' + +type SessionEvent = + | { type: 'message'; message: ChatMessage } + | { type: 'draft'; text: string } + +type RunChatTurnOptions = { + session: ChatSessionState + input: ChatTurnInput + onEvent?: (event: SessionEvent) => void +} + +type TurnMode = 'read-only' | 'edit-target' | 'create-chapter' + +export function createChatSession(projectId: string): ChatSessionState { + return { + sessionId: createId('session'), + projectId, + messages: [], + status: 'idle', + currentDraftText: '', + currentTarget: null, + lastRagResult: null, + } +} + +export async function runChatTurn(options: RunChatTurnOptions): Promise { + const { input, onEvent } = options + const session: ChatSessionState = { + ...options.session, + status: 'running', + currentDraftText: '', + lastWrittenPath: undefined, + } + + const target = deriveChatTargetFromPath(input.activeFilePath) + session.currentTarget = target + const taskType = analyzeTurnMode(input.instruction, target) + session.lastTaskType = taskType + + pushMessage(session, createUserMessage(input.instruction), onEvent) + pushMessage(session, createContextSummary(target), onEvent) + pushMessage( + session, + { + id: createId('message'), + role: 'system', + kind: 'context-summary', + summary: summarizeTurnMode(taskType), + createdAt: new Date().toISOString(), + }, + onEvent, + ) + + const runtime = createToolRuntimeContext({ + project: input.project, + config: input.config, + target, + session, + }) + + let targetFileContent = '' + let targetPath = taskType === 'read-only' + ? target?.primaryPath + : resolveWritePath(input.project, target, taskType) + const recentChapters = await loadRecentChapters( + input.project, + input.config.settings.generationRecentChapters, + target?.type === 'chapter' ? target.primaryPath : undefined, + ) + + if ((taskType === 'edit-target' || taskType === 'read-only') && target?.primaryPath) { + const readOutput = await callTool(fileReadTool, { path: target.primaryPath }, runtime, session, onEvent) + targetFileContent = readOutput.content + targetPath = target.primaryPath + } + + if (recentChapters.length > 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({ + instruction: input.instruction, + target, + targetFileContent, + recentChapters, + ragSummary: summarizeRag(session.lastRagResult), + }) + + pushMessage(session, createAssistantText('正在根据当前目标和上下文生成本轮结果。'), onEvent) + + let generatedText = '' + + 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.currentDraftText += event.text + onEvent?.({ type: 'draft', text: session.currentDraftText }) + } + }, + ) + } catch (error) { + const message = error instanceof Error ? error.message : '模型生成失败' + session.status = 'error' + pushErrorMessage(session, message, true, onEvent) + throw error + } + + session.currentDraftText = generatedText.trim() + onEvent?.({ type: 'draft', text: session.currentDraftText }) + + if (taskType === 'read-only') { + pushMessage( + session, + { + id: createId('message'), + role: 'assistant', + kind: 'action-summary', + summary: targetPath + ? `已完成只读分析,未修改文件。分析目标:${targetPath}` + : '已完成项目级只读分析,未修改任何文件。', + targetPath, + relatedPaths: session.lastRagResult?.candidates.slice(0, 3).map((item) => item.sourcePath) ?? [], + createdAt: new Date().toISOString(), + }, + onEvent, + ) + + session.status = 'waiting-user' + + return { + session, + target, + writtenPath: undefined, + } + } + + if (!targetPath) { + const message = '当前任务需要写文件,但没有可用的目标路径' + session.status = 'error' + pushErrorMessage(session, message, false, onEvent) + throw new Error(message) + } + + if (taskType === 'edit-target' && target?.primaryPath) { + await callTool(fileEditTool, { path: targetPath, content: session.currentDraftText }, runtime, session, onEvent) + } else { + await callTool(fileWriteTool, { path: targetPath, content: session.currentDraftText }, runtime, session, onEvent) + } + + session.lastWrittenPath = targetPath + + pushMessage( + session, + { + id: createId('message'), + role: 'assistant', + kind: 'action-summary', + summary: `已完成本轮处理,并写回 ${targetPath}`, + targetPath, + relatedPaths: session.lastRagResult?.candidates.slice(0, 3).map((item) => item.sourcePath) ?? [], + createdAt: new Date().toISOString(), + }, + onEvent, + ) + + session.status = 'waiting-user' + + return { + session, + target, + writtenPath: targetPath, + } +} + +async function callTool( + tool: ToolDefinition, + input: TInput, + runtime: ReturnType, + session: ChatSessionState, + onEvent?: (event: SessionEvent) => void, +) { + pushMessage( + session, + { + id: createId('message'), + role: 'system', + kind: 'tool-call', + toolName: tool.name, + inputSummary: tool.summarizeInput(input), + createdAt: new Date().toISOString(), + }, + onEvent, + ) + + try { + const output = await tool.call(tool.validateInput(input), runtime) + + pushMessage( + session, + { + id: createId('message'), + role: 'system', + kind: 'tool-result', + toolName: tool.name, + ok: true, + resultSummary: tool.summarizeOutput(output), + createdAt: new Date().toISOString(), + }, + onEvent, + ) + + return output + } catch (error) { + const message = error instanceof Error ? error.message : `${tool.name} 执行失败` + session.status = 'error' + pushMessage( + session, + { + id: createId('message'), + role: 'system', + kind: 'tool-result', + toolName: tool.name, + ok: false, + resultSummary: message, + createdAt: new Date().toISOString(), + }, + onEvent, + ) + pushErrorMessage(session, message, true, onEvent) + throw error + } +} + +async function loadRecentChapters( + project: ChatTurnInput['project'], + limit: number, + excludedPath?: string, +) { + const chapterPaths = flattenChapterPaths(project.tree) + .filter((path) => path !== excludedPath) + .sort((left, right) => right.localeCompare(left, 'zh-Hans-CN')) + .slice(0, limit) + + return Promise.all( + chapterPaths.map(async (path) => ({ + path, + title: path.split('/').pop() || path, + content: await readProjectTextFile(project.handle, path), + })), + ) +} + +function flattenChapterPaths(tree: ChatTurnInput['project']['tree']) { + const stack = [...tree] + const paths: string[] = [] + + while (stack.length > 0) { + const node = stack.shift() + + if (!node) { + continue + } + + if (node.kind === 'file' && node.path.startsWith('chapters/') && node.name.endsWith('.md')) { + paths.push(node.path) + continue + } + + if (node.children?.length) { + stack.unshift(...node.children) + } + } + + return paths +} + +function pushMessage( + session: ChatSessionState, + message: ChatMessage, + onEvent?: (event: SessionEvent) => void, +) { + session.messages = [...session.messages, message] + onEvent?.({ type: 'message', message }) +} + +function pushErrorMessage( + session: ChatSessionState, + message: string, + recoverable: boolean, + onEvent?: (event: SessionEvent) => void, +) { + pushMessage( + session, + { + id: createId('message'), + role: 'system', + kind: 'error', + message, + recoverable, + createdAt: new Date().toISOString(), + }, + onEvent, + ) +} + +function createUserMessage(text: string): ChatMessage { + return { + id: createId('message'), + role: 'user', + kind: 'text', + text, + createdAt: new Date().toISOString(), + } +} + +function createAssistantText(text: string): ChatMessage { + return { + id: createId('message'), + role: 'assistant', + kind: 'text', + text, + createdAt: new Date().toISOString(), + } +} + +function createContextSummary(target: ChatTargetContext | null): ChatMessage { + return { + id: createId('message'), + role: 'system', + kind: 'context-summary', + summary: target?.primaryPath + ? `本轮默认目标:${target.displayName}(${target.primaryPath})` + : '本轮默认目标:当前项目,将生成新的章节草稿', + createdAt: new Date().toISOString(), + } +} + +function shouldUseRag(instruction: string, target: ChatTargetContext | null) { + if (isReadOnlyIntent(instruction)) { + return true + } + + if (target?.type === 'chapter' || target?.type === 'element') { + return true + } + + return /人物|角色|地点|设定|世界观|剧情|线索|前文|一致性/.test(instruction) +} + +function analyzeTurnMode( + instruction: string, + target: ChatTargetContext | null, +): TurnMode { + const normalized = instruction.trim() + + if (isReadOnlyIntent(normalized)) { + return 'read-only' + } + + if (/下一章|新章节|写下一章|写一章|续写|继续写/.test(normalized)) { + return 'create-chapter' + } + + if (!target?.primaryPath) { + return 'create-chapter' + } + + if (target.type !== 'chapter') { + return 'edit-target' + } + + if (/创建|新建/.test(normalized) && /章节|一章/.test(normalized)) { + return 'create-chapter' + } + + return 'edit-target' +} + +function isReadOnlyIntent(instruction: string) { + return /看一下|总结|概括|分析|梳理|列出|有哪些|都写了哪些|回顾|介绍一下|说明一下/.test(instruction) +} + +function resolveWritePath( + project: ChatTurnInput['project'], + target: ChatTargetContext | null, + taskType: 'edit-target' | 'create-chapter', +) { + if (taskType === 'edit-target' && target?.primaryPath) { + return target.primaryPath + } + + return buildNextChapterPath(project) +} + +function buildNextChapterPath(project: ChatTurnInput['project']) { + const chapterPaths = flattenChapterPaths(project.tree) + const maxSequence = chapterPaths.reduce((max, path) => { + const matched = path.match(/(\d{1,4})/) + if (!matched) { + return max + } + + return Math.max(max, Number(matched[1])) + }, 0) + + const nextSequence = String(maxSequence + 1).padStart(3, '0') + return `chapters/第${nextSequence}章-未命名章节.md` +} + +function summarizeTurnMode(mode: TurnMode) { + if (mode === 'read-only') { + return '本轮任务类型:只读分析,不修改文件' + } + + if (mode === 'create-chapter') { + return '本轮任务类型:生成新章节' + } + + return '本轮任务类型:修改当前目标文件' +} + +function summarizeRag(result: ChatSessionState['lastRagResult']) { + if (!result || result.candidates.length === 0) { + return '无相关 RAG 候选' + } + + return result.candidates + .slice(0, 5) + .map((item, index) => `${index + 1}. ${item.name} - ${item.summary}`) + .join('\n') +} + +function summarizeRagContext(result: ChatSessionState['lastRagResult']) { + if (!result || result.candidates.length === 0) { + return 'RAG 未命中可用要素上下文' + } + + return `RAG 已补充 ${result.candidates.length} 条候选,上下文优先使用前 ${Math.min(result.candidates.length, 5)} 条` +} + +function buildUserPrompt(input: { + instruction: string + target: ChatTargetContext | null + targetFileContent: string + recentChapters: Array<{ path: string; title: string; content: string }> + ragSummary: string +}) { + return [ + '你是 NovAI 第一阶段会话引擎中的小说写作智能体。', + '你的输出会直接写回目标文件,所以请只输出最终文件内容,不要额外解释、不要加前言、不要用代码块。', + '如果任务是只读分析,请直接输出给用户看的分析结果,不要伪装成小说正文或文件内容。', + '如果任务是修改已有文件,请输出完整修改后的全文,而不是局部片段。', + '如果任务是生成新章节,请输出完整 Markdown 章节正文。', + '保持与当前项目设定、人物状态、情节连续性一致;若上下文不足,优先保守延续现有内容。', + `用户意图:${input.instruction}`, + `当前目标:${input.target?.displayName ?? '当前项目'}`, + `目标路径:${input.target?.primaryPath ?? '将创建新章节文件'}`, + `任务类型:${input.target?.primaryPath ? '基于当前目标执行分析或修改' : '新章节生成或项目级分析'}`, + '当前文件内容:', + input.targetFileContent || '当前目标文件为空。', + '近期章节上下文:', + formatRecentChapters(input.recentChapters), + 'RAG 检索摘要:', + input.ragSummary, + '输出要求:', + '1. 只返回最终文件内容。', + '2. 不要解释你做了什么。', + '3. 不要输出 JSON、标签、标题说明或额外注释。', + ].join('\n\n') +} + +function formatRecentChapters(chapters: Array<{ path: string; title: string; content: string }>) { + if (chapters.length === 0) { + return '无可用近期章节。' + } + + return chapters + .map((chapter, index) => { + const excerpt = chapter.content.trim().slice(0, 1200) + return `${index + 1}. ${chapter.path}\n${excerpt || '(空内容)'}` + }) + .join('\n\n') +} + +function createId(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2, 10)}` +} diff --git a/src/core/chat/target.ts b/src/core/chat/target.ts new file mode 100644 index 0000000..4365fdc --- /dev/null +++ b/src/core/chat/target.ts @@ -0,0 +1,63 @@ +import type { ChatTargetContext } from '../../types/chat' + +export function deriveChatTargetFromPath(path?: string | null): ChatTargetContext | null { + if (!path) { + return { + type: 'project', + displayName: '当前项目', + derivedFrom: 'selection', + } + } + + if (path.startsWith('chapters/')) { + return { + type: 'chapter', + primaryPath: path, + groupName: 'chapters', + displayName: basename(path), + derivedFrom: 'preview', + } + } + + if (path === 'prompts/system.md') { + return { + type: 'prompt-system', + primaryPath: path, + groupName: 'prompts', + displayName: 'system prompt', + derivedFrom: 'preview', + } + } + + if (path.startsWith('prompts/scenes/')) { + return { + type: 'prompt-scene', + primaryPath: path, + groupName: 'prompts', + displayName: basename(path), + derivedFrom: 'preview', + } + } + + if (path.startsWith('elements/')) { + return { + type: 'element', + primaryPath: path, + groupName: 'elements', + displayName: basename(path), + derivedFrom: 'preview', + } + } + + return { + type: 'project', + primaryPath: path, + displayName: basename(path), + derivedFrom: 'preview', + } +} + +function basename(path: string) { + const parts = path.split('/') + return parts[parts.length - 1] || path +} diff --git a/src/core/chat/tools.ts b/src/core/chat/tools.ts new file mode 100644 index 0000000..5a7fb93 --- /dev/null +++ b/src/core/chat/tools.ts @@ -0,0 +1,213 @@ +import { readProjectTextFile, writeProjectTextFile } from '../fs/project-fs' +import { searchRagCandidates } from '../rag/search' + +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 +} + +export type RagSearchInput = { + query: string + topK?: number +} + +function asObject(input: unknown) { + if (!input || typeof input !== 'object') { + throw new Error('工具输入必须是对象') + } + + 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(), + } + }, + 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} 个字符` + }, +} + +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, + } + }, + 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} 个字符` + }, +} + +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, + } + }, + 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} 个字符` + }, +} + +export const ragSearchTool: ToolDefinition = { + name: 'RagSearch', + description: '基于项目要素索引做语义检索', + validateInput(input) { + const value = asObject(input) + + if (typeof value.query !== 'string' || !value.query.trim()) { + throw new Error('RagSearch 需要有效的 query') + } + + return { + query: value.query.trim(), + topK: typeof value.topK === 'number' ? value.topK : undefined, + } + }, + async call(input, context) { + return searchRagCandidates({ + projectId: context.project.id, + query: input.query, + topK: input.topK ?? context.config.settings.ragCandidateLimit, + }, context.config) + }, + summarizeInput(input) { + return `检索与“${input.query}”相关的故事要素` + }, + summarizeOutput(output) { + return output.total > 0 + ? `共召回 ${output.total} 条候选` + : '索引中暂无可召回候选' + }, +} + +export const bashTool: ToolDefinition<{ command: string }, { output: string }> = { + name: 'Bash', + description: '第一阶段保留占位,后续再接真实命令执行', + validateInput(input) { + const value = asObject(input) + + if (typeof value.command !== 'string' || !value.command.trim()) { + throw new Error('Bash 需要有效的 command') + } + + return { + command: value.command.trim(), + } + }, + async call() { + throw new Error('第一阶段 Session Lab 还未接入真实 Bash 工具') + }, + summarizeInput(input) { + return `执行命令:${input.command}` + }, + summarizeOutput(output) { + return output.output + }, +} + +export function createToolRuntimeContext(context: ToolRuntimeContext): ToolRuntimeContext { + return context +} diff --git a/src/core/elements/extractor.ts b/src/core/elements/extractor.ts new file mode 100644 index 0000000..0e6816b --- /dev/null +++ b/src/core/elements/extractor.ts @@ -0,0 +1,15 @@ +import type { ElementExtractionResult } from '../../types/elements' + +export async function extractElementsFromChapter(_input: { + chapterMarkdown: string + chapterPath?: string + systemPrompt?: string +}): Promise { + return { + characters: [], + locations: [], + timeline: [], + plots: [], + worldbuilding: [], + } +} diff --git a/src/core/elements/parser.ts b/src/core/elements/parser.ts new file mode 100644 index 0000000..8b83ae4 --- /dev/null +++ b/src/core/elements/parser.ts @@ -0,0 +1,93 @@ +import type { ElementDocument, ElementFrontmatter } from '../../types/elements' + +export function parseElementFile(sourcePath: string, content: string): ElementDocument { + const { frontmatter, body } = splitFrontmatter(content) + + return { + sourcePath, + frontmatter: normalizeFrontmatter(frontmatter), + body: body.trim(), + } +} + +function splitFrontmatter(content: string) { + const normalized = content.replace(/\r\n/g, '\n') + + if (!normalized.startsWith('---\n')) { + return { + frontmatter: '', + body: normalized, + } + } + + const endIndex = normalized.indexOf('\n---\n', 4) + + if (endIndex === -1) { + return { + frontmatter: '', + body: normalized, + } + } + + return { + frontmatter: normalized.slice(4, endIndex), + body: normalized.slice(endIndex + 5), + } +} + +function normalizeFrontmatter(raw: string): ElementFrontmatter { + const lines = raw.split('\n') + const record: Record = {} + let currentListKey = '' + + for (const sourceLine of lines) { + const line = sourceLine.trim() + + if (!line) { + continue + } + + if (line.startsWith('- ') && currentListKey) { + record[currentListKey] = `${record[currentListKey] ?? ''}, ${line.slice(2).trim()}`.trim() + continue + } + + const separatorIndex = line.indexOf(':') + + if (separatorIndex === -1) { + currentListKey = '' + continue + } + + const key = line.slice(0, separatorIndex).trim() + const value = line.slice(separatorIndex + 1).trim() + record[key] = value + currentListKey = value ? '' : key + } + + return { + id: record.id ?? '', + type: normalizeType(record.type), + name: record.name ?? '', + summary: record.summary ?? '', + tags: splitList(record.tags), + lastUpdatedChapter: record.lastUpdatedChapter ?? record.last_updated_chapter ?? '', + relatedChapters: splitList(record.relatedChapters ?? record.related_chapters), + updatedAt: record.updatedAt ?? record.updated_at ?? '', + } +} + +function normalizeType(value: string | undefined): ElementFrontmatter['type'] { + if (value === 'location' || value === 'timeline' || value === 'plot' || value === 'worldbuilding') { + return value + } + + return 'character' +} + +function splitList(value: string | undefined) { + return (value ?? '') + .split(/[,,]/) + .map((item) => item.trim()) + .filter(Boolean) +} diff --git a/src/core/elements/writer.ts b/src/core/elements/writer.ts new file mode 100644 index 0000000..c886ccb --- /dev/null +++ b/src/core/elements/writer.ts @@ -0,0 +1,74 @@ +import type { ElementDocument, ElementExtractionItem, ElementWriteResult } from '../../types/elements' +import type { ElementType } from '../../types/rag' + +import { writeProjectTextFile } from '../fs/project-fs' + +const ELEMENT_DIRECTORY_MAP: Record = { + character: 'elements/characters', + location: 'elements/locations', + timeline: 'elements/timeline', + plot: 'elements/plots', + worldbuilding: 'elements/worldbuilding', +} + +export async function writeElementDocuments( + rootHandle: FileSystemDirectoryHandle, + elements: ElementDocument[], +): Promise { + const result: ElementWriteResult = { + created: [], + updated: [], + skipped: [], + } + + for (const element of elements) { + const directory = ELEMENT_DIRECTORY_MAP[element.frontmatter.type] + const fileName = `${slugifyElementName(element.frontmatter.name || element.frontmatter.id || 'element')}.md` + const path = `${directory}/${fileName}` + + await writeProjectTextFile(rootHandle, path, stringifyElementDocument(element)) + result.updated.push(path) + } + + return result +} + +export function createElementDocument(item: ElementExtractionItem): ElementDocument { + return { + sourcePath: '', + frontmatter: { + id: '', + type: item.type, + name: item.name, + summary: item.summary, + tags: item.tags, + lastUpdatedChapter: item.lastUpdatedChapter, + relatedChapters: item.relatedChapters, + updatedAt: new Date().toISOString(), + }, + body: item.body.trim(), + } +} + +function stringifyElementDocument(element: ElementDocument) { + const frontmatterLines = [ + `id: ${element.frontmatter.id}`, + `type: ${element.frontmatter.type}`, + `name: ${element.frontmatter.name}`, + `summary: ${element.frontmatter.summary}`, + `tags: ${element.frontmatter.tags.join(', ')}`, + `lastUpdatedChapter: ${element.frontmatter.lastUpdatedChapter}`, + `relatedChapters: ${element.frontmatter.relatedChapters.join(', ')}`, + `updatedAt: ${element.frontmatter.updatedAt}`, + ] + + return `---\n${frontmatterLines.join('\n')}\n---\n\n${element.body}\n` +} + +function slugifyElementName(value: string) { + return value + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9\-_一-龥]/g, '') +} diff --git a/src/core/embedding/client.ts b/src/core/embedding/client.ts new file mode 100644 index 0000000..1ca048b --- /dev/null +++ b/src/core/embedding/client.ts @@ -0,0 +1,112 @@ +import { createJsonHeaders, extractErrorMessage, normalizeBaseUrl, readJsonResponse, resolveApiUrl } from '../ai/shared' + +import type { ModelConnectionInput, ModelConnectionResult } from '../../types/ai' + +/** + * 使用 OpenAI 兼容的 `/models` 接口测试 Embedding 配置是否可用。 + * 第一版先只验证“服务可达 + 鉴权通过”,不引入真实向量请求。 + */ +export async function testEmbeddingConnection( + input: Omit, +): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl) + + if (!baseUrl || !input.apiKey.trim()) { + return { + ok: false, + message: '请先填写 API 地址和 API Key', + } + } + + try { + // 测试连接先走最轻量的 models 接口,避免第一版就被具体 embedding 输入格式卡住。 + const response = await fetch(`${baseUrl}/models`, { + method: 'GET', + headers: createJsonHeaders(input.apiKey, baseUrl), + }) + + if (!response.ok) { + const payload = await readJsonResponse(response) + return { + ok: false, + message: extractErrorMessage(payload, 'Embedding 测试连接失败'), + } + } + + return { + ok: true, + message: 'Embedding 连接成功', + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : 'Embedding 测试连接失败', + } + } +} + +export async function createEmbedding(input: { + baseUrl: string + apiKey: string + model: string + text: string +}) { + const baseUrl = normalizeBaseUrl(input.baseUrl) + + if (!baseUrl || !input.apiKey.trim() || !input.model.trim()) { + throw new Error('请先填写 Embedding 的 API 地址、API Key 和模型名称') + } + + if (!input.text.trim()) { + throw new Error('Embedding 输入文本不能为空') + } + + const response = await fetch(resolveApiUrl(baseUrl, '/embeddings'), { + method: 'POST', + headers: createJsonHeaders(input.apiKey, baseUrl), + body: JSON.stringify({ + model: input.model.trim(), + input: input.text.trim(), + }), + }) + + if (!response.ok) { + const payload = await readJsonResponse(response) + throw new Error(extractErrorMessage(payload, 'Embedding 请求失败')) + } + + const payload = await readJsonResponse(response) + const embedding = extractEmbedding(payload) + + if (!embedding) { + throw new Error('Embedding 响应中未找到可用向量') + } + + return { + vector: embedding, + dimension: embedding.length, + } +} + +function extractEmbedding(payload: unknown) { + if ( + payload && + typeof payload === 'object' && + 'data' in payload && + Array.isArray(payload.data) && + payload.data.length > 0 + ) { + const firstItem = payload.data[0] + + if ( + firstItem && + typeof firstItem === 'object' && + 'embedding' in firstItem && + Array.isArray(firstItem.embedding) + ) { + return firstItem.embedding.filter((value: unknown): value is number => typeof value === 'number') + } + } + + return null +} diff --git a/src/core/fs/project-fs.ts b/src/core/fs/project-fs.ts index ef61b20..e2cf75e 100644 --- a/src/core/fs/project-fs.ts +++ b/src/core/fs/project-fs.ts @@ -1,6 +1,7 @@ import { createDefaultConfig, createDefaultManifest, DEFAULT_SCENE_PROMPT, DEFAULT_SYSTEM_PROMPT } from '../project/defaults' import type { + ProjectInspection, ProjectConfig, ProjectFileContent, ProjectManifest, @@ -11,11 +12,31 @@ import type { const TEXT_FILE_EXTENSIONS = ['.md', '.json', '.txt'] const ROOT_DIRECTORY_ORDER = ['chapters', 'elements', 'prompts', '.novel'] - +const REQUIRED_DIRECTORY_PATHS = [ + 'chapters', + 'elements', + 'elements/characters', + 'elements/locations', + 'elements/timeline', + 'elements/plots', + 'elements/worldbuilding', + 'prompts', + 'prompts/scenes', + '.novel', +] as const + +/** + * 判断当前运行环境是否支持浏览器目录读写能力。 + * 测试页在执行任何项目级操作前都应先用它做兜底判断。 + */ export function isFileSystemAccessSupported() { return typeof window !== 'undefined' && 'showDirectoryPicker' in window } +/** + * 创建一个新的小说项目目录,并写入最小可用的默认结构与配置文件。 + * 返回值会携带完整快照,调用方可以直接把它当作“当前项目”使用。 + */ export async function createProject(projectName: string): Promise { const parentHandle = await window.showDirectoryPicker({ mode: 'readwrite' }) const rootHandle = await parentHandle.getDirectoryHandle(projectName, { create: true }) @@ -38,11 +59,125 @@ export async function createProject(projectName: string): Promise { const rootHandle = await window.showDirectoryPicker({ mode: 'readwrite' }) return loadProjectFromHandle(rootHandle) } +/** + * 仅让用户选择目录句柄,不附带任何项目合法性判断。 + * 适合“打开项目前先检查/修复”的交互流程。 + */ +export async function pickProjectDirectory() { + return window.showDirectoryPicker({ mode: 'readwrite' }) +} + +/** + * 检查一个目录是否满足 NovAI 最小项目结构。 + * 返回的 issues 会区分“文件缺失”和“文件存在但已损坏”两种情况,便于测试页决定是否提供修复入口。 + */ +export async function inspectProject(rootHandle: FileSystemDirectoryHandle): Promise { + const issues: ProjectInspection['issues'] = [] + + // 配置和清单除了要存在,也要能成功解析,否则后续加载仍会失败。 + const hasConfig = await pathExists(rootHandle, 'novel.config.json', 'file') + const hasManifest = await pathExists(rootHandle, '.novel/manifest.json', 'file') + + if (!hasConfig) { + issues.push('missing-config') + } else if (!(await isJsonFileValid(rootHandle, 'novel.config.json'))) { + issues.push('invalid-config') + } + + if (!hasManifest) { + issues.push('missing-manifest') + } else if (!(await isJsonFileValid(rootHandle, '.novel/manifest.json'))) { + issues.push('invalid-manifest') + } + + if (!(await pathExists(rootHandle, 'prompts/system.md', 'file'))) { + issues.push('missing-prompts-system') + } + + if (!(await pathExists(rootHandle, 'prompts/scenes', 'directory'))) { + issues.push('missing-prompts-scenes') + } + + if (!(await pathExists(rootHandle, 'chapters', 'directory'))) { + issues.push('missing-chapters') + } + + if (!(await pathExists(rootHandle, 'elements', 'directory'))) { + issues.push('missing-elements') + } + + if (!(await pathExists(rootHandle, '.novel', 'directory'))) { + issues.push('missing-internal-directory') + } + + return { + rootName: rootHandle.name, + issues, + canLoad: issues.length === 0, + } +} + +/** + * 尝试把任意目录补齐为可加载的 NovAI 项目。 + * 它会创建缺失目录、补齐默认文件,并在配置/清单损坏时重建为可用内容。 + */ +export async function repairProject( + rootHandle: FileSystemDirectoryHandle, +): Promise { + for (const directoryPath of REQUIRED_DIRECTORY_PATHS) { + await ensureDirectory(rootHandle, directoryPath) + } + + const hasConfig = await pathExists(rootHandle, 'novel.config.json', 'file') + const hasManifest = await pathExists(rootHandle, '.novel/manifest.json', 'file') + // 修复时优先保留已有项目名;只有缺失或为空时才回退到目录名。 + const repairedProjectName = await resolveProjectNameForRepair(rootHandle) + + if (!hasConfig || !(await isJsonFileValid(rootHandle, 'novel.config.json'))) { + await writeJson(rootHandle, 'novel.config.json', createDefaultConfig(repairedProjectName)) + } else { + const currentConfig = await readJson(rootHandle, 'novel.config.json') + + if (!currentConfig.project.name.trim()) { + await writeJson(rootHandle, 'novel.config.json', { + ...currentConfig, + project: { + ...currentConfig.project, + name: repairedProjectName, + updatedAt: new Date().toISOString(), + }, + }) + } + } + + if (!hasManifest || !(await isJsonFileValid(rootHandle, '.novel/manifest.json'))) { + await writeJson(rootHandle, '.novel/manifest.json', createDefaultManifest(createProjectId())) + } + + if (!(await pathExists(rootHandle, 'prompts/system.md', 'file'))) { + await writeText(rootHandle, 'prompts/system.md', DEFAULT_SYSTEM_PROMPT) + } + + if (!(await pathExists(rootHandle, 'prompts/scenes/scene-001.md', 'file'))) { + await writeText(rootHandle, 'prompts/scenes/scene-001.md', DEFAULT_SCENE_PROMPT) + } + + return loadProjectFromHandle(rootHandle) +} + +/** + * 从一个已知合法的目录句柄中读取配置、清单和文件树,并组装成项目快照。 + * 这是测试页后续所有项目级操作的基础输入。 + */ export async function loadProjectFromHandle(rootHandle: FileSystemDirectoryHandle): Promise { const config = await readJson(rootHandle, 'novel.config.json') const manifest = await readJson(rootHandle, '.novel/manifest.json') @@ -60,6 +195,9 @@ export async function loadProjectFromHandle(rootHandle: FileSystemDirectoryHandl } } +/** + * 读取项目中的单个文本文件,并补充格式与更新时间信息,方便页面直接预览。 + */ export async function readProjectFile(snapshot: ProjectSnapshot, path: string): Promise { const fileHandle = await resolveFileHandle(snapshot.handle, path) const file = await fileHandle.getFile() @@ -74,10 +212,95 @@ export async function readProjectFile(snapshot: ProjectSnapshot, path: string): } } +/** + * 重新扫描当前项目目录,返回最新文件树。 + * 适合在保存章节、修复目录后刷新测试页列表。 + */ export async function rescanProject(snapshot: ProjectSnapshot): Promise { return scanDirectory(snapshot.handle) } +/** + * 读取 `novel.config.json` 并返回当前项目配置。 + */ +export async function readProjectConfig(rootHandle: FileSystemDirectoryHandle): Promise { + return readJson(rootHandle, 'novel.config.json') +} + +/** + * 写回 `novel.config.json`,并自动更新项目名兜底值与 `updatedAt` 时间。 + * 调用方只需要传入想保存的配置对象,不需要自己处理这些元信息。 + */ +export async function writeProjectConfig( + rootHandle: FileSystemDirectoryHandle, + config: ProjectConfig, +): Promise { + const nextConfig: ProjectConfig = { + ...config, + project: { + ...config.project, + name: config.project.name || rootHandle.name, + updatedAt: new Date().toISOString(), + }, + } + + await writeJson(rootHandle, 'novel.config.json', nextConfig) + return nextConfig +} + +/** + * 按相对路径读取项目中的任意文本文件。 + * 适合测试页或后续业务层读取 prompt、章节、要素原文。 + */ +export async function readProjectTextFile(rootHandle: FileSystemDirectoryHandle, path: string) { + return readText(rootHandle, path) +} + +/** + * 按相对路径写入项目中的任意文本文件。 + * 路径上的中间目录会自动创建。 + */ +export async function writeProjectTextFile( + rootHandle: FileSystemDirectoryHandle, + path: string, + content: string, +) { + await writeText(rootHandle, path, content) +} + +/** + * 读取项目中的 `prompts/system.md`。 + */ +export async function readSystemPrompt(rootHandle: FileSystemDirectoryHandle) { + return readText(rootHandle, 'prompts/system.md') +} + +/** + * 写回项目中的 `prompts/system.md`。 + */ +export async function writeSystemPrompt(rootHandle: FileSystemDirectoryHandle, content: string) { + await writeText(rootHandle, 'prompts/system.md', content) +} + +/** + * 将生成结果保存为章节 Markdown 文件,并返回最终采用的文件名。 + * 如果调用方没有提供合法名称,这里会自动生成一个可落盘的默认值。 + */ +export async function writeChapterFile( + rootHandle: FileSystemDirectoryHandle, + fileName: string, + markdown: string, +) { + // 测试页允许直接输入文件名,这里统一兜底成稳定的 .md 文件名。 + const normalizedName = normalizeChapterFileName(fileName) + await writeText(rootHandle, `chapters/${normalizedName}`, markdown) + return normalizedName +} + +/** + * 从文件树中找到第一个可直接预览的文本文件路径。 + * 适合项目激活时给测试页提供一个默认打开目标。 + */ export function findFirstReadableFile(tree: TreeNode[]): string | null { const stack = [...tree] @@ -112,6 +335,25 @@ function inferFormat(name: string): ProjectFileContent['format'] { return 'text' } +function normalizeChapterFileName(fileName: string) { + const trimmed = fileName.trim() + + if (!trimmed) { + const now = new Date() + const stamp = [ + now.getFullYear(), + `${now.getMonth() + 1}`.padStart(2, '0'), + `${now.getDate()}`.padStart(2, '0'), + `${now.getHours()}`.padStart(2, '0'), + `${now.getMinutes()}`.padStart(2, '0'), + `${now.getSeconds()}`.padStart(2, '0'), + ].join('') + return `chapter-${stamp}.md` + } + + return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md` +} + async function scanDirectory( rootHandle: FileSystemDirectoryHandle, parentPath = '', @@ -126,6 +368,7 @@ async function scanDirectory( name: entry.name, path, kind: 'directory', + // 文件树在这里一次性递归展开,测试页后面就只负责展示和选择。 children: await scanDirectory(entry, path), }) continue @@ -181,6 +424,48 @@ async function ensureDirectory(rootHandle: FileSystemDirectoryHandle, path: stri } } +async function pathExists( + rootHandle: FileSystemDirectoryHandle, + path: string, + kind: 'file' | 'directory', +) { + try { + if (kind === 'file') { + await resolveFileHandle(rootHandle, path) + } else { + await resolveDirectoryHandle(rootHandle, path) + } + + return true + } catch { + return false + } +} + +async function isJsonFileValid(rootHandle: FileSystemDirectoryHandle, path: string) { + try { + await readJson(rootHandle, path) + return true + } catch { + return false + } +} + +async function resolveProjectNameForRepair(rootHandle: FileSystemDirectoryHandle) { + try { + const config = await readJson(rootHandle, 'novel.config.json') + const configName = config.project.name.trim() + + if (configName) { + return configName + } + } catch { + // Fall back to the directory name when config is missing or invalid. + } + + return rootHandle.name +} + async function writeText(rootHandle: FileSystemDirectoryHandle, path: string, content: string) { const fileHandle = await ensureFileHandle(rootHandle, path) const writable = await fileHandle.createWritable() @@ -237,6 +522,17 @@ async function resolveFileHandle(rootHandle: FileSystemDirectoryHandle, path: st return current.getFileHandle(fileName) } +async function resolveDirectoryHandle(rootHandle: FileSystemDirectoryHandle, path: string) { + const segments = path.split('/').filter(Boolean) + let current = rootHandle + + for (const segment of segments) { + current = await current.getDirectoryHandle(segment) + } + + return current +} + function summarizeProject( tree: TreeNode[], config: ProjectConfig, diff --git a/src/core/llm/client.ts b/src/core/llm/client.ts new file mode 100644 index 0000000..6f36181 --- /dev/null +++ b/src/core/llm/client.ts @@ -0,0 +1,235 @@ +import { createJsonHeaders, extractErrorMessage, normalizeBaseUrl, readJsonResponse, resolveApiUrl } from '../ai/shared' + +import type { + LlmStreamEvent, + LlmStreamInput, + ModelConnectionInput, + ModelConnectionResult, +} from '../../types/ai' + +/** + * 使用 OpenAI 兼容的 `/models` 接口测试 LLM 配置是否可用。 + * 返回值已经整理成适合直接展示给用户的结果结构。 + */ +export async function testLlmConnection( + input: Omit, +): Promise { + const baseUrl = normalizeBaseUrl(input.baseUrl) + + if (!baseUrl || !input.apiKey.trim()) { + return { + ok: false, + message: '请先填写 API 地址和 API Key', + } + } + + try { + const response = await fetch(`${baseUrl}/models`, { + method: 'GET', + headers: createJsonHeaders(input.apiKey, baseUrl), + }) + + if (!response.ok) { + const payload = await readJsonResponse(response) + return { + ok: false, + message: extractErrorMessage(payload, 'LLM 测试连接失败'), + } + } + + return { + ok: true, + message: 'LLM 连接成功', + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : 'LLM 测试连接失败', + } + } +} + +/** + * 发起一次最小化的流式对话生成,并把底层 SSE 数据适配成统一事件流。 + * 调用方只需要监听 `start / delta / finish / error`,无需关心原始 chunk 格式。 + */ +export async function streamChatCompletion( + input: LlmStreamInput, + onEvent: (event: LlmStreamEvent) => 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 messages = [] + + if (input.systemPrompt?.trim()) { + messages.push({ + role: 'system', + content: input.systemPrompt.trim(), + }) + } + + messages.push({ + role: 'user', + content: input.instruction.trim(), + }) + + 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, + }), + }) + + if (!response.ok) { + const payload = await readJsonResponse(response) + const message = extractErrorMessage(payload, '章节生成失败') + onEvent({ type: 'error', message }) + throw new Error(message) + } + + if (!response.body) { + const payload = await readJsonResponse(response) + const text = extractCompletionText(payload) + onEvent({ type: 'start' }) + onEvent({ type: 'finish', text }) + return text + } + + onEvent({ type: 'start' }) + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + let fullText = '' + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + // OpenAI 兼容流通常以空行分隔事件,这里先按事件块切开,再逐行解析 data。 + const chunks = buffer.split('\n\n') + buffer = chunks.pop() ?? '' + + for (const chunk of chunks) { + const lines = chunk + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + + const data = line.slice(5).trim() + + if (!data || data === '[DONE]') { + continue + } + + try { + const payload = JSON.parse(data) + const deltaText = extractDeltaText(payload) + + if (deltaText) { + fullText += deltaText + onEvent({ type: 'delta', text: deltaText }) + } + } catch { + continue + } + } + } + } + + onEvent({ type: 'finish', text: fullText }) + return fullText +} + +function extractDeltaText(payload: unknown) { + if ( + payload && + typeof payload === 'object' && + 'choices' in payload && + Array.isArray(payload.choices) && + payload.choices.length > 0 + ) { + const firstChoice = payload.choices[0] + + if ( + firstChoice && + typeof firstChoice === 'object' && + 'delta' in firstChoice && + firstChoice.delta && + typeof firstChoice.delta === 'object' && + 'content' in firstChoice.delta + ) { + const content = firstChoice.delta.content + + if (typeof content === 'string') { + return content + } + + if (Array.isArray(content)) { + // 一些兼容实现会把内容拆成富文本片段数组,这里只抽取 text 片段并拼回纯文本。 + return content + .map((item) => { + if ( + item && + typeof item === 'object' && + 'type' in item && + item.type === 'text' && + 'text' in item && + typeof item.text === 'string' + ) { + return item.text + } + + return '' + }) + .join('') + } + } + } + + return '' +} + +function extractCompletionText(payload: unknown) { + if ( + payload && + typeof payload === 'object' && + 'choices' in payload && + Array.isArray(payload.choices) && + payload.choices.length > 0 + ) { + const firstChoice = payload.choices[0] + + if ( + firstChoice && + typeof firstChoice === 'object' && + 'message' in firstChoice && + firstChoice.message && + typeof firstChoice.message === 'object' && + 'content' in firstChoice.message && + typeof firstChoice.message.content === 'string' + ) { + return firstChoice.message.content + } + } + + return '' +} diff --git a/src/core/project/defaults.ts b/src/core/project/defaults.ts index 4ef4b12..9759f75 100644 --- a/src/core/project/defaults.ts +++ b/src/core/project/defaults.ts @@ -23,13 +23,24 @@ export const DEFAULT_CONFIG = { apiKey: '', model: '', }, + rerank: { + enabled: false, + baseUrl: '', + apiKey: '', + model: '', + mode: 'text', + topN: 8, + }, settings: { generationRecentChapters: 3, ragCandidateLimit: 20, + ragContextMaxItems: 8, proofreadDefaultChapters: 3, organizeDefaultChapters: 10, conversationTokenLimit: 12000, compressionKeepRecentTurns: 5, + embeddingTextVersion: 1, + enableBackgroundIndexing: true, }, } as const diff --git a/src/core/rag/context.ts b/src/core/rag/context.ts new file mode 100644 index 0000000..5017d81 --- /dev/null +++ b/src/core/rag/context.ts @@ -0,0 +1,24 @@ +import type { GenerationContextDraft, RetrievalCandidate } from '../../types/rag' + +export function buildGenerationContextDraft(input: { + query: string + recentChapters?: GenerationContextDraft['recentChapters'] + retrievedCandidates?: RetrievalCandidate[] + rerankedCandidates?: RetrievalCandidate[] + finalContextItems?: RetrievalCandidate[] +}): GenerationContextDraft { + return { + query: input.query, + recentChapters: input.recentChapters ?? [], + retrievedCandidates: input.retrievedCandidates ?? [], + rerankedCandidates: input.rerankedCandidates ?? [], + finalContextItems: input.finalContextItems ?? [], + } +} + +export function selectFinalContextItems( + candidates: RetrievalCandidate[], + limit: number, +): RetrievalCandidate[] { + return candidates.slice(0, limit) +} diff --git a/src/core/rag/explain.ts b/src/core/rag/explain.ts new file mode 100644 index 0000000..558d12a --- /dev/null +++ b/src/core/rag/explain.ts @@ -0,0 +1,32 @@ +import type { RetrievalCandidate, RetrievalExplanation } from '../../types/rag' + +export function explainRetrievalCandidates( + candidates: RetrievalCandidate[], + selectedBy: RetrievalExplanation['selectedBy'], +): RetrievalExplanation[] { + return candidates.map((candidate) => ({ + id: candidate.id, + name: candidate.name, + type: candidate.type, + summary: candidate.summary, + sourcePath: candidate.sourcePath, + selectedBy, + reason: buildReason(candidate, selectedBy), + lastUpdatedChapter: candidate.lastUpdatedChapter, + })) +} + +function buildReason( + candidate: RetrievalCandidate, + selectedBy: RetrievalExplanation['selectedBy'], +) { + if (selectedBy === 'final-context') { + return `已进入最终生成上下文,来源于 ${candidate.type} 要素检索` + } + + if (selectedBy === 'rerank') { + return `已进入重排结果,候选摘要为:${candidate.summary || candidate.name}` + } + + return `已被粗召回命中,候选摘要为:${candidate.summary || candidate.name}` +} diff --git a/src/core/rag/index-store.ts b/src/core/rag/index-store.ts new file mode 100644 index 0000000..c514f3c --- /dev/null +++ b/src/core/rag/index-store.ts @@ -0,0 +1,112 @@ +import type { IndexedElementDocument, ProjectIndexMeta } from '../../types/rag' + +export type RagIndexStore = { + getProjectMeta(projectId: string): Promise + saveProjectMeta(meta: ProjectIndexMeta): Promise + listProjectDocuments(projectId: string): Promise + upsertDocuments(documents: IndexedElementDocument[]): Promise + removeDocuments(projectId: string, ids: string[]): Promise + clearProject(projectId: string): Promise +} + +const DB_NAME = 'novai-rag' +const DB_VERSION = 1 +const ELEMENT_STORE = 'element_documents' +const META_STORE = 'index_meta' + +type StoredElementDocument = IndexedElementDocument & { + docKey: string +} + +export function createRagIndexStore(): RagIndexStore { + return { + async getProjectMeta(projectId) { + const db = await openDatabase() + const record = await requestValue( + db.transaction(META_STORE, 'readonly').objectStore(META_STORE).get(projectId), + ) + return record ?? null + }, + async saveProjectMeta(meta) { + const db = await openDatabase() + await requestValue( + db.transaction(META_STORE, 'readwrite').objectStore(META_STORE).put(meta), + ) + }, + async listProjectDocuments(projectId) { + const db = await openDatabase() + const records = await requestValue( + db.transaction(ELEMENT_STORE, 'readonly').objectStore(ELEMENT_STORE).index('projectId').getAll(projectId), + ) + + return records.map(({ docKey: _docKey, ...document }) => document) + }, + async upsertDocuments(documents) { + if (documents.length === 0) { + return + } + + const db = await openDatabase() + const store = db.transaction(ELEMENT_STORE, 'readwrite').objectStore(ELEMENT_STORE) + + for (const document of documents) { + await requestValue( + store.put({ + ...document, + docKey: getDocumentKey(document.projectId, document.id), + }), + ) + } + }, + async removeDocuments(projectId, ids) { + if (ids.length === 0) { + return + } + + const db = await openDatabase() + const store = db.transaction(ELEMENT_STORE, 'readwrite').objectStore(ELEMENT_STORE) + + for (const id of ids) { + await requestValue(store.delete(getDocumentKey(projectId, id))) + } + }, + async clearProject(projectId) { + const documents = await this.listProjectDocuments(projectId) + await this.removeDocuments(projectId, documents.map((document) => document.id)) + }, + } +} + +function getDocumentKey(projectId: string, id: string) { + return `${projectId}::${id}` +} + +function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onupgradeneeded = () => { + const db = request.result + + if (!db.objectStoreNames.contains(ELEMENT_STORE)) { + const elementStore = db.createObjectStore(ELEMENT_STORE, { keyPath: 'docKey' }) + elementStore.createIndex('projectId', 'projectId', { unique: false }) + elementStore.createIndex('sourcePath', 'sourcePath', { unique: false }) + } + + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: 'projectId' }) + } + } + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('打开 IndexedDB 失败')) + }) +} + +function requestValue(request: IDBRequest) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('IndexedDB 请求失败')) + }) +} diff --git a/src/core/rag/indexer.ts b/src/core/rag/indexer.ts new file mode 100644 index 0000000..dba84ba --- /dev/null +++ b/src/core/rag/indexer.ts @@ -0,0 +1,243 @@ +import { createEmbedding } from '../embedding/client' +import { parseElementFile } from '../elements/parser' +import { readProjectFile } from '../fs/project-fs' + +import type { ProjectSnapshot, TreeNode } from '../../types/project' +import type { IndexBuildRequest, IndexBuildResult, ProjectIndexMeta } from '../../types/rag' + +import { createRagIndexStore } from './index-store' +import { buildRetrievalText } from './retrieval-text' + +export async function getProjectIndexMeta(projectId: string): Promise { + const store = createRagIndexStore() + return store.getProjectMeta(projectId) +} + +export async function buildProjectIndex( + project: ProjectSnapshot, + request: IndexBuildRequest, +): Promise { + const store = createRagIndexStore() + const embeddingProvider = project.config.embedding.baseUrl + const embeddingModel = project.config.embedding.model + + if (!embeddingProvider || !project.config.embedding.apiKey || !embeddingModel) { + throw new Error('请先完成 Embedding 配置,再执行索引构建') + } + + await store.saveProjectMeta({ + projectId: project.id, + status: request.reason === 'full-rebuild' || request.reason === 'manual-rebuild' ? 'rebuilding' : 'building', + documentCount: 0, + embeddingProvider, + embeddingModel, + embeddingDim: 0, + embeddingTextVersion: project.config.settings.embeddingTextVersion, + rerankProvider: project.config.rerank.baseUrl || undefined, + rerankModel: project.config.rerank.model || undefined, + lastBuildAt: new Date().toISOString(), + }) + + try { + const elementPaths = collectElementPaths(project.tree, request.sourcePaths) + + if (request.reason === 'full-rebuild' || request.reason === 'manual-rebuild' || request.reason === 'initial-build') { + await store.clearProject(project.id) + } + + const documents = [] + let embeddingDim = 0 + + for (const path of elementPaths) { + const file = await readProjectFile(project, path) + const parsed = parseElementFile(path, file.content) + const name = parsed.frontmatter.name || inferNameFromPath(path) + const type = parsed.frontmatter.type || inferTypeFromPath(path) + const summary = parsed.frontmatter.summary || summarizeBody(parsed.body) + const retrievalText = buildRetrievalText({ + ...parsed, + frontmatter: { + ...parsed.frontmatter, + type, + name, + summary, + }, + }) + + const embedding = await createEmbedding({ + baseUrl: embeddingProvider, + apiKey: project.config.embedding.apiKey, + model: embeddingModel, + text: retrievalText, + }) + + embeddingDim = embedding.dimension + + documents.push({ + id: parsed.frontmatter.id || createStableElementId(path), + projectId: project.id, + sourcePath: path, + type, + name, + summary, + retrievalText, + vector: embedding.vector, + lastUpdatedChapter: parsed.frontmatter.lastUpdatedChapter, + relatedChapters: parsed.frontmatter.relatedChapters, + tags: parsed.frontmatter.tags, + sourceModifiedAt: file.updatedAt, + indexedAt: new Date().toISOString(), + contentHash: hashContent(file.content), + embeddingProvider, + embeddingModel, + embeddingDim: embedding.dimension, + embeddingTextVersion: project.config.settings.embeddingTextVersion, + }) + } + + await store.upsertDocuments(documents) + + const meta: ProjectIndexMeta = { + projectId: project.id, + status: documents.length > 0 ? 'ready' : 'empty', + documentCount: documents.length, + embeddingProvider, + embeddingModel, + embeddingDim, + embeddingTextVersion: project.config.settings.embeddingTextVersion, + rerankProvider: project.config.rerank.baseUrl || undefined, + rerankModel: project.config.rerank.model || undefined, + lastBuildAt: new Date().toISOString(), + lastFullRebuildAt: + request.reason === 'full-rebuild' || request.reason === 'manual-rebuild' + ? new Date().toISOString() + : undefined, + } + + await store.saveProjectMeta(meta) + + return { + projectId: project.id, + status: meta.status, + indexedCount: documents.length, + skippedCount: 0, + failedCount: 0, + message: + documents.length > 0 + ? `索引构建完成,共写入 ${documents.length} 条要素文档` + : '索引构建完成,但当前项目下还没有可索引的要素文件', + } + } catch (error) { + await store.saveProjectMeta({ + projectId: project.id, + status: 'error', + documentCount: 0, + embeddingProvider, + embeddingModel, + embeddingDim: 0, + embeddingTextVersion: project.config.settings.embeddingTextVersion, + rerankProvider: project.config.rerank.baseUrl || undefined, + rerankModel: project.config.rerank.model || undefined, + lastBuildAt: new Date().toISOString(), + lastError: error instanceof Error ? error.message : '索引构建失败', + }) + + throw error + } +} + +export async function markProjectIndexStale( + projectId: string, + reason: string, +): Promise { + const store = createRagIndexStore() + const meta = await store.getProjectMeta(projectId) + + await store.saveProjectMeta({ + projectId, + status: 'stale', + documentCount: meta?.documentCount ?? 0, + embeddingProvider: meta?.embeddingProvider ?? '', + embeddingModel: meta?.embeddingModel ?? '', + embeddingDim: meta?.embeddingDim ?? 0, + embeddingTextVersion: meta?.embeddingTextVersion ?? 1, + rerankProvider: meta?.rerankProvider, + rerankModel: meta?.rerankModel, + lastBuildAt: meta?.lastBuildAt, + lastFullRebuildAt: meta?.lastFullRebuildAt, + lastError: reason, + }) +} + +function collectElementPaths(tree: TreeNode[], preferredPaths?: string[]) { + const source = new Set(preferredPaths ?? []) + const paths: string[] = [] + const stack = [...tree] + + while (stack.length > 0) { + const node = stack.shift() + + if (!node) { + continue + } + + if (node.kind === 'file' && node.path.startsWith('elements/') && node.name.endsWith('.md')) { + if (source.size === 0 || source.has(node.path)) { + paths.push(node.path) + } + continue + } + + if (node.children?.length) { + stack.unshift(...node.children) + } + } + + return paths +} + +function inferTypeFromPath(path: string) { + if (path.startsWith('elements/locations/')) { + return 'location' as const + } + + if (path.startsWith('elements/timeline/')) { + return 'timeline' as const + } + + if (path.startsWith('elements/plots/')) { + return 'plot' as const + } + + if (path.startsWith('elements/worldbuilding/')) { + return 'worldbuilding' as const + } + + return 'character' as const +} + +function inferNameFromPath(path: string) { + return path.split('/').pop()?.replace(/\.md$/i, '') ?? '未命名要素' +} + +function summarizeBody(body: string) { + return body + .trim() + .split('\n') + .find((line) => line.trim())?.trim() ?? '' +} + +function createStableElementId(path: string) { + return `element-${hashContent(path)}` +} + +function hashContent(content: string) { + let hash = 2166136261 + + for (let index = 0; index < content.length; index += 1) { + hash ^= content.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + + return `h${(hash >>> 0).toString(16)}` +} diff --git a/src/core/rag/rerank.ts b/src/core/rag/rerank.ts new file mode 100644 index 0000000..e652643 --- /dev/null +++ b/src/core/rag/rerank.ts @@ -0,0 +1,62 @@ +import { rerankCandidates, type RerankConnectionInput } from '../ai/rerank-client' + +import type { ProjectConfig } from '../../types/project' +import type { RetrievalCandidate, RerankResult } from '../../types/rag' + +export async function rerankRetrievalCandidates( + config: ProjectConfig, + query: string, + candidates: RetrievalCandidate[], +): Promise { + if (!config.rerank.enabled || candidates.length === 0) { + return candidates.slice(0, config.rerank.topN) + } + + try { + const result = await rerankWithConfig( + { + baseUrl: config.rerank.baseUrl, + apiKey: config.rerank.apiKey, + model: config.rerank.model, + }, + query, + candidates, + config.rerank.topN, + ) + + const scoreMap = new Map(result.items.map((item) => [item.id, item.score])) + + return candidates + .filter((candidate) => scoreMap.has(candidate.id)) + .map((candidate) => ({ + ...candidate, + rerankScore: scoreMap.get(candidate.id), + })) + .sort((left, right) => (right.rerankScore ?? 0) - (left.rerankScore ?? 0)) + } catch { + // 浏览器直连某些 Rerank 服务时可能被 CORS 拦截;这里自动降级为仅使用粗召回结果。 + return candidates.slice(0, config.rerank.topN) + } +} + +export async function rerankWithConfig( + connection: RerankConnectionInput, + query: string, + candidates: RetrievalCandidate[], + topN: number, +): Promise { + return rerankCandidates({ + ...connection, + query, + topN, + candidates: candidates.map((candidate) => ({ + id: candidate.id, + type: candidate.type, + name: candidate.name, + summary: candidate.summary, + retrievalText: candidate.retrievalText, + lastUpdatedChapter: candidate.lastUpdatedChapter, + relatedChapters: candidate.relatedChapters, + })), + }) +} diff --git a/src/core/rag/retrieval-text.ts b/src/core/rag/retrieval-text.ts new file mode 100644 index 0000000..1f0bb95 --- /dev/null +++ b/src/core/rag/retrieval-text.ts @@ -0,0 +1,34 @@ +import type { ElementDocument } from '../../types/elements' + +const MAX_TAGS = 10 +const MAX_RELATED_CHAPTERS = 20 + +const TYPE_LABELS = { + character: '人物', + location: '地点', + timeline: '时间线', + plot: '情节', + worldbuilding: '世界观', +} as const + +export function buildRetrievalText(element: ElementDocument) { + const lines = [ + formatLine('类型', TYPE_LABELS[element.frontmatter.type]), + formatLine('名称', element.frontmatter.name), + formatLine('摘要', element.frontmatter.summary), + formatLine('标签', element.frontmatter.tags.slice(0, MAX_TAGS).join('、')), + formatLine('最后更新章节', element.frontmatter.lastUpdatedChapter), + formatLine('相关章节', element.frontmatter.relatedChapters.slice(0, MAX_RELATED_CHAPTERS).join('、')), + ].filter(Boolean) + + if (element.body.trim()) { + lines.push('', '正文:', element.body.trim()) + } + + return lines.join('\n') +} + +function formatLine(label: string, value: string) { + const normalizedValue = value.trim() + return normalizedValue ? `${label}:${normalizedValue}` : '' +} diff --git a/src/core/rag/search.ts b/src/core/rag/search.ts new file mode 100644 index 0000000..e88b0e5 --- /dev/null +++ b/src/core/rag/search.ts @@ -0,0 +1,94 @@ +import { createEmbedding } from '../embedding/client' + +import type { ProjectConfig } from '../../types/project' +import type { RetrievalCandidate, RetrievalQuery, RetrievalResult } from '../../types/rag' + +import { createRagIndexStore } from './index-store' + +export async function searchRagCandidates( + query: RetrievalQuery, + config: ProjectConfig, +): Promise { + const store = createRagIndexStore() + const documents = await store.listProjectDocuments(query.projectId) + + if (documents.length === 0) { + return { + query: query.query, + candidates: [], + total: 0, + } + } + + const queryEmbedding = await createEmbedding({ + baseUrl: config.embedding.baseUrl, + apiKey: config.embedding.apiKey, + model: config.embedding.model, + text: query.query, + }) + + const candidates: RetrievalCandidate[] = documents + .filter((document) => matchesFilters(document, query)) + .map((document) => ({ + id: document.id, + projectId: document.projectId, + sourcePath: document.sourcePath, + type: document.type, + name: document.name, + summary: document.summary, + retrievalText: document.retrievalText, + tags: document.tags, + lastUpdatedChapter: document.lastUpdatedChapter, + relatedChapters: document.relatedChapters, + score: cosineSimilarity(queryEmbedding.vector, document.vector), + })) + .sort((left, right) => (right.score ?? 0) - (left.score ?? 0)) + .slice(0, query.topK) + + return { + query: query.query, + candidates, + total: candidates.length, + } +} + +function matchesFilters( + document: { type: RetrievalCandidate['type']; tags: string[]; lastUpdatedChapter: string }, + query: RetrievalQuery, +) { + if (query.filters?.type?.length && !query.filters.type.includes(document.type)) { + return false + } + + if (query.filters?.tags?.length && !query.filters.tags.some((tag) => document.tags.includes(tag))) { + return false + } + + if (query.filters?.lastUpdatedChapter && query.filters.lastUpdatedChapter !== document.lastUpdatedChapter) { + return false + } + + return true +} + +function cosineSimilarity(left: number[], right: number[]) { + if (left.length === 0 || right.length === 0 || left.length !== right.length) { + return 0 + } + + let dot = 0 + let leftNorm = 0 + let rightNorm = 0 + + for (let index = 0; index < left.length; index += 1) { + dot += left[index] * right[index] + leftNorm += left[index] * left[index] + rightNorm += right[index] * right[index] + } + + if (leftNorm === 0 || rightNorm === 0) { + return 0 + } + + return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)) +} diff --git a/src/stores/chat.ts b/src/stores/chat.ts new file mode 100644 index 0000000..11d0e42 --- /dev/null +++ b/src/stores/chat.ts @@ -0,0 +1,69 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import { createChatSession } from '../core/chat/session' +import { deriveChatTargetFromPath } from '../core/chat/target' + +import type { ChatSessionState, ChatTargetContext } from '../types/chat' + +export const useChatStore = defineStore('chat', () => { + const session = ref(null) + const runStatus = ref('还没有开始执行。') + const defaultTarget = ref(null) + + const hasSession = computed(() => session.value !== null) + const currentTarget = computed(() => session.value?.currentTarget ?? defaultTarget.value) + + function ensureSession(projectId: string) { + if (!session.value || session.value.projectId !== projectId) { + session.value = createChatSession(projectId) + session.value.currentTarget = defaultTarget.value + } + + return session.value + } + + function setSession(nextSession: ChatSessionState) { + session.value = nextSession + } + + function syncDefaultTarget(projectId?: string, activeFilePath?: string | null) { + defaultTarget.value = deriveChatTargetFromPath(activeFilePath) + + if (projectId) { + ensureSession(projectId) + } + + if (session.value) { + session.value.currentTarget = defaultTarget.value + } + } + + function resetSession(projectId?: string) { + session.value = projectId ? createChatSession(projectId) : null + if (session.value) { + session.value.currentTarget = defaultTarget.value + } + if (!projectId) { + defaultTarget.value = null + } + runStatus.value = '还没有开始执行。' + } + + function setRunStatus(nextStatus: string) { + runStatus.value = nextStatus + } + + return { + session, + runStatus, + hasSession, + currentTarget, + defaultTarget, + ensureSession, + setSession, + syncDefaultTarget, + resetSession, + setRunStatus, + } +}) diff --git a/src/types/ai.ts b/src/types/ai.ts new file mode 100644 index 0000000..70b8f0c --- /dev/null +++ b/src/types/ai.ts @@ -0,0 +1,27 @@ +export type ModelKind = 'llm' | 'embedding' + +export type ModelConnectionInput = { + baseUrl: string + apiKey: string + model?: string + kind: ModelKind +} + +export type ModelConnectionResult = { + ok: boolean + message: string +} + +export type LlmStreamEvent = + | { type: 'start' } + | { type: 'delta'; text: string } + | { type: 'finish'; text: string } + | { type: 'error'; message: string } + +export type LlmStreamInput = { + baseUrl: string + apiKey: string + model: string + systemPrompt?: string + instruction: string +} diff --git a/src/types/chat.ts b/src/types/chat.ts new file mode 100644 index 0000000..b413dc6 --- /dev/null +++ b/src/types/chat.ts @@ -0,0 +1,127 @@ +import type { ProjectConfig, ProjectSnapshot } from './project' +import type { RetrievalResult } from './rag' + +export type ChatToolName = 'FileRead' | 'FileWrite' | 'FileEdit' | 'Bash' | 'RagSearch' + +export type UserTextMessage = { + id: string + role: 'user' + kind: 'text' + text: string + createdAt: string +} + +export type AssistantTextMessage = { + id: string + role: 'assistant' + kind: 'text' + text: string + createdAt: string +} + +export type AssistantActionSummaryMessage = { + id: string + role: 'assistant' + kind: 'action-summary' + summary: string + targetPath?: string + relatedPaths?: string[] + createdAt: string +} + +export type ToolCallMessage = { + id: string + role: 'system' + kind: 'tool-call' + toolName: ChatToolName + inputSummary: string + createdAt: string +} + +export type ToolResultMessage = { + id: string + role: 'system' + kind: 'tool-result' + toolName: ChatToolName + ok: boolean + resultSummary: string + createdAt: string +} + +export type ErrorMessage = { + id: string + role: 'system' + kind: 'error' + message: string + recoverable: boolean + createdAt: string +} + +export type ContextSummaryMessage = { + id: string + role: 'system' + kind: 'context-summary' + summary: string + createdAt: string +} + +export type ChatMessage = + | UserTextMessage + | AssistantTextMessage + | AssistantActionSummaryMessage + | ToolCallMessage + | ToolResultMessage + | ErrorMessage + | ContextSummaryMessage + +export type ChatTargetContext = { + type: 'chapter' | 'prompt-system' | 'prompt-scene' | 'element' | 'project' + primaryPath?: string + groupName?: string + displayName: string + derivedFrom: 'preview' | 'selection' | 'explicit-user-intent' +} + +export type ChatSessionStatus = 'idle' | 'running' | 'waiting-user' | 'error' + +export type ChatSessionState = { + sessionId: string + projectId: string + messages: ChatMessage[] + status: ChatSessionStatus + currentDraftText: string + currentTarget: ChatTargetContext | null + lastRagResult: RetrievalResult | null + lastWrittenPath?: string + lastTaskType?: 'read-only' | 'edit-target' | 'create-chapter' +} + +export type ToolRuntimeContext = { + project: ProjectSnapshot + config: ProjectConfig + target: ChatTargetContext | null + session: ChatSessionState +} + +export type ToolDefinition = { + name: ChatToolName + description: string + validateInput: (input: unknown) => TInput + call: (input: TInput, context: ToolRuntimeContext) => Promise + summarizeInput: (input: TInput) => string + summarizeOutput: (output: TOutput) => string +} + +export type ChatTurnInput = { + instruction: string + project: ProjectSnapshot + config: ProjectConfig + systemPrompt: string + activeFilePath?: string | null +} + +export type ChatTurnResult = { + session: ChatSessionState + target: ChatTargetContext | null + writtenPath?: string +} diff --git a/src/types/elements.ts b/src/types/elements.ts new file mode 100644 index 0000000..74e2852 --- /dev/null +++ b/src/types/elements.ts @@ -0,0 +1,42 @@ +import type { ElementType } from './rag' + +export type ElementFrontmatter = { + id: string + type: ElementType + name: string + summary: string + tags: string[] + lastUpdatedChapter: string + relatedChapters: string[] + updatedAt: string +} + +export type ElementDocument = { + frontmatter: ElementFrontmatter + body: string + sourcePath: string +} + +export type ElementExtractionItem = { + type: ElementType + name: string + summary: string + tags: string[] + lastUpdatedChapter: string + relatedChapters: string[] + body: string +} + +export type ElementExtractionResult = { + characters: ElementExtractionItem[] + locations: ElementExtractionItem[] + timeline: ElementExtractionItem[] + plots: ElementExtractionItem[] + worldbuilding: ElementExtractionItem[] +} + +export type ElementWriteResult = { + created: string[] + updated: string[] + skipped: string[] +} diff --git a/src/types/project.ts b/src/types/project.ts index 8f54669..e9a527f 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -32,13 +32,24 @@ export type ProjectConfig = { apiKey: string model: string } + rerank: { + enabled: boolean + baseUrl: string + apiKey: string + model: string + mode: 'text' | 'multimodal' + topN: number + } settings: { generationRecentChapters: number ragCandidateLimit: number + ragContextMaxItems: number proofreadDefaultChapters: number organizeDefaultChapters: number conversationTokenLimit: number compressionKeepRecentTurns: number + embeddingTextVersion: number + enableBackgroundIndexing: boolean } } @@ -49,6 +60,23 @@ export type ProjectManifest = { lastOpenedAt: string } +export type ProjectIssue = + | 'missing-config' + | 'invalid-config' + | 'missing-manifest' + | 'invalid-manifest' + | 'missing-prompts-system' + | 'missing-prompts-scenes' + | 'missing-chapters' + | 'missing-elements' + | 'missing-internal-directory' + +export type ProjectInspection = { + rootName: string + issues: ProjectIssue[] + canLoad: boolean +} + export type ProjectSnapshot = { id: string name: string diff --git a/src/types/rag.ts b/src/types/rag.ts new file mode 100644 index 0000000..9d118c7 --- /dev/null +++ b/src/types/rag.ts @@ -0,0 +1,141 @@ +export type ElementType = 'character' | 'location' | 'timeline' | 'plot' | 'worldbuilding' + +export type IndexStatus = 'empty' | 'building' | 'ready' | 'stale' | 'rebuilding' | 'error' + +export type IndexedElementDocument = { + id: string + projectId: string + sourcePath: string + type: ElementType + name: string + summary: string + retrievalText: string + vector: number[] + lastUpdatedChapter: string + relatedChapters: string[] + tags: string[] + sourceModifiedAt: string + indexedAt: string + contentHash: string + embeddingProvider: string + embeddingModel: string + embeddingDim: number + embeddingTextVersion: number +} + +export type ProjectIndexMeta = { + projectId: string + status: IndexStatus + documentCount: number + embeddingProvider: string + embeddingModel: string + embeddingDim: number + embeddingTextVersion: number + rerankProvider?: string + rerankModel?: string + lastBuildAt?: string + lastFullRebuildAt?: string + lastError?: string +} + +export type RetrievalCandidate = { + id: string + projectId: string + sourcePath: string + type: ElementType + name: string + summary: string + retrievalText: string + tags: string[] + lastUpdatedChapter: string + relatedChapters: string[] + score?: number + rerankScore?: number +} + +export type RetrievalQuery = { + projectId: string + query: string + topK: number + filters?: { + type?: ElementType[] + tags?: string[] + lastUpdatedChapter?: string + } +} + +export type RetrievalResult = { + query: string + candidates: RetrievalCandidate[] + total: number +} + +export type RerankInput = { + query: string + candidates: Array<{ + id: string + type: string + name: string + summary: string + retrievalText: string + lastUpdatedChapter: string + relatedChapters: string[] + }> + topN: number +} + +export type RerankResultItem = { + id: string + score: number +} + +export type RerankResult = { + items: RerankResultItem[] + model?: string +} + +export type GenerationContextDraft = { + query: string + recentChapters: Array<{ + path: string + title: string + content: string + }> + retrievedCandidates: RetrievalCandidate[] + rerankedCandidates: RetrievalCandidate[] + finalContextItems: RetrievalCandidate[] +} + +export type IndexBuildReason = + | 'initial-build' + | 'incremental-update' + | 'full-rebuild' + | 'model-changed' + | 'template-upgraded' + | 'manual-rebuild' + +export type IndexBuildRequest = { + projectId: string + reason: IndexBuildReason + sourcePaths?: string[] +} + +export type IndexBuildResult = { + projectId: string + status: IndexStatus + indexedCount: number + skippedCount: number + failedCount: number + message: string +} + +export type RetrievalExplanation = { + id: string + name: string + type: ElementType + summary: string + sourcePath: string + selectedBy: 'recall' | 'rerank' | 'final-context' + reason: string + lastUpdatedChapter: string +} diff --git a/src/views/SessionTestView.vue b/src/views/SessionTestView.vue new file mode 100644 index 0000000..493b01f --- /dev/null +++ b/src/views/SessionTestView.vue @@ -0,0 +1,414 @@ + + +