+
diff --git a/packages/app/src/components/layout/FileTreeSidebar.vue b/packages/app/src/components/layout/FileTreeSidebar.vue
deleted file mode 100644
index ac84aef..0000000
--- a/packages/app/src/components/layout/FileTreeSidebar.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-
-
-
-
-
diff --git a/packages/app/src/components/layout/IndexStatusBar.vue b/packages/app/src/components/layout/IndexStatusBar.vue
new file mode 100644
index 0000000..5b697d1
--- /dev/null
+++ b/packages/app/src/components/layout/IndexStatusBar.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📍
+
{{ activeSceneName }}
+
+
+
+
+
+
+ {{ indexStore.errorMessage }}
+ {{ runStatus }}
+
+
+
diff --git a/packages/app/src/components/layout/ResizeHandle.vue b/packages/app/src/components/layout/ResizeHandle.vue
new file mode 100644
index 0000000..b69babd
--- /dev/null
+++ b/packages/app/src/components/layout/ResizeHandle.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
diff --git a/packages/app/src/components/settings/SettingsModal.vue b/packages/app/src/components/settings/SettingsModal.vue
new file mode 100644
index 0000000..ffdb9cd
--- /dev/null
+++ b/packages/app/src/components/settings/SettingsModal.vue
@@ -0,0 +1,503 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
生成设置
+
+
+
+
生成时携带最近 N 章的原文作为上下文
+
+
+
+
+
+
+
+
RAG 设置
+
+
+
+
粗检索阶段返回的候选要素数量
+
+
+
+
+
最终拼入生成上下文的最大要素数量
+
+
+
+
+
+
+
+
对话设置
+
+
+
+
接近上限时触发上下文压缩
+
+
+
+
+
上下文压缩时保留最近 N 轮对话的原文
+
+
+
+
+
+
+
+
+
校对与整理
+
+
+
+
自动校对最近 N 章
+
+
+
+
+
自动整理最近 N 章
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ testResult.message }}
+
+
+
+
+
+
diff --git a/packages/app/src/components/ui/PasswordInput.vue b/packages/app/src/components/ui/PasswordInput.vue
new file mode 100644
index 0000000..34e05f0
--- /dev/null
+++ b/packages/app/src/components/ui/PasswordInput.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/ui/Tooltip.vue b/packages/app/src/components/ui/Tooltip.vue
new file mode 100644
index 0000000..0a23cd1
--- /dev/null
+++ b/packages/app/src/components/ui/Tooltip.vue
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+
+
+ {{ text }}
+
+
+
+
diff --git a/packages/app/src/composables/useElementExtraction.ts b/packages/app/src/composables/useElementExtraction.ts
new file mode 100644
index 0000000..35453da
--- /dev/null
+++ b/packages/app/src/composables/useElementExtraction.ts
@@ -0,0 +1,186 @@
+import { ref } from 'vue'
+import { previewElementExtraction, writeExtractedElements } from '@novai/core/services/element-service'
+import { readFile } from '@novai/core/services/file-service'
+import type {
+ ElementExtractionItemView,
+ ElementExtractionResultView,
+ ElementWriteResultView,
+} from '@novai/core/services/types'
+
+/**
+ * 要素提取流程状态(R6)。
+ *
+ * 编排「选章节 → 逐章提取 → 智能合并 → 候选预览 → 确认写入」的纯 UI 流程。
+ * 不进 Agent 对话,不污染 chatStore.messages。状态集中在 composable 里,
+ * ChatPanel 的 ExtractionFlowPanel 按 phase 渲染对应界面。
+ */
+
+export type ExtractionPhase = 'idle' | 'extracting' | 'preview' | 'writing' | 'done' | 'error'
+
+export type ChapterPick = {
+ path: string
+ name: string
+}
+
+const phase = ref
('idle')
+const selectedChapters = ref([])
+const extractionResult = ref(null)
+const writeResult = ref(null)
+/** 提取进度:当前章节序号(从 1 起)和名称 */
+const progressCurrent = ref(0)
+const progressTotal = ref(0)
+const progressChapterName = ref('')
+const errorMessage = ref('')
+/** 当前 projectId(流程进行中锁定) */
+let activeProjectId = ''
+
+/**
+ * 智能合并多个章节的提取结果。
+ *
+ * 同 type 同 name 的候选项合并:body 用 \n\n 拼接、relatedChapters 取并集、tags 去重。
+ * 不同 name 直接 concat。lastUpdatedChapter 取最后出现的章节。
+ */
+function mergeResults(results: ElementExtractionResultView[]): ElementExtractionResultView {
+ const buckets: Array = [
+ 'characters', 'locations', 'entities', 'timeline', 'plots', 'worldbuilding',
+ ]
+ const merged: ElementExtractionResultView = {
+ characters: [], locations: [], entities: [], timeline: [], plots: [], worldbuilding: [],
+ }
+
+ for (const bucket of buckets) {
+ const map = new Map()
+ for (const result of results) {
+ for (const item of result[bucket]) {
+ const key = item.name.trim()
+ const existing = map.get(key)
+ if (existing) {
+ // 合并:body 拼接(去重复段)、relatedChapters 并集、tags 去重、lastUpdatedChapter 取较新
+ const bodyParts = [existing.body, item.body].filter(Boolean)
+ existing.body = [...new Set(bodyParts)].join('\n\n')
+ existing.relatedChapters = [...new Set([...existing.relatedChapters, ...item.relatedChapters])]
+ existing.tags = [...new Set([...existing.tags, ...item.tags])]
+ existing.lastUpdatedChapter = item.lastUpdatedChapter || existing.lastUpdatedChapter
+ } else {
+ map.set(key, { ...item })
+ }
+ }
+ }
+ merged[bucket] = [...map.values()]
+ }
+
+ return merged
+}
+
+/** 统计候选总数 */
+export function countExtractionItems(result: ElementExtractionResultView): number {
+ return (
+ result.characters.length +
+ result.locations.length +
+ result.entities.length +
+ result.timeline.length +
+ result.plots.length +
+ result.worldbuilding.length
+ )
+}
+
+/** 重置全部状态 */
+function reset() {
+ phase.value = 'idle'
+ selectedChapters.value = []
+ extractionResult.value = null
+ writeResult.value = null
+ progressCurrent.value = 0
+ progressTotal.value = 0
+ progressChapterName.value = ''
+ errorMessage.value = ''
+ activeProjectId = ''
+}
+
+/**
+ * 启动提取流程:逐章读取正文 → 逐章 LLM 提取 → 合并 → 进入预览态。
+ */
+async function startExtraction(projectId: string, chapters: ChapterPick[]) {
+ if (chapters.length === 0) return
+ activeProjectId = projectId
+ phase.value = 'extracting'
+ selectedChapters.value = chapters
+ extractionResult.value = null
+ writeResult.value = null
+ errorMessage.value = ''
+ progressTotal.value = chapters.length
+ progressCurrent.value = 0
+
+ try {
+ const perChapterResults: ElementExtractionResultView[] = []
+ for (let i = 0; i < chapters.length; i++) {
+ const chapter = chapters[i]
+ progressCurrent.value = i + 1
+ progressChapterName.value = chapter.name
+
+ const file = await readFile(projectId, chapter.path)
+ const result = await previewElementExtraction({
+ projectId,
+ chapterContent: file.content,
+ chapterPath: chapter.path,
+ })
+ perChapterResults.push(result)
+ }
+
+ extractionResult.value = mergeResults(perChapterResults)
+ phase.value = 'preview'
+ } catch (error) {
+ errorMessage.value = error instanceof Error ? error.message : '要素提取失败'
+ phase.value = 'error'
+ }
+}
+
+/**
+ * 确认写入:调 writeExtractedElements 落盘。
+ * 返回写入结果,供调用方刷新文件树。
+ */
+async function confirmWrite(): Promise {
+ if (!extractionResult.value || phase.value !== 'preview') return null
+
+ phase.value = 'writing'
+ try {
+ const result = await writeExtractedElements({
+ projectId: activeProjectId,
+ extraction: extractionResult.value,
+ })
+ writeResult.value = result
+ phase.value = 'done'
+ return result
+ } catch (error) {
+ errorMessage.value = error instanceof Error ? error.message : '写入要素失败'
+ phase.value = 'error'
+ return null
+ }
+}
+
+/** 取消流程(预览态/错误态可用),丢弃候选 */
+function cancel() {
+ reset()
+}
+
+/** 关闭面板(完成态/错误态),回到 idle */
+function dismiss() {
+ reset()
+}
+
+export function useElementExtraction() {
+ return {
+ phase,
+ selectedChapters,
+ extractionResult,
+ writeResult,
+ progressCurrent,
+ progressTotal,
+ progressChapterName,
+ errorMessage,
+ startExtraction,
+ confirmWrite,
+ cancel,
+ dismiss,
+ }
+}
diff --git a/packages/app/src/constants/category.ts b/packages/app/src/constants/category.ts
new file mode 100644
index 0000000..5d872a9
--- /dev/null
+++ b/packages/app/src/constants/category.ts
@@ -0,0 +1,15 @@
+/**
+ * Activity Bar 的分类类型。
+ *
+ * 对应左侧 Activity Bar 的 5 个图标,每个分类切换右侧 CategoryPanel 的内容。
+ * 注意:`settings` 是动作型入口(打开模态框),不会作为 CategoryPanel 的渲染分支,
+ * 仅在此保留以统一 Activity Bar 的图标语义。
+ */
+export type Category = 'conversation' | 'chapter' | 'element' | 'prompt'
+
+/**
+ * 设置是动作型入口,不切换 CategoryPanel,单独标识以便 ActivityBar 区分行为。
+ */
+export type SettingsAction = 'settings'
+
+export type ActivityItem = Category | SettingsAction
diff --git a/packages/app/src/constants/elements.ts b/packages/app/src/constants/elements.ts
new file mode 100644
index 0000000..99c7c22
--- /dev/null
+++ b/packages/app/src/constants/elements.ts
@@ -0,0 +1,31 @@
+import type { ElementType } from '@novai/core'
+
+/**
+ * 要素分组的 UI 常量定义。
+ *
+ * core 层只有 `ElementType` 类型和散落的目录映射(writer.ts 内私有常量),
+ * 没有集中维护「type ↔ 目录 ↔ 中文标题 ↔ 图标」的常量。
+ * 分类面板需要固定的中文标题展示,因此在 app 层统一维护这份映射,
+ * 不依赖磁盘目录名做中文翻译。
+ *
+ * 顺序固定,对应 UI 中 6 个可折叠分组的展示顺序。
+ */
+export interface ElementCategory {
+ /** core 的 ElementType,作为稳定 key */
+ key: ElementType
+ /** UI 展示的中文标题(写死) */
+ label: string
+ /** 对应的磁盘目录前缀,用于从 currentProject.files 过滤 */
+ directory: string
+ /** UI 展示的图标(emoji,后续可替换为 svg) */
+ icon: string
+}
+
+export const ELEMENT_CATEGORIES: ElementCategory[] = [
+ { key: 'character', label: '人物', directory: 'elements/characters', icon: '👤' },
+ { key: 'location', label: '地点', directory: 'elements/locations', icon: '📍' },
+ { key: 'entity', label: '其他实体', directory: 'elements/entities', icon: '🔶' },
+ { key: 'timeline', label: '时间线', directory: 'elements/timeline', icon: '📅' },
+ { key: 'plot', label: '情节', directory: 'elements/plots', icon: '📌' },
+ { key: 'worldbuilding', label: '设定', directory: 'elements/worldbuilding', icon: '🌐' },
+]
diff --git a/packages/app/src/constants/slash-commands.ts b/packages/app/src/constants/slash-commands.ts
new file mode 100644
index 0000000..af3b6ad
--- /dev/null
+++ b/packages/app/src/constants/slash-commands.ts
@@ -0,0 +1,35 @@
+/**
+ * 斜杠命令注册表(R6)。
+ *
+ * 输入框输入 / 后弹出命令菜单,列出这里注册的命令。
+ * 每个命令选中后展开对应的二级交互界面(目前只有 extract)。
+ * 后续可扩展 /校对、/整理 等。
+ */
+export type SlashCommandId = 'extract' | 'init'
+
+export type SlashCommand = {
+ /** 命令唯一标识 */
+ id: SlashCommandId
+ /** 显示名(含 / 前缀,用于菜单展示和匹配) */
+ label: string
+ /** 简短描述 */
+ description: string
+ /** 图标(emoji) */
+ icon: string
+}
+
+/** 当前可用的斜杠命令 */
+export const SLASH_COMMANDS: SlashCommand[] = [
+ {
+ id: 'extract',
+ label: '/提取要素',
+ description: '从章节中提取人物、地点、剧情等要素',
+ icon: '✨',
+ },
+ {
+ id: 'init',
+ label: '/生成项目记忆',
+ description: '扫描项目生成/更新 prompts/NovAI.md 项目总览',
+ icon: '📋',
+ },
+]
diff --git a/packages/app/src/stores/chat.ts b/packages/app/src/stores/chat.ts
index 70fb58f..0664a9a 100644
--- a/packages/app/src/stores/chat.ts
+++ b/packages/app/src/stores/chat.ts
@@ -3,33 +3,166 @@ import { defineStore } from 'pinia'
import {
createSession as createAgentSession,
+ respondConfirmation as respondAgentConfirmation,
runTurn as runAgentTurn,
+ getSession as getAgentSession,
+ listSessions as listAgentSessions,
+ renameSession as renameAgentSession,
+ deleteSession as deleteAgentSession,
} from '@novai/core/services/agent-service'
import type {
AgentUiEvent,
ChangedFileView,
ChatMessageView,
+ ChatSessionSummaryView,
ChatSessionView,
+ FileChangeConfirmationView,
RunAgentTurnInput,
RunAgentTurnResult,
} from '@novai/core/services/types'
+import { useProjectStore } from './project'
+
+/** runStatus 的语义类型,供状态栏按类型上色,避免 UI 靠字符串猜测 */
+export type RunStatusType = 'idle' | 'running' | 'error'
+
export const useChatStore = defineStore('chat', () => {
const sessionView = ref(null)
const agentEvents = ref([])
const changedFiles = ref([])
const runStatus = ref('还没有开始执行。')
+ // runStatus 的语义类型,供状态栏按类型上色(idle 灰 / running 蓝 / error 红)
+ const runStatusType = ref('idle')
+ const isRunning = ref(false)
+ // 用户已请求停止、正在等待当前工具执行完成
+ const isStopping = ref(false)
+ // 当前等待用户确认的写操作;Agent Loop 在此暂停
+ const pendingConfirmation = ref(null)
+
+ // 历史会话列表(对话分类面板渲染),按 updatedAt 降序
+ const sessions = ref([])
+ // 当前激活会话 id(高亮 + runTurn 路由用)
+ const activeSessionId = ref(null)
+ const isLoadingSessions = ref(false)
+
+ // 当前运行持有的停止控制器;仅 isRunning 期间存在
+ let activeAbortController: AbortController | null = null
const hasSessionView = computed(() => sessionView.value !== null)
const messages = computed(() => sessionView.value?.messages ?? [])
- async function ensureSessionView(projectId: string) {
- if (!sessionView.value || sessionView.value.projectId !== projectId) {
- sessionView.value = await createAgentSession(projectId)
+ /**
+ * 项目打开时的会话初始化入口:
+ * 拉取历史会话列表,有历史则激活最近一条,无历史则新建。
+ * 取代旧的 ensureSessionView(后者只支持单会话)。
+ */
+ async function initSessions(projectId: string): Promise {
+ await loadSessions(projectId)
+
+ if (sessions.value.length > 0) {
+ await selectSession(projectId, sessions.value[0].sessionId)
+ return sessionView.value!
+ }
+
+ // 无历史:新建。列表刚拉过且为空,跳过 createNewSession 内部的二次全量扫描,
+ // 直接本地插入新会话摘要。
+ return createNewSession(projectId, { skipReload: true })
+ }
+
+ /**
+ * 拉取并刷新历史会话列表。
+ * 只负责更新列表数据,**不修改 activeSessionId**——激活态的清理由明确语义的调用点
+ * (如 deleteSession)自行处理,避免列表刷新与激活态维护耦合产生的竞态误清。
+ */
+ async function loadSessions(projectId: string) {
+ isLoadingSessions.value = true
+ try {
+ sessions.value = await listAgentSessions(projectId)
+ } finally {
+ isLoadingSessions.value = false
+ }
+ }
+
+ /** 切换到指定历史会话:加载其完整消息体并设为激活。 */
+ async function selectSession(projectId: string, sessionId: string): Promise {
+ const view = await getAgentSession(projectId, sessionId)
+ if (!view) {
+ return null
+ }
+
+ sessionView.value = view
+ activeSessionId.value = sessionId
+ // 切换会话时清空上一轮的运行态残留,避免跨会话串扰
+ agentEvents.value = []
+ changedFiles.value = []
+ return view
+ }
+
+ /**
+ * 新建会话:创建 + 激活 + 刷新列表。
+ * skipReload=true 时跳过内部全量刷新(调用方已确知列表状态,如 initSessions 走过 loadSessions、
+ * 或 deleteSession 清空后),改为本地插入新会话摘要到列表头部。
+ */
+ async function createNewSession(
+ projectId: string,
+ options: { skipReload?: boolean } = {},
+ ): Promise {
+ const view = await createAgentSession(projectId)
+ sessionView.value = view
+ activeSessionId.value = view.sessionId
+
+ if (options.skipReload) {
+ sessions.value = [{
+ sessionId: view.sessionId,
+ projectId,
+ title: view.title ?? '新对话',
+ createdAt: view.createdAt ?? new Date().toISOString(),
+ updatedAt: view.updatedAt ?? new Date().toISOString(),
+ messageCount: 0,
+ }, ...sessions.value]
+ } else {
+ await loadSessions(projectId)
}
+ return view
+ }
- return sessionView.value
+ /** 重命名会话标题,并同步列表对应项。 */
+ async function renameSession(projectId: string, sessionId: string, title: string): Promise {
+ const view = await renameAgentSession(projectId, sessionId, title)
+ // 同步列表项标题
+ sessions.value = sessions.value.map((s) =>
+ s.sessionId === sessionId ? { ...s, title: view.title ?? s.title } : s,
+ )
+ // 若是当前会话,同步视图
+ if (sessionView.value?.sessionId === sessionId) {
+ sessionView.value = { ...sessionView.value, title: view.title }
+ }
+ }
+
+ /**
+ * 删除会话;若删的是当前激活会话,则切到列表第一条或新建空会话。
+ * 删光后保留一个空「新对话」是有意为之:否则 UI 进入无活跃会话态,发消息会报错。
+ */
+ async function deleteSession(projectId: string, sessionId: string): Promise {
+ await deleteAgentSession(projectId, sessionId)
+
+ const wasActive = activeSessionId.value === sessionId
+ // 先从本地列表移除,避免 await 期间 UI 闪烁残留项
+ sessions.value = sessions.value.filter((s) => s.sessionId !== sessionId)
+
+ if (!wasActive) {
+ return
+ }
+
+ // 删的是当前会话:切到剩余的第一条(列表已按 updatedAt 降序)
+ if (sessions.value.length > 0) {
+ await selectSession(projectId, sessions.value[0].sessionId)
+ return
+ }
+
+ // 列表已空:新建空会话并本地插入,省一次全量重扫(skipReload)
+ await createNewSession(projectId, { skipReload: true })
}
async function runServiceTurn(
@@ -37,16 +170,27 @@ export const useChatStore = defineStore('chat', () => {
onEvent?: (event: AgentUiEvent) => void
},
): Promise {
- const currentSession = await ensureSessionView(input.projectId)
+ if (!sessionView.value) {
+ throw new Error('没有活跃的会话')
+ }
+ const currentSession = sessionView.value
+ // 记录本轮前标题,用于 finally 判断是否需要刷新列表(首轮会自动生成标题)
+ const titleBeforeTurn = currentSession.title
agentEvents.value = []
changedFiles.value = []
- runStatus.value = '正在执行本轮 Agent...'
+ setRunStatus('正在执行本轮 Agent...', 'running')
+ isRunning.value = true
+ isStopping.value = false
+
+ // 每轮新建 controller,signal 透传到 core Agent Loop
+ activeAbortController = new AbortController()
try {
const result = await runAgentTurn({
...input,
sessionId: currentSession.sessionId,
+ signal: activeAbortController.signal,
onEvent(event) {
handleAgentEvent(event)
input.onEvent?.(event)
@@ -55,26 +199,54 @@ export const useChatStore = defineStore('chat', () => {
sessionView.value = result.session
changedFiles.value = result.changedFiles
- runStatus.value = result.changedFiles.length > 0
- ? `本轮执行完成,变更 ${result.changedFiles.length} 个文件`
- : '本轮执行完成,未修改任何文件'
+ setRunStatus(
+ result.changedFiles.length > 0
+ ? `本轮执行完成,变更 ${result.changedFiles.length} 个文件`
+ : '本轮执行完成,未修改任何文件',
+ )
return result
} catch (error) {
- runStatus.value = error instanceof Error ? error.message : '执行会话失败'
+ setRunStatus(error instanceof Error ? error.message : '执行会话失败', 'error')
throw error
+ } finally {
+ isRunning.value = false
+ isStopping.value = false
+ activeAbortController = null
+ // 仅当标题发生变化(典型:首轮发送后自动生成标题)才全量刷新列表,
+ // 避免每轮对话都全量重扫文件系统。updatedAt 的时间戳显示精度可接受滞后。
+ const titleAfterTurn = sessionView.value?.title
+ if (titleAfterTurn && titleAfterTurn !== titleBeforeTurn) {
+ void loadSessions(currentSession.projectId)
+ }
}
}
- function setRunStatus(nextStatus: string) {
+ /** 用户点击停止:中断当前模型流式请求,Agent Loop 会在边界优雅结束。 */
+ function abortRun() {
+ if (activeAbortController) {
+ activeAbortController.abort()
+ activeAbortController = null
+ // 立即进入「停止中」态:UI 反馈 + 等待当前工具完成
+ isStopping.value = true
+ setRunStatus('已请求停止,等待当前工具执行完成…', 'running')
+ }
+ }
+
+ /**
+ * 统一的 runStatus 写入入口:同时设置文案与语义类型,
+ * UI(状态栏)按 type 上色,无需靠字符串匹配。
+ */
+ function setRunStatus(nextStatus: string, type: RunStatusType = 'idle') {
runStatus.value = nextStatus
+ runStatusType.value = type
}
function handleAgentEvent(event: AgentUiEvent) {
agentEvents.value = [...agentEvents.value, event]
if (event.type === 'run-start') {
- runStatus.value = 'Agent 正在执行...'
+ setRunStatus('Agent 正在执行...', 'running')
return
}
@@ -91,46 +263,91 @@ export const useChatStore = defineStore('chat', () => {
return
}
+ if (event.type === 'confirmation-required') {
+ pendingConfirmation.value = event.request
+ setRunStatus('等待确认写入操作…')
+ return
+ }
+
if (event.type === 'run-error') {
- runStatus.value = event.error.message
+ setRunStatus(event.error.message, 'error')
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} 个文件`
- : '本轮执行完成,未修改任何文件'
+ setRunStatus(
+ event.result.changedFiles.length > 0
+ ? `本轮执行完成,变更 ${event.result.changedFiles.length} 个文件`
+ : '本轮执行完成,未修改任何文件',
+ )
}
}
- async function createSession(projectId: string) {
- return ensureSessionView(projectId)
- }
-
- async function sendMessage(text: string) {
+ async function sendMessage(text: string, quote?: string) {
if (!sessionView.value) {
throw new Error('没有活跃的会话')
}
+ // 把当前打开的文件路径作为隐式上下文传入,供 Agent 工具约束(如「只改当前文件」)解析使用。
+ const projectStore = useProjectStore()
+ const activeFilePath = projectStore.activeFile?.path
return runServiceTurn({
projectId: sessionView.value.projectId,
instruction: text,
+ quote,
+ activeFilePath,
})
}
+ /** 用户确认当前待确认的写操作,唤醒 Agent Loop 继续执行。 */
+ function confirmWriteTool() {
+ const confirmation = pendingConfirmation.value
+ if (!confirmation) {
+ return
+ }
+ pendingConfirmation.value = null
+ respondAgentConfirmation(confirmation.id, true)
+ setRunStatus('Agent 正在执行...', 'running')
+ }
+
+ /** 用户拒绝当前待确认的写操作,Agent 收到拒绝结果后可调整。 */
+ function rejectWriteTool() {
+ const confirmation = pendingConfirmation.value
+ if (!confirmation) {
+ return
+ }
+ pendingConfirmation.value = null
+ respondAgentConfirmation(confirmation.id, false)
+ setRunStatus('Agent 正在执行...', 'running')
+ }
+
return {
agentEvents,
changedFiles,
+ isRunning,
+ isStopping,
+ isLoadingSessions,
messages,
+ pendingConfirmation,
sessionView,
+ sessions,
+ activeSessionId,
runStatus,
+ runStatusType,
hasSessionView,
- createSession,
- ensureSessionView,
- sendMessage,
+ abortRun,
+ confirmWriteTool,
+ createNewSession,
+ deleteSession,
+ initSessions,
+ loadSessions,
+ rejectWriteTool,
+ renameSession,
runServiceTurn,
+ selectSession,
+ sendMessage,
setRunStatus,
}
})
diff --git a/packages/app/src/stores/index.ts b/packages/app/src/stores/index.ts
new file mode 100644
index 0000000..160fc20
--- /dev/null
+++ b/packages/app/src/stores/index.ts
@@ -0,0 +1,139 @@
+import { computed, ref } from 'vue'
+import { defineStore } from 'pinia'
+
+import { inspectIndex, rebuildIndex, subscribeIndexChange } from '@novai/core/services/rag-service'
+import type {
+ IndexBuildResultView,
+ IndexStatusView,
+ ProjectIndexMetaView,
+} from '@novai/core/services/types'
+
+/**
+ * RAG 索引状态的响应式 store。
+ *
+ * 持有当前项目的索引 meta,订阅 core 层事件总线自动更新
+ * (要素写入标 stale / 重建完成 / 构建失败都会通过事件刷新)。
+ * 状态栏与设置页共享同一份状态,避免重复请求。
+ */
+export const useIndexStore = defineStore('rag-index', () => {
+ const indexMeta = ref(null)
+ const isBusy = ref(false)
+ const errorMessage = ref('')
+
+ let unsubscribe: (() => void) | null = null
+
+ const status = computed(() => indexMeta.value?.status ?? null)
+ const documentCount = computed(() => indexMeta.value?.documentCount ?? 0)
+
+ /** 索引是否处于「可点击重建」的过期/异常态。 */
+ const canRebuild = computed(
+ () => !isBusy.value && (status.value === 'stale' || status.value === 'error' || status.value === 'ready' || status.value === 'empty'),
+ )
+
+ async function init(projectId: string) {
+ // 重新 init 前先退订旧订阅,避免泄漏。
+ dispose()
+
+ unsubscribe = subscribeIndexChange(projectId, (meta) => {
+ indexMeta.value = meta
+ })
+
+ await refresh(projectId)
+ }
+
+ function dispose() {
+ if (unsubscribe) {
+ unsubscribe()
+ unsubscribe = null
+ }
+ indexMeta.value = null
+ isBusy.value = false
+ errorMessage.value = ''
+ }
+
+ async function refresh(projectId: string) {
+ try {
+ indexMeta.value = await inspectIndex(projectId)
+ } catch (error) {
+ errorMessage.value = error instanceof Error ? error.message : '读取索引状态失败'
+ }
+ }
+
+ /**
+ * 重建索引。不传 sourcePaths 为全量;传入则增量(仅重建指定文件,复用未变向量)。
+ * 重建过程中状态栏进入 busy 态,完成后由事件总线或显式 refresh 更新。
+ */
+ async function rebuild(projectId: string, sourcePaths?: string[]): Promise {
+ if (isBusy.value) {
+ return null
+ }
+
+ errorMessage.value = ''
+ isBusy.value = true
+
+ try {
+ const result = await rebuildIndex(projectId, sourcePaths)
+ // 事件总线通常已更新 meta,这里兜底刷新一次保证一致。
+ await refresh(projectId)
+ return result
+ } catch (error) {
+ errorMessage.value = error instanceof Error ? error.message : '重建索引失败'
+ await refresh(projectId)
+ return null
+ } finally {
+ isBusy.value = false
+ }
+ }
+
+ return {
+ indexMeta,
+ isBusy,
+ errorMessage,
+ status,
+ documentCount,
+ canRebuild,
+ init,
+ dispose,
+ refresh,
+ rebuild,
+ }
+})
+
+const STATUS_LABELS: Record = {
+ empty: '空索引',
+ building: '构建中',
+ ready: '索引就绪',
+ stale: '部分过期',
+ rebuilding: '重建中',
+ error: '索引异常',
+}
+
+const STATUS_CLASSES: Record = {
+ empty: 'bg-gray-100 text-gray-500 ring-gray-200',
+ building: 'bg-blue-50 text-blue-600 ring-blue-200',
+ ready: 'bg-green-50 text-green-700 ring-green-200',
+ stale: 'bg-amber-50 text-amber-700 ring-amber-200',
+ rebuilding: 'bg-blue-50 text-blue-600 ring-blue-200',
+ error: 'bg-red-50 text-red-700 ring-red-200',
+}
+
+const STATUS_DOT_CLASSES: Record = {
+ empty: 'bg-gray-400',
+ building: 'bg-blue-500 animate-pulse',
+ ready: 'bg-green-500',
+ stale: 'bg-amber-500',
+ rebuilding: 'bg-blue-500 animate-pulse',
+ error: 'bg-red-500',
+}
+
+export function getIndexStatusLabel(status: IndexStatusView): string {
+ return STATUS_LABELS[status]
+}
+
+export function getIndexStatusClass(status: IndexStatusView): string {
+ return STATUS_CLASSES[status]
+}
+
+export function getIndexStatusDotClass(status: IndexStatusView): string {
+ return STATUS_DOT_CLASSES[status]
+}
diff --git a/packages/app/src/stores/project.ts b/packages/app/src/stores/project.ts
index f56b7df..7281177 100644
--- a/packages/app/src/stores/project.ts
+++ b/packages/app/src/stores/project.ts
@@ -10,13 +10,16 @@ import {
getRecentProjects,
isProjectAccessSupported,
openProject,
+ refreshRecentProjectCounts,
restoreRecentProject,
restoreLastProject,
} from '@novai/core/services/project-service'
import {
readFile,
refreshFiles,
+ writeFile,
} from '@novai/core/services/file-service'
+import { updateConfig } from '@novai/core/services/settings-service'
import type {
FileContentView,
LastProjectSummaryView,
@@ -26,6 +29,12 @@ import type {
} from '@novai/core/services/types'
import type { RecentProject } from '@novai/core/types/project'
+/**
+ * 首屏计数刷新的并发开关。
+ * onMounted 等场景可能多次触发 loadRecentProjects,用 module 级 flag 保证同一时刻只跑一次后台刷新。
+ */
+let isRefreshingCounts = false
+
export const useProjectStore = defineStore('project', () => {
const currentProject = ref(null)
const recentProjects = ref([])
@@ -99,13 +108,8 @@ export const useProjectStore = defineStore('project', () => {
async function loadRecentProjects() {
try {
const summaries = await getRecentProjects()
- recentProjects.value = summaries.map((summary) => ({
- id: summary.projectId,
- name: summary.name,
- updatedAt: summary.lastOpenedAt,
- chapterCount: 0,
- wordCount: 0,
- }))
+ recentProjects.value = toRecentProjectsFromSummaries(summaries)
+ refreshRecentProjectsInBackground()
return recentProjects.value
} catch (error) {
errorMessage.value = toMessage(error, '读取最近项目列表失败')
@@ -113,6 +117,47 @@ export const useProjectStore = defineStore('project', () => {
}
}
+ /**
+ * 把 service 层返回的最近项目摘要映射成 store 内部结构。
+ */
+ function toRecentProjectsFromSummaries(summaries: LastProjectSummaryView[]): RecentProject[] {
+ return summaries.map((summary) => ({
+ id: summary.projectId,
+ name: summary.name,
+ updatedAt: summary.lastOpenedAt,
+ chapterCount: summary.chapterCount,
+ elementCount: summary.elementCount,
+ wordCount: 0,
+ }))
+ }
+
+ /**
+ * 后台静默刷新最近项目的章节数/要素数。
+ *
+ * 解决「必须打开一遍才显示计数」:对已持有目录权限的项目重新扫描计数并回写。
+ * 不阻塞首屏(loadRecentProjects 已用旧值先渲染),刷新完成后覆盖 store 触发重渲染。
+ * 单次并发保护,避免 onMounted 重复触发。
+ */
+ async function refreshRecentProjectsInBackground() {
+ if (isRefreshingCounts) {
+ return
+ }
+
+ isRefreshingCounts = true
+ try {
+ const refreshedSummaries = await refreshRecentProjectCounts()
+ // 刷新期间若用户已打开项目,避免覆盖 setCurrentProject 写入的更新数据:仅当
+ // 当前 store 状态仍是列表(非空且未被清空)时回填。
+ if (recentProjects.value.length > 0) {
+ recentProjects.value = toRecentProjectsFromSummaries(refreshedSummaries)
+ }
+ } catch {
+ // 后台刷新失败不影响首屏已有数据。
+ } finally {
+ isRefreshingCounts = false
+ }
+ }
+
async function forgetLastOpenedProject() {
return runProjectAction(async () => {
await forgetLastProject()
@@ -175,6 +220,27 @@ export const useProjectStore = defineStore('project', () => {
}
}
+ /**
+ * 保存内容面板编辑模式的草稿到磁盘。
+ * 写盘成功后更新 activeFile(同步最新 updatedAt),不刷新整个文件树
+ *(文件树结构未变,仅内容更新)。
+ */
+ async function saveFile(path: string, content: string) {
+ if (!currentProject.value) {
+ return null
+ }
+
+ errorMessage.value = ''
+
+ try {
+ activeFile.value = await writeFile(currentProject.value.id, path, content)
+ return activeFile.value
+ } catch (error) {
+ errorMessage.value = toMessage(error, '保存文件失败')
+ return null
+ }
+ }
+
async function refreshTree() {
if (!currentProject.value) {
return
@@ -220,6 +286,34 @@ export const useProjectStore = defineStore('project', () => {
}
}
+ /**
+ * 切换当前激活的场景提示词。
+ *
+ * 这条 action 解决了一个已知坑点:直接调 settingsStore.saveConfig 只会更新 settingsStore
+ * 自己的 config 副本,不会同步到 projectStore.currentProject.config(后者是 setCurrentProject
+ * 那一刻生成的快照)。分类面板读取的是 projectStore 侧,因此这里在写盘成功后显式调
+ * updateCurrentProjectConfig 同步本地,避免 UI 显示过期数据。
+ *
+ * @param path 场景提示词路径,传 null 表示关闭场景
+ */
+ async function changeActiveScenePromptPath(projectId: string, path: string | null) {
+ errorMessage.value = ''
+
+ try {
+ const savedConfig = await updateConfig(projectId, {
+ settings: { activeScenePromptPath: path },
+ })
+ updateCurrentProjectConfig(savedConfig)
+ statusMessage.value = path
+ ? '已切换场景提示词,新建会话后生效'
+ : '已关闭场景提示词,新建会话后生效'
+ return savedConfig
+ } catch (error) {
+ errorMessage.value = toMessage(error, '切换场景提示词失败')
+ return null
+ }
+ }
+
async function runProjectAction(action: () => Promise) {
errorMessage.value = ''
isBusy.value = true
@@ -244,6 +338,7 @@ export const useProjectStore = defineStore('project', () => {
lastProjectSummary,
recentProjects,
statusMessage,
+ changeActiveScenePromptPath,
closeCurrentProject,
createNewProject,
forgetLastOpenedProject,
@@ -255,6 +350,7 @@ export const useProjectStore = defineStore('project', () => {
refreshTree,
removeRecentProject,
restoreLastOpenedProject,
+ saveFile,
updateCurrentProjectConfig,
}
})
@@ -295,6 +391,7 @@ function toRecentProject(project: ProjectView): RecentProject {
name: project.name,
updatedAt: project.config.project.updatedAt,
chapterCount: countChapterFiles(project.files),
+ elementCount: countElementFiles(project.files),
wordCount: 0,
}
}
@@ -310,3 +407,15 @@ function countChapterFiles(nodes: ProjectFileNodeView[]): number {
return total + countChapterFiles(node.children ?? [])
}, 0)
}
+
+function countElementFiles(nodes: ProjectFileNodeView[]): number {
+ return nodes.reduce((total, node) => {
+ if (node.kind === 'file') {
+ return node.path.startsWith('elements/') && /\.(md|json|txt)$/i.test(node.name)
+ ? total + 1
+ : total
+ }
+
+ return total + countElementFiles(node.children ?? [])
+ }, 0)
+}
diff --git a/packages/app/src/utils/file-tree.ts b/packages/app/src/utils/file-tree.ts
new file mode 100644
index 0000000..d8cd57e
--- /dev/null
+++ b/packages/app/src/utils/file-tree.ts
@@ -0,0 +1,76 @@
+import type { ProjectFileNodeView } from '@novai/core/services/types'
+
+/**
+ * 文件树工具函数。
+ *
+ * currentProject.files 是嵌套结构:顶层是项目根目录的直接条目(chapters/、elements/ 等
+ * 目录节点 + novel.config.json 文件),目录的子项在 node.children 里。
+ *
+ * 分类面板需要按业务前缀从这棵嵌套树里提取内容,这里集中维护提取逻辑。
+ */
+
+/**
+ * 在嵌套树里找到路径精确匹配 prefix 的目录节点,返回它的直接子节点(保持目录层级)。
+ *
+ * 用于章节页:取 chapters/ 目录的直接子项(可能含子目录「卷/部」+ 章节文件)。
+ * 用于提示词页:取 prompts/ 目录的直接子项(system.md + scenes/ 子目录)。
+ *
+ * @param files 全量嵌套树
+ * @param prefix 目录路径,如 'chapters' 或 'prompts'
+ * @returns 该目录的直接子节点数组;目录不存在时返回 []
+ */
+export function pickDirectoryChildren(
+ files: ProjectFileNodeView[],
+ prefix: string,
+): ProjectFileNodeView[] {
+ for (const node of files) {
+ if (node.kind === 'directory' && node.path === prefix) {
+ return node.children ?? []
+ }
+ // 递归往子目录里找(应对嵌套较深的场景)
+ if (node.kind === 'directory' && node.children?.length) {
+ const found = pickDirectoryChildren(node.children, prefix)
+ if (found.length > 0) {
+ return found
+ }
+ }
+ }
+ return []
+}
+
+/**
+ * 递归收集某目录前缀下的所有文件节点(拍平,不含目录)。
+ *
+ * 用于要素页:取 elements/characters/ 等目录下的所有 .md 文件,
+ * 无论嵌套多深都收集为扁平列表。
+ *
+ * 注意:必须**无条件递归遍历所有目录**,因为目标目录的祖先(如 elements/)
+ * 自身路径不以 'elements/characters/' 开头,但它的 children 里藏着目标文件。
+ * 只在收集 file 节点时做前缀判断即可。
+ *
+ * @param files 全量嵌套树
+ * @param prefix 目录路径前缀,如 'elements/characters'
+ * @returns 该前缀下所有 file 节点(已扁平化);目录不存在或为空时返回 []
+ */
+export function collectFilesByPrefix(
+ files: ProjectFileNodeView[],
+ prefix: string,
+): ProjectFileNodeView[] {
+ const result: ProjectFileNodeView[] = []
+ const visit = (nodes: ProjectFileNodeView[]) => {
+ for (const node of nodes) {
+ // file 节点:路径前缀匹配则收集
+ if (node.kind === 'file' && node.path.startsWith(`${prefix}/`)) {
+ result.push(node)
+ continue
+ }
+ // directory 节点:无条件递归进 children(祖先目录的 path 不匹配前缀,
+ // 但目标文件可能藏在更深层)
+ if (node.kind === 'directory' && node.children?.length) {
+ visit(node.children)
+ }
+ }
+ }
+ visit(files)
+ return result
+}
diff --git a/packages/app/src/views/HomeView.vue b/packages/app/src/views/HomeView.vue
index 06835e0..aeddeb3 100644
--- a/packages/app/src/views/HomeView.vue
+++ b/packages/app/src/views/HomeView.vue
@@ -163,7 +163,9 @@ async function handleConfirmDeleteProject() {
{{ project.name }}
-
{{ project.chapterCount }} 章
+
+ {{ project.chapterCount }} 章·{{ project.elementCount }} 个要素
+