Fix/frontend live data #16
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 项测试通过。
|
||||
|
||||
@@ -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 已停止',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('调用工具')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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树,牺牲了部分平衡性以换取更少的旋转操作...',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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] || [] : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 加载失败'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user