Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules
.pnpm-store
dist
.DS_Store
*.tsbuildinfo
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ This means NovAI should gradually move away from a simple "single prompt -> sing

For implementation reference and comparative study, keep this external repository available next to the NovAI repo:

- `/Users/honlnk/project/claude-code-sound`
- `/Users/honlnk/project/claude-code`

This repository is intentionally cloned outside the NovAI git repository so that:

Expand Down
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,19 @@ packages/core @novai/core → 业务逻辑层(无框架依赖,纯 TypeScri

**数据流:** `Vue Component → Pinia Store → Service → Core Domain`

- **app/src/stores/** — 三个 Pinia store:`project`(项目/文件状态)、`chat`(Agent 会话/事件流)、`settings`(配置/模型)
- **core/src/services/** — Service 层桥接 core 类型和 UI 视图类型,管理内存中的项目运行时注册表
- **core/src/core/agent/** — Agent 循环:组装消息 → LLM 流式调用 → 解析 tool_calls → 执行工具 → 循环(最多 8 轮)
- **core/src/core/tools/** — 7 个文件工具(ReadFile/EditFile/CreateFile/RenameFile/DeleteFile/ListDirectory/FindFiles),全部基于浏览器 File System Access API
- **core/src/core/rag/** — RAG 管线(规划中/部分实现
- **packages/app/src/stores/** — 三个 Pinia store:`project`(项目/文件状态)、`chat`(Agent 会话/事件流)、`settings`(配置/模型)
- **packages/core/src/services/** — Service 层桥接 core 类型和 UI 视图类型,管理内存中的项目运行时注册表
- **packages/core/src/core/agent/** — Agent 循环:组装消息 → LLM 流式调用 → 解析 tool_calls → 执行工具 → 循环(最多 8 轮)
- **packages/core/src/core/tools/** — 7 个文件工具(ReadFile/EditFile/CreateFile/RenameFile/DeleteFile/ListDirectory/FindFiles),全部基于浏览器 File System Access API;Agent 额外暴露 `RagSearch` 作为只读检索工具
- **packages/core/src/core/rag/** — RAG 管线(IndexedDB 记录 + 手写 cosineSimilarity 的过渡召回,已接入可选 Rerank;正式 Orama 召回待补齐

## Key Patterns

- **文件工具状态保护:** EditFile 要求先 ReadFile,文件变更后拒绝编辑(防陈旧写入)。测试覆盖了这些场景。
- **API 代理:** Vite dev server 内置 `/api-proxy` 中间件,通过 `x-target-base` header 转发请求到 LLM/Embedding API,解决浏览器 CORS 限制。
- **@novai/core 无构建步骤:** app 直接通过源码导入 core(见 core/package.json exports map),不需要先构建 core。
- **Agent 事件流:** AgentService 发出 `AgentUiEvent` 流供 UI 消费,实现流式响应展示。
- **Agent LLM fallback:** 流式 tool_calls 解析异常或流式响应空闲超时时,会回退到非流式请求;相关行为已有 Vitest 覆盖。

## Commit Convention

Expand All @@ -57,4 +58,4 @@ packages/core @novai/core → 业务逻辑层(无框架依赖,纯 TypeScri

- Node.js 18+, pnpm 10+
- 浏览器需支持 File System Access API(仅 Chromium)
- `docs/` 是 git submodule,指向 NovAI-document 仓库
- `docs/` 是 git submodule,指向 NovAI-document 仓库;文档按 `product/`、`architecture/`、`decisions/`、`project/` 分层维护
30 changes: 16 additions & 14 deletions INTERFACE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# NovAI UI 接口说明

最后更新:2026-05-01
最后更新:2026-06-12

本文档说明当前 NovAI 代码中 UI 层应如何使用项目能力。它面向前端协作者,重点回答三个问题:

Expand All @@ -14,16 +14,16 @@

约定如下:

- UI 页面优先使用 `src/stores/*`。
- Store 通过 `src/services/*` 调用 core 能力。
- 页面和组件不要直接 import `src/core/*`。
- UI 页面优先使用 `packages/app/src/stores/*`。
- Store 通过 `packages/core/src/services/*` 调用 core 能力。
- 页面和组件不要直接 import `packages/core/src/core/*`。
- UI 不直接持有 `FileSystemDirectoryHandle` 或 `ProjectSnapshot`。
- 项目、文件、会话、Agent 事件都通过 `src/services/types.ts` 中的 view type 暴露。
- 项目、文件、会话、Agent 事件都通过 `packages/core/src/services/types.ts` 中的 view type 暴露。

当前已经完成迁移验证:

- `src/views/SessionTestView.vue`
- `src/views/TestLabView.vue`
- `packages/app/src/views/SessionTestView.vue`
- `packages/app/src/views/TestLabView.vue`

## 推荐调用路径

Expand Down Expand Up @@ -148,7 +148,7 @@ const result = await chatStore.runServiceTurn({

## Service 入口

所有 service 都在 `src/services/` 下。
所有 service 都在 `packages/core/src/services/` 下。

| 文件 | 职责 |
|:-----|:-----|
Expand All @@ -170,7 +170,7 @@ const result = await chatStore.runServiceTurn({
核心接口类型统一放在:

```txt
src/services/types.ts
packages/core/src/services/types.ts
```

常用类型:
Expand Down Expand Up @@ -227,7 +227,7 @@ type AgentUiEvent =

- 用户消息
- 上下文摘要
- assistant 流式输出
- assistant 文本消息
- 工具调用记录
- 工具结果记录
- 文件变更
Expand Down Expand Up @@ -275,6 +275,7 @@ chatStore.syncDefaultTarget(project.id, projectStore.activeFile?.path)

```txt
chapters/chapter-001.md
chapters/第001章.txt
prompts/system.md
elements/characters/foo.md
```
Expand Down Expand Up @@ -315,21 +316,22 @@ function resolvePreferredPath(changes: ChangedFileView[]) {
- 文件 diff 预览尚未实现。
- `changedFiles` 仍是 service 层推导,不是工具层结构化输出。
- Agent 会话还没有持久化。
- RAG 调试接口已存在,但 `RagSearch` 尚未作为正式 Agent 工具接入。
- 要素提取当前仍是预览/占位能力。
- `RagSearch` 已作为正式 Agent 工具接入,但真实创作任务中的触发率和召回质量仍需验证。
- 要素提取已能规则型提取并写入 `elements/`,但还不是 LLM 结构化提取,也缺少用户确认、去重合并和覆盖策略。
- 旧的 `pendingFileChange / confirmPendingFileChange` 确认流程代码仍有残留,但当前主工作流没有接入。

## 协作规则

为了减少多人协作冲突,建议遵守:

- UI 页面只依赖 stores 和 services。
- 新 UI 组件不要直接 import `src/core/*`。
- 新 UI 组件不要直接 import `packages/core/src/core/*`。
- 新增 UI 需要的数据,优先扩展 `services/types.ts` 和 mapper。
- core 内部结构变化时,尽量只修改 service / mapper,不让页面跟着改。
- 如果需要新增业务动作,优先新增 service,再由 store 适配 UI 状态。

更详细的设计记录见:

```txt
docs/UI协作接口契约设计.md
docs/architecture/UI协作接口契约设计.md
```
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ NovAI 的思路是换一条路:
尚未完整落地的核心能力包括:

- 写工具执行前确认
- 文件 diff 预览、Agent 停止运行与工具调用边界控制
- LLM 结构化要素抽取、要素去重合并与覆盖确认
- RAG 在真实创作任务中的召回质量验证与使用策略调优
- 自动增量索引、索引过期提醒与更完整的降级策略
- 近期章节上下文拼装
- Agent 会话持久化与上下文压缩
- Rerank 精排与更完整的创作工作流

## MVP 目标
Expand All @@ -82,6 +84,7 @@ NovAI 采用“一个文件夹就是一个小说项目”的思路。当前默
├── elements/
│ ├── characters/
│ ├── locations/
│ ├── entities/
│ ├── timeline/
│ ├── plots/
│ └── worldbuilding/
Expand Down Expand Up @@ -185,15 +188,20 @@ pnpm typecheck

## 文档

项目文档主要放在 [`docs/`](./docs):
项目文档主要放在 [`docs/`](./docs),当前按用途分为五类:

- [`docs/product/`](./docs/product):产品愿景、AI 功能需求、UI 设计
- [`docs/architecture/`](./docs/architecture):技术架构、Agent Loop、工具系统、RAG、UI 接口契约
- [`docs/decisions/`](./docs/decisions):阶段性技术和产品决策记录
- [`docs/plans/`](./docs/plans):阶段性功能计划和重构计划
- [`docs/project/`](./docs/project):路线图、MVP、当前进度、开发日志

常用入口:

- [`docs/NovAI产品规划.md`](./docs/NovAI产品规划.md):产品定位与核心思路
- [`docs/AI功能需求说明书.md`](./docs/AI功能需求说明书.md):AI 功能需求
- [`docs/UI设计文档.md`](./docs/UI设计文档.md):界面与交互设计
- [`docs/技术架构设计.md`](./docs/技术架构设计.md):技术选型与架构说明
- [`docs/project/项目总览.md`](./docs/project/项目总览.md):项目阶段与目标
- [`docs/project/MVP清单.md`](./docs/project/MVP清单.md):MVP 范围与执行顺序
- [`docs/project/当前进度.md`](./docs/project/当前进度.md):开发推进情况
- [`docs/project/项目总览.md`](./docs/project/项目总览.md):项目阶段与目标
- [`docs/product/产品愿景.md`](./docs/product/产品愿景.md):产品定位与核心思路
- [`docs/architecture/技术架构设计.md`](./docs/architecture/技术架构设计.md):技术选型与架构说明
- [`INTERFACE.md`](./INTERFACE.md):当前 UI 协作接口、stores/services 使用入口

## 仓库目标
Expand Down
12 changes: 0 additions & 12 deletions packages/app/src/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import ProjectView from '../views/ProjectView.vue'
import SettingsView from '../views/SettingsView.vue'
import SessionTestView from '../views/SessionTestView.vue'
import TestLabView from '../views/TestLabView.vue'

export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
Expand All @@ -24,15 +22,5 @@ export const router = createRouter({
name: 'settings',
component: SettingsView,
},
{
path: '/test',
name: 'test',
component: TestLabView,
},
{
path: '/session-test',
name: 'session-test',
component: SessionTestView,
},
],
})
3 changes: 2 additions & 1 deletion packages/app/src/components/layout/ChatPanel.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useChatStore } from '../../stores/chat'
import { shouldSubmitOnEnter } from '../../composables/keyboard'
import MessageItem from '../chat/MessageItem.vue'

defineProps<{
Expand Down Expand Up @@ -58,7 +59,7 @@ async function handleSend() {

function handleKeydown(event: KeyboardEvent) {
// Enter 发送,Shift+Enter 换行
if (event.key === 'Enter' && !event.shiftKey) {
if (shouldSubmitOnEnter(event)) {
event.preventDefault()
handleSend()
}
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/components/layout/ContentPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const canExtractElements = computed(() => {
return Boolean(
props.file &&
props.file.path.startsWith('chapters/') &&
props.file.name.endsWith('.txt') &&
/\.(txt|md)$/i.test(props.file.name) &&
props.file.content.trim(),
)
})
Expand Down Expand Up @@ -83,11 +83,12 @@ async function handlePreviewElements() {
}

isExtracting.value = true
extractionStatus.value = '正在提取要素...'
extractionStatus.value = '正在用 AI 提取要素...'
elementWriteResult.value = null

try {
extractionPreview.value = await previewElementExtraction({
projectId: props.projectId,
chapterContent: props.file.content,
chapterPath: props.file.path,
})
Expand All @@ -96,7 +97,8 @@ async function handlePreviewElements() {
? `已提取 ${extractionCount.value} 个候选要素`
: '未提取到可写入的要素'
} catch (error) {
extractionStatus.value = error instanceof Error ? error.message : '要素提取失败'
const message = error instanceof Error ? error.message : '要素提取失败'
extractionStatus.value = `AI 提取失败:${message}。请检查设置页的 LLM 配置。`
} finally {
isExtracting.value = false
}
Expand Down Expand Up @@ -139,6 +141,7 @@ function resetExtractionState() {
function countExtractionItems(result: ElementExtractionResultView) {
return result.characters.length +
result.locations.length +
result.entities.length +
result.timeline.length +
result.plots.length +
result.worldbuilding.length
Expand Down Expand Up @@ -259,7 +262,7 @@ function countExtractionItems(result: ElementExtractionResultView) {

<div
v-if="extractionPreview"
class="grid grid-cols-5 gap-1 text-center text-xs"
class="grid grid-cols-3 gap-1 text-center text-xs"
>
<div class="rounded-md bg-gray-50 px-1 py-1">
<div class="font-semibold text-gray-800">{{ extractionPreview.characters.length }}</div>
Expand All @@ -269,6 +272,10 @@ function countExtractionItems(result: ElementExtractionResultView) {
<div class="font-semibold text-gray-800">{{ extractionPreview.locations.length }}</div>
<div class="text-gray-500">地点</div>
</div>
<div class="rounded-md bg-gray-50 px-1 py-1">
<div class="font-semibold text-gray-800">{{ extractionPreview.entities.length }}</div>
<div class="text-gray-500">实体</div>
</div>
<div class="rounded-md bg-gray-50 px-1 py-1">
<div class="font-semibold text-gray-800">{{ extractionPreview.plots.length }}</div>
<div class="text-gray-500">情节</div>
Expand Down
27 changes: 27 additions & 0 deletions packages/app/src/composables/keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { isImeComposing, shouldSubmitOnEnter } from './keyboard'

function keyEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: '',
shiftKey: false,
isComposing: false,
keyCode: 0,
...overrides,
} as KeyboardEvent
}

describe('keyboard helpers', () => {
it('detects IME composition events', () => {
expect(isImeComposing(keyEvent({ isComposing: true }))).toBe(true)
expect(isImeComposing(keyEvent({ keyCode: 229 }))).toBe(true)
expect(isImeComposing(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false)
})

it('submits only for a plain Enter outside IME composition', () => {
expect(shouldSubmitOnEnter(keyEvent({ key: 'Enter' }))).toBe(true)
expect(shouldSubmitOnEnter(keyEvent({ key: 'Enter', shiftKey: true }))).toBe(false)
expect(shouldSubmitOnEnter(keyEvent({ key: 'Enter', isComposing: true }))).toBe(false)
expect(shouldSubmitOnEnter(keyEvent({ key: 'Enter', keyCode: 229 }))).toBe(false)
})
})
9 changes: 9 additions & 0 deletions packages/app/src/composables/keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type KeyboardLikeEvent = Pick<KeyboardEvent, 'key' | 'shiftKey' | 'isComposing' | 'keyCode'>

export function isImeComposing(event: KeyboardLikeEvent): boolean {
return event.isComposing || event.keyCode === 229
}

export function shouldSubmitOnEnter(event: KeyboardLikeEvent): boolean {
return event.key === 'Enter' && !event.shiftKey && !isImeComposing(event)
}
1 change: 1 addition & 0 deletions packages/app/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface FilePickerOptions {
interface FileSystemHandle {
readonly kind: 'file' | 'directory'
readonly name: string
remove?(options?: { recursive?: boolean }): Promise<void>
}

interface FileSystemWritableFileStream {
Expand Down
Loading
Loading