diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 22b47e8..2cebba3 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -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 diff --git a/backend/app/routes.py b/backend/app/routes.py index 38d319b..ef9f8d3 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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: diff --git a/backend/app/services/index_service.py b/backend/app/services/index_service.py index c501f57..e635577 100644 --- a/backend/app/services/index_service.py +++ b/backend/app/services/index_service.py @@ -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, diff --git a/backend/tests/test_live_frontend_metadata.py b/backend/tests/test_live_frontend_metadata.py new file mode 100644 index 0000000..96c6d4e --- /dev/null +++ b/backend/tests/test_live_frontend_metadata.py @@ -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) diff --git a/docs/contracts/后端接口契约-开发版.md b/docs/contracts/后端接口契约-开发版.md index 60b401c..b2a3ed8 100644 --- a/docs/contracts/后端接口契约-开发版.md +++ b/docs/contracts/后端接口契约-开发版.md @@ -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`,值取自后端当前生效的 PermissionPolicy。此接口只读,不提供全局修改能力,运行时权限确认仍使用既有 Agent permission endpoint。 diff --git a/docs/development/前端壳子与接口层开发说明.md b/docs/development/前端壳子与接口层开发说明.md index 964912f..13437e4 100644 --- a/docs/development/前端壳子与接口层开发说明.md +++ b/docs/development/前端壳子与接口层开发说明.md @@ -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..` 的远程工具名匹配中文展示,支持 `web_search`(网页搜索)、`understand_image`(图像理解),并补充 `text.uppercase`(文本转大写)。此映射只影响界面,工具调用与权限选择仍使用完整原始 ID。 + +卡片默认显示三行摘要,完整服务原文可展开查看,展开操作不会改变工具选择。服务已提供中文说明时优先保留;未收录的 MCP 工具明确提示暂无中文说明,不将本地摘要当作服务协议或自动翻译结果。原始说明及其中的参数规则完整保留。 + +验证:前端 84 项测试、类型检查与生产构建通过。新增回归覆盖不同服务器命名空间、未知工具、服务中文说明、原文完整性,以及选择工具时保留原始 ID。 + +### 聊天模型选择审阅修复 + +返回聊天页时保留仍启用的提供商与手动模型 ID,仅刷新其模型列表;未选择、已删除或已禁用的提供商才回退到默认值。提供商加载失败时保留当前选择并展示错误。新增页面重新挂载与异常分支回归,前端共 89 项测试通过。 + +补充卸载时序修复:提供商或技能加载期间离开聊天页后,旧页面的初始化回调不再修改聊天选择,迟到错误也不再更新旧页面。两种加载延迟均通过先失败、修复后通过的回归测试,并验证返回页面后的默认模型和发送按钮状态;前端共 91 项测试通过。 diff --git a/frontend/src/components/common/StatusBar.vue b/frontend/src/components/common/StatusBar.vue index 992191e..db815a2 100644 --- a/frontend/src/components/common/StatusBar.vue +++ b/frontend/src/components/common/StatusBar.vue @@ -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 = { + unknown: 'AI Core 状态未获取', starting: 'AI Core 启动中', running: 'AI Core 运行中', stopped: 'AI Core 已停止', diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 8449a6e..d550c0a 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -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 diff --git a/frontend/src/features/agent/AgentView.vue b/frontend/src/features/agent/AgentView.vue index a7f41fd..283f44d 100644 --- a/frontend/src/features/agent/AgentView.vue +++ b/frontend/src/features/agent/AgentView.vue @@ -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) {

{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}

配置执行边界,并实时查看模型、工具和权限事件。

-
{{ pageError || agentStore.error }}
+
{{ pageError || agentStore.error || providerStore.error }}