Compare commits

...
39 changed files with 554 additions and 879 deletions
+2
View File
@@ -993,6 +993,8 @@ class TranscriptionJob(Contract):
class IndexStatus(Contract):
total_notes: int = 0
total_blocks: int = 0
status: Literal["idle", "queued", "running", "failed"] = "idle"
pending_jobs: int = 0
active_job_id: str | None = None
+7
View File
@@ -120,6 +120,13 @@ from app.services.attachment_service import attachment_path
router = APIRouter(prefix="/api")
@router.get("/permissions/policy", tags=["Permissions"])
async def get_permission_policy() -> dict[str, str]:
from app.agent.permissions import KNOWN_PERMISSIONS
return {permission: container.permissions.policy.mode_for(permission).value
for permission in sorted(KNOWN_PERMISSIONS)}
async def mcp_call_async(operation):
"""Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop."""
try:
+4 -1
View File
@@ -126,9 +126,12 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
def get_status() -> IndexStatus:
counts = repository.stats()
if _active_job_id is not None:
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id)
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id,
total_notes=counts["notes"], total_blocks=counts["blocks"])
return IndexStatus(
total_notes=counts["notes"], total_blocks=counts["blocks"],
status="failed" if _last_error else "idle",
pending_jobs=0,
last_completed_at=_last_completed_at,
@@ -0,0 +1,31 @@
import asyncio
from fastapi.testclient import TestClient
from app.main import app
from app.container import container
from app.agent.permissions import PermissionMode
from app.services.note_service import create_note
def test_index_status_returns_real_counts():
with TestClient(app) as client:
initial = client.get('/api/index/status').json()
assert (initial['total_notes'], initial['total_blocks']) == (0, 0)
note = asyncio.run(create_note(title='Real note', markdown='# Real note\n\ncontent', folder=None, tags=[]))
result = client.get('/api/index/status').json()
assert result['total_notes'] == 1
assert result['total_blocks'] == len(note.blocks)
def test_permissions_endpoint_reads_effective_backend_policy():
policy = container.permissions.policy
original = policy.mode_for('attachments.read')
try:
policy.set_rule('attachments.read', PermissionMode.deny)
with TestClient(app) as client:
response = client.get('/api/permissions/policy')
assert response.status_code == 200
assert response.json()['attachments.read'] == 'deny'
finally:
policy.set_rule('attachments.read', original)
@@ -190,3 +190,9 @@ RunCancelled
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。
### 前端真实状态补充(2026-09-04)
- `GET /api/index/status` 额外返回 `total_notes: int``total_blocks: int`,来自当前 SQLite 索引;未建立内容索引时为 0。
- `GET /api/permissions/policy` 返回 `Record<string, "allow" | "confirm" | "deny">`,值取自后端当前生效的 PermissionPolicy。此接口只读,不提供全局修改能力,运行时权限确认仍使用既有 Agent permission endpoint。
@@ -1,6 +1,6 @@
# 前端壳子与接口层开发说明
> 更新日期:2026-09-02
> 更新日期:2026-09-04
> 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
@@ -153,12 +153,7 @@ Service 已适配当前 FastAPI Contract
- 识别 `Done``RunCompleted``RunFailed``RunCancelled`
- 支持 AbortController 主动取消。
Chat Store 已从定时器模拟输出切换为真实 `/api/chat` SSE。默认离线联调配置为:
```text
provider_id = mock
model = mock-1
```
Chat Store 使用真实 `/api/chat` SSE。提供商从后端配置加载,前端不展示后端内置测试 Provider,也不预选模拟模型;模型 ID 使用所选提供商保存的默认值,并支持手动输入。
## 8. 环境和启动
@@ -207,3 +202,34 @@ Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
- Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。
## 阶段 F 前:前端真实数据清理
已删除运行时的聊天示例、Agent Run/Event/Tool/权限示例、Provider/Model、Task、Skill、Plugin、IndexStatus 常量和 searchMock。测试文件中的隔离桩保留,仅用于自动化验证。
- 所有业务 Store 从空集合开始,由真实 API 填充;连接失败显示错误,不回退演示记录。
- 普通聊天仅显示用户实际输入和 SSE 响应;当前会话列表保留在页面会话内,刷新后清空,后端暂无聊天历史持久化接口。切换会话保留本次会话内的真实消息,取消旧流并屏蔽迟到回调。
- 聊天页移除尚未接入的知识库与 Skill 开关,知识库工具和 Skill 通过 Agent 使用。
- 设置页不再伪造健康状态、版本、42 篇笔记/318 个 Block、模型名称和索引能力开关。状态未获取时显示 unknown/未获取;应用版本来自 package.json,后端版本来自 /api/status。
- GET /api/index/status 增加 total_notes、total_blocks,直接读取 SQLite 的当前索引统计。
- GET /api/permissions/policy 返回 PermissionPolicy 的实际生效值。设置页只读展示;全局策略编辑暂未开放,运行权限确认仍走原有 Agent 接口。
- 删除模拟重启成功逻辑,说明 Web 端不具备进程重启能力;索引页面只保留后端已实现的全量重建。
- Task DTO 不再填充后端未返回的优先级和来源,Agent Token 用量不再把未知输入/输出拆分填成 0。
- Plugin/Skill/Provider 无记录时显示空状态,模型发现失败时允许使用真实的手动模型 ID。
验证:前端 81 项测试、类型检查与生产构建通过;后端 454 项测试通过。新增测试覆盖空初始状态、离线错误、真实统计与权限、测试 Provider 过滤、真实聊天历史及旧流隔离。本次未调用真实付费推理 API。
### MCP 工具中文展示补充
Agent 工具列表按 `mcp.<server_id>.<remote_name>` 的远程工具名匹配中文展示,支持 `web_search`(网页搜索)、`understand_image`(图像理解),并补充 `text.uppercase`(文本转大写)。此映射只影响界面,工具调用与权限选择仍使用完整原始 ID。
卡片默认显示三行摘要,完整服务原文可展开查看,展开操作不会改变工具选择。服务已提供中文说明时优先保留;未收录的 MCP 工具明确提示暂无中文说明,不将本地摘要当作服务协议或自动翻译结果。原始说明及其中的参数规则完整保留。
验证:前端 84 项测试、类型检查与生产构建通过。新增回归覆盖不同服务器命名空间、未知工具、服务中文说明、原文完整性,以及选择工具时保留原始 ID。
### 聊天模型选择审阅修复
返回聊天页时保留仍启用的提供商与手动模型 ID,仅刷新其模型列表;未选择、已删除或已禁用的提供商才回退到默认值。提供商加载失败时保留当前选择并展示错误。新增页面重新挂载与异常分支回归,前端共 89 项测试通过。
补充卸载时序修复:提供商或技能加载期间离开聊天页后,旧页面的初始化回调不再修改聊天选择,迟到错误也不再更新旧页面。两种加载延迟均通过先失败、修复后通过的回归测试,并验证返回页面后的默认模型和发送按钮状态;前端共 91 项测试通过。
+2 -1
View File
@@ -39,11 +39,12 @@ const saveStatusColor = computed(() => {
const indexStatusText = computed(() => {
const s = settingsStore.indexStatus.status
return s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误'
return s === 'unknown' ? '索引状态未获取' : s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误'
})
const aiCoreStatusText = computed(() => {
const map: Record<string, string> = {
unknown: 'AI Core 状态未获取',
starting: 'AI Core 启动中',
running: 'AI Core 运行中',
stopped: 'AI Core 已停止',
+12 -10
View File
@@ -207,8 +207,8 @@ export interface PermissionRequest {
}
export interface TokenUsage {
input_tokens: number
output_tokens: number
input_tokens?: number
output_tokens?: number
total_tokens: number
}
@@ -462,11 +462,11 @@ export interface TaskItem {
title: string
description?: string
status: TaskStatus
priority: TaskPriority
priority?: TaskPriority
due_date?: string
note_id?: string
note_title?: string
source: TaskSource
source?: TaskSource
created_at: string
updated_at: string
}
@@ -487,12 +487,12 @@ export interface ThemeConfig {
// ============ Index ============
export interface IndexStatus {
status: 'idle' | 'indexing' | 'error'
status: 'unknown' | 'idle' | 'indexing' | 'error'
pending_jobs: number
total_notes: number
total_blocks: number
fts_enabled: boolean
vector_enabled: boolean
total_notes: number | null
total_blocks: number | null
fts_enabled?: boolean
vector_enabled?: boolean
embedding_model?: string
reranker_model?: string
last_indexed_at?: string
@@ -527,7 +527,7 @@ export type SaveStatus =
| 'external_changed'
| 'conflict'
export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error'
export type AiCoreStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
// ============ FastAPI wire contracts ============
// UI view models above may contain presentation-only fields. Services must use
@@ -777,6 +777,8 @@ export interface ApiTask {
}
export interface ApiIndexStatus {
total_notes: number
total_blocks: number
status: 'idle' | 'queued' | 'running' | 'failed'
pending_jobs: number
active_job_id?: string | null
+14 -14
View File
@@ -5,7 +5,8 @@ import { useAgentStore } from '@/stores/agent'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import type { AgentEvent } from '@/contracts'
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolDescription, toolLabel } from './labels'
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
import ToolOption from './ToolOption.vue'
const route = useRoute()
const router = useRouter()
@@ -14,7 +15,7 @@ const providerStore = useProviderStore()
const skillStore = useSkillStore()
const pageError = ref('')
const form = reactive({
input: '', provider_id: 'mock', model: 'mock-1', skill_id: '', max_steps: 10,
input: '', provider_id: '', model: '', skill_id: '', max_steps: 10,
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000,
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
})
@@ -25,7 +26,7 @@ const isNewRun = computed(() => !route.params.runId)
onMounted(async () => {
try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
await providerStore.loadModels(form.provider_id)
form.provider_id = providerStore.defaultProviderId
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
})
@@ -35,7 +36,10 @@ watch(() => route.params.runId, async (runId) => {
}, { immediate: true })
watch(() => form.provider_id, async (providerId) => {
try { await providerStore.loadModels(providerId); form.model = models.value[0]?.model_id ?? '' } catch { /* page keeps current selection */ }
form.model = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
if (!providerId) return
try { await providerStore.loadModels(providerId) }
catch (error) { if (form.provider_id === providerId) pageError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
})
function toggleTool(name: string) {
@@ -47,6 +51,7 @@ function toggleTool(name: string) {
async function createRun() {
pageError.value = ''
try {
if (!form.provider_id || !form.model.trim()) throw new Error('请选择提供商并填写模型 ID。')
const run = await agentStore.createRun({
input: form.input, provider_id: form.provider_id, model: form.model,
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
@@ -71,12 +76,12 @@ function eventText(event: AgentEvent) {
<section class="feature-page agent-page">
<header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界并实时查看模型工具和权限事件</p></div>
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header>
<div v-if="pageError || agentStore.error" class="error-banner">{{ pageError || agentStore.error }}</div>
<div v-if="pageError || agentStore.error || providerStore.error" class="error-banner">{{ pageError || agentStore.error || providerStore.error }}</div>
<form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun">
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div>
<div class="form-grid">
<div class="field"><label>模型提供商</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
<div class="field"><label>模型</label><select v-model="form.model" class="select"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></select></div>
<div class="field"><label>模型</label><input v-model="form.model" class="input" list="agent-models" placeholder="填写模型 ID" required /><datalist id="agent-models"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></datalist></div>
<div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
<div class="field"><label>工具超时</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
@@ -84,9 +89,9 @@ function eventText(event: AgentEvent) {
<div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
</div>
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ toolLabel(tool.name) }}</strong><code>{{ tool.name }}</code><small>{{ toolDescription(tool.name, tool.description) }}</small></span></label></div></div>
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
</form>
<div v-else class="trace-layout">
@@ -110,12 +115,7 @@ function eventText(event: AgentEvent) {
<style scoped>
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
.run-form { display: grid; gap: var(--space-xl); }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
.tool-option:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.tool-option:has(input:checked) { border-color: var(--color-accent-primary); background: var(--color-accent-soft); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-primary) 10%, transparent); }
.tool-option small { display: block; color: var(--color-text-secondary); }
.tool-option code { display: block; margin: 2px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.network { display: flex; gap: var(--space-sm); }
.trace-layout { display: grid; gap: var(--space-lg); }
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
@@ -0,0 +1,19 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import ToolOption from './ToolOption.vue'
it('shows Chinese summaries, preserves raw metadata and emits the original tool ID', async () => {
const name = 'mcp.9ca7ee21603a.web_search'
const description = 'Search the web. query: string. ' + 'Full provider instructions. '.repeat(40)
const wrapper = mount(ToolOption, { props: { name, description, selected: false } })
expect(wrapper.get('strong').text()).toBe('网页搜索')
expect(wrapper.get('code').text()).toBe(name)
expect(wrapper.get('.tool-summary').text()).toContain('搜索关键词')
expect(wrapper.get('details').attributes('open')).toBeUndefined()
expect(wrapper.get('details p').element.textContent).toBe(description)
await wrapper.get('summary').trigger('click')
expect(wrapper.emitted('toggle')).toBeUndefined()
await wrapper.get('input').setValue(true)
expect(wrapper.emitted('toggle')).toEqual([[name]])
})
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toolDescription, toolLabel } from './labels'
const props = defineProps<{ name: string; description: string; selected: boolean }>()
const emit = defineEmits<{ toggle: [name: string] }>()
const summary = computed(() => toolDescription(props.name, props.description))
const showOriginal = computed(() => props.description.length > 0)
</script>
<template>
<article class="tool-choice" :class="{ selected }">
<label class="tool-selection">
<input type="checkbox" :checked="selected" @change="emit('toggle', name)" />
<span class="tool-copy">
<strong>{{ toolLabel(name) }}</strong>
<code>{{ name }}</code>
<small class="tool-summary">{{ summary }}</small>
</span>
</label>
<details v-if="showOriginal" class="tool-original">
<summary>查看服务原文与参数</summary>
<p>{{ description }}</p>
</details>
</article>
</template>
<style scoped>
.tool-choice { min-width: 0; padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.tool-choice.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); }
.tool-selection { display: flex; align-items: flex-start; gap: var(--space-sm); cursor: pointer; }
.tool-selection input { flex-shrink: 0; margin-top: 4px; }
.tool-copy { min-width: 0; overflow-wrap: anywhere; }
.tool-copy strong, .tool-copy code, .tool-summary { display: block; }
.tool-copy code { margin: 3px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.tool-summary { color: var(--color-text-secondary); line-height: 1.6; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
.tool-original { margin-top: var(--space-sm); font-size: var(--font-size-xs); }
.tool-original summary { cursor: pointer; color: var(--color-text-secondary); }
.tool-original p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 240px; overflow: auto; margin-top: var(--space-sm); user-select: text; }
</style>
@@ -9,6 +9,21 @@ import {
} from './labels'
describe('智能体页面中文标签', () => {
it('按 MCP 远程工具名匹配中文,不依赖服务器 ID', () => {
for (const server of ['9ca7ee21603a', 'another-server']) {
expect(toolLabel(`mcp.${server}.web_search`)).toBe('网页搜索')
expect(toolLabel(`mcp.${server}.understand_image`)).toBe('图像理解')
expect(toolDescription(`mcp.${server}.web_search`, 'Search the web')).toContain('搜索关键词')
}
expect(toolLabel('text.uppercase')).toBe('文本转大写')
expect(toolDescription('text.uppercase', 'Convert input text to uppercase.')).toContain('大写')
})
it('保留服务端中文,未知工具不编造翻译或套用内置工具语义', () => {
expect(toolDescription('mcp.server.web_search', '仅搜索指定站点。')).toBe('仅搜索指定站点。')
expect(toolDescription('mcp.server.custom_action', 'Private action')).toContain('暂无中文说明')
expect(toolLabel('mcp.server.notes.delete')).toBe('MCP 工具 · notes.delete')
})
it('转换运行状态和事件名称', () => {
expect(runStatusLabel('waiting_permission')).toBe('等待授权')
expect(eventLabel('ToolCall')).toBe('调用工具')
+27 -1
View File
@@ -42,6 +42,7 @@ const toolLabels: Record<string, string> = {
'tasks.list': '列出任务',
'attachments.read': '读取附件',
'audio.transcribe': '音频转写',
'text.uppercase': '文本转大写',
}
const toolDescriptions: Record<string, string> = {
@@ -58,7 +59,25 @@ const toolDescriptions: Record<string, string> = {
'tasks.update': '更新已有任务。',
'tasks.list': '列出已持久化的任务。',
'attachments.read': '读取由宿主管理的 UTF-8 附件。',
'audio.transcribe': '读取音频附件已有的宿主转写结果。',
'audio.transcribe': '将音频转写为文本,按模型路由使用 API 或本地后端。',
'text.uppercase': '将输入文本中的字母转换为大写。',
}
// MCP IDs contain a server-specific namespace. Localize the remote tool name
// for presentation only; requests must keep using the complete original ID.
const mcpTools: Record<string, { label: string; description: string }> = {
web_search: {
label: '网页搜索',
description: '搜索实时或外部网页信息。输入搜索关键词;结果包含标题、链接、摘要等信息。时效性问题可在关键词中加入日期,完整参数以服务原文为准。',
},
understand_image: {
label: '图像理解',
description: '根据提示词分析图片、描述内容或提取信息。输入分析要求和图片地址或本地路径;支持的格式与路径规则请查看服务原文。',
},
}
function mcpName(name: string): string | undefined {
return /^mcp\.[^.]+\.(.+)$/.exec(name)?.[1]
}
const permissionLabels: Record<string, string> = {
@@ -105,10 +124,17 @@ export function eventLabel(event: AgentEventType): string {
}
export function toolLabel(name: string): string {
const remote = mcpName(name)
if (remote) return mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`
return toolLabels[name] ?? name
}
export function toolDescription(name: string, fallback: string): string {
const remote = mcpName(name)
if (remote) {
if (/\p{Script=Han}/u.test(fallback)) return fallback
return mcpTools[remote]?.description ?? '暂无中文说明,请展开查看服务原文。'
}
return toolDescriptions[name] ?? fallback
}
@@ -0,0 +1,89 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import ChatView from './ChatView.vue'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } }))
beforeEach(() => {
setActivePinia(createPinia())
const providers = useProviderStore()
providers.providers = ['a', 'b'].map(id => ({
provider_id: id, provider_type: 'openai_compatible', name: id,
default_model: `${id}-default`, enabled: true, capabilities: { chat: true }, has_credential: false,
}))
providers.defaultProviderId = 'a'
vi.spyOn(providers, 'loadProviders').mockResolvedValue(undefined)
vi.spyOn(providers, 'loadModels').mockResolvedValue([])
vi.spyOn(useSkillStore(), 'loadSkills').mockResolvedValue(undefined)
})
it('preserves the selected provider and manual model after leaving and returning to chat', async () => {
const chat = useChatStore()
const first = mount(ChatView)
await flushPromises()
await first.get('select').setValue('b')
await first.get('input[list="chat-models"]').setValue('b-manual')
first.unmount()
const returned = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('b')
expect(chat.selectedModel).toBe('b-manual')
expect(useProviderStore().loadModels).toHaveBeenLastCalledWith('b')
returned.unmount()
})
it.each(['missing', 'disabled', 'unselected'])('uses the default when the selected provider is %s', async state => {
const chat = useChatStore()
chat.selectedProviderId = state === 'unselected' ? '' : state === 'missing' ? 'deleted' : 'b'
chat.selectedModel = 'old-model'
if (state === 'disabled') useProviderStore().providers[1]!.enabled = false
const wrapper = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('a')
expect(chat.selectedModel).toBe('a-default')
wrapper.unmount()
})
it('preserves the selection when provider discovery fails', async () => {
const chat = useChatStore()
chat.selectedProviderId = 'b'
chat.selectedModel = 'b-manual'
useProviderStore().error = 'offline'
const wrapper = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('b')
expect(chat.selectedModel).toBe('b-manual')
expect(wrapper.get('.error-banner').text()).toBe('offline')
wrapper.unmount()
})
it.each(['providers', 'skills'])('ignores initialization after unmount while %s are loading', async source => {
const chat = useChatStore()
let finish!: () => void
const pending = new Promise<void>(resolve => { finish = resolve })
if (source === 'providers') vi.mocked(useProviderStore().loadProviders).mockReturnValueOnce(pending)
else vi.mocked(useSkillStore().loadSkills).mockReturnValueOnce(pending)
const first = mount(ChatView)
first.unmount()
finish()
await flushPromises()
expect(chat.selectedProviderId).toBe('')
expect(chat.selectedModel).toBe('')
expect(useProviderStore().loadModels).not.toHaveBeenCalled()
const returned = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('a')
expect(chat.selectedModel).toBe('a-default')
await returned.get('textarea').setValue('hello')
expect(returned.get('button.button-primary').attributes('disabled')).toBeUndefined()
returned.unmount()
})
+27 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Citation } from '@/contracts'
import { useChatStore } from '@/stores/chat'
@@ -16,26 +16,37 @@ const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
const router = useRouter()
const loadError = ref('')
let disposed = false
onBeforeUnmount(() => { disposed = true })
const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? [])
onMounted(async () => {
try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
await providerStore.loadModels(chatStore.selectedProviderId)
if (disposed || providerStore.error) return
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
if (!selected) {
chatStore.selectedProviderId = providerStore.defaultProviderId
} else {
await refreshModels(selected.provider_id)
}
} catch (error) {
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,当前展示本地数据。'
if (disposed) return
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,请检查后端连接。'
}
})
async function refreshModels(providerId: string) {
loadError.value = ''
if (!providerId) return
try { await providerStore.loadModels(providerId) }
catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
}
watch(() => chatStore.selectedProviderId, async (providerId) => {
try {
await providerStore.loadModels(providerId)
const firstModel = providerStore.modelsByProvider[providerId]?.[0]
if (firstModel) chatStore.selectedModel = firstModel.model_id
} catch (error) {
loadError.value = error instanceof Error ? error.message : '模型列表加载失败'
}
chatStore.selectedModel = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
await refreshModels(providerId)
})
function send() { void chatStore.sendMessage(chatStore.inputText) }
@@ -54,17 +65,12 @@ async function openCitation(citation: Citation) {
<div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select">
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
</select></div>
<div class="field compact"><label>Model</label><select v-model="chatStore.selectedModel" class="select">
<option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
</select></div>
<div class="field compact"><label>Skill</label><select v-model="chatStore.selectedSkillId" class="select">
<option :value="null">不使用 Skill</option><option v-for="skill in skillStore.enabledSkills" :key="skill.skill_id" :value="skill.skill_id">{{ skill.name }}</option>
</select></div>
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" /> 使用知识库</label>
<div class="field compact"><label>模型 ID</label><input v-model="chatStore.selectedModel" class="input" list="chat-models" placeholder="填写模型 ID" /><datalist id="chat-models"><option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></div>
<span class="subtle">知识库问答与技能请使用智能体普通聊天尚未接入这些能力</span>
</header>
<div v-if="loadError" class="error-banner chat-error">{{ loadError }}</div>
<div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div>
<main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>可以直接提问也可以打开 RAG 让模型基于当前 Vault 回答</p></div></div>
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>请先配置模型提供商聊天记录仅保留在本次页面会话中</p></div></div>
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
<div class="message-body">
@@ -78,7 +84,7 @@ async function openCitation(citation: Citation) {
</button>
</div>
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }}</small>
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}<span v-if="message.usage.input_tokens !== undefined && message.usage.output_tokens !== undefined">输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }}</span></small>
</div>
</article>
</main>
@@ -87,7 +93,7 @@ async function openCitation(citation: Citation) {
@keydown.ctrl.enter.prevent="send" />
<div class="composer-actions"><span class="subtle">回答可能包含错误请核对 Citation</span>
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button>
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim()" @click="send">发送</button>
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">发送</button>
</div>
</footer>
</section>
@@ -27,6 +27,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill{{ pluginStore.selectedPlugin.dependent_skills.join('') }}</div>
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
</div>
<div v-else-if="!pluginStore.plugins.length" class="empty-state"><div><strong>{{ pluginStore.isLoading ? '正在加载…' : pluginStore.error ? '加载失败' : '尚未安装' }}</strong><button class="button-secondary" @click="pluginStore.loadPlugins">重新加载</button></div></div>
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} Contribution</p></article></div>
</section>
</template>
@@ -109,7 +109,7 @@ async function save() {
error.value = ''
saving.value = true
try {
if (!form.name.trim() || (form.provider_type !== 'mock' && !form.base_url.trim())) throw new Error('请填写名称和 Base URL。')
if (!form.name.trim() || !form.base_url.trim()) throw new Error('请填写名称和 Base URL。')
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false }
@@ -147,9 +147,9 @@ async function save() {
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
<div class="form-grid">
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option><option v-if="provider?.provider_type === 'mock'" value="mock">Mock</option></select></label>
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></label>
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" :required="form.provider_type !== 'mock'" @change="changeConnection" /></label>
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" required @change="changeConnection" /></label>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p>
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
@@ -70,6 +70,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<button class="button-primary" @click="openProvider()">新增 Provider</button>
</div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? '正在加载提供商' : '尚无可用提供商请添加真实 API 或本地 Ollama 配置' }}</p>
<div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main">
@@ -91,17 +92,17 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中' : '刷新模型' }}</button>
<button class="button-secondary" @click="testProvider(provider)">测试</button>
<button class="button-secondary" @click="openProvider(provider)">编辑</button>
<button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button>
<button class="button-danger" @click="removeProvider(provider)">删除</button>
</div>
</article>
</div>
</div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? '未获取' }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? '未获取' }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><span class="subtle">当前后端支持全量重建</span></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">尚未获取权限策略,请检查后端连接并重新检测。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? '允许' : policy === 'confirm' ? '每次确认' : '拒绝' }}</span></div></div></div>
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>AI Core 连接状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><span class="subtle">当前 Web 端不支持重启后端进程请在运行后端的终端中操作</span></div></div>
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
</section>
@@ -32,6 +32,7 @@ async function uninstall(skillId: string, name: string) {
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖{{ skillStore.selectedSkill.missing_dependencies.join('') }}</div>
</div>
<div v-else-if="!skillStore.skills.length" class="empty-state"><div><strong>{{ skillStore.isLoading ? '正在加载…' : skillStore.error ? '加载失败' : '尚未安装' }}</strong><button class="button-secondary" @click="skillStore.loadSkills">重新加载</button></div></div>
<div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><AppIcon :icon="Lightning" :size="22" /><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div>
</section>
</template>
+3 -3
View File
@@ -23,13 +23,13 @@ onMounted(async () => {
await openVault(lastVaultPath)
return
} catch {
// Mock Vault
// Vault
localStorage.removeItem('last-vault-path')
}
}
setTimeout(() => {
{
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}, 800)
}
})
async function openVault(path: string) {
+1 -207
View File
@@ -1,6 +1,6 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition } from '@/contracts'
function toAgentRun(run: ApiAgentRun): AgentRun {
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
@@ -10,8 +10,6 @@ function toAgentRun(run: ApiAgentRun): AgentRun {
current_step: run.current_step,
max_steps: run.max_steps,
token_usage: {
input_tokens: 0,
output_tokens: 0,
total_tokens: run.token_usage,
},
started_at: run.created_at,
@@ -107,207 +105,3 @@ export async function respondToPermission(
decision,
})
}
export const mockTools: ToolDefinition[] = [
{
name: 'notes.search',
description: '搜索笔记,支持关键词和语义检索',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' },
limit: { type: 'number', description: '返回结果数量' },
},
required: ['query'],
},
source: 'builtin',
},
{
name: 'notes.read',
description: '读取指定笔记的完整内容',
parameters: {
type: 'object',
properties: {
note_id: { type: 'string' },
},
required: ['note_id'],
},
source: 'builtin',
},
{
name: 'notes.create',
description: '创建新笔记',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
content: { type: 'string' },
folder_path: { type: 'string' },
},
required: ['title', 'content'],
},
source: 'builtin',
},
{
name: 'rag.search',
description: '基于 RAG 的语义检索,返回相关知识片段',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
top_k: { type: 'number' },
},
required: ['query'],
},
source: 'builtin',
},
{
name: 'tasks.create',
description: '创建任务',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
},
required: ['title'],
},
source: 'builtin',
},
{
name: 'system.echo',
description: '回显输入内容(测试用)',
parameters: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
source: 'builtin',
},
{
name: 'math.add',
description: '两数相加(测试用)',
parameters: {
type: 'object',
properties: {
a: { type: 'number' },
b: { type: 'number' },
},
required: ['a', 'b'],
},
source: 'builtin',
},
]
export const mockAgentRuns: AgentRun[] = [
{
run_id: 'run-1',
status: 'completed',
current_step: 3,
max_steps: 10,
token_usage: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
started_at: '2026-08-25T11:00:00Z',
completed_at: '2026-08-25T11:02:30Z',
},
{
run_id: 'run-2',
status: 'running',
current_step: 2,
max_steps: 10,
token_usage: { input_tokens: 1500, output_tokens: 420, total_tokens: 1920 },
started_at: '2026-08-26T09:30:00Z',
},
]
export const mockAgentEvents: AgentEvent[] = [
{
event: 'RunStarted',
sequence: 1,
run_id: 'run-1',
data: { task: '帮我整理红黑树的核心知识点' },
timestamp: '2026-08-25T11:00:00Z',
},
{
event: 'ThinkingDelta',
sequence: 2,
run_id: 'run-1',
data: { text: '我需要先搜索笔记中关于红黑树的内容...' },
timestamp: '2026-08-25T11:00:01Z',
},
{
event: 'ToolCall',
sequence: 3,
run_id: 'run-1',
data: {
tool_call_id: 'tc-1',
name: 'notes.search',
parameters: { query: '红黑树 插入 删除', limit: 5 },
status: 'running',
},
timestamp: '2026-08-25T11:00:02Z',
},
{
event: 'ToolResult',
sequence: 4,
run_id: 'run-1',
data: {
tool_call_id: 'tc-1',
name: 'notes.search',
status: 'completed',
result: '找到 5 条相关结果,包括红黑树性质、插入操作、删除操作等...',
duration_ms: 320,
},
timestamp: '2026-08-25T11:00:02Z',
},
{
event: 'Citation',
sequence: 5,
run_id: 'run-1',
data: {
note_id: 'n-rbt',
block_id: 'b1',
heading_path: '数据结构 / 红黑树 / 性质',
},
timestamp: '2026-08-25T11:00:03Z',
},
{
event: 'ThinkingDelta',
sequence: 6,
run_id: 'run-1',
data: { text: '搜索结果很全面,让我整理一下结构...' },
timestamp: '2026-08-25T11:00:03Z',
},
{
event: 'TextDelta',
sequence: 7,
run_id: 'run-1',
data: { text: '## 红黑树核心知识点整理\n\n### 1. 基本性质\n红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性...' },
timestamp: '2026-08-25T11:00:04Z',
},
{
event: 'Usage',
sequence: 8,
run_id: 'run-1',
data: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
timestamp: '2026-08-25T11:02:30Z',
},
{
event: 'RunCompleted',
sequence: 9,
run_id: 'run-1',
data: { message: 'Task completed successfully' },
timestamp: '2026-08-25T11:02:30Z',
},
]
export const mockPermissionRequest: PermissionRequest = {
request_id: 'perm-1',
run_id: 'run-2',
tool_name: 'notes.create',
permission: 'notes.write',
parameters: { title: '红黑树知识点总结', folder_path: '/数据结构' },
impact: '将在你的知识库中创建一篇新笔记',
}
+1 -85
View File
@@ -1,5 +1,5 @@
import { SseClient } from './sseClient'
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts'
import type { ModelEvent } from '@/contracts'
export interface ChatRequest {
provider_id: string
@@ -46,87 +46,3 @@ export function streamChat(
client.connect().catch(() => {})
return client
}
export const mockConversations: Conversation[] = [
{
conversation_id: 'conv-1',
title: '关于红黑树的讨论',
created_at: '2026-08-25T10:00:00Z',
updated_at: '2026-08-25T10:30:00Z',
message_count: 6,
},
{
conversation_id: 'conv-2',
title: '死锁避免算法',
created_at: '2026-08-24T14:00:00Z',
updated_at: '2026-08-24T15:20:00Z',
message_count: 4,
},
{
conversation_id: 'conv-3',
title: 'TCP三次握手',
created_at: '2026-08-22T09:00:00Z',
updated_at: '2026-08-22T09:15:00Z',
message_count: 3,
},
]
export const mockMessages: Record<string, ChatMessage[]> = {
'conv-1': [
{
message_id: 'msg-1',
conversation_id: 'conv-1',
role: 'user',
content: '红黑树的插入操作有哪些情况需要调整?',
created_at: '2026-08-25T10:00:00Z',
},
{
message_id: 'msg-2',
conversation_id: 'conv-1',
role: 'assistant',
content:
'红黑树插入后可能违反红黑性质,需要通过旋转和变色来修复。主要有以下几种情况:\n\n**情况1:叔叔节点是红色**\n- 将父节点和叔叔节点设为黑色\n- 将祖父节点设为红色\n- 当前节点上移到祖父节点,继续向上调整\n\n**情况2:叔叔节点是黑色,且当前节点是右孩子**\n- 以父节点为支点左旋\n- 将当前节点转换为左孩子,进入情况3\n\n**情况3:叔叔节点是黑色,且当前节点是左孩子**\n- 以祖父节点为支点右旋\n- 将父节点设为黑色,祖父节点设为红色\n\n以上三种情况需要从插入节点开始向上递归处理,直到根节点。',
created_at: '2026-08-25T10:01:00Z',
citations: [
{
note_id: 'n-rbt',
block_id: 'b1',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 插入操作',
content: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
},
{
note_id: 'n-rbt',
block_id: 'b2',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 五种情况',
content: 'Case 1: 叔叔为红色,直接变色...',
},
],
},
{
message_id: 'msg-3',
conversation_id: 'conv-1',
role: 'user',
content: '能对比一下 AVL 树和红黑树的性能差异吗?',
created_at: '2026-08-25T10:10:00Z',
},
{
message_id: 'msg-4',
conversation_id: 'conv-1',
role: 'assistant',
content:
'AVL 树和红黑树都是平衡二叉搜索树,但它们的平衡策略和性能特点有所不同:\n\n| 特性 | AVL 树 | 红黑树 |\n|------|--------|--------|\n| 严格平衡 | 高度差 ≤ 1 | 黑色高度相同 |\n| 查找速度 | 更快(严格平衡) | 略慢但仍为 O(log n) |\n| 插入/删除 | 旋转更多 | 旋转更少(颜色调整为主) |\n| 存储开销 | 每个节点存高度(int) | 每个节点存颜色(1 bit) |\n| 适用场景 | 读多写少 | 读写均衡或写较多 |\n\n**总结:**\n- 如果应用以查询为主,很少修改,AVL 树可能更优\n- 如果插入删除频繁,红黑树更合适,重平衡开销更低\n- 红黑树在工业界应用更广泛(C++ STL 的 map/set、Java 的 TreeMap 等)',
created_at: '2026-08-25T10:11:00Z',
citations: [
{
note_id: 'n-rbt',
block_id: 'b3',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 与AVL树对比',
content: '红黑树相比AVL树,牺牲了部分平衡性以换取更少的旋转操作...',
},
],
},
],
}
+2 -16
View File
@@ -5,10 +5,8 @@ function toIndexStatus(status: ApiIndexStatus): IndexStatus {
return {
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
pending_jobs: status.pending_jobs,
total_notes: 0,
total_blocks: 0,
fts_enabled: true,
vector_enabled: true,
total_notes: status.total_notes ?? null,
total_blocks: status.total_blocks ?? null,
last_indexed_at: status.last_completed_at ?? undefined,
error: status.error_message ?? undefined,
}
@@ -26,15 +24,3 @@ export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): P
export async function getIndexJob(jobId: string): Promise<ApiIndexJob> {
return apiClient.get(`/api/index/jobs/${jobId}`)
}
export const mockIndexStatus: IndexStatus = {
status: 'idle',
pending_jobs: 0,
total_notes: 42,
total_blocks: 318,
fts_enabled: true,
vector_enabled: true,
embedding_model: 'bge-m3',
reranker_model: 'bge-reranker-base',
last_indexed_at: new Date().toISOString(),
}
-89
View File
@@ -126,92 +126,3 @@ export async function deletePluginSecret(pluginId: string, key: string): Promise
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`)
}
export const mockPlugins: Plugin[] = [
{
plugin_id: 'github-integration',
name: 'GitHub 集成',
version: '1.3.2',
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
icon: '',
author: 'NotesAgent 团队',
status: 'ready',
enabled: true,
permissions: ['notes.read', 'network.request'],
contributions: [
{ type: 'tool', id: 'github.search_issues', name: '搜索 Issue', description: '搜索 GitHub 仓库中的 Issue' },
{ type: 'tool', id: 'github.get_pr', name: '获取 PR 详情', description: '获取 Pull Request 的详细信息' },
{ type: 'command', id: 'github.open_repo', name: '打开仓库', description: '在浏览器中打开对应 GitHub 仓库' },
],
backend_type: 'mcp',
transport: 'stdio',
dependent_skills: ['research-assistant'],
},
{
plugin_id: 'translator',
name: '翻译助手',
version: '1.0.0',
description: '提供多语言翻译能力,支持文档批量翻译',
icon: '',
author: '社区贡献',
status: 'ready',
enabled: false,
permissions: ['notes.read', 'notes.write', 'network.request'],
contributions: [
{ type: 'tool', id: 'translator.translate', name: '翻译文本', description: '翻译指定文本到目标语言' },
{ type: 'command', id: 'translator.translate_note', name: '翻译当前笔记', description: '翻译当前打开的笔记' },
{ type: 'settings_section', id: 'translator.settings', name: '翻译设置', description: '配置翻译服务和默认语言' },
],
backend_type: 'mcp',
transport: 'stdio',
},
{
plugin_id: 'kanban',
name: '看板视图',
version: '0.8.0',
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
icon: '',
author: '社区贡献',
status: 'installed',
enabled: false,
permissions: ['tasks.read', 'tasks.write'],
contributions: [
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
],
backend_type: 'internal_rpc',
},
{
plugin_id: 'pdf-importer',
name: 'PDF 导入',
version: '2.1.0',
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
icon: '',
author: 'NotesAgent 团队',
status: 'error',
enabled: false,
permissions: ['notes.write', 'attachments.read'],
contributions: [
{ type: 'importer', id: 'pdf.import', name: 'PDF 导入器', description: '从 PDF 文件导入内容' },
],
backend_type: 'mcp',
transport: 'stdio',
last_error: 'PDF 解析库初始化失败,请检查 Python 依赖',
},
{
plugin_id: 'calendar',
name: '日历同步',
version: '0.5.0',
description: '同步日历事件,自动生成相关笔记和任务提醒',
icon: '',
author: '社区贡献',
status: 'dependency_missing',
enabled: false,
permissions: ['tasks.read', 'tasks.write', 'network.request'],
contributions: [
{ type: 'tool', id: 'calendar.events', name: '日历事件', description: '获取日历事件列表' },
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
],
backend_type: 'mcp',
transport: 'http',
},
]
+2 -101
View File
@@ -15,7 +15,7 @@ function toProvider(provider: ApiProviderConfig): ProviderConfig {
enabled: provider.enabled,
capabilities: capabilityMap(provider.capabilities),
credential_id: provider.credential_id ?? undefined,
has_credential: Boolean(provider.credential_id) || provider.provider_type === 'mock',
has_credential: Boolean(provider.credential_id),
}
}
@@ -25,7 +25,7 @@ function toModel(model: ApiModelInfo): ModelInfo {
export async function listProviders(): Promise<ProviderConfig[]> {
const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers')
return response.items.map(toProvider)
return response.items.filter(provider => provider.provider_type !== 'mock').map(toProvider)
}
export async function getProvider(providerId: string): Promise<ProviderConfig> {
@@ -97,102 +97,3 @@ export async function testProvider(providerId: string): Promise<TestResult> {
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
}
}
export const mockProviders: ProviderConfig[] = [
{
provider_id: 'mock',
provider_type: 'mock',
name: 'Mock Provider (测试)',
default_model: 'mock-1',
enabled: true,
has_credential: true,
capabilities: {
chat: true,
tool_calling: true,
streaming: true,
vision: false,
reasoning: false,
structured_output: true,
embedding: false,
},
},
{
provider_id: 'openai-compat-1',
provider_type: 'openai_compatible',
name: 'OpenAI 兼容服务',
base_url: 'https://api.openai.com/v1',
default_model: 'gpt-4o-mini',
enabled: true,
has_credential: true,
capabilities: {
chat: true,
tool_calling: true,
streaming: true,
vision: true,
reasoning: false,
structured_output: true,
embedding: true,
},
},
{
provider_id: 'ollama-local',
provider_type: 'ollama',
name: 'Ollama (本地)',
base_url: 'http://127.0.0.1:11434',
default_model: 'qwen2.5:7b',
enabled: false,
has_credential: false,
capabilities: {
chat: true,
tool_calling: false,
streaming: true,
vision: false,
reasoning: false,
structured_output: false,
embedding: true,
},
},
]
export const mockModels: Record<string, ModelInfo[]> = {
mock: [
{
model_id: 'mock-1',
name: 'Mock Model v1',
capabilities: { chat: true, tool_calling: true, streaming: true, structured_output: true },
context_window: 8192,
},
],
'openai-compat-1': [
{
model_id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true },
context_window: 128000,
},
{
model_id: 'gpt-4o',
name: 'GPT-4o',
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true, reasoning: true },
context_window: 128000,
},
{
model_id: 'text-embedding-3-small',
name: 'Text Embedding 3 Small',
capabilities: { embedding: true },
},
],
'ollama-local': [
{
model_id: 'qwen2.5:7b',
name: 'Qwen 2.5 7B',
capabilities: { chat: true, streaming: true },
context_window: 32768,
},
{
model_id: 'bge-m3',
name: 'BGE M3',
capabilities: { embedding: true },
},
],
}
-66
View File
@@ -35,69 +35,3 @@ export async function search(request: SearchRequest): Promise<{
mode: response.mode,
}
}
export async function searchMock(
query: string,
mode: 'fts' | 'vector' | 'hybrid' = 'hybrid'
): Promise<{
results: SearchResult[]
total: number
mode: 'fts' | 'vector' | 'hybrid'
}> {
await new Promise((r) => setTimeout(r, 300))
if (!query.trim()) return { results: [], total: 0, mode }
const results: SearchResult[] = [
{
block_id: 'b1',
note_id: 'n-rbt',
note_title: '红黑树',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 插入操作',
snippet: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
score: 0.95,
match_type: 'hybrid',
tags: ['数据结构', '树'],
},
{
block_id: 'b2',
note_id: 'n-rbt',
note_title: '红黑树',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 性质',
snippet: '红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性(红或黑)...',
score: 0.87,
match_type: 'fts',
tags: ['数据结构'],
},
{
block_id: 'b3',
note_id: 'n-bst',
note_title: '二叉搜索树',
file_path: '/数据结构/二叉搜索树.md',
heading_path: '数据结构 / 二叉搜索树 / 基本操作',
snippet: '二叉搜索树的插入需要先找到合适的位置,再添加新节点...',
score: 0.72,
match_type: 'vector',
tags: ['数据结构', '树'],
},
{
block_id: 'b4',
note_id: 'n-deadlock',
note_title: '死锁',
file_path: '/操作系统/死锁.md',
heading_path: '操作系统 / 死锁 / 必要条件',
snippet: '死锁的四个必要条件:互斥、占有并等待、不可抢占、循环等待...',
score: 0.45,
match_type: 'vector',
tags: ['操作系统'],
},
]
const filtered = results.filter(
(r) =>
r.note_title.includes(query) ||
r.snippet.includes(query) ||
r.heading_path.includes(query) ||
query.length > 1
)
return { results: filtered, total: filtered.length, mode }
}
-74
View File
@@ -42,77 +42,3 @@ export async function disableSkill(skillId: string): Promise<Skill> {
export async function uninstallSkill(skillId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/skills/${skillId}`)
}
export const mockSkills: Skill[] = [
{
skill_id: 'exam-review',
name: '期末复习助手',
version: '1.0.0',
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
icon: '',
author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 10, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling'] },
status: 'ready',
enabled: true,
},
{
skill_id: 'meeting-summary',
name: '会议纪要生成',
version: '1.1.0',
description: '从音频或文本中提取会议要点、行动项和待办任务',
icon: '',
author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
retrieval_config: { top_k: 5, rerank: false, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling', 'structured_output'] },
status: 'ready',
enabled: true,
},
{
skill_id: 'code-explainer',
name: '代码解读助手',
version: '0.9.0',
description: '分析代码片段,解释功能、复杂度和优化建议',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read'],
tools: ['notes.search', 'notes.read', 'rag.search'],
retrieval_config: { top_k: 8, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling'] },
status: 'installed',
enabled: false,
},
{
skill_id: 'research-assistant',
name: '文献研究助手',
version: '1.2.0',
description: '自动整理文献笔记,生成研究综述和引用关系图',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'notes.write'],
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
retrieval_config: { top_k: 15, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling', 'reasoning'] },
status: 'dependency_missing',
enabled: false,
missing_dependencies: ['文献引用插件', '知识图谱插件'],
},
{
skill_id: 'language-tutor',
name: '语言学习助手',
version: '0.5.0',
description: '基于你的学习笔记生成语言练习和记忆卡片',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 6, rerank: false, citation: false },
model_requirements: { capabilities: ['chat'] },
status: 'ready',
enabled: true,
},
]
+8 -17
View File
@@ -1,23 +1,14 @@
import apiClient from './apiClient'
import type { SystemStatus } from '@/contracts'
export async function healthCheck(): Promise<{ status: string }> {
try {
return await apiClient.get<{ status: string }>('/health')
} catch {
return { status: 'unavailable' }
}
export function healthCheck(): Promise<{ status: string }> {
return apiClient.get('/health')
}
export async function getStatus(): Promise<SystemStatus> {
try {
return await apiClient.get<SystemStatus>('/api/status')
} catch {
return {
status: 'ok',
name: 'notes-agent',
version: '0.1.0',
environment: import.meta.env.DEV ? 'development' : 'production',
}
}
export function getStatus(): Promise<SystemStatus> {
return apiClient.get('/api/status')
}
export function getPermissionPolicy(): Promise<Record<string, 'allow' | 'confirm' | 'deny'>> {
return apiClient.get('/api/permissions/policy')
}
+1 -67
View File
@@ -1,5 +1,5 @@
import apiClient from './apiClient'
import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus, TaskPriority } from '@/contracts'
import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus } from '@/contracts'
function toTask(task: ApiTask): TaskItem {
return {
@@ -7,10 +7,8 @@ function toTask(task: ApiTask): TaskItem {
title: task.title,
description: task.description,
status: task.status,
priority: 'medium',
due_date: task.due_at ?? undefined,
note_id: task.note_id ?? undefined,
source: 'user',
created_at: task.created_at,
updated_at: task.updated_at,
}
@@ -60,67 +58,3 @@ export async function updateTask(
export async function deleteTask(taskId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/tasks/${taskId}`)
}
export const mockTasks: TaskItem[] = [
{
task_id: 't-1',
title: '完成红黑树章节复习',
description: '整理插入、删除操作的所有情况,准备期末复习',
status: 'todo',
priority: 'high',
due_date: '2026-08-30T23:59:00Z',
note_id: 'n-rbt',
note_title: '红黑树',
source: 'user',
created_at: '2026-08-20T10:00:00Z',
updated_at: '2026-08-25T14:30:00Z',
},
{
task_id: 't-2',
title: '理解死锁的银行家算法',
description: '推导银行家算法的安全性检查过程',
status: 'in_progress',
priority: 'medium',
note_id: 'n-deadlock',
note_title: '死锁',
source: 'agent',
created_at: '2026-08-22T09:00:00Z',
updated_at: '2026-08-24T16:00:00Z',
},
{
task_id: 't-3',
title: 'TCP 三次握手与四次挥手',
description: '',
status: 'done',
priority: 'high',
note_id: 'n-tcp',
note_title: 'TCP_IP',
source: 'user',
created_at: '2026-08-15T08:00:00Z',
updated_at: '2026-08-18T20:00:00Z',
},
{
task_id: 't-4',
title: 'HTTP 状态码整理',
description: '整理常见 HTTP 状态码及含义',
status: 'todo',
priority: 'low',
note_id: 'n-http',
note_title: 'HTTP协议',
source: 'note',
created_at: '2026-08-10T10:00:00Z',
updated_at: '2026-08-10T10:00:00Z',
},
{
task_id: 't-5',
title: '链表操作实现练习',
description: '实现单链表和双向链表的基本操作',
status: 'todo',
priority: 'medium',
note_id: 'n-slist',
note_title: '单链表',
source: 'agent',
created_at: '2026-08-23T11:00:00Z',
updated_at: '2026-08-23T11:00:00Z',
},
]
+15 -13
View File
@@ -1,21 +1,21 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
import * as agentService from '@/services/agentService'
import type { SseClient } from '@/services/sseClient'
export const useAgentStore = defineStore('agent', () => {
const runs = ref<AgentRun[]>(mockAgentRuns)
const activeRunId = ref<string | null>('run-1')
const events = ref<AgentEvent[]>(mockAgentEvents.filter((e) => e.run_id === 'run-1'))
const tools = ref<ToolDefinition[]>(mockTools)
const runs = ref<AgentRun[]>([])
const activeRunId = ref<string | null>(null)
const events = ref<AgentEvent[]>([])
const tools = ref<ToolDefinition[]>([])
const isCreating = ref(false)
const isRunning = ref(false)
const permissionRequest = ref<PermissionRequest | null>(null)
const toolCalls = ref<ToolCall[]>([])
const error = ref<string | null>(null)
let eventStream: SseClient | null = null
let selectionVersion = 0
const activeRun = computed(() =>
runs.value.find((r) => r.run_id === activeRunId.value) || null
@@ -40,9 +40,15 @@ export const useAgentStore = defineStore('agent', () => {
}
async function loadRun(runId: string) {
const version = ++selectionVersion
eventStream?.cancel()
activeRunId.value = runId
events.value = []
toolCalls.value = []
permissionRequest.value = null
isRunning.value = false
const run = await agentService.getAgentRun(runId)
if (version !== selectionVersion) return
const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
if (existingIndex >= 0) runs.value[existingIndex] = run
else runs.value.unshift(run)
@@ -106,9 +112,9 @@ export const useAgentStore = defineStore('agent', () => {
isRunning.value = true
error.value = null
eventStream = agentService.streamAgentEvents(runId, {
onEvent: processEvent,
onError(streamError) { error.value = streamError.message; isRunning.value = false },
onDone() { isRunning.value = false; eventStream = null },
onEvent(event) { if (activeRunId.value === runId) processEvent(event) },
onError(streamError) { if (activeRunId.value === runId) { error.value = streamError.message; isRunning.value = false } },
onDone() { if (activeRunId.value === runId) { isRunning.value = false; eventStream = null } },
})
}
@@ -116,6 +122,7 @@ export const useAgentStore = defineStore('agent', () => {
isCreating.value = true
try {
const run = await agentService.createAgentRun(request)
selectionVersion++
runs.value.unshift(run)
activeRunId.value = run.run_id
events.value = []
@@ -143,10 +150,6 @@ export const useAgentStore = defineStore('agent', () => {
permissionRequest.value = null
}
function showPermissionDemo() {
permissionRequest.value = mockPermissionRequest
}
return {
runs,
activeRunId,
@@ -166,6 +169,5 @@ export const useAgentStore = defineStore('agent', () => {
createRun,
cancelRun,
respondPermission,
showPermissionDemo,
}
})
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from './chat'
import { streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() }))
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
})
it('sends real user history, applies streaming changes, and restores it when switching conversations', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'configured-model'
await store.sendMessage('user input')
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } })
expect(store.messages[1]?.content).toBe('real response')
handlers.onDone?.()
const id = store.activeConversationId!
store.createNewConversation()
expect(store.messages).toEqual([])
await store.setActiveConversation(id)
expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response'])
})
it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => {
const store = useChatStore()
await store.sendMessage('no provider')
expect(streamChat).not.toHaveBeenCalled()
store.selectedProviderId = 'real'
store.selectedModel = 'configured-model'
await store.sendMessage('first')
const old = vi.mocked(streamChat).mock.calls[0]![1]
store.createNewConversation()
await store.sendMessage('second')
old.onDone?.()
expect(store.isStreaming).toBe(true)
expect(store.messages[0]?.content).toBe('second')
})
+43 -21
View File
@@ -1,22 +1,24 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, reactive } from 'vue'
import type { ChatMessage, Conversation } from '@/contracts'
import { mockConversations, mockMessages, streamChat } from '@/services/chatService'
import { streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>(mockConversations)
const activeConversationId = ref<string | null>('conv-1')
const messages = ref<ChatMessage[]>(mockMessages['conv-1'] || [])
const conversations = ref<Conversation[]>([])
const activeConversationId = ref<string | null>(null)
const messages = ref<ChatMessage[]>([])
const isStreaming = ref(false)
const inputText = ref('')
const useRag = ref(true)
const useRag = ref(false)
const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('mock')
const selectedModel = ref('mock-1')
const selectedProviderId = ref('')
const selectedModel = ref('')
let sseClient: SseClient | null = null
let streamVersion = 0
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
// User-created conversations live in this browser session; no fabricated history.
const history = reactive<Record<string, ChatMessage[]>>({})
const activeConversation = computed(() =>
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
@@ -27,13 +29,14 @@ export const useChatStore = defineStore('chat', () => {
)
async function setActiveConversation(id: string) {
stopGeneration()
activeConversationId.value = id
messages.value = mockMessages[id] || []
messages.value = history[id] ?? []
}
async function sendMessage(text: string) {
if (!text.trim() || isStreaming.value) return
const conversationId = activeConversationId.value || `conv-${Date.now()}`
if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
const conversationId = activeConversationId.value || crypto.randomUUID()
if (!activeConversationId.value) {
const newConv: Conversation = {
@@ -47,8 +50,10 @@ export const useChatStore = defineStore('chat', () => {
activeConversationId.value = conversationId
}
history[conversationId] = messages.value
const conversationMessages = messages.value
const userMsg: ChatMessage = {
message_id: `msg-${Date.now()}`,
message_id: crypto.randomUUID(),
conversation_id: conversationId,
role: 'user',
content: text,
@@ -57,29 +62,34 @@ export const useChatStore = defineStore('chat', () => {
messages.value.push(userMsg)
inputText.value = ''
isStreaming.value = true
const conversation = conversations.value.find(c => c.conversation_id === conversationId)
if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length }
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg: ChatMessage = {
message_id: `msg-${Date.now() + 1}`,
const aiMsg = reactive<ChatMessage>({
message_id: crypto.randomUUID(),
conversation_id: conversationId,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
citations: [],
tool_calls: [],
}
})
messages.value.push(aiMsg)
const version = ++streamVersion
const argumentBuffers = new Map<string, string>()
sseClient = streamChat({
provider_id: selectedProviderId.value,
model: selectedModel.value,
conversation_id: conversationId,
use_rag: useRag.value,
messages: messages.value
.filter((message) => message !== aiMsg)
.filter((message) => message.message_id !== aiMsg.message_id)
.map((message) => ({ role: message.role, content: message.content })),
}, {
onEvent(event) {
if (version !== streamVersion) return
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
if (event.event === 'ToolCallStart') {
@@ -92,6 +102,11 @@ export const useChatStore = defineStore('chat', () => {
}
if (event.event === 'ToolCallDelta') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
if (call && typeof event.data.arguments_delta === 'string') {
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
argumentBuffers.set(call.tool_call_id, buffer)
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
}
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
Object.assign(call.parameters, event.data.arguments)
}
@@ -116,14 +131,16 @@ export const useChatStore = defineStore('chat', () => {
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
},
onError(error) {
if (version !== streamVersion) return
aiMsg.content += `\n\n连接失败:${error.message}`
isStreaming.value = false
sseClient = null
},
onDone() {
if (version !== streamVersion) return
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
if (conversation) {
conversation.message_count = messages.value.length
conversation.message_count = conversationMessages.length
conversation.updated_at = new Date().toISOString()
}
isStreaming.value = false
@@ -133,6 +150,7 @@ export const useChatStore = defineStore('chat', () => {
}
function stopGeneration() {
streamVersion++
if (sseClient) {
sseClient.cancel()
sseClient = null
@@ -141,8 +159,9 @@ export const useChatStore = defineStore('chat', () => {
}
function createNewConversation() {
stopGeneration()
const newConv: Conversation = {
conversation_id: `conv-${Date.now()}`,
conversation_id: crypto.randomUUID(),
title: '新对话',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -150,16 +169,19 @@ export const useChatStore = defineStore('chat', () => {
}
conversations.value.unshift(newConv)
activeConversationId.value = newConv.conversation_id
messages.value = []
history[newConv.conversation_id] = []
messages.value = history[newConv.conversation_id]
}
function deleteConversation(id: string) {
if (activeConversationId.value === id) stopGeneration()
delete history[id]
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
if (idx > -1) {
conversations.value.splice(idx, 1)
if (activeConversationId.value === id) {
activeConversationId.value = conversations.value[0]?.conversation_id || null
messages.value = conversations.value[0] ? mockMessages[conversations.value[0].conversation_id] || [] : []
messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAgentStore } from './agent'
import { useChatStore } from './chat'
import { useTaskStore } from './task'
import { usePluginStore } from './plugin'
import { useSkillStore } from './skill'
import { useProviderStore } from './provider'
import { useSettingsStore } from './settings'
import { listProviders } from '@/services/providerService'
import { getStatus } from '@/services/systemService'
beforeEach(() => { setActivePinia(createPinia()); localStorage.clear() })
afterEach(() => vi.unstubAllGlobals())
describe('runtime data sources', () => {
it('starts with no fabricated domain records or healthy diagnostics', () => {
expect(useAgentStore().runs).toEqual([])
expect(useAgentStore().events).toEqual([])
expect(useAgentStore().tools).toEqual([])
expect(useAgentStore().permissionRequest).toBeNull()
expect(useChatStore().conversations).toEqual([])
expect(useChatStore().messages).toEqual([])
expect(useTaskStore().tasks).toEqual([])
expect(usePluginStore().plugins).toEqual([])
expect(useSkillStore().skills).toEqual([])
expect(useProviderStore().providers).toEqual([])
expect(useProviderStore().defaultProviderId).toBe('')
expect(useSettingsStore().aiCoreStatus).toBe('unknown')
expect(useSettingsStore().indexStatus.total_notes).toBeNull()
expect(useSettingsStore().permissionPolicy).toEqual({})
})
it('keeps initial collections empty and exposes errors when the API is offline', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
const stores = [useTaskStore(), usePluginStore(), useSkillStore(), useProviderStore()] as const
await Promise.all([stores[0].loadTasks(), stores[1].loadPlugins(), stores[2].loadSkills(), stores[3].loadProviders()])
expect(stores.every(store => store.error)).toBe(true)
await useSettingsStore().loadDiagnostics()
expect(useSettingsStore().aiCoreStatus).toBe('error')
expect(useSettingsStore().indexStatus.total_blocks).toBeNull()
expect(useSettingsStore().diagnosticsError).toBeTruthy()
await expect(getStatus()).rejects.toThrow()
})
it('renders backend counts and effective permissions and excludes the test provider', async () => {
const data: Record<string, unknown> = {
'/health': { status: 'ok' }, '/api/status': { version: '9.2.1' },
'/api/index/status': { status: 'idle', pending_jobs: 0, total_notes: 7, total_blocks: 19 },
'/api/permissions/policy': { 'attachments.read': 'allow' },
'/api/providers': { items: [
{ provider_id: 'mock', provider_type: 'mock', capabilities: [] },
{ provider_id: 'real', name: 'Real', provider_type: 'ollama', capabilities: [], enabled: true, default_model: 'installed-model' },
] },
}
vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(JSON.stringify(data[url]), { status: 200, headers: { "content-type": "application/json" } })))
expect((await listProviders()).map(p => p.provider_id)).toEqual(['real'])
await useProviderStore().loadProviders()
expect(useProviderStore().defaultProviderId).toBe('real')
await useSettingsStore().loadDiagnostics()
expect(useSettingsStore().indexStatus.total_notes).toBe(7)
expect(useSettingsStore().indexStatus.total_blocks).toBe(19)
expect(useSettingsStore().aiCoreVersion).toBe('9.2.1')
expect(useSettingsStore().permissionPolicy).toEqual({ 'attachments.read': 'allow' })
})
})
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Plugin } from '@/contracts'
import * as pluginService from '@/services/pluginService'
export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>(pluginService.mockPlugins)
const plugins = ref<Plugin[]>([])
const selectedPluginId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
-2
View File
@@ -4,8 +4,6 @@ import { createPinia, setActivePinia } from 'pinia'
import type { ProviderConfig, ProviderPreset } from '@/contracts'
vi.mock('@/services/providerService', () => ({
mockProviders: [],
mockModels: {},
listProviders: vi.fn(),
listProviderPresets: vi.fn(),
getCredentialStatus: vi.fn(),
+7 -4
View File
@@ -1,17 +1,17 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, mockProviders, mockModels, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient'
export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders)
const providers = ref<ProviderConfig[]>([])
const presets = ref<ProviderPreset[]>([])
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
const modelsByProvider = ref<Record<string, ModelInfo[]>>({})
const modelLoadingByProvider = ref<Record<string, boolean>>({})
const modelErrorsByProvider = ref<Record<string, string>>({})
const credentialConfiguredById = ref<Record<string, boolean>>({})
const defaultProviderId = ref('mock')
const defaultProviderId = ref('')
const isLoading = ref(false)
const error = ref<string | null>(null)
@@ -24,6 +24,9 @@ export const useProviderStore = defineStore('provider', () => {
isLoading.value = true
try {
providers.value = await listProviders()
if (!enabledProviders.value.some(p => p.provider_id === defaultProviderId.value)) {
defaultProviderId.value = enabledProviders.value[0]?.provider_id ?? ''
}
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
+18 -47
View File
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { mockIndexStatus } from '@/services/indexService'
import { resolveApiUrl } from '@/services/apiClient'
import packageInfo from '../../package.json'
import * as indexService from '@/services/indexService'
import * as systemService from '@/services/systemService'
@@ -14,8 +15,8 @@ export const useSettingsStore = defineStore('settings', () => {
const restoreLastVault = ref(saved.restoreLastVault !== false)
const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
const language = ref<'zh-CN' | 'en'>(saved.language === 'en' ? 'en' : 'zh-CN')
const appVersion = ref('0.1.0')
const aiCoreVersion = ref('0.1.0')
const appVersion = ref(packageInfo.version)
const aiCoreVersion = ref('未获取')
// Editor
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
@@ -23,24 +24,15 @@ export const useSettingsStore = defineStore('settings', () => {
const spellCheck = ref(saved.spellCheck === true)
// AI Core
const aiCoreStatus = ref<AiCoreStatus>('running')
const aiCoreAddress = ref('http://127.0.0.1:8000')
const aiCoreStatus = ref<AiCoreStatus>('unknown')
const aiCoreAddress = ref(resolveApiUrl('/api') || '/api')
// Index
const indexStatus = ref<IndexStatus>(mockIndexStatus)
const emptyIndex = (): IndexStatus => ({ status: 'unknown', pending_jobs: 0, total_notes: null, total_blocks: null })
const indexStatus = ref<IndexStatus>(emptyIndex())
// Permissions
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({
'notes.read': 'allow',
'notes.search': 'allow',
'notes.write': 'confirm',
'notes.delete': 'confirm',
'tasks.read': 'allow',
'tasks.write': 'confirm',
'attachments.read': 'confirm',
'network.request': 'confirm',
'secrets.use': 'confirm',
})
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({})
const diagnosticsError = ref<string | null>(null)
watch(() => ({
@@ -50,18 +42,15 @@ export const useSettingsStore = defineStore('settings', () => {
}), (value) => localStorage.setItem('app-settings', JSON.stringify(value)), { deep: true })
async function loadDiagnostics() {
try {
const [health, status, index] = await Promise.all([
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(),
])
aiCoreStatus.value = health.status === 'ok' ? 'running' : 'error'
aiCoreVersion.value = status.version
indexStatus.value = index
diagnosticsError.value = null
} catch (reason) {
aiCoreStatus.value = 'error'
diagnosticsError.value = reason instanceof Error ? reason.message : '诊断信息加载失败'
}
const results = await Promise.allSettled([
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(), systemService.getPermissionPolicy(),
])
const [health, status, index, policy] = results
aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error'
aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '未获取'
indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex()
permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {}
diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : '后端请求失败').join('') || null
}
function setAutoSaveInterval(ms: number) {
@@ -72,21 +61,6 @@ export const useSettingsStore = defineStore('settings', () => {
defaultEditorMode.value = mode
}
function setPermission(permission: string, policy: 'allow' | 'confirm' | 'deny') {
permissionPolicy.value[permission] = policy
}
function setAiCoreStatus(status: AiCoreStatus) {
aiCoreStatus.value = status
}
async function restartAiCore(): Promise<boolean> {
aiCoreStatus.value = 'starting'
await new Promise((r) => setTimeout(r, 1500))
aiCoreStatus.value = 'running'
return true
}
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
indexStatus.value.status = 'indexing'
try {
@@ -115,9 +89,6 @@ export const useSettingsStore = defineStore('settings', () => {
loadDiagnostics,
setAutoSaveInterval,
setDefaultEditorMode,
setPermission,
setAiCoreStatus,
restartAiCore,
rebuildIndex,
}
})
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Skill } from '@/contracts'
import * as skillService from '@/services/skillService'
export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>(skillService.mockSkills)
const skills = ref<Skill[]>([])
const selectedSkillId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
+3 -3
View File
@@ -1,10 +1,10 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, mockTasks, updateTask as updateTaskRequest } from '@/services/taskService'
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService'
export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>(mockTasks)
const tasks = ref<TaskItem[]>([])
const filterStatus = ref<TaskStatus | 'all'>('all')
const filterPriority = ref<TaskPriority | 'all'>('all')
const filterSource = ref<TaskSource | 'all'>('all')
@@ -47,7 +47,7 @@ export const useTaskStore = defineStore('task', () => {
const task = tasks.value.find((t) => t.task_id === taskId)
if (task) {
const updated = await updateTaskRequest(taskId, data)
Object.assign(task, updated, data)
Object.assign(task, updated)
}
}