From ddbc7bcae23b541c17c92fdede9353a6154c427a Mon Sep 17 00:00:00 2001 From: honlnk Date: Sat, 4 Apr 2026 00:12:01 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(test):=20=E6=89=93=E9=80=9A=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=B5=8B=E8=AF=95=E9=A1=B5=E4=B8=8EAI=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/ai/shared.ts | 44 +++++ src/core/embedding/client.ts | 41 +++++ src/core/fs/project-fs.ts | 228 +++++++++++++++++++++++ src/core/llm/client.ts | 225 +++++++++++++++++++++++ src/types/ai.ts | 27 +++ src/types/project.ts | 17 ++ src/views/TestLabView.vue | 339 ++++++++++++++++++++++++++++++++--- 7 files changed, 898 insertions(+), 23 deletions(-) create mode 100644 src/core/ai/shared.ts create mode 100644 src/core/embedding/client.ts create mode 100644 src/core/llm/client.ts create mode 100644 src/types/ai.ts diff --git a/src/core/ai/shared.ts b/src/core/ai/shared.ts new file mode 100644 index 0000000..c4c9a27 --- /dev/null +++ b/src/core/ai/shared.ts @@ -0,0 +1,44 @@ +export function normalizeBaseUrl(baseUrl: string) { + return baseUrl.trim().replace(/\/+$/, '') +} + +export function createJsonHeaders(apiKey: string) { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey.trim()}`, + } +} + +export async function readJsonResponse(response: Response) { + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('application/json')) { + return response.json() + } + + return response.text() +} + +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 +} diff --git a/src/core/embedding/client.ts b/src/core/embedding/client.ts new file mode 100644 index 0000000..291bde0 --- /dev/null +++ b/src/core/embedding/client.ts @@ -0,0 +1,41 @@ +import { createJsonHeaders, extractErrorMessage, normalizeBaseUrl, readJsonResponse } from '../ai/shared' + +import type { ModelConnectionInput, ModelConnectionResult } from '../../types/ai' + +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 { + const response = await fetch(`${baseUrl}/models`, { + method: 'GET', + headers: createJsonHeaders(input.apiKey), + }) + + 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 测试连接失败', + } + } +} diff --git a/src/core/fs/project-fs.ts b/src/core/fs/project-fs.ts index ef61b20..87d067e 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,6 +12,18 @@ 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 @@ -43,6 +56,98 @@ export async function openProject(): Promise { return loadProjectFromHandle(rootHandle) } +export async function pickProjectDirectory() { + return window.showDirectoryPicker({ mode: 'readwrite' }) +} + +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, + } +} + +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') @@ -78,6 +183,57 @@ export async function rescanProject(snapshot: ProjectSnapshot): Promise { + return readJson(rootHandle, 'novel.config.json') +} + +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 +} + +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) +} + +export async function readSystemPrompt(rootHandle: FileSystemDirectoryHandle) { + return readText(rootHandle, 'prompts/system.md') +} + +export async function writeSystemPrompt(rootHandle: FileSystemDirectoryHandle, content: string) { + await writeText(rootHandle, 'prompts/system.md', content) +} + +export async function writeChapterFile( + rootHandle: FileSystemDirectoryHandle, + fileName: string, + markdown: string, +) { + const normalizedName = normalizeChapterFileName(fileName) + await writeText(rootHandle, `chapters/${normalizedName}`, markdown) + return normalizedName +} + export function findFirstReadableFile(tree: TreeNode[]): string | null { const stack = [...tree] @@ -112,6 +268,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 = '', @@ -181,6 +356,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 +454,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..66a166d --- /dev/null +++ b/src/core/llm/client.ts @@ -0,0 +1,225 @@ +import { createJsonHeaders, extractErrorMessage, normalizeBaseUrl, readJsonResponse } from '../ai/shared' + +import type { + LlmStreamEvent, + LlmStreamInput, + ModelConnectionInput, + ModelConnectionResult, +} from '../../types/ai' + +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), + }) + + 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 测试连接失败', + } + } +} + +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(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: createJsonHeaders(input.apiKey), + 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 }) + 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)) { + 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/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/project.ts b/src/types/project.ts index 8f54669..a0ca29c 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -49,6 +49,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/views/TestLabView.vue b/src/views/TestLabView.vue index b45d672..8087104 100644 --- a/src/views/TestLabView.vue +++ b/src/views/TestLabView.vue @@ -1,32 +1,257 @@ @@ -35,10 +260,68 @@ function mockRun() {

NovAI Test Lab

这个页面只用来测试 AI 功能,不做正式 UI。

{{ status }}

+

当前项目:{{ projectLabel }}

+ +
+

项目入口

+ +

+ 目录检查结果: + {{ inspection ? (inspection.canLoad ? '可直接打开' : inspection.issues.join('、')) : '还未选择目录' }} +

+ + + +
+ +
+

配置读写

+ + + + + + + + +
{{ configPreview }}
+
+ +
+

连接测试

+

{{ connectionStatus }}

+ + +

System Prompt