diff --git a/INTERFACE.md b/INTERFACE.md new file mode 100644 index 0000000..ad712be --- /dev/null +++ b/INTERFACE.md @@ -0,0 +1,335 @@ +# NovAI UI 接口说明 + +最后更新:2026-05-01 + +本文档说明当前 NovAI 代码中 UI 层应如何使用项目能力。它面向前端协作者,重点回答三个问题: + +- UI 应该依赖哪些入口? +- 当前有哪些可用接口? +- 哪些接口还只是第一版,需要后续继续收敛? + +## 当前结论 + +当前重构已经可以作为第一版 UI 协作接口使用。 + +约定如下: + +- UI 页面优先使用 `src/stores/*`。 +- Store 通过 `src/services/*` 调用 core 能力。 +- 页面和组件不要直接 import `src/core/*`。 +- UI 不直接持有 `FileSystemDirectoryHandle` 或 `ProjectSnapshot`。 +- 项目、文件、会话、Agent 事件都通过 `src/services/types.ts` 中的 view type 暴露。 + +当前已经完成迁移验证: + +- `src/views/SessionTestView.vue` +- `src/views/TestLabView.vue` + +## 推荐调用路径 + +常规 UI 使用: + +```txt +Vue Component +-> Pinia Store +-> Service +-> Core +``` + +调试页或底层验证可以直接使用 service: + +```txt +Vue Component +-> Service +-> Core +``` + +正式 UI 建议优先从这三个 store 开始: + +```ts +import { useProjectStore } from '../stores/project' +import { useSettingsStore } from '../stores/settings' +import { useChatStore } from '../stores/chat' +``` + +## Store 入口 + +### `useProjectStore` + +负责项目和文件预览状态。 + +常用状态: + +```ts +currentProject +activeFile +lastProjectSummary +isBusy +errorMessage +statusMessage +isFileSystemSupported +``` + +常用动作: + +```ts +createNewProject(projectName) +openExistingProject() +restoreLastOpenedProject() +loadLastProjectSummary() +forgetLastOpenedProject() +closeCurrentProject() +openFile(path) +refreshTree() +updateCurrentProjectConfig(config) +``` + +### `useSettingsStore` + +负责配置、系统提示词和模型连接测试。 + +常用状态: + +```ts +config +systemPrompt +lastConnectionTest +isBusy +errorMessage +statusMessage +``` + +常用动作: + +```ts +loadSettings(projectId) +saveConfig(projectId, patch) +saveSystemPrompt(projectId, content) +testLlmConfig(config) +testEmbeddingConfig(config) +testRerankConfig(config) +resetSettings() +``` + +### `useChatStore` + +负责 Agent 会话展示、运行状态、工具轨迹和文件变更。 + +常用状态: + +```ts +sessionView +agentEvents +changedFiles +runStatus +currentTarget +defaultTarget +``` + +常用动作: + +```ts +ensureSessionView(projectId) +runServiceTurn(input) +syncDefaultTarget(projectId, activeFilePath) +resetSession(projectId?) +setRunStatus(status) +``` + +发送一轮 Agent 指令: + +```ts +const result = await chatStore.runServiceTurn({ + projectId, + instruction, + activeFilePath, +}) +``` + +## Service 入口 + +所有 service 都在 `src/services/` 下。 + +| 文件 | 职责 | +|:-----|:-----| +| `project-service.ts` | 项目创建、打开、恢复、关闭、刷新、检查 | +| `file-service.ts` | 文件树、文件读取、章节写入、刷新 | +| `settings-service.ts` | 配置读写、system prompt、模型连接测试 | +| `agent-service.ts` | Agent 会话、运行一轮指令、事件映射 | +| `generation-service.ts` | 流式生成调试接口 | +| `rag-service.ts` | 索引状态、索引重建、RAG 调试 | +| `element-service.ts` | 要素提取预览 | +| `project-runtime.ts` | service 内部运行时项目注册表 | +| `mappers.ts` | core 内部结构到 UI view type 的映射 | +| `types.ts` | UI 协作接口类型 | + +常规 UI 不需要直接使用 `project-runtime.ts` 或 `mappers.ts`。 + +## 核心 View Type + +核心接口类型统一放在: + +```txt +src/services/types.ts +``` + +常用类型: + +```ts +ProjectView +ProjectFileNodeView +ProjectConfigView +ProjectConfigPatch +FileContentView +ChatSessionView +ChatMessageView +AgentUiEvent +ChangedFileView +RunAgentTurnInput +RunAgentTurnResult +NovAiError +``` + +### 文件变更 + +当前 Agent 运行结果中的文件变更统一使用: + +```ts +type ChangedFileView = + | { type: 'created'; path: string } + | { type: 'updated'; path: string } + | { type: 'renamed'; fromPath: string; toPath: string } + | { type: 'deleted'; path: string; trashPath?: string } +``` + +注意:当前 `changedFiles` 仍由 `agent-service` 根据工具调用和工具结果文本保守推导。后续应改为由 core 工具层直接返回结构化变更结果。 + +## Agent 事件 + +Agent 运行时会通过 `AgentUiEvent` 向 UI 回传事件: + +```ts +type AgentUiEvent = + | { type: 'run-start'; runId: string; sessionId: string } + | { type: 'message'; message: ChatMessageView } + | { type: 'assistant-delta'; text: string; fullText: string } + | { type: 'model-start'; step: number } + | { type: 'model-finish'; step: number; toolCallCount: number; finishReason?: string } + | { type: 'tool-call'; toolCall: ToolCallView } + | { type: 'tool-result'; toolResult: ToolResultView } + | { type: 'file-changed'; file: ChangedFileView } + | { type: 'confirmation-required'; request: FileChangeConfirmationView } + | { type: 'run-error'; error: NovAiError } + | { type: 'run-finish'; result: RunAgentTurnResult } +``` + +当前已可用于展示: + +- 用户消息 +- 上下文摘要 +- assistant 流式输出 +- 工具调用记录 +- 工具结果记录 +- 文件变更 +- 运行完成 / 运行错误 + +当前预留但尚未完整落地: + +- `confirmation-required` +- `model-start` +- `model-finish` +- `stopRun` + +## 项目打开流程 + +推荐 UI 流程: + +```ts +const projectStore = useProjectStore() +const settingsStore = useSettingsStore() +const chatStore = useChatStore() + +const project = await projectStore.openExistingProject() + +if (project) { + await settingsStore.loadSettings(project.id) + chatStore.resetSession(project.id) + chatStore.syncDefaultTarget(project.id, projectStore.activeFile?.path) +} +``` + +说明: + +- `openExistingProject()` 内部会选择目录并打开项目。 +- 当前打开项目时会温和补齐默认结构。 +- UI 不需要直接调用 `repairProject()`。 + +## 文件预览流程 + +```ts +await projectStore.openFile(path) +chatStore.syncDefaultTarget(project.id, projectStore.activeFile?.path) +``` + +文件路径统一使用项目内相对路径,例如: + +```txt +chapters/chapter-001.md +prompts/system.md +elements/characters/foo.md +``` + +## Agent 执行后刷新文件 + +推荐在 Agent 执行完成后根据 `changedFiles` 刷新文件树: + +```ts +const result = await chatStore.runServiceTurn({ + projectId, + instruction, + activeFilePath, +}) + +await projectStore.refreshTree() +``` + +如果需要打开最近变更文件,按如下规则取路径: + +```ts +function resolvePreferredPath(changes: ChangedFileView[]) { + const lastChange = changes[changes.length - 1] + + if (!lastChange) return undefined + if (lastChange.type === 'created' || lastChange.type === 'updated') return lastChange.path + if (lastChange.type === 'renamed') return lastChange.toPath + return undefined +} +``` + +## 当前限制 + +这套接口已经可以开始 UI 协作,但仍是第一版: + +- Agent 停止运行尚未实现。 +- 写工具执行前确认尚未实现。 +- 文件 diff 预览尚未实现。 +- `changedFiles` 仍是 service 层推导,不是工具层结构化输出。 +- Agent 会话还没有持久化。 +- RAG 调试接口已存在,但 `RagSearch` 尚未作为正式 Agent 工具接入。 +- 要素提取当前仍是预览/占位能力。 + +## 协作规则 + +为了减少多人协作冲突,建议遵守: + +- UI 页面只依赖 stores 和 services。 +- 新 UI 组件不要直接 import `src/core/*`。 +- 新增 UI 需要的数据,优先扩展 `services/types.ts` 和 mapper。 +- core 内部结构变化时,尽量只修改 service / mapper,不让页面跟着改。 +- 如果需要新增业务动作,优先新增 service,再由 store 适配 UI 状态。 + +更详细的设计记录见: + +```txt +docs/UI协作接口契约设计.md +``` diff --git a/README.md b/README.md index 3a433de..04528a7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ NovAI 的思路是换一条路: ## 当前状态 -项目目前处于 MVP 早期实现阶段,正在优先验证 AI 最小可用创作闭环。 +项目目前处于 MVP 早期实现阶段,正在优先验证 Claude Code 风格的文件工具 Agent Loop。 当前仓库已经完成的内容主要包括: @@ -38,10 +38,17 @@ NovAI 的思路是换一条路: - `prompts/system.md` 读取与保存 - 章节文件写入 `chapters/` - 测试页中的项目文档分组与原文预览 +- `ReadFile / EditFile / CreateFile / RenameFile / DeleteFile / ListDirectory / FindFiles` 项目文件工具 +- OpenAI-compatible `tools / tool_calls` 解析 +- 工具结果回灌模型后的多轮 Query Loop +- 项目级 JSONL 日志 `.novel/logs/agent.log.jsonl` +- 最近项目记忆与刷新后恢复 - 项目规划、需求说明、UI 设计、技术架构等文档整理 尚未完整落地的核心能力包括: +- 写工具执行前确认 +- `readFileState` 与文件过期写入保护 - 要素抽取 - Embedding 向量化与 RAG 检索 - 近期章节上下文拼装 @@ -52,7 +59,7 @@ NovAI 的思路是换一条路: 当前希望优先打通这条最小闭环: -`创建项目 -> 配置模型 -> 输入指令 -> 生成章节 -> 写入文件 -> 提取要素 -> 下次生成可复用要素` +`创建项目 -> 配置模型 -> 输入指令 -> Agent 读取/修改文件 -> 写入文件 -> 提取要素 -> 下次生成可复用要素` 如果这条链路成立,就能验证 NovAI 最核心的产品判断: @@ -79,7 +86,9 @@ NovAI 采用“一个文件夹就是一个小说项目”的思路。当前默 │ └── scenes/ │ └── scene-001.md └── .novel/ - └── manifest.json + ├── manifest.json + └── logs/ + └── agent.log.jsonl ``` 这样做的好处是: @@ -113,9 +122,12 @@ NovAI 采用“一个文件夹就是一个小说项目”的思路。当前默 - 编辑并保存项目配置 - 测试 LLM / Embedding 连通性 - 发起流式生成 +- 通过 Agent Loop 调用文件工具 - 保存 SYSTEM Prompt - 保存生成章节 - 浏览项目中的 Markdown / JSON / 文本文档原文 +- 自动恢复最近打开的项目 +- 记录项目生命周期、模型调用和工具调用日志 ## 本地开发 @@ -153,6 +165,7 @@ pnpm typecheck - [`docs/project/项目总览.md`](./docs/project/项目总览.md):项目阶段与目标 - [`docs/project/MVP清单.md`](./docs/project/MVP清单.md):MVP 范围与执行顺序 - [`docs/project/当前进度.md`](./docs/project/当前进度.md):开发推进情况 +- [`INTERFACE.md`](./INTERFACE.md):当前 UI 协作接口、stores/services 使用入口 ## 仓库目标 diff --git a/docs b/docs index 16c52ec..ac57a0a 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 16c52ecb52101d784680adb7e6307e09c642d203 +Subproject commit ac57a0a709d33f26462c191edb6cb454791acbad diff --git a/package.json b/package.json index b2bb32f..866b3c4 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,21 @@ { - "name": "novai", + "name": "novai-workspace", "private": true, - "version": "0.0.0", "packageManager": "pnpm@10.21.0", "type": "module", "scripts": { - "dev": "vite", - "build": "vue-tsc --noEmit && vite build", + "dev": "pnpm --filter @novai/app dev", + "build": "pnpm --filter @novai/app build", + "typecheck": "tsc --build", "commitlint": "commitlint --edit", - "prepare": "husky", - "typecheck": "vue-tsc --noEmit", - "preview": "vite preview" - }, - "dependencies": { - "pinia": "^3.0.4", - "vue": "^3.5.13", - "vue-router": "^5.0.4" + "prepare": "husky" }, "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", - "@tailwindcss/vite": "^4.2.2", - "@vitejs/plugin-vue": "^5.2.1", "commitlint-plugin-function-rules": "^4.3.2", "husky": "^9.1.7", - "sass": "^1.98.0", - "tailwindcss": "^4.2.2", "typescript": "^6.0.2", - "vite": "^6.2.0", - "vue-tsc": "^3.2.6" + "vite": "^6.2.0" } } diff --git a/index.html b/packages/app/index.html similarity index 100% rename from index.html rename to packages/app/index.html diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000..c5bd55c --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,27 @@ +{ + "name": "@novai/app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "typecheck": "vue-tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@novai/core": "workspace:*", + "pinia": "^3.0.4", + "vue": "^3.5.13", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@vitejs/plugin-vue": "^5.2.1", + "sass": "^1.98.0", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^6.2.0", + "vue-tsc": "^3.2.6" + } +} diff --git a/public/404.html b/packages/app/public/404.html similarity index 100% rename from public/404.html rename to packages/app/public/404.html diff --git a/src/App.vue b/packages/app/src/App.vue similarity index 100% rename from src/App.vue rename to packages/app/src/App.vue diff --git a/src/app/router.ts b/packages/app/src/app/router.ts similarity index 100% rename from src/app/router.ts rename to packages/app/src/app/router.ts diff --git a/src/env.d.ts b/packages/app/src/env.d.ts similarity index 100% rename from src/env.d.ts rename to packages/app/src/env.d.ts diff --git a/src/main.ts b/packages/app/src/main.ts similarity index 100% rename from src/main.ts rename to packages/app/src/main.ts diff --git a/packages/app/src/stores/chat.ts b/packages/app/src/stores/chat.ts new file mode 100644 index 0000000..c820f4e --- /dev/null +++ b/packages/app/src/stores/chat.ts @@ -0,0 +1,149 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import { + createSession as createAgentSession, + deriveTargetFromPath, + runTurn as runAgentTurn, +} from '@novai/core/services/agent-service' + +import type { + AgentUiEvent, + ChangedFileView, + ChatSessionView, + ChatTargetView, + RunAgentTurnInput, + RunAgentTurnResult, +} from '@novai/core/services/types' + +export const useChatStore = defineStore('chat', () => { + const sessionView = ref(null) + const agentEvents = ref([]) + const changedFiles = ref([]) + const runStatus = ref('还没有开始执行。') + const defaultTarget = ref(null) + + const hasSessionView = computed(() => sessionView.value !== null) + const currentTarget = computed(() => defaultTarget.value) + + async function ensureSessionView(projectId: string) { + if (!sessionView.value || sessionView.value.projectId !== projectId) { + sessionView.value = await createAgentSession(projectId) + } + + return sessionView.value + } + + async function runServiceTurn( + input: Omit & { + onEvent?: (event: AgentUiEvent) => void + }, + ): Promise { + const currentSession = await ensureSessionView(input.projectId) + + agentEvents.value = [] + changedFiles.value = [] + runStatus.value = '正在执行本轮 Agent...' + + try { + const result = await runAgentTurn({ + ...input, + sessionId: currentSession.sessionId, + onEvent(event) { + handleAgentEvent(event) + input.onEvent?.(event) + }, + }) + + sessionView.value = result.session + changedFiles.value = result.changedFiles + runStatus.value = result.changedFiles.length > 0 + ? `本轮执行完成,变更 ${result.changedFiles.length} 个文件` + : '本轮执行完成,未修改任何文件' + + return result + } catch (error) { + runStatus.value = error instanceof Error ? error.message : '执行会话失败' + throw error + } + } + + function syncDefaultTarget(projectId?: string, activeFilePath?: string | null) { + void projectId + defaultTarget.value = deriveTargetFromPath(activeFilePath) + } + + function resetSession(projectId?: string) { + sessionView.value = null + agentEvents.value = [] + changedFiles.value = [] + if (!projectId) { + defaultTarget.value = null + } + runStatus.value = '还没有开始执行。' + } + + function setRunStatus(nextStatus: string) { + runStatus.value = nextStatus + } + + function handleAgentEvent(event: AgentUiEvent) { + if (event.type !== 'assistant-delta') { + agentEvents.value = [...agentEvents.value, event] + } + + if (event.type === 'run-start') { + runStatus.value = 'Agent 正在执行...' + return + } + + if (event.type === 'message' && sessionView.value) { + sessionView.value = { + ...sessionView.value, + messages: [...sessionView.value.messages, event.message], + } + return + } + + if (event.type === 'assistant-delta' && sessionView.value) { + sessionView.value = { + ...sessionView.value, + currentDraftText: event.fullText, + } + return + } + + if (event.type === 'file-changed') { + changedFiles.value = [...changedFiles.value, event.file] + return + } + + if (event.type === 'run-error') { + runStatus.value = event.error.message + return + } + + if (event.type === 'run-finish') { + sessionView.value = event.result.session + changedFiles.value = event.result.changedFiles + runStatus.value = event.result.changedFiles.length > 0 + ? `本轮执行完成,变更 ${event.result.changedFiles.length} 个文件` + : '本轮执行完成,未修改任何文件' + } + } + + return { + agentEvents, + changedFiles, + sessionView, + runStatus, + hasSessionView, + currentTarget, + defaultTarget, + ensureSessionView, + runServiceTurn, + syncDefaultTarget, + resetSession, + setRunStatus, + } +}) diff --git a/packages/app/src/stores/project.ts b/packages/app/src/stores/project.ts new file mode 100644 index 0000000..16db127 --- /dev/null +++ b/packages/app/src/stores/project.ts @@ -0,0 +1,252 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import { + closeProject, + createProject, + forgetLastProject, + getLastProjectSummary, + isProjectAccessSupported, + openProject, + restoreLastProject, +} from '@novai/core/services/project-service' +import { + readFile, + refreshFiles, +} from '@novai/core/services/file-service' +import type { + FileContentView, + LastProjectSummaryView, + ProjectConfigView, + ProjectFileNodeView, + ProjectView, +} from '@novai/core/services/types' +import type { RecentProject } from '@novai/core/types/project' + +export const useProjectStore = defineStore('project', () => { + const currentProject = ref(null) + const recentProjects = ref([]) + const activeFile = ref(null) + const lastProjectSummary = ref(null) + const isBusy = ref(false) + const errorMessage = ref('') + const statusMessage = ref('等待选择小说项目') + + const isReady = computed(() => currentProject.value !== null) + const isFileSystemSupported = computed(() => isProjectAccessSupported()) + + async function createNewProject(projectName: string) { + return runProjectAction(async () => { + const project = await createProject(projectName) + await setCurrentProject(project) + statusMessage.value = `已创建项目「${project.name}」` + return project + }) + } + + async function openExistingProject() { + return runProjectAction(async () => { + const project = await openProject() + await setCurrentProject(project) + statusMessage.value = `已打开项目「${project.name}」` + return project + }) + } + + async function restoreLastOpenedProject() { + return runProjectAction(async () => { + const project = await restoreLastProject() + + if (!project) { + statusMessage.value = '没有可恢复的上次项目,或目录权限尚未授权' + return null + } + + await setCurrentProject(project) + statusMessage.value = `已恢复上次项目「${project.name}」` + return project + }) + } + + async function loadLastProjectSummary() { + try { + lastProjectSummary.value = await getLastProjectSummary() + return lastProjectSummary.value + } catch (error) { + errorMessage.value = toMessage(error, '读取最近项目记录失败') + return null + } + } + + async function forgetLastOpenedProject() { + return runProjectAction(async () => { + await forgetLastProject() + lastProjectSummary.value = null + statusMessage.value = '已忘记上次项目记录' + }) + } + + async function closeCurrentProject() { + if (!currentProject.value) { + statusMessage.value = '当前没有打开的项目' + return + } + + const project = currentProject.value + + await runProjectAction(async () => { + await closeProject(project.id) + currentProject.value = null + activeFile.value = null + statusMessage.value = `已关闭项目「${project.name}」` + }) + } + + async function openFile(path: string) { + if (!currentProject.value) { + return null + } + + errorMessage.value = '' + + try { + activeFile.value = await readFile(currentProject.value.id, path) + return activeFile.value + } catch (error) { + errorMessage.value = toMessage(error, '读取文件失败') + return null + } + } + + async function refreshTree() { + if (!currentProject.value) { + return + } + + errorMessage.value = '' + + try { + currentProject.value = { + ...currentProject.value, + files: await refreshFiles(currentProject.value.id), + } + statusMessage.value = '文件树已刷新' + } catch (error) { + errorMessage.value = toMessage(error, '刷新文件树失败') + } + } + + async function setCurrentProject(project: ProjectView) { + currentProject.value = project + recentProjects.value = [ + toRecentProject(project), + ...recentProjects.value.filter((item) => item.id !== project.id), + ].slice(0, 8) + + const firstFilePath = project.activeFilePath ?? findFirstReadableFile(project.files) + if (firstFilePath) { + await openFile(firstFilePath) + } else { + activeFile.value = null + } + } + + function updateCurrentProjectConfig(config: ProjectConfigView) { + if (!currentProject.value) { + return + } + + currentProject.value = { + ...currentProject.value, + name: config.project.name || currentProject.value.rootName, + config, + } + } + + async function runProjectAction(action: () => Promise) { + errorMessage.value = '' + isBusy.value = true + + try { + return await action() + } catch (error) { + errorMessage.value = toMessage(error, '项目操作失败') + return null + } finally { + isBusy.value = false + } + } + + return { + activeFile, + currentProject, + errorMessage, + isBusy, + isFileSystemSupported, + isReady, + lastProjectSummary, + recentProjects, + statusMessage, + closeCurrentProject, + createNewProject, + forgetLastOpenedProject, + loadLastProjectSummary, + openExistingProject, + openFile, + refreshTree, + restoreLastOpenedProject, + updateCurrentProjectConfig, + } +}) + +function toMessage(error: unknown, fallback: string) { + if (error instanceof Error) { + return error.message + } + + return fallback +} + +function findFirstReadableFile(tree: ProjectFileNodeView[]): string | null { + const stack = [...tree] + + while (stack.length > 0) { + const current = stack.shift() + + if (!current) { + continue + } + + if (current.kind === 'file' && /\.(md|json|txt)$/i.test(current.name)) { + return current.path + } + + if (current.children?.length) { + stack.unshift(...current.children) + } + } + + return null +} + +function toRecentProject(project: ProjectView): RecentProject { + return { + id: project.id, + name: project.name, + updatedAt: project.config.project.updatedAt, + chapterCount: countChapterFiles(project.files), + wordCount: 0, + } +} + +function countChapterFiles(nodes: ProjectFileNodeView[]): number { + return nodes.reduce((total, node) => { + if (node.kind === 'file') { + return node.path.startsWith('chapters/') && node.name.endsWith('.md') + ? total + 1 + : total + } + + return total + countChapterFiles(node.children ?? []) + }, 0) +} diff --git a/packages/app/src/stores/settings.ts b/packages/app/src/stores/settings.ts new file mode 100644 index 0000000..62cd6d3 --- /dev/null +++ b/packages/app/src/stores/settings.ts @@ -0,0 +1,135 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +import { + getConfig, + readSystemPrompt, + testEmbedding, + testLlm, + testRerank, + updateConfig, + writeSystemPrompt, +} from '@novai/core/services/settings-service' +import type { + ConnectionTestResultView, + EmbeddingConfigView, + LlmConfigView, + ProjectConfigPatch, + ProjectConfigView, + RerankConfigView, +} from '@novai/core/services/types' + +export const useSettingsStore = defineStore('settings', () => { + const config = ref(null) + const systemPrompt = ref('') + const isBusy = ref(false) + const errorMessage = ref('') + const statusMessage = ref('等待打开项目') + const lastConnectionTest = ref(null) + + async function loadSettings(projectId: string) { + return runSettingsAction(async () => { + const [nextConfig, nextSystemPrompt] = await Promise.all([ + getConfig(projectId), + readSystemPrompt(projectId), + ]) + + config.value = nextConfig + systemPrompt.value = nextSystemPrompt + statusMessage.value = '项目配置已载入' + + return { + config: nextConfig, + systemPrompt: nextSystemPrompt, + } + }) + } + + async function saveConfig(projectId: string, patch: ProjectConfigPatch) { + return runSettingsAction(async () => { + const savedConfig = await updateConfig(projectId, patch) + + config.value = savedConfig + statusMessage.value = '项目配置已保存' + + return savedConfig + }) + } + + async function saveSystemPrompt(projectId: string, content: string) { + return runSettingsAction(async () => { + await writeSystemPrompt(projectId, content) + systemPrompt.value = content + statusMessage.value = '系统提示词已保存' + }) + } + + async function testLlmConfig(input: LlmConfigView) { + return runConnectionTest(() => testLlm(input)) + } + + async function testEmbeddingConfig(input: EmbeddingConfigView) { + return runConnectionTest(() => testEmbedding(input)) + } + + async function testRerankConfig(input: RerankConfigView) { + return runConnectionTest(() => testRerank(input)) + } + + function resetSettings() { + config.value = null + systemPrompt.value = '' + lastConnectionTest.value = null + errorMessage.value = '' + statusMessage.value = '等待打开项目' + } + + async function runConnectionTest(action: () => Promise) { + return runSettingsAction(async () => { + const result = await action() + + lastConnectionTest.value = result + statusMessage.value = result.message + + return result + }) + } + + async function runSettingsAction(action: () => Promise) { + errorMessage.value = '' + isBusy.value = true + + try { + return await action() + } catch (error) { + errorMessage.value = toMessage(error, '设置操作失败') + return null + } finally { + isBusy.value = false + } + } + + return { + config, + errorMessage, + isBusy, + lastConnectionTest, + statusMessage, + systemPrompt, + loadSettings, + resetSettings, + saveConfig, + saveSystemPrompt, + testEmbeddingConfig, + testLlmConfig, + testRerankConfig, + } +}) + +function toMessage(error: unknown, fallback: string) { + if (error instanceof Error) { + return error.message + } + + return fallback +} diff --git a/src/views/SessionTestView.vue b/packages/app/src/views/SessionTestView.vue similarity index 52% rename from src/views/SessionTestView.vue rename to packages/app/src/views/SessionTestView.vue index 493b01f..b16a748 100644 --- a/src/views/SessionTestView.vue +++ b/packages/app/src/views/SessionTestView.vue @@ -1,74 +1,26 @@