Compare commits

...
Author SHA1 Message Date
admin 2becaf0c65 docs: 建立代码注释与TODO维护约定 2026-08-30 23:00:11 +08:00
admin 49c856a69c chore(frontend): 补充状态与服务边界注释 2026-08-30 22:59:58 +08:00
admin 629a6bda9c chore(backend): 补充核心流程注释与待办 2026-08-30 22:59:45 +08:00
admin df044d7888 docs(frontend): 补充 Shiki 预览与选择器回归说明 2026-08-30 22:31:51 +08:00
admin 9bcb3bf3a6 fix(frontend): 修复 Shiki 主题选择器并添加真实预览 2026-08-30 22:31:21 +08:00
admin 206a8f5b0a docs(frontend): 记录 GitHub 代码主题配置 2026-08-30 20:42:00 +08:00
admin 3595021d80 feat(frontend): 添加 GitHub 代码块主题设置 2026-08-30 20:41:42 +08:00
admin 7803ae5110 chore(frontend): 将暂定品牌名统一为 NotesAgent 2026-08-30 20:25:51 +08:00
admin 668139706b fix(frontend): 提升 Markdown 表格与列表对比度 2026-08-30 20:23:22 +08:00
admin 7fa2e9c404 docs(frontend): 记录视觉优化与动效约束 2026-08-30 20:14:22 +08:00
admin ef03a8f745 feat(frontend): 统一页面视觉与轻量动效 2026-08-30 20:13:29 +08:00
admin 2c84a98474 docs: 添加第一阶段测试验证手册 2026-08-30 15:20:12 +08:00
Kronecker f75d486e00 Merge pull request 'docs: 同步当前工程实现与验证基线' (#5) from fix/frontend-review-findings into main
Reviewed-on: #5
2026-08-30 15:16:17 +08:00
Kronecker 82cb396685 Merge branch 'main' into fix/frontend-review-findings 2026-08-30 15:16:07 +08:00
admin 3872ef3304 docs: 同步当前工程实现与验证基线 2026-08-30 15:15:00 +08:00
Kronecker b70aac1934 Merge pull request 'Fix/frontend review findings' (#4) from fix/frontend-review-findings into main
Reviewed-on: #4
2026-08-30 15:06:05 +08:00
admin bec308b5d1 feat(frontend): 完成智能体页面汉化 2026-08-30 11:04:13 +08:00
admin 0bb78e004b docs(provider): 记录本地密钥加密边界 2026-08-30 10:57:14 +08:00
admin 352975d753 feat(frontend): 支持直接配置模型API密钥 2026-08-30 10:57:05 +08:00
admin 0836807aa2 feat(provider): 添加API密钥加密存储 2026-08-30 10:56:57 +08:00
admin 0842413d29 docs(provider): 补充DeepSeek开发凭据配置 2026-08-30 10:48:35 +08:00
admin 31e29f25bb fix(frontend): 提示模型凭据缺失与鉴权失败 2026-08-30 10:48:25 +08:00
admin dc5ee76bed fix(provider): 修复DeepSeek开发凭据解析 2026-08-30 10:48:12 +08:00
admin 340bfbbd07 docs(provider): 补充模型发现与凭据边界说明 2026-08-30 10:44:13 +08:00
admin 0f6938c6a2 feat(frontend): 支持提供商预设与自动获取模型 2026-08-30 10:44:04 +08:00
admin f7d864bc4d feat(provider): 添加厂商预设与模型发现接口 2026-08-30 10:43:55 +08:00
admin 11cb384115 docs(frontend): 记录文件切换与代码块调整 2026-08-30 10:29:23 +08:00
admin f993ee9657 fix(frontend): 默认展开代码块编辑器 2026-08-30 10:29:15 +08:00
admin 1564df434d fix(frontend): 修复文件树点击切换竞态 2026-08-30 10:29:08 +08:00
admin 99a0595dbc docs(frontend): 记录编辑器交互修复与回归验证 2026-08-30 10:25:10 +08:00
admin 5a4084de1e fix(frontend): 修复Markdown工具栏选区交互 2026-08-30 10:25:00 +08:00
admin 693c86c24d fix(frontend): 修复可视化编辑器文件切换时序 2026-08-30 10:24:47 +08:00
admin 9bbe4d3c86 docs(frontend): 补充Markdown插入工具说明 2026-08-30 10:15:35 +08:00
admin d7a640a147 feat(frontend): 扩展Markdown插入工具并汉化块菜单 2026-08-30 10:15:27 +08:00
admin 9b55ccb02a docs(frontend): 记录Markdown工具栏增强 2026-08-30 10:07:40 +08:00
admin 8ee3adae6a feat(frontend): 完善Markdown选区格式工具栏 2026-08-30 10:07:31 +08:00
admin 9d223be5ca docs(frontend): 补充写作体验优化说明 2026-08-30 09:57:31 +08:00
admin c5193626a5 feat(frontend): 引入可视化Markdown写作与Shiki高亮 2026-08-30 09:57:22 +08:00
admin c33610a295 feat(frontend): 统一界面图标体系 2026-08-30 09:57:04 +08:00
Kronecker 32f6bd6468 Merge pull request 'Fix/frontend review findings' (#3) from fix/frontend-review-findings into main
Reviewed-on: #3
2026-08-30 00:16:34 +08:00
admin bba3041867 docs(frontend): 补充合并终审修复记录 2026-08-30 00:14:21 +08:00
admin 0dbd32a757 fix(frontend): 防止切换文件丢稿并修复主题恢复 2026-08-30 00:14:10 +08:00
admin a7772430d5 docs(frontend): 记录全页面审阅问题与修复 2026-08-29 23:59:22 +08:00
admin cd405da21c fix(frontend): 接通设置状态与搜索降级 2026-08-29 23:59:22 +08:00
admin d6f6b7e6e2 fix(frontend): 修复任务关联字段持久化 2026-08-29 23:59:11 +08:00
admin 4f9ebc55ec fix(frontend): 清理Agent跨Run权限状态 2026-08-29 23:59:11 +08:00
admin afd49ba00f fix(frontend): 修复文件树与编辑器状态一致性 2026-08-29 23:59:11 +08:00
admin 5aa0026bb9 fix(frontend): 完善Markdown与Chat事件渲染 2026-08-29 23:59:01 +08:00
admin e5f803c364 feat(frontend): 接通完整页面路由与桌面壳层 2026-08-29 22:49:53 +08:00
admin 5192a8b4e8 feat(frontend): 完成主题与设置页面 2026-08-29 22:49:44 +08:00
admin 3239bab696 feat(frontend): 完成Skill与Plugin管理页面 2026-08-29 22:49:44 +08:00
admin 3c9142533e feat(frontend): 完成任务管理页面 2026-08-29 22:49:33 +08:00
admin 6b362ccb6f feat(frontend): 完成Agent运行与Trace页面 2026-08-29 22:49:32 +08:00
admin 48dc6ce75b feat(frontend): 完成知识对话页面 2026-08-29 22:49:32 +08:00
admin 02e19be8bb feat(frontend): 完成知识库搜索页面 2026-08-29 22:48:58 +08:00
admin 610fc77b0f fix(frontend): 修复合并审阅发现的构建与契约问题
恢复 Vue TypeScript 生产构建,补齐可运行页面壳子,并修复文件树与 SSE 状态问题。

按 FastAPI Wire Contract 统一 Service DTO 映射,同时补充前端开发说明和问题修复复盘。
2026-08-29 12:10:33 +08:00
Kronecker c6c28e4ebe Merge pull request 'feat(frontend): 搭建桌面端基础界面与 Workspace' (#2) from feat/frontend-workspace into main
Reviewed-on: #2
2026-08-29 11:47:07 +08:00
109 changed files with 6619 additions and 916 deletions
+1
View File
@@ -13,6 +13,7 @@ backend/**/__pycache__/
backend/.env
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
backend/data/*.db*
backend/data/credentials/
# Editors and operating systems
.idea/
+42 -2
View File
@@ -2,6 +2,8 @@
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
> 当前基线:2026-08-30。第一阶段 Web 联调版的前端页面、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 预设与本地加密凭据链路均已实现;Tauri Host、Stronghold、真实桌面文件系统和 Sync Server 尚未接入。
## 当前目录
```text
@@ -14,7 +16,7 @@ NotesAgent/
## 开发环境
当前前后端壳子需要:
当前开发版需要:
| 环境 | 要求 | 说明 |
| --- | --- | --- |
@@ -34,7 +36,7 @@ python --version
uv --version
```
当前壳子暂不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/AI笔记软件技术栈说明-团队版-v2.2.md` 安装 Rust Toolchain 与 Tauri CLI。
当前 Web 联调不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/AI笔记软件技术栈说明-团队版-v2.2.md` 安装 Rust Toolchain 与 Tauri CLI。
## 首次初始化
@@ -74,6 +76,14 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
- API 文档:<http://127.0.0.1:8000/docs>
- OpenAPI JSON<http://127.0.0.1:8000/openapi.json>
#### 开发环境使用外部模型
在“设置 → 模型提供商”中选择 DeepSeek 或 OpenAI 预设后,直接在密码输入框填写 API Key。前端只在提交期间持有该值,不写入 Pinia 或 localStorageAI Core 将其加密保存到本机 `backend/data/credentials/`Provider 配置只保留内部 Credential ID。
该目录同时包含本地开发用主密钥和密文,并已加入 `.gitignore`。这提供本地静态加密和完整性校验,但不能替代操作系统凭据库。开始 Tauri 桌面集成后,应将存储实现迁移到 Stronghold,保留现有 Credential API 与 Provider 接口边界。
无界面或自动化环境仍可使用 `DEEPSEEK_API_KEY``OPENAI_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;设置页保存的本地密钥优先,环境变量仅在本地未保存对应 Credential ID 时作为回退。密钥不得写入仓库文件、README、Issue、提交信息或聊天记录。
### 终端二:启动前端
```powershell
@@ -101,8 +111,38 @@ cd frontend
pnpm build
```
前端单元与组件测试:
```powershell
cd frontend
pnpm test
```
当前回归基线为后端 71 项测试、前端 23 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。
## 文档导航
| 文档 | 用途 |
| --- | --- |
| [技术栈说明](docs/AI笔记软件技术栈说明-团队版-v2.2.md) | 目标架构、当前实施边界与模块依赖 |
| [第一阶段分工表](docs/第一阶段分工表.md) | 成员职责、协作关系与当前交付状态 |
| [第一阶段测试验证操作手册](docs/第一阶段测试验证操作手册.md) | 自动化测试、接口主链路、前端人工验收与记录模板 |
| [后端接口契约](docs/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
| [AI Core 与 Agent Core](docs/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
| [Knowledge 与 Retrieval Core](docs/Knowledge与Retrieval-Core开发说明.md) | Block、索引、混合检索和 Citation |
| [模型提供商与模型发现](docs/模型提供商与模型发现开发说明.md) | Provider 预设、模型发现和凭据边界 |
| [前端页面需求](docs/前端页面需求说明-开发版.md) | 页面、交互、状态与验收基线 |
| [前端实现说明](docs/前端壳子与接口层开发说明.md) | 当前前端目录、Service、SSE 和运行边界 |
| [前端写作体验](docs/前端写作体验优化开发说明.md) | Milkdown、CodeMirror、格式栏和 Shiki |
| [前端视觉与轻量动效](docs/前端视觉与轻量动效优化开发说明.md) | Design Token、页面美化、性能边界与主题注入约定 |
| [Git 使用细则](docs/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [代码注释与 TODO 约定](docs/代码注释与TODO约定.md) | 注释原则、TODO 格式、领域标签与当前待办索引 |
| [后端审阅复盘](docs/后端全面审阅问题与修复复盘.md) | 后端问题原因、后果与修复方案 |
| [Knowledge/Retrieval 复盘](docs/Knowledge与Retrieval-Core问题与修复复盘.md) | 检索与事务问题复盘 |
| [前端审阅复盘](docs/前端合并审阅问题与修复复盘.md) | 前端工程、契约和交互问题复盘 |
## 日常开发注意事项
- Python 依赖统一修改 `backend/pyproject.toml`,修改后执行 `uv sync`
+12 -1
View File
@@ -1,7 +1,9 @@
# Backend
# Notes Agent Backend
FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。
当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 OllamaOpenAI Responses、Anthropic Messages、MCP 独立 Host 和真实语音模型仍属于后续阶段。
```powershell
uv sync
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
@@ -13,6 +15,15 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
- 健康检查:<http://127.0.0.1:8000/health>
- API 文档:<http://127.0.0.1:8000/docs>
- OpenAPI<http://127.0.0.1:8000/openapi.json>
运行回归测试:
```powershell
uv run pytest
```
当前基线为 71 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
团队接口清单见 `../docs/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
+6
View File
@@ -1,3 +1,5 @@
"""Agent 工具权限策略与一次性确认票据。"""
import asyncio
from dataclasses import dataclass
from enum import Enum
@@ -51,6 +53,7 @@ class PermissionPolicy:
def mode_for(self, permission: str | None) -> PermissionMode:
if permission is None:
return PermissionMode.allow
# 未登记权限一律拒绝,防止扩展通过拼写错误或新权限绕过策略。
return self._rules.get(permission, PermissionMode.deny)
@@ -63,6 +66,8 @@ class PermissionTicket:
class PermissionManager:
"""管理当前进程内的确认请求与会话级授权。"""
def __init__(self, policy: PermissionPolicy) -> None:
self.policy = policy
self._pending: dict[tuple[str, str], PermissionTicket] = {}
@@ -94,6 +99,7 @@ class PermissionManager:
if ticket is None or ticket.future.done():
return False
if decision == "allow_session":
# 会话授权只存在于进程内,应用重启后按默认策略重新确认。
self._session_grants.add(ticket.permission)
ticket.future.set_result(decision)
return True
+13
View File
@@ -1,3 +1,5 @@
"""Agent 运行时:负责模型轮次、工具调用、权限确认与事件发布。"""
from __future__ import annotations
import asyncio
@@ -50,6 +52,8 @@ MAX_TOOL_CALLS_PER_TURN = 50
@dataclass(slots=True)
class RunRecord:
"""单次运行的可变上下文,仅由 AgentRuntime 持有。"""
run: AgentRun
request: AgentRunCreateRequest
skill_config: AgentConfiguration | None = None
@@ -60,6 +64,8 @@ class RunRecord:
class AgentRuntime:
"""进程内 Agent 编排器;对外返回深拷贝,避免调用方修改运行状态。"""
def __init__(
self,
providers: ProviderRegistry,
@@ -98,6 +104,7 @@ class AgentRuntime:
)
allowed_tools = list(request.allowed_tools)
if skill_config is not None:
# 同时指定 Skill 与工具白名单时取交集,避免 Skill 扩大调用权限。
allowed_tools = (
[name for name in skill_config.allowed_tools if name in allowed_tools]
if allowed_tools
@@ -142,6 +149,8 @@ class AgentRuntime:
async def events(self, run_id: str) -> AsyncIterator[AgentEvent]:
record = self._get_record(run_id)
# 先回放快照再订阅实时事件,使晚加入的 SSE 客户端也能恢复界面状态。
# TODO(agent): 持久化事件并支持 Last-Event-ID,进程重启后仍可续传。
queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
record.subscribers.add(queue)
history = [event.model_copy(deep=True) for event in record.events]
@@ -243,6 +252,7 @@ class AgentRuntime:
messages.append(
Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls)
)
# 工具可以并发执行,但结果按模型原始调用顺序写回上下文,保证轮次可复现。
semaphore = asyncio.Semaphore(record.request.max_concurrent_tools)
async def execute(call: ToolCall) -> ToolResult:
@@ -313,6 +323,7 @@ class AgentRuntime:
if mode == PermissionMode.deny:
result = self._permission_denied(call)
elif mode == PermissionMode.confirm and permission:
# 运行状态必须在等待期间可见,前端才能展示并处理权限确认卡片。
ticket = self.permissions.create_ticket(record.run.run_id, permission)
record.run.status = AgentRunStatus.waiting_permission
self._publish(
@@ -408,6 +419,7 @@ class AgentRuntime:
timestamp=datetime.now(timezone.utc),
)
record.events.append(event)
# 内存事件只保留最近窗口;完整审计轨迹应由后续持久化层承担。
if len(record.events) > MAX_EVENTS_PER_RUN:
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
for queue in record.subscribers:
@@ -448,6 +460,7 @@ class AgentRuntime:
raise AgentRunNotFoundError(run_id) from exc
def _prune_records(self) -> None:
# 只清理终态记录,绝不为了容量取消仍在执行或等待授权的任务。
overflow = len(self._records) - MAX_RUN_RECORDS + 1
if overflow <= 0:
return
+6 -1
View File
@@ -1,3 +1,5 @@
"""Agent 工具注册与执行边界。"""
import inspect
from dataclasses import dataclass
from time import perf_counter
@@ -29,6 +31,8 @@ class ToolNotFoundError(LookupError):
class ToolRegistry:
"""统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。"""
def __init__(self) -> None:
self._tools: dict[str, RegisteredTool] = {}
@@ -80,6 +84,7 @@ class ToolRegistry:
)
try:
# JSON Schema 约束模型可见的协议,Pydantic 再完成运行时类型转换。
Draft202012Validator(registered.definition.parameters).validate(call.arguments)
arguments = registered.arguments_model.model_validate(call.arguments)
except (ValidationError, JsonSchemaValidationError) as exc:
@@ -103,7 +108,7 @@ class ToolRegistry:
output=output,
duration_ms=round((perf_counter() - started) * 1000),
)
except Exception as exc: # Tool failures are isolated from the Agent loop.
except Exception as exc: # 工具失败转换成结构化结果,由模型决定是否降级或重试。
return ToolResult(
tool_call_id=call.tool_call_id,
name=call.name,
+11 -2
View File
@@ -6,13 +6,18 @@ from app.contracts import ModelCapability, ProviderConfig, ProviderType
from app.config import BACKEND_DIR
from app.extensions import PluginRuntime, SkillRuntime
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.credentials import EnvironmentCredentialResolver
from app.providers.credentials import (
ChainedCredentialResolver,
EncryptedCredentialStore,
EnvironmentCredentialResolver,
)
@dataclass(frozen=True)
class ApplicationContainer:
providers: ProviderRegistry
provider_factory: ProviderFactory
credentials: EncryptedCredentialStore
tools: ToolRegistry
permissions: PermissionManager
skills: SkillRuntime
@@ -21,7 +26,10 @@ class ApplicationContainer:
def build_container() -> ApplicationContainer:
provider_factory = ProviderFactory(EnvironmentCredentialResolver())
credentials = EncryptedCredentialStore()
provider_factory = ProviderFactory(
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
)
providers = ProviderRegistry()
providers.register(
ProviderConfig(
@@ -61,6 +69,7 @@ def build_container() -> ApplicationContainer:
return ApplicationContainer(
providers=providers,
provider_factory=provider_factory,
credentials=credentials,
tools=tools,
permissions=permissions,
skills=skills,
+23 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from enum import Enum
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, SecretStr
class Contract(BaseModel):
@@ -438,6 +438,28 @@ class ProviderListResponse(Contract):
items: list[ProviderConfig] = Field(default_factory=list)
class ProviderPreset(Contract):
preset_id: str
name: str
provider_type: ProviderType
base_url: str
default_credential_id: str | None = None
requires_credential: bool = True
class ProviderPresetListResponse(Contract):
items: list[ProviderPreset] = Field(default_factory=list)
class CredentialWriteRequest(Contract):
api_key: SecretStr = Field(min_length=1, max_length=8192)
class CredentialStatus(Contract):
credential_id: str
configured: bool
class ModelInfo(Contract):
model: str
display_name: str
+4
View File
@@ -67,6 +67,7 @@ class SkillRuntime:
self._records: dict[str, _SkillRecord] = {}
def install(self, package_path: str | Path) -> Skill:
# TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态。
root = _package_dir(package_path)
raw = _read_yaml(root / "skill.yaml")
if "id" in raw and "skill_id" not in raw:
@@ -252,6 +253,7 @@ class PluginRuntime:
self._records: dict[str, _PluginRecord] = {}
def install(self, package_path: str | Path) -> Plugin:
# 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码。
root = _package_dir(package_path)
raw = _read_yaml(root / "plugin.yaml")
if "id" in raw and "plugin_id" not in raw:
@@ -315,6 +317,7 @@ class PluginRuntime:
if record.plugin.enabled:
return record.plugin.model_copy(deep=True)
if record.plugin.manifest.backend.type == "mcp":
# TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。
record.plugin.status = PluginStatus.dependency_missing
raise ExtensionError(
"PLUGIN_HOST_UNAVAILABLE",
@@ -366,6 +369,7 @@ class PluginRuntime:
)
record.registered_tools.append(spec.name)
except Exception as exc:
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
+156 -1
View File
@@ -1,7 +1,23 @@
"""Provider 凭据解析及本地加密存储。"""
import json
import os
import re
import threading
from pathlib import Path
from typing import Protocol
from cryptography.fernet import Fernet, InvalidToken
from app.config import get_settings
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
class CredentialStoreError(RuntimeError):
pass
class CredentialResolver(Protocol):
def resolve(self, credential_id: str | None) -> str | None: ...
@@ -10,8 +26,147 @@ class CredentialResolver(Protocol):
class EnvironmentCredentialResolver:
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
_development_aliases = {
"openai": "OPENAI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
}
def resolve(self, credential_id: str | None) -> str | None:
if not credential_id:
return None
normalized = re.sub(r"[^A-Za-z0-9]", "_", credential_id).upper()
return os.getenv(f"AINOTE_CREDENTIAL_{normalized}")
injected = os.getenv(f"AINOTE_CREDENTIAL_{normalized}")
if injected:
return injected
alias = self._development_aliases.get(credential_id.lower())
return os.getenv(alias) if alias else None
class EncryptedCredentialStore:
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
# TODO(security): 桌面 Host 接入后将主密钥迁移到系统钥匙串/凭据保险库。
def __init__(self) -> None:
self._lock = threading.RLock()
@staticmethod
def _validate_id(credential_id: str) -> None:
if not _CREDENTIAL_ID.fullmatch(credential_id):
raise CredentialStoreError("Credential ID contains unsupported characters.")
@staticmethod
def _paths() -> tuple[Path, Path]:
directory = get_settings().data_dir / "credentials"
return directory / "master.key", directory / "credentials.json"
@staticmethod
def _restrict(path: Path, mode: int) -> None:
try:
path.chmod(mode)
except OSError:
pass
def _fernet(self) -> Fernet:
key_path, _ = self._paths()
environment_key = os.getenv("APP_CREDENTIAL_MASTER_KEY")
if environment_key:
try:
return Fernet(environment_key.encode("ascii"))
except (ValueError, UnicodeEncodeError) as exc:
raise CredentialStoreError("APP_CREDENTIAL_MASTER_KEY is invalid.") from exc
key_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(key_path.parent, 0o700)
if not key_path.exists():
# 先写临时文件再原子替换,避免异常退出留下半截主密钥。
temporary = key_path.with_suffix(".tmp")
temporary.write_bytes(Fernet.generate_key())
self._restrict(temporary, 0o600)
try:
temporary.replace(key_path)
except FileExistsError:
temporary.unlink(missing_ok=True)
self._restrict(key_path, 0o600)
try:
return Fernet(key_path.read_bytes().strip())
except (OSError, ValueError) as exc:
raise CredentialStoreError("Credential master key cannot be loaded.") from exc
def _read_tokens(self) -> dict[str, str]:
_, store_path = self._paths()
if not store_path.exists():
return {}
try:
data = json.loads(store_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CredentialStoreError("Encrypted credential store cannot be loaded.") from exc
if not isinstance(data, dict) or not all(
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
):
raise CredentialStoreError("Encrypted credential store has an invalid format.")
return data
def _write_tokens(self, tokens: dict[str, str]) -> None:
_, store_path = self._paths()
store_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(store_path.parent, 0o700)
temporary = store_path.with_suffix(".tmp")
temporary.write_text(
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
encoding="utf-8",
)
self._restrict(temporary, 0o600)
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
temporary.replace(store_path)
self._restrict(store_path, 0o600)
def put(self, credential_id: str, secret: str) -> None:
self._validate_id(credential_id)
if not secret:
raise CredentialStoreError("Credential secret cannot be empty.")
with self._lock:
tokens = self._read_tokens()
token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii")
tokens[credential_id] = token
self._write_tokens(tokens)
def resolve(self, credential_id: str | None) -> str | None:
if not credential_id:
return None
self._validate_id(credential_id)
with self._lock:
token = self._read_tokens().get(credential_id)
if token is None:
return None
try:
return self._fernet().decrypt(token.encode("ascii")).decode("utf-8")
except (InvalidToken, UnicodeDecodeError) as exc:
raise CredentialStoreError("Credential cannot be decrypted.") from exc
def has(self, credential_id: str) -> bool:
self._validate_id(credential_id)
with self._lock:
return credential_id in self._read_tokens()
def delete(self, credential_id: str) -> bool:
self._validate_id(credential_id)
with self._lock:
tokens = self._read_tokens()
removed = tokens.pop(credential_id, None) is not None
if removed:
self._write_tokens(tokens)
return removed
class ChainedCredentialResolver:
def __init__(self, *resolvers: CredentialResolver) -> None:
self._resolvers = resolvers
def resolve(self, credential_id: str | None) -> str | None:
# 顺序即优先级:调用方可让 Host 注入值覆盖本地开发凭据。
for resolver in self._resolvers:
value = resolver.resolve(credential_id)
if value:
return value
return None
+27 -1
View File
@@ -1,4 +1,4 @@
from app.contracts import ModelCapability, ProviderConfig, ProviderType
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
from app.providers.base import ModelProvider
from app.providers.credentials import CredentialResolver
from app.providers.ollama import OllamaProvider
@@ -27,6 +27,32 @@ class ProviderFactory:
return OllamaProvider(config.base_url or "http://127.0.0.1:11434")
raise UnsupportedProviderError(config.provider_type.value)
@staticmethod
def presets() -> list[ProviderPreset]:
return [
ProviderPreset(
preset_id="openai",
name="OpenAI",
provider_type=ProviderType.openai_chat,
base_url="https://api.openai.com/v1",
default_credential_id="openai",
),
ProviderPreset(
preset_id="deepseek",
name="DeepSeek",
provider_type=ProviderType.openai_compatible,
base_url="https://api.deepseek.com",
default_credential_id="deepseek",
),
ProviderPreset(
preset_id="ollama",
name="Ollama",
provider_type=ProviderType.ollama,
base_url="http://127.0.0.1:11434",
requires_credential=False,
),
]
@staticmethod
def capabilities(provider_type: ProviderType) -> list[ModelCapability]:
if provider_type in {
+13 -2
View File
@@ -14,7 +14,7 @@ from app.contracts import (
ModelRequest,
)
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
from app.providers.credentials import CredentialResolver
from app.providers.credentials import CredentialResolver, CredentialStoreError
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
@@ -263,7 +263,18 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
api_key = self.credentials.resolve(self.credential_id)
try:
api_key = self.credentials.resolve(self.credential_id)
except CredentialStoreError as exc:
raise ProviderError(
"PROVIDER_CREDENTIAL_UNAVAILABLE",
"Credential could not be decrypted by the AI Core.",
) from exc
if self.credential_id and not api_key:
raise ProviderError(
"PROVIDER_CREDENTIAL_MISSING",
f'Credential "{self.credential_id}" is not available in the AI Core process.',
)
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
+73 -1
View File
@@ -10,6 +10,8 @@ from app.contracts import (
AgentRunCreateRequest,
AgentRunListResponse,
ChatRequest,
CredentialStatus,
CredentialWriteRequest,
ExtensionInstallRequest,
IndexJob,
IndexRebuildRequest,
@@ -31,6 +33,7 @@ from app.contracts import (
ProviderCreateRequest,
ProviderListResponse,
ProviderModelsResponse,
ProviderPresetListResponse,
ProviderTestRequest,
ProviderTestResponse,
ProviderUpdateRequest,
@@ -52,6 +55,8 @@ from app.errors import ApiError
from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError
from app.providers.base import ProviderError
from app.providers.credentials import CredentialStoreError
from app.retrieval.engine import engine
from app.services import index_service, note_service, task_service, transcription_service
@@ -411,11 +416,61 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
# Providers
@router.get(
"/credentials/{credential_id}",
response_model=CredentialStatus,
tags=["Providers"],
)
async def get_credential_status(credential_id: str) -> CredentialStatus:
try:
configured = container.credentials.has(credential_id)
except CredentialStoreError as exc:
raise ApiError(422, "CREDENTIAL_INVALID", str(exc)) from exc
return CredentialStatus(credential_id=credential_id, configured=configured)
@router.put(
"/credentials/{credential_id}",
response_model=CredentialStatus,
tags=["Providers"],
)
async def put_credential(
credential_id: str, request: CredentialWriteRequest
) -> CredentialStatus:
try:
container.credentials.put(credential_id, request.api_key.get_secret_value())
except CredentialStoreError as exc:
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
return CredentialStatus(credential_id=credential_id, configured=True)
@router.delete(
"/credentials/{credential_id}",
response_model=CredentialStatus,
tags=["Providers"],
)
async def delete_credential(credential_id: str) -> CredentialStatus:
try:
container.credentials.delete(credential_id)
except CredentialStoreError as exc:
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
return CredentialStatus(credential_id=credential_id, configured=False)
@router.get("/providers", response_model=ProviderListResponse, tags=["Providers"])
async def list_providers() -> ProviderListResponse:
return ProviderListResponse(items=container.providers.list_configs())
@router.get(
"/providers/presets",
response_model=ProviderPresetListResponse,
tags=["Providers"],
)
async def list_provider_presets() -> ProviderPresetListResponse:
return ProviderPresetListResponse(items=container.provider_factory.presets())
@router.get(
"/providers/{provider_id}",
response_model=ProviderConfig,
@@ -502,9 +557,26 @@ async def delete_provider(provider_id: str) -> OperationResponse:
)
async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
provider_or_404(provider_id)
try:
models = await container.providers.list_models(provider_id)
except ProviderError as exc:
status_code = {
"PROVIDER_CREDENTIAL_MISSING": 422,
"PROVIDER_CREDENTIAL_UNAVAILABLE": 500,
"PROVIDER_AUTH_FAILED": 401,
"MODEL_NOT_FOUND": 404,
"PROVIDER_RATE_LIMITED": 429,
"PROVIDER_TIMEOUT": 504,
}.get(exc.code, 502)
raise ApiError(
status_code,
exc.code,
exc.message,
{"provider_id": provider_id},
) from exc
return ProviderModelsResponse(
provider_id=provider_id,
items=await container.providers.list_models(provider_id),
items=models,
)
@@ -1,3 +1,5 @@
"""转写适配层;第一阶段消费文本附件或桌面 Host 预生成的旁路文本。"""
from __future__ import annotations
from collections import OrderedDict
@@ -13,6 +15,7 @@ MAX_JOBS = 100
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
# TODO(ai-core): 第二阶段接入本地 ASR 队列后,保留相同 Job 契约替换此同步降级实现。
del language # 预生成 transcript 暂不需要语言识别。
source = attachment_path(attachment_id)
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
+1
View File
@@ -5,6 +5,7 @@ description = "Notes Agent 的 FastAPI 基础壳子"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"cryptography>=45,<52",
"fastapi>=0.116,<1.0",
"httpx>=0.28,<1.0",
"jsonschema>=4.25,<5.0",
+28 -1
View File
@@ -1,7 +1,14 @@
import asyncio
from app.main import health, service_status
from app.routes import get_index_status, list_notes, list_plugins, list_providers, list_skills
from app.routes import (
get_index_status,
list_notes,
list_plugins,
list_provider_presets,
list_providers,
list_skills,
)
from app.routes import (
create_provider,
create_task,
@@ -53,6 +60,24 @@ def test_core_collections_are_typed() -> None:
assert index.status == "idle"
def test_provider_presets_include_openai_and_deepseek() -> None:
presets = asyncio.run(list_provider_presets())
by_id = {item.preset_id: item for item in presets.items}
assert by_id["openai"].base_url == "https://api.openai.com/v1"
assert by_id["deepseek"].base_url == "https://api.deepseek.com"
assert by_id["deepseek"].provider_type == ProviderType.openai_compatible
assert by_id["deepseek"].default_credential_id == "deepseek"
def test_provider_presets_static_route_precedes_provider_id_route() -> None:
from app.routes import router
get_paths = [route.path for route in router.routes if "GET" in getattr(route, "methods", set())]
assert get_paths.index("/api/providers/presets") < get_paths.index("/api/providers/{provider_id}")
def test_openapi_contains_documented_frontend_interfaces() -> None:
from app.main import app
@@ -70,6 +95,8 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
"/api/plugins/{plugin_id}/enable",
"/api/plugins/{plugin_id}/disable",
"/api/providers/test",
"/api/providers/presets",
"/api/credentials/{credential_id}",
"/api/index/rebuild",
}
+74
View File
@@ -0,0 +1,74 @@
import asyncio
import httpx
from app.config import get_settings
from app.contracts import CredentialWriteRequest
from app.providers.credentials import (
ChainedCredentialResolver,
EncryptedCredentialStore,
EnvironmentCredentialResolver,
)
from app.providers.openai_compatible import OpenAICompatibleProvider
from app.routes import get_credential_status, put_credential
def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None:
store = EncryptedCredentialStore()
secret = "sk-test-sensitive-value"
store.put("deepseek", secret)
store_path = get_settings().data_dir / "credentials" / "credentials.json"
key_path = get_settings().data_dir / "credentials" / "master.key"
assert store_path.exists()
assert key_path.exists()
assert secret not in store_path.read_text(encoding="utf-8")
assert secret not in key_path.read_text(encoding="ascii")
assert store.resolve("deepseek") == secret
assert store.has("deepseek") is True
assert store.delete("deepseek") is True
assert store.resolve("deepseek") is None
def test_credential_api_never_returns_secret() -> None:
written = asyncio.run(
put_credential(
"deepseek",
CredentialWriteRequest(api_key="sk-test-sensitive-value"),
)
)
status = asyncio.run(get_credential_status("deepseek"))
assert written.model_dump() == {"credential_id": "deepseek", "configured": True}
assert status.configured is True
assert "sk-test-sensitive-value" not in written.model_dump_json()
def test_provider_reads_decrypted_api_key_from_encrypted_store() -> None:
store = EncryptedCredentialStore()
store.put("deepseek", "sk-test-sensitive-value")
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["Authorization"] == "Bearer sk-test-sensitive-value"
return httpx.Response(200, json={"data": [{"id": "deepseek-chat"}]})
provider = OpenAICompatibleProvider(
base_url="https://api.deepseek.test",
credential_id="deepseek",
credentials=store,
transport=httpx.MockTransport(handler),
)
models = asyncio.run(provider.list_models())
assert [model.model for model in models] == ["deepseek-chat"]
def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch) -> None:
monkeypatch.setenv("DEEPSEEK_API_KEY", "environment-key")
store = EncryptedCredentialStore()
store.put("deepseek", "saved-key")
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
assert resolver.resolve("deepseek") == "saved-key"
+49
View File
@@ -12,6 +12,8 @@ from app.contracts import (
ToolDefinition,
)
from app.providers.ollama import OllamaProvider
from app.providers.base import ProviderError
from app.providers.credentials import EnvironmentCredentialResolver
from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -134,6 +136,53 @@ def test_openai_compatible_preserves_tool_call_context() -> None:
assert turn.text == "done"
def test_openai_compatible_fetches_and_maps_model_list() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert request.url.path == "/v1/models"
assert request.headers["Authorization"] == "Bearer secret-test-key"
return httpx.Response(
200,
json={"data": [{"id": "model-b"}, {"id": "model-a"}]},
)
provider = OpenAICompatibleProvider(
base_url="https://provider.test/v1",
credential_id="provider-test",
credentials=StaticCredentials(),
transport=httpx.MockTransport(handler),
)
models = run(provider.list_models())
assert [item.model for item in models] == ["model-b", "model-a"]
def test_environment_credentials_support_deepseek_development_alias(monkeypatch) -> None:
monkeypatch.setenv("DEEPSEEK_API_KEY", "secret-test-key")
assert EnvironmentCredentialResolver().resolve("deepseek") == "secret-test-key"
def test_openai_compatible_rejects_missing_named_credential_before_request() -> None:
class EmptyCredentials:
def resolve(self, credential_id: str | None) -> str | None:
return None
provider = OpenAICompatibleProvider(
base_url="https://provider.test/v1",
credential_id="deepseek",
credentials=EmptyCredentials(),
)
try:
run(provider.list_models())
except ProviderError as error:
assert error.code == "PROVIDER_CREDENTIAL_MISSING"
else:
raise AssertionError("Missing credential should fail before the provider request")
def test_ollama_maps_models_and_completion() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/tags":
+165
View File
@@ -51,6 +51,104 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -69,6 +167,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
{ url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" },
{ url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" },
{ url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" },
{ url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" },
{ url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
@@ -215,6 +369,7 @@ name = "notes-agent-backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jsonschema" },
@@ -230,6 +385,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=45,<52" },
{ name = "fastapi", specifier = ">=0.116,<1.0" },
{ name = "httpx", specifier = ">=0.28,<1.0" },
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
@@ -259,6 +415,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
+12 -2
View File
@@ -2,6 +2,8 @@
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
> 更新日期:2026-08-30。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成,后端当前回归基线为 71 项测试通过。
## 当前实现
当前已经建立第一条可运行链路:
@@ -125,20 +127,26 @@ ollama
}
```
凭证 ID `openai-main` 对应 Sidecar 进程中的临时环境变量 `AINOTE_CREDENTIAL_OPENAI_MAIN`环境变量由 Rust Host 从 Stronghold 读取后注入,不写入 Provider Config、日志或前端 Store
凭证 ID `openai-main` 可以对应开发环境变量 `AINOTE_CREDENTIAL_OPENAI_MAIN`当前 Web 联调版也允许设置页通过 Credential API 提交 API Key,由 `EncryptedCredentialStore` 使用 Fernet 加密保存;前端 Store、Provider Config、日志和读取响应都不保存或返回明文。未来接入 Tauri 后,由 Rust Host 从 Stronghold 注入或替换存储实现
Provider 配置生命周期接口已经可用:
```text
GET /api/providers
GET /api/providers/presets
POST /api/providers
GET /api/providers/{provider_id}
PATCH /api/providers/{provider_id}
DELETE /api/providers/{provider_id}
GET /api/providers/{provider_id}/models
POST /api/providers/test
GET /api/credentials/{credential_id}
PUT /api/credentials/{credential_id}
DELETE /api/credentials/{credential_id}
```
设置页现已提供 OpenAI、DeepSeek 和 Ollama 预设,并在保存后自动获取、排序和去重模型列表。模型发现会区分凭据缺失、鉴权失败、限流、超时和上游不可用等错误。
## Agent Run
创建普通 Agent Run
@@ -321,10 +329,12 @@ Skill Manifest
## 当前限制与下一步
前端智能体页面已经完成中文联调:运行状态、Agent Event、内置 Tool、Permission 和常用事件详情字段均通过集中标签映射展示中文;`notes.search` 等技术 ID 继续保留,便于与后端 Trace、日志和接口契约对应。
- 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama AdapterOpenAI Responses 和 Anthropic Messages 尚未实现。
- Provider 配置暂存内存,后续通过 Repository 接入 SQLitePATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
- Run/Trace 暂存内存;下一步抽象 Repository 并接入 SQLite。
- Permission 已有核心等待/恢复机制,前端确认 UI 尚未联调
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示
- Task 已持久化到 SQLiteAttachment Tool 读取 Host 管理目录中的 UTF-8 文件。
- `audio.transcribe` 当前消费 Host 预生成的 transcriptfaster-whisper 与说话人分离仍按技术基线在第二阶段接入。
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
@@ -4,6 +4,8 @@
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
> 实施状态更新:2026-08-30。本文同时包含目标架构和当前实现。当前已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Tauri/Rust Host、Stronghold、真实桌面文件系统、独立 MCP Host、真实多模态模型和 Sync Server 尚未实现。
---
## 1. 文档目的
@@ -34,7 +36,7 @@
| 桌面容器 | Tauri 2 + Rust | 桌面窗口、系统 API、本地文件访问、Sidecar 管理、安全边界 |
| 前端 | Vue 3 + TypeScript + Vite | 工作区、编辑器、AI 对话、搜索、设置、扩展管理等用户界面 |
| 状态管理 | Pinia | 管理工作区、编辑器、搜索、会话、Agent、Skill、主题与模型状态 |
| UI 基础 | Reka UI / Headless Components + Design Token | 通用交互组件和主题化能力 |
| UI 基础 | 当前公共 Vue 组件 + Element Plus 图标 + Design Token;目标按需引入 Reka UI | 通用交互组件、无障碍交互和主题化能力 |
| Markdown 编辑器 | Milkdown + CodeMirror 6 | 可视化 Markdown 编辑与源码编辑 |
| 本地核心服务 | Python + FastAPI + Pydantic v2 | RAG、Agent、Skill、模型访问、多模态、索引和本地 API |
| Python 打包 | PyInstaller / Nuitka | 将 Python AI Core 打包为 Tauri Sidecar |
@@ -52,9 +54,9 @@
| ASR | faster-whisper | 音频转写 |
| 说话人分离 | pyannote.audio | 课堂、会议等多人音频中的说话人区分 |
| 情感识别 | emotion2vec | 可选音频分析能力 |
| 密钥存储 | Tauri Stronghold | 保存模型 API Key 和同步凭证 |
| 密钥存储 | 当前 Fernet 开发存储;目标 Tauri Stronghold | Web 联调期避免明文落盘,桌面集成后保存模型 API Key 和同步凭证 |
| 云同步 | 独立 Sync ServerFastAPI + PostgreSQL + S3/MinIO | 可选自托管,多设备 Vault 同步、版本管理和设备管理 |
| 测试 | pytest + 自建 RAG / Agent Dataset | 单元、接口、检索、Agent 和模型适配器测试 |
| 测试 | pytest + Vitest + 自建 RAG / Agent Dataset | 后端、前端组件、接口、检索、Agent 和模型适配器测试 |
表中的技术选型构成当前开发基线。新增依赖时需要明确其所属层、调用方、运行位置和替换成本,避免同一功能出现多套并行实现。
@@ -1146,7 +1148,7 @@ Done
### 13.4 Provider 配置与 API Key
普通 Provider 配置保存在 SQLite
目标桌面架构将普通 Provider 配置保存在 SQLite。当前 Web 联调版由内存 `ProviderRegistry` 持有,AI Core 重启后清空
```text
provider_id
@@ -1157,18 +1159,19 @@ credential_id
enabled
```
Stronghold 保存 `credential_id` 对应的实际 API Key。
当前开发版使用 Fernet 加密文件保存 `credential_id` 对应的实际 API Key,并允许环境变量回退;Tauri 集成后由 Stronghold 替换该存储实现
设置页面执行“测试连接”时:
```text
读取 Provider Config
Rust Host 读取 Secret
→ 构造临时 Credential Context
Credential Resolver 按 ID 读取开发密文或环境变量
→ AI Core 调用 Provider
→ 返回连接测试结果
```
当前设置页已经提供 OpenAI、DeepSeek 与 Ollama 预设,保存后通过 `/api/providers/{provider_id}/models` 自动发现模型。Credential API 只返回配置状态,不提供任何明文读取接口。
日志中不记录完整 API Key。请求异常信息在进入前端前过滤 Authorization Header 和密钥片段。
---
@@ -1838,36 +1841,34 @@ ainote/
### 19.1 基础环境
团队开发机需要准备:
当前 Web 联调开发机需要准备:
```text
Node.js
pnpm
Rust toolchain
Tauri CLI
Python 3.x
uv / Poetry(团队确定一种)
Node.js 22+
pnpm 10+
Python 3.11+
uv
SQLite
Git
```
Python 环境需要支持 faster-whisper、pyannote.audio、Embedding 和 Reranker 所需依赖。涉及 CUDA 的开发成员可以安装 GPU 版本,基础功能仍需提供 CPU 可运行路径
Rust Toolchain 与 Tauri CLI 只在桌面容器阶段安装。当前轻量 Embedding/Reranker 不要求 CUDA;接入 faster-whisper、pyannote.audio 或真实本地模型时再按所选运行时增加 CPU/GPU 依赖
### 19.2 本地开发
开发模式下分别启动 AI Core 和 Tauri
当前开发模式下分别启动 FastAPI 和 Vite
```text
Terminal A
services/ai-core
→ start FastAPI dev server
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
Terminal B
apps/desktop
pnpm tauri dev
cd frontend
pnpm dev
```
开发配置允许桌面端连接固定开发端口。正式构建时改为 Sidecar 随机端口模式。
Vite 将 `/api``/health` 代理到固定开发端口。正式桌面构建时改为 Tauri Sidecar 随机端口和临时访问令牌模式。
### 19.3 配置
@@ -2125,6 +2126,8 @@ Markdown Workspace
第一阶段的 Plugin Runtime 需要完成安装、启用、停用、权限、Tool 注册和至少一个示例 Plugin,建立 Skill 调用 Plugin Tool 的完整链路。
截至 2026-08-30,上述第一阶段后端链路和 Web 联调前端均已完成。当前验证基线为后端 71 项测试、前端 14 项测试及生产构建通过。
第二阶段接入:
```text
@@ -2133,8 +2136,8 @@ pyannote.audio
主题导入与社区格式
MCP Bridge
Plugin Command / Settings Contribution
更多 Provider
Agent Trace 可视化
更多 Provider Adapter
高级 Agent Trace 可视化
RAG / Agent Benchmark
```
@@ -2188,10 +2191,10 @@ Sync Server 按独立服务开发和部署,不进入桌面客户端核心启
## 25. 当前技术基线摘要
目桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript。用户笔记以 Markdown 和 Assets 保存在本地 Vault,SQLite 管理元数据、全文索引、向量索引和 Agent Trace。
桌面端采用 Tauri 2、Rust、Vue 3 和 TypeScript;当前可运行形态是 Vue/Vite Web 前端加 FastAPI。用户笔记以 Markdown 和 Assets 保存在本地 VaultSQLite 管理笔记元数据、全文索引、向量索引和任务;Agent Trace 与 Provider/Extension Registry 当前仍为内存实现
Python AI Core 作为 Tauri Sidecar 运行,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 通过 FTS5、Embedding、sqlite-vec、RRF 和 Reranker 提供混合检索;Agent Runtime 使用 Tool Registry 操作知识库和任务;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置;Plugin Runtime 通过 Plugin Manifest、Plugin Host 和 MCP Bridge 扩展 Tool、Command、导入导出和受控 UI ContributionPlugin 注册的 Tool 可以被 Agent 与 Skill 共同使用;Provider Adapter 对接 OpenAI、OpenAI-Compatible、Anthropic 和 Ollama 等模型服务
Python AI Core 未来作为 Tauri Sidecar 运行,当前由开发命令独立启动,FastAPI 提供本地接口。Knowledge Core 管理笔记结构;Retrieval Core 通过 FTS5、轻量 Embedding、sqlite-vec、RRF 和 Reranker 提供混合检索;Agent Runtime 使用 Tool Registry 操作知识库和任务;Skill Runtime 将提示词、工具、权限和检索参数组装为可复用 Agent 配置;当前 Plugin Runtime 支持 Manifest、生命周期和声明式白名单 Tool Contribution独立 Plugin Host 与 MCP Bridge 留待后续。Provider Adapter 当前实现 Mock、OpenAI Chat/OpenAI-Compatible 与 OllamaOpenAI Responses 和 Anthropic Messages 留待后续
多模态处理使用 faster-whisperpyannote.audio 完成音频转写和说话人分离,emotion2vec 作为扩展分析能力。API Key 和同步凭证存放在 Tauri Stronghold。多设备同步由独立 Sync Server 提供,采用 FastAPI、PostgreSQL 和 S3/MinIO,可由用户自托管。客户端在没有 Sync Server 时保持完整本地功能;连接服务器后同步 Markdown、Assets 和必要配置,各设备自行维护 FTS5、Embedding 和 Vector Index
多模态目标方案使用 faster-whisperpyannote.audio 和可选 emotion2vec;当前只读取 Host 预生成 transcript。API Key 在 Web 联调期由 Fernet 开发存储加密保存,桌面版迁移到 Tauri Stronghold。多设备同步的目标方案为独立、可自托管的 Sync Server,目前尚未实现;本地核心功能不依赖 Sync Server
该技术基线用于指导当前比赛版本的代码组织、接口设计、模块协作、测试和交付。
+3
View File
@@ -2,6 +2,8 @@
> 本文档用于 Notes Agent 团队日常开发。目标是让三名成员可以并行开发、稳定联调,并确保 `main` 始终处于可运行状态。
> 更新日期:2026-08-30。当前远程只使用 `gitea`,功能分支不添加个人或工具名称前缀;合并门槛为相关测试、前端生产构建、文档同步和 `git diff --check` 全部通过。
## 1. 仓库与远程
团队代码统一使用 Gitea
@@ -68,6 +70,7 @@ chore/backend-dependencies
- 一个分支只处理一个主要问题;
- 不使用 `test1``new``final``xxx-dev` 等无法识别用途的名称;
- 功能合入后删除远程分支,避免长期堆积。
- 不在分支名前添加 `codex/`、成员姓名或设备名;归属由提交作者、Issue 和 PR Reviewer 表达。
## 3. 模块分工与改动边界
@@ -3,6 +3,8 @@
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
> 更新日期:2026-08-30。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 71 项测试通过。
## 当前实现
当前已经建立第一条可运行的检索链路:
@@ -72,7 +74,7 @@ block_id = "blk_" + sha256(note_id | heading_path | content)[:16]
citation_id = "cit_" + block_id
```
> 注意:MVP 阶段 `note_id` 由相对路径派生,移动文件会改变 ID;后续 `move` 流程会保留原 ID。
> 注意:`note_id` 是稳定业务 ID;移动接口会保留原 ID,文件路径不能代替业务实体 ID。
每个 Block 记录 `heading_path`(章节路径)、`start_offset` / `end_offset`(相对原文的
字符偏移,用于前端跳转高亮)、`content_hash``token_count`
@@ -130,7 +132,7 @@ POST /api/notes
GET /api/notes/{note_id}
PATCH /api/notes/{note_id}
DELETE /api/notes/{note_id}
POST /api/notes/{note_id}/move 501,待定语义
POST /api/notes/{note_id}/move 已实现,保留 note_id
```
创建笔记:
@@ -196,7 +198,7 @@ cd backend
uv run pytest -q
```
当前后端完整测试共 62 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把
当前后端完整测试共 71 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把
数据目录/DB/Vault 重定向到临时目录,不读写真实 `backend/data`,任何本机状态下结果确定。
## 配置
@@ -3,6 +3,8 @@
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。
> 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。
> 2026-08-30 状态补充:本文中的 43 项测试是当时该模块的历史基线,不应替换为当前全仓测试数。相关修复仍有效,当前完整后端回归为 71 项通过,Knowledge/Retrieval 已通过 `notes.*``rag.search` Tool 接入 Agent Runtime。
## 1. 背景
Knowledge Core 与 Retrieval Core 建立了项目第一条完整的本地知识检索链路:
@@ -499,4 +501,3 @@ backend/app/knowledge/parser.py Block ID 与 tags 解析语义
backend/tests/test_retrieval.py 审阅回归测试
docs/Knowledge与Retrieval-Core开发说明.md 模块开发说明
```
+43
View File
@@ -0,0 +1,43 @@
# 代码注释与 TODO 约定
本文用于统一团队在前后端代码中编写注释和待办项的方式。注释应解释设计意图、边界条件和不明显的取舍,不重复代码本身已经清楚表达的内容。
## 注释原则
- 模块或核心类说明其职责和边界,例如 Agent 编排器、工具执行边界、凭据存储边界。
- 异步流程说明顺序、快照、去重、回滚和竞态处理原因。
- 安全相关流程说明默认拒绝、权限收敛、输入净化和凭据优先级。
- 简单赋值、显然的条件判断、类型定义和展示模板不添加翻译式注释。
- 注释随实现一并维护;实现变化后已经失真的注释应在同一提交中修改或删除。
## TODO 格式
前端使用:
```ts
// TODO(editor): 描述尚未完成的能力、完成条件或替换目标。
```
后端使用:
```python
# TODO(agent): 描述尚未完成的能力、完成条件或替换目标。
```
领域标签使用小写英文,当前约定包括 `agent``ai-core``chat``desktop``editor``extension``performance``security``streaming`。一个 TODO 应对应真实存在的工程缺口;小型清理工作直接完成,不长期保留无负责人、无目标的占位待办。
## 当前待办索引
以下内容可通过 `rg "TODO\\(" backend/app frontend/src` 定位,代码中的注释是最新状态:
| 领域 | 当前边界 |
| --- | --- |
| Agent / Streaming | 运行事件仍在进程内保存,后续需要持久化、`Last-Event-ID` 和断线重连 |
| Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 |
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Workspace 仍使用 Web Mock,后续由 Tauri IPC 文件系统适配器替换 |
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
@@ -0,0 +1,124 @@
# 前端写作体验优化开发说明
> 更新日期:2026-08-30。本文所述优化均已进入当前分支;前端完整回归基线为 14 项测试通过,TypeScript 检查和 Vite 生产构建通过。
## 1. 本次目标
本次优化聚焦笔记写作主流程,不调整后端接口:
- 将界面中的装饰性 Emoji 统一替换为 Element Plus 图标;
- 将“写作”模式由 Markdown 源码与预览双栏改为单一可视化编辑区;
- 为写作区增加标题、加粗、斜体、有序列表、无序列表工具栏;
- 写作页代码块默认展开为可编辑状态;只读 Markdown 区域使用 Shiki 提供亮暗主题高亮。
## 2. 实现说明
### 2.1 图标体系
新增 `AppIcon.vue` 作为轻量图标出口,页面直接传入 `@element-plus/icons-vue` 组件。侧边栏、文件树、Vault 入口、主题按钮、空状态及扩展列表不再使用 Emoji 表达操作含义。
这样处理后,图标尺寸、颜色和主题状态都由 CSS 统一控制,也避免不同系统 Emoji 字体造成的显示差异。
### 2.2 可视化 Markdown 编辑器
写作模式使用 Milkdown Crepe 渲染 Markdown 文档,磁盘中仍保存标准 Markdown 文本。编辑器监听 Markdown 更新并写回 Pinia 状态,继续复用原有自动保存逻辑。
“源码”模式保留为独立模式,便于需要精确编辑 Markdown 的用户使用;写作模式中不再同时展示 Markdown 源码。
编辑器按当前文件路径重新挂载,保证切换文件、切换源码模式后,展示内容与 Store 中的最新 Markdown 一致。
### 2.3 Markdown 工具栏
写作区顶部提供以下基础格式操作:
- H1 至 H6 标题下拉选择,标题默认使用粗体显示;
- 加粗;
- 斜体;
- 有序列表;
- 无序列表;
- 12 px 至 32 px 字号选择;
- 行内代码与代码块;
- 行内公式与公式块;
- 链接插入。
标题、加粗、斜体和列表工具调用 Milkdown Command 修改当前选区或块级结构,因此能正确处理光标、选区和嵌套列表。工具栏使用常见的 `H``B``I``1.``•` 排版符号,减少图标语义歧义。
标准 Markdown 没有字号语法。字号功能仅在用户已选择文本时生效,并将内容写为兼容 Markdown 的内联 HTML
```markdown
<span style="font-size: 18px">选中的文本</span>
```
Milkdown 自定义插件在写作模式中隐藏 HTML 标记,并通过 ProseMirror Decoration 显示实际字号;切换到源码模式时可以直接看到并修改上述 Markdown 内容。
字号栏同时提供预设下拉框和 `896 px` 数值输入框。输入数值后按 Enter 或点击“应用”即可写入当前选区。标题下拉框提供“正文”选项,用于将标题恢复为普通段落;正文显式使用正常字重,只有 H1 至 H6 默认加粗。
选中文本后出现的 Crepe 浮动格式栏使用应用正文前景色、实色描边和悬浮强调色,避免亮暗主题下图标对比度不足。
浮动栏由 Crepe Tooltip Provider 挂载,不保证位于 Vue scoped 样式容器内部,因此对比度规则使用全局 `.milkdown-toolbar` 选择器,并通过主题变量适配亮暗模式。顶部格式按钮统一在 `pointerdown` 阶段阻止默认焦点迁移并执行命令,确保点击工具栏时不会丢失编辑器选区。
有序列表与无序列表使用相同尺寸、相同线条结构的经典列表符号,仅通过左侧的数字或圆点区分类型。亮色主题下,表格边框使用更高对比度的文本辅助色,列表序号、圆点及任务图标也改用辅助文本色并增加字重。
编辑器左侧加号打开的块菜单已完成中文本地化:
- “文本”分组包含正文、H1 至 H6、引用和分割线;
- “列表”分组包含无序列表、有序列表和任务列表;
- “插入”分组包含图片、代码块、表格和公式块。
代码语言搜索、复制操作、链接编辑及公式确认浮层也统一使用中文文案。
### 2.4 代码块编辑与 Shiki 高亮
代码高亮使用 Shiki 的 JavaScript 正则引擎,并只注册第一阶段常用语言:Markdown、HTML、CSS、JavaScript、TypeScript、JSON、Python、Shell 和 SQL。未知语言回退为 Markdown 语法展示,不阻塞整篇内容渲染。
Shiki 同时生成 `github-light``github-dark` 两套 CSS 变量。主题页提供“跟随主题 / GitHub Light / GitHub Dark”选项,通过根节点 `data-code-theme` 切换对应变量,无需重新执行高亮。偏好写入 `editor-appearance`,内置主题和后续主题包也可通过 `ThemeConfig.code_theme` 指定默认代码主题。
代码主题选择器下方使用真实的 `MarkdownContent` 和 Shiki 渲染 TypeScript 示例,选项变化后立即展示对应 GitHub 高亮效果。该预览只存在于主题设置页,不会恢复写作页代码块的额外预览面板。
代码块容器使用 GitHub 风格的背景、边框、6px 圆角、16px 内边距和等宽字体;相关颜色由 `--color-code-*` Token 控制,方便主题商店覆盖。
高亮结果同时生成 `github-light``github-dark` 颜色变量。根节点的 `data-theme` 变化后由 CSS 选择对应颜色,因此切换主题无需重新解析整篇 Markdown。
写作编辑器中的普通代码块进入文档后直接展开 CodeMirror 编辑区,不再先显示 Shiki 预览,也不再提供“编辑代码/查看高亮”切换,减少一次多余操作。公式块仍由 Milkdown 的 LaTeX 功能负责编辑和渲染。
Shiki 仅应用于:
- AI 对话中的 Markdown 代码块。
- Search、智能体等复用 `MarkdownContent` 的只读 Markdown 代码块。
Markdown HTML 仍在写入 DOM 前经过 DOMPurify 清理。
## 3. 新增依赖
- `@element-plus/icons-vue`:统一界面图标;
- `@milkdown/crepe``@milkdown/kit`:可视化 Markdown 编辑器及命令;
- `shiki``@shikijs/langs``@shikijs/themes``@shikijs/engine-javascript`:代码高亮和按需语言注册。
## 4. 验证记录
`frontend` 目录执行:
```bash
pnpm build
pnpm test
```
验证结果:TypeScript 类型检查与 Vite 生产构建均通过。当前前端完整回归测试共 14 项;其中写作与文件切换相关回归覆盖:
- 顶部工具栏对选区应用加粗;
- 浮动工具栏对选区应用斜体;
- 自定义字号输入写入 Markdown;
- 标题恢复为普通正文;
- 连续切换文件后渲染新文件内容;
- 从文件树连续点击时,活动路径与编辑器内容同步切换;
- 欢迎笔记的异步初始化不会覆盖用户刚点击的文件。
文件切换失效包含两层原因。第一层是旧实现先更新 `currentFilePath`、后等待文件内容,导致编辑器使用新路径和旧内容提前重建;现在改为文件读取成功后一次性提交路径和内容。第二层是工作区欢迎笔记的异步初始化结束后会无条件设为活动文件,可能覆盖用户在此期间的真实点击;现在点击文件时立即同步工作区活动路径,默认初始化仅在用户尚未选择文件且欢迎笔记确实加载成功时提交。文件读取失败时则恢复点击前的活动文件。
本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步,以及跟随主题、GitHub Light、GitHub Dark 三种代码块设置下的显示效果。
## 5. 后续建议
- 根据真实文档规模评估 Milkdown 与只读 Markdown 高亮模块的懒加载拆包;
- 为工具栏补充撤销、重做、引用、行内代码和链接;
- 增加编辑器选区命令与文件切换的组件测试。
@@ -0,0 +1,284 @@
# 前端合并审阅问题与修复复盘
> 审阅与修复日期:2026-08-29
> 涉及提交:`f9efc4f`,合并提交 `c6c28e4`
> 文档用途:记录前端分支合并后暴露的问题域、形成原因、实际后果、修复思路和落地方案,供后续技术文档、比赛材料与博客写作使用。
> 2026-08-30 状态补充:在本文两轮修复之后,项目又完成 Milkdown 写作工具栏、文件切换二次竞态修复、Shiki 只读高亮、Provider 预设/模型发现/加密 API Key 输入,以及智能体页面汉化。当前前端回归基线为 14 项测试和生产构建通过。
## 1. 结论
原前端提交一次增加了 42 个文件和约 8000 行内容,但没有在提交前执行成功的生产构建。合并后同时存在工程配置、组件完整性、接口契约、流式协议和文件树状态五个问题域。
本轮处理结果:
| 编号 | 问题域 | 原级别 | 处理结果 |
| --- | --- | --- | --- |
| F-01 | TypeScript 与生产构建不可用 | P0 | 已修复,`pnpm build` 通过 |
| F-02 | 路由引用未提交页面 | P0 | 已改为统一占位页,并补充基础编辑器组件 |
| F-03 | 前后端 Contract 系统性漂移 | P1 | 已增加 Wire DTO 和显式 Service 映射 |
| F-04 | SSE 跨网络分片丢失事件 | P1 | 已重写增量解析状态机 |
| F-05 | 文件树右键操作目标错误 | P1 | 已改为保存实际右键节点 |
| F-06 | 根目录新增文件不可见且路径异常 | P2 | 已处理顶层插入与路径拼接 |
| F-07 | Chat 仍使用模拟流式输出 | P2 | 已接入真实 `/api/chat` SSE |
## 2. F-01TypeScript 与生产构建不可用
### 原因
Vite 配置了 `@` 指向 `src`,但 `tsconfig.app.json` 没有配置 `baseUrl``paths`。Vite 和 TypeScript 使用不同的模块解析配置,只配置其中一侧后,开发服务器可能暂时工作,`vue-tsc` 仍无法解析全部别名。
提交中还存在多个独立错误:
- `ComputedRef` 与字符串直接比较,缺少 `.value`
- `FileTreePanel.vue` 在两个 Script 中重复导入 `FileNode`
- 浏览器 ESM 代码调用 CommonJS `require()`
- Chat Store 使用不存在的 `conversation_id` 变量;
- Service 聚合文件导出不存在的 `ApiError`
- StatusBar 使用没有表达式的 `@click`
### 后果
- `pnpm build` 无法生成生产包;
- CI 无法验证前端;
- 别名错误产生的大量隐式 `any` 干扰真正错误定位;
- `main` 不再满足“可构建”要求。
### 解决思路
先恢复唯一可信的构建基线,再处理运行时问题。路径别名同时配置给 Vite 和 TypeScript,其余错误按 Vue 3 Composition API 和浏览器 ESM 规则逐项修复。
### 解决方案
- 在 `tsconfig.app.json` 增加 `baseUrl``@/*` 映射;
- 在 Script 中通过 `.value` 读取 ComputedRef
- 拆分递归文件树组件,删除第二个 Script 和 `require()`
- 修正 Chat 变量名和类型导出;
- 删除无意义的空事件绑定;
- 将 `pnpm build` 作为提交前强制检查。
## 3. F-02:路由和公共组件引用未提交文件
### 原因
路由表和 Secondary Sidebar 按最终页面结构一次性写完,但对应页面没有随提交进入仓库。Workspace 也引用了不存在的 `EditorHeader.vue``EditorPane.vue`。缺失项覆盖 Search、Chat、Agent、Task、Skill、Plugin、Theme、Settings 和多个 Sidebar Panel,共 21 个 Vue 文件。
### 后果
- 修复路径别名后,TypeScript 和 Vite 仍因模块不存在而失败;
- 开发者无法判断页面是遗漏提交,还是尚未实现;
- 后续成员可能分别创建同名但职责不同的组件。
### 解决思路
路由只能引用当前提交真实存在的组件。为保留产品信息架构,使用一个明确标注“功能开发中”的公共占位页,避免建立一批内容为空的伪页面。
### 解决方案
- 新增统一 `PlaceholderView.vue`
- 未实现功能路由暂时指向占位页;
- Secondary Sidebar 对未实现 Panel 显示说明文本;
- 增加可运行的基础 Editor Header 和 Textarea Pane
- 文档明确占位路由不代表业务页面完成。
## 4. F-03:前后端 Contract 系统性漂移
### 原因
前端先按页面需要定义了扁平 View Model,并直接把它们作为 HTTP 请求和响应类型。后端已经形成明确的 Pydantic Contract,包括分页包装、嵌套 Manifest、枚举和值对象,两边没有通过 OpenAPI 或人工核对完成同步。
典型差异:
| 模块 | 原前端假设 | FastAPI 实际 Contract |
| --- | --- | --- |
| Notes | `folder_path/content` | `folder/markdown` |
| Search | 单值筛选、`results/total` | 数组筛选、`items/page` |
| Agent | `task`、可选 Provider | `input`、Provider 与 Model 必填 |
| Permission | `allow + scope` | `allow_once/allow_session/deny` |
| Skill / Plugin | 扁平对象 | `manifest + runtime status` |
| Provider | Capability 对象 | Capability 数组 |
| Task | `due_date`、priority、source | `due_at`,后两项尚未进入后端 |
| Index | `full/fts/vector` | `all/notes/vectors` |
### 后果
- Agent 创建、Permission 响应等请求稳定返回 422;
- 列表接口拿到对象后被当成数组使用;
- Skill、Plugin 和 Provider 页面读取不到标识和能力;
- TypeScript 声称调用安全,但运行时结构完全不同;
- 捕获异常后回退 Mock 会掩盖真实联调失败。
### 解决思路
区分 Wire DTO 和 View Model。HTTP 边界严格使用与 FastAPI 一致的 `Api*` 类型,Service 显式完成转换,页面展示字段不反向污染后端请求。
### 解决方案
- 增加 `ApiNote``ApiAgentRun``ApiSkill``ApiPlugin``ApiProviderConfig``ApiTask``ApiIndexStatus` 等 Wire DTO
- Notes Service 改用 `folder``markdown` 和真实分页结构;
- Search Service 将单值 UI Filter 转换为后端数组,并映射 `items/page`
- Agent Service 使用 `input``tool_timeout_seconds``run_timeout_seconds`
- Permission Store 将 `allow + once/session` 转换为后端枚举;
- Skill 和 Plugin Service 展开嵌套 Manifest
- 增加 Plugin Permission PUT
- Provider Capability 数组转换为界面布尔 Map;
- Task Service 只发送后端支持字段,并转换 `due_date/due_at`
- Index Service 显式转换 Scope
- 默认离线 Provider ID 统一为后端的 `mock`
## 5. F-04SSE 跨网络分片丢失事件
### 原因
旧解析器把 `eventName``dataStr` 声明在每次 `reader.read()` 的循环内部。网络 Chunk 与 SSE Event 没有一一对应关系,一个事件的 `event:``data:` 和结尾空行可以分别落在多个 Chunk 中。
```text
Chunk 1: event: TextDelta\n
Chunk 2: data: {"event":"TextDelta", ...}\n\n
```
读取 Chunk 2 时事件名已被重置成 `message`。如果 data 与空行分开,data 内容也会丢失。
### 后果
- Chat 增量文本偶发不显示;
- Agent 终态事件无法触发完成回调;
- 问题受网络分片影响,开发机难以稳定复现;
- 长回答和远程 Provider 更容易出现错误。
### 解决思路
SSE 解析状态必须跨 Chunk 保存,以完整行和空行结束事件为边界,不能以单次网络读取为边界。
### 解决方案
- 将 Event Name 和 Data Lines 移到读取循环外;
- Buffer 只移除已经形成完整行的内容;
- 支持 LF、CRLF、多行 data 和注释行;
- 流结束时 flush TextDecoder 和剩余 Event
- 终态回调增加去重;
- SSE URL 复用普通 HTTP 的 Base URL 解析。
## 6. F-05:文件树右键操作目标错误
### 原因
右键菜单打开时保存了 `contextMenuPath`,但执行删除和重命名时读取的是 `workspaceStore.activeFile`。右键节点与当前编辑节点是两个独立状态。
### 后果
用户右键未激活文件并点击删除时,可能关闭或修改正在编辑的另一个文件。这属于潜在数据破坏问题。
### 解决思路
菜单操作必须绑定菜单打开时的目标对象,不能在点击命令时从无关的 Active State 推断。
### 解决方案
- 使用 `contextTarget: Ref<FileNode | null>` 保存右键节点;
- Rename 和 Delete 只消费 `contextTarget`
- 菜单关闭后清空目标;
- 将递归 Node 独立为 `FileTreeNode.vue`,通过类型化 Emit 向上传递节点。
## 7. F-06:根目录新增文件不可见且路径异常
### 原因
Store 把 `/` 当作普通父节点查找,但文件树没有代表根目录的虚拟节点。Service 直接使用 `folderPath + '/' + name` 拼接路径,根目录会得到 `//name.md`
### 后果
- Service 返回成功,但新建项目没有加入界面文件树;
- 打开的文件路径带双斜杠;
- 接入真实文件系统后可能产生平台间路径差异。
### 解决思路与方案
顶层数组本身就是根节点的 children。当 `parentPath``/` 或空字符串时直接写入 `fileTree.value`Mock Service 拼接根目录路径时只保留一个 `/`
## 8. F-07Chat 使用模拟流式输出
### 原因
Chat Store 已经存在 SSE Service,但发送消息后仍通过 `setInterval` 拼接固定文本,没有调用后端。
### 后果
- 后端 Provider、RAG、错误事件和取消无法通过前端验证;
- 页面看似工作,实际没有形成前后端链路;
- SSE 解析缺陷长期被 Mock 掩盖。
### 解决思路与方案
保留初始展示数据,但用户主动发送消息时调用真实 `/api/chat`。请求使用当前 Provider、Model、RAG 开关和消息历史;TextDelta 追加到 Assistant MessageError、网络失败、Done 和主动取消同步更新 Streaming State。默认使用离线 `mock / mock-1`,无需外部 API Key。
## 9. 验证
```powershell
cd frontend
pnpm install --frozen-lockfile
pnpm build
cd ../backend
uv run pytest
cd ..
git diff --check
```
结果:
```text
frontend production build passed
73 frontend modules transformed
71 backend tests passed
preview returned HTTP 200
git diff --check passed
```
## 10. 预防措施
- PR 创建前必须执行与 CI 相同的 `pnpm build`
- 路由只引用当前提交存在的文件;
- FastAPI `/openapi.json` 是 Wire Contract 的唯一事实来源;
- View Model 与 API DTO 分层,Service 必须显式转换;
- 不用 Mock Fallback 掩盖 4xx、5xx 和契约错误;
- SSE 测试按任意 Chunk 边界构造数据,不能假定一次 read 等于一次 Event;
- 删除、移动和覆盖等高影响操作必须携带明确目标 ID 或对象;
- 合并后如果发现 P0,先恢复主分支构建,再继续业务页面开发。
## 11. 当前边界与后续事项
首次修复解决了前端壳子的工程正确性和接口边界。之后已继续补齐全部业务路由页面;Tauri Host、Stronghold 和真实文件系统仍属于桌面集成阶段。后续仍需要:
- 为 Service DTO 映射增加自动化契约测试;
- 为 SSE Parser 增加跨 Chunk 单元测试;
- 用 Tauri Command 替换 Mock Workspace Service
- 在 CI 中加入前端构建和后端测试两个必需检查。
## 12. 全页面完成后的第二轮审阅与修复
全部页面接通后再次审阅,发现编译通过并不等于交互状态正确。本轮问题与处理如下:
| 编号 | 问题 | 原因与后果 | 解决方案 |
| --- | --- | --- | --- |
| F-08 | Markdown 没有真实渲染 | 写作与源码模式共用同一个 `textarea`Chat 也把 Markdown 当纯文本显示 | 使用 `marked` 解析 GFM,使用 DOMPurify 清洗 HTML;编辑页提供源码输入和实时预览,Chat 回答复用安全渲染器 |
| F-09 | 重命名后路径仍是旧值 | 只修改节点名称,没有同步节点、子节点、打开文件和编辑器路径,后续保存或删除会作用于旧路径 | 新增递归路径迁移,同时更新 `openFiles`、活动路径和 Editor 当前路径;Mock 内容缓存也随路径迁移 |
| F-10 | 删除活动文件后正文错位 | Workspace 切换了活动文件,但 Editor 仍保留已删除文件正文 | 删除文件或文件夹时统一清理其所有打开路径;若存在下一个文件则加载,否则关闭编辑器 |
| F-11 | 异步读取和保存存在竞态 | 快速切换文件可能让较早请求覆盖较新文件;保存过程中继续编辑会被错误标记为已保存 | 使用读取版本号丢弃过期结果;保存使用路径和正文快照,只有快照仍是最新内容时才标记 `saved` |
| F-12 | Agent 权限弹窗跨 Run 残留 | 切换 Run、ToolResult 和终态事件没有释放 PermissionRequest | 加载 Run 前清空请求,并在 ToolResult、Completed、Failed、Cancelled 时同步清理;同时更新本地 Run 状态 |
| F-13 | Chat 丢弃非文本 SSE 事件 | Store 只处理 TextDelta 和 ErrorTool Call 请求会留下空消息 | 增加 Thinking、ToolCall Start/Delta/End、Usage 和 Citation 状态处理及页面卡片展示 |
| F-14 | Task 字段表现为保存但实际丢失 | 前端展示后端不支持的 Priority/Source,更新请求又漏掉后端已支持的 `note_id` | 暂时移除不可持久化字段的编辑与筛选;补齐 `note_id` 更新与解除关联的 `null` 语义 |
| F-15 | 编辑器设置不生效 | Settings Store 与 Editor/Theme 没有联动,自动保存固定为 1500ms | 自动保存、默认模式、拼写检查、字号、行高和行宽改为实际驱动编辑器,并保存到 Local Storage |
| F-16 | Vector 降级提示永远不可达 | `vectorUnavailable` 只声明不赋值,向量错误直接清空结果 | 对明确的向量、Embedding、模型和 Provider 不可用错误自动重试 FTS,并显示降级状态 |
| F-17 | 护眼主题与 Plugin 导航状态异常 | Sepia 只有预览卡没有 TokenPlugin 路由被错误映射为 Skill 激活状态 | 增加 Sepia Design Token,并按真实路由名计算主导航选中项 |
| F-18 | 文件切换仍可能丢失未保存内容 | `loadFile` 直接替换路径和正文,且旧的自动保存定时器会在切换后保存错误文件 | 切换前取消定时器,等待正在执行的保存并保存最新快照;保存失败或存在冲突时阻止切换;各入口仅在加载成功后更新 Workspace 活动路径 |
| F-19 | 编辑器外观启动时被默认值覆盖 | Theme Store 的立即监听早于 `initTheme` 执行,先把默认值写进 Local Storage | 增加 Hydration 状态,初始化前监听只更新 CSS,不持久化;读取本地配置完成后再允许写入 |
安全边界:Markdown 解析结果不得直接使用未经清洗的 `v-html`。DOMPurify 是渲染链路的必需依赖,后续升级 `marked` 或允许扩展 Markdown 时也必须保留清洗步骤。
## 13. 写作、Provider 与智能体页面的后续修复
第三轮交互完善继续处理了文件切换、Markdown 选区格式、代码块默认状态、亮暗主题对比度和浮动工具栏失效问题。Provider 设置页增加 OpenAI、DeepSeek、Ollama 预设与模型自动发现,API Key 改为提交给后端加密保存,不进入 Pinia 或 Local Storage。智能体页面的运行状态、事件、工具、权限及导航文案已完成中文化,同时保留技术 ID 便于排障。
该轮新增 Store、Workspace、文件树、编辑器和中文标签回归测试;当前结果为前端 14 项、后端 71 项测试通过,生产构建通过。
@@ -0,0 +1,207 @@
# 前端壳子与接口层开发说明
> 更新日期:2026-08-30
> 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
## 1. 当前实现状态
当前前端已经形成一条可安装、可类型检查、可生产构建和可联调的基础链路:
```text
Vue Router
→ App Shell
→ Pinia Store
→ Service / FastAPI DTO Adapter
→ HTTP 或 SSE
→ FastAPI
```
当前已经落地的页面和公共界面包括:
- Vault 入口页;
- 应用标题栏、主侧边栏、辅助侧边栏和状态栏;
- Workspace 文件树;
- Markdown 写作/源码模式、手动保存和自动保存状态;
- 文件打开、新建、删除和重命名交互壳子;
- Search 查询、筛选、结果列表和 Citation 定位;
- Chat 会话、Provider/Model/Skill 选择和 SSE 输出;
- Agent Run 创建、Trace、取消和权限确认;
- Task 筛选、创建、编辑、状态切换和删除;
- Skill、Plugin 生命周期管理;
- Theme 预览、切换和编辑器 Token 覆盖;
- Settings 的通用、编辑器、Provider、索引、权限和 AI Core 诊断分区;
- 可收起主导航、功能型二级侧栏、状态栏和 `Ctrl+P` 命令面板。
- Milkdown 可视化写作、CodeMirror 源码/代码块编辑、Markdown 格式栏和 Shiki 只读代码高亮;
- OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入;
- 智能体页面、运行状态、事件、工具和权限详情的中文展示。
原统一占位页已经删除,所有已注册业务路由均指向真实页面。当前 Workspace 文件能力仍使用 Web Mock AdapterTauri 文件系统、Stronghold 和桌面窗口能力应在桌面容器阶段接入,不影响页面与 Store 的调用边界。
## 2. 目录与职责
```text
frontend/src/
├── components/common/ App Shell、导航、命令面板与扩展公共组件
├── contracts/index.ts UI View Model 与 FastAPI Wire DTO
├── features/ 按页面领域拆分的业务组件
├── features/editor/ 编辑器头部与写作/源码编辑区
├── features/vault/ Vault 入口
├── features/workspace/ Workspace 与递归文件树
├── router/index.ts 页面路由和 Vault Guard
├── services/ HTTP、SSE、DTO 映射和模块 API
├── stores/ Pinia 状态
└── styles/tokens.css Design Token
```
职责约定:
- Component 不直接拼接后端 URL
- Store 负责页面状态和业务操作编排;
- Service 负责 HTTP/SSE 调用以及 Wire DTO 到 View Model 的转换;
- `contracts/index.ts` 同时保留界面模型和以 `Api` 开头的 FastAPI DTO,两者不能混用;
- OpenAPI `/openapi.json` 是后端 Wire Contract 的最终依据。
## 3. 路由与页面壳子
已注册路由:
```text
/
/workspace
/search
/chat
/agent/runs/:runId?
/tasks
/extensions/skills
/extensions/plugins
/themes
/settings
```
除 Vault 入口外,其余路由需要先打开 Vault。全部路由均使用懒加载真实页面组件,既保持首屏包体可控,也避免占位页面掩盖缺失实现。
## 4. 页面实现边界
| 页面 | 当前可用能力 | 主要 Store / Service |
| --- | --- | --- |
| Workspace | 文件树、新建、重命名、删除、打开、编辑、保存、模式切换 | `workspaceStore``editorStore``workspaceService` |
| Search | FTS/Vector/Hybrid、文件夹与标签筛选、结果定位 | `searchStore``searchService` |
| Chat | 会话选择、模型配置、RAG、Skill、SSE、Citation | `chatStore``providerStore``chatService` |
| Agent | Run 配置、Tool 选择、Trace SSE、权限确认、取消 | `agentStore``agentService` |
| Tasks | 状态筛选、CRUD、完成与恢复 | `taskStore``taskService` |
| Skills | 列表、详情、安装、启停、卸载 | `skillStore``skillService` |
| Plugins | 列表、权限确认、安装、启停、卸载 | `pluginStore``pluginService` |
| Themes | 主题预览、应用、字体与行高覆盖、恢复默认 | `themeStore` |
| Settings | 通用、编辑器、Provider、索引、权限、诊断 | `settingsStore``providerStore`、相关 Service |
## 5. Workspace 与编辑器
Workspace 当前由以下组件构成:
```text
WorkspaceView
├── EditorHeader
└── EditorPane
SecondarySidebar
└── FileTreePanel
└── FileTreeNode(递归)
```
文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。
当前 `workspaceService` 仍是 Web 开发模式下的 Mock Adapter。保存、重命名和删除只保留调用边界,尚未接入 Tauri 文件系统命令。进入桌面端阶段后,应替换 Service 内部实现,不改变 Component 和 Store 的调用方式。
## 6. HTTP 接口层
公共请求由 `apiClient.ts` 处理:
- 支持 GET、POST、PUT、PATCH 和 DELETE
- 使用 `VITE_API_BASE_URL`,并兼容旧的 `VITE_API_BASE`
- 自动附加 `X-Request-Id`
- 将后端统一错误体转换为 `ApiErrorClass`
- 204 响应返回 `undefined`
Service 已适配当前 FastAPI Contract
| 模块 | 主要适配内容 |
| --- | --- |
| Notes | `folder``markdown`、直接 Note 响应和 `{items, page}` |
| Search | 数组筛选字段、`items/page` 响应和 Search View Model 映射 |
| Chat | `provider_id``model``messages` 和 ModelEvent SSE |
| Agent | `input`、秒级 Timeout 字段、Run DTO 和 Permission Decision |
| Skill / Plugin | 嵌套 `manifest`、安装 `package_path` 和 Plugin Permission PUT |
| Provider | Provider Type、Capability 数组、模型列表包装和 Test 响应 |
| Task | `due_at`、分页响应和当前后端支持字段 |
| Index | `all/notes/vectors` Scope、Job 与状态 DTO |
| System | `/health``/api/status` 的真实响应字段 |
界面模型中存在的展示字段不能直接发送给后端。例如 Task View Model 的 `priority``source` 当前只是界面层字段,Service 创建与更新请求不会把它们发送给不支持这些字段的 FastAPI Contract。
## 7. SSE
`SseClient` 同时服务于 Chat 和 Agent Event
- 使用与普通 HTTP 相同的 API Base URL
- 支持 POST Chat Stream 和 GET Agent Event Stream
- 使用 `TextDecoder` 处理 UTF-8 增量字节;
- 在网络分片之间保留 `event` 和多行 `data` 状态;
- 以空行作为单个 SSE Event 的结束标志;
- 识别 `Done``RunCompleted``RunFailed``RunCancelled`
- 支持 AbortController 主动取消。
Chat Store 已从定时器模拟输出切换为真实 `/api/chat` SSE。默认离线联调配置为:
```text
provider_id = mock
model = mock-1
```
## 8. 环境和启动
```powershell
cd frontend
pnpm install --frozen-lockfile
pnpm dev
```
联调前在另一个终端启动后端:
```powershell
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
生产构建:
```powershell
cd frontend
pnpm build
```
## 9. 当前验证基线
```text
pnpm build passed
pnpm test 23 passed
uv run pytest 71 passed
preview smoke HTTP 200
git diff --check passed
```
当前前端使用 Vitest 执行 Store、Workspace、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 71 项测试结果,也不涉及产品代码。
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
浏览器可视化冒烟在本次执行环境中因浏览器运行资源缺失未能启动;HTTP 冒烟已确认前端入口、后端健康检查与 OpenAPI 均能访问。进入合并验收前,仍建议团队在本机打开各路由完成一次人工视觉检查。
## 10. 后续开发要求
- 新页面文件与路由修改必须在同一提交中出现;
- 新增或修改接口时同步更新 FastAPI DTO、Service 映射和接口文档;
- 不允许用 `as any` 或错误返回类型掩盖 Contract 差异;
- SSE 相关变更需要覆盖跨 Chunk、CRLF、多行 data、终态事件和取消;
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
- Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。
@@ -0,0 +1,113 @@
# 前端视觉与轻量动效优化开发说明
> 更新日期:2026-08-30
> 适用范围:全局 Design Token、App Shell、功能页、卡片、表单、弹窗和轻量交互动效
## 1. 目标
本轮优化不改变页面功能和前后端契约,主要解决原界面层级偏平、组件间距不统一、交互反馈不足的问题,同时为后续主题商店 CSS 注入保留稳定边界。
设计原则:
- 颜色、圆角、阴影、间距和速度继续使用 CSS 变量;
- 页面与弹窗动画只改变 `opacity``transform`
- 不使用背景模糊、连续粒子、视差、复杂 SVG 或大范围布局动画;
- 不使用 `transition: all`,只声明需要变化的属性;
- 尊重系统 `prefers-reduced-motion` 设置;
- 主题只需覆盖现有 Token,不需要了解组件内部动画实现。
## 2. 全局视觉基线
更新 `tokens.css`
- 调整四级圆角和阴影,使卡片、弹窗与导航层次更清楚;
- 调整标题栏、状态栏和双侧栏尺寸;
- 统一更短的运动时间与缓动曲线;
- 增加键盘 `focus-visible` 焦点环;
- 为 checkbox、radio 和 range 使用主题强调色;
- 窄窗口下收缩辅助侧栏与展开导航宽度;
- 在减少动态效果模式下,把动画和过渡缩短到近似即时完成。
更新 `features.css`
- 功能页增加受控内容宽度、响应式留白和低强度主题渐变;
- 卡片增加边框、阴影与最多 2px 的悬浮位移;
- 按钮、输入框、Badge、空状态和通知统一交互反馈;
- 设置页导航改为分段式卡片导航;
- 弹窗与页面增加一次性淡入和轻微位移动画。
## 3. 页面与布局优化
### 3.1 App Shell
- 主侧栏增加明确的 Active 标记、悬浮反馈和宽度过渡;
- 辅助侧栏统一为次级 Surface,并改善标题与标签层级;
- 标题栏应用名改为轻量胶囊标识;
- 状态栏强化状态点和窄窗口降级;
- 命令面板增加轻量入场、圆角、阴影和列表反馈。
### 3.2 业务页面
- Search 结果卡片增加左侧强调线和统一内容宽度;
- Chat 增加消息容器、头像层级、Citation 悬浮反馈和 Composer 顶部阴影;
- Agent Tool 选择卡增加选中状态,Trace 使用轻量时间线;
- Task 完成按钮增加主题化状态反馈;
- Settings 行在悬浮时提供背景提示;
- Workspace 空状态与 Vault 入口增加清晰的层级和一次性入场动画。
## 4. 动效性能边界
允许的常规动效:
```text
opacity
transform: translate / scale / rotate
background-color
border-color
color
box-shadow
```
默认禁止:
```text
transition: all
backdrop-filter / filter 模糊
持续改变 width / height / margin / padding 的动画
无限循环的装饰动画
全屏高频渐变或粒子动画
```
状态栏 Spinner 和 AI Core 检查状态点属于有明确状态含义的循环动画,并会被 `prefers-reduced-motion` 全局规则降级。
## 5. 主题商店接入约定
自定义主题优先覆盖以下 Token
- `--color-background-*`
- `--color-surface-*`
- `--color-text-*`
- `--color-accent-*`
- `--color-border-*`
- `--color-code-*`
- `--shadow-*`
- `--radius-*`
- `--motion-*`
主题 CSS 不应给通配选择器增加动画,不应重新启用高成本滤镜,也不应覆盖 `prefers-reduced-motion` 的降级规则。若主题需要完全静态的界面,可把三个 `--motion-*` Token 设置为接近 0ms。
## 6. 验证
新增 `styles/motion.spec.ts`,防止全局样式重新引入 `transition: all`、高成本模糊滤镜或布局型页面入场动画,并约束 Markdown 表格与列表使用独立的高对比度主题变量。
当前验证结果:
```text
pnpm test 9 files / 23 tests passed
pnpm build passed
git diff --check passed
```
生产构建仍有已有的大 Chunk 警告,主要来自 Milkdown、CodeMirror、KaTeX 和 Shiki;本轮样式及动效未增加 JavaScript 动画库或运行时依赖。
本轮已完成样式静态检查、自动化测试和生产构建。由于本机内置浏览器运行资源路径缺失,亮色/暗色主题的人工页面巡检需在 PR 验收环境补做。
+8 -2
View File
@@ -4,6 +4,8 @@
> 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。
> 依据:`第一阶段分工表.md``AI笔记软件技术栈说明-团队版-v2.2.md``后端接口契约-开发版.md`
> 实现状态:更新至 2026-08-30。全部已注册业务路由均已有真实页面;Markdown 写作/源码模式、Search、Chat、智能体执行轨迹、扩展管理、设置、Provider 预设、模型发现和开发阶段加密凭据输入均已落地。当前仍以 Web Mock Workspace 代替 Tauri 文件系统。
## 1. 第一阶段目标
桌面端需要形成一条可以完整演示的本地知识工作流:
@@ -34,7 +36,7 @@
| --- | --- |
| 框架 | Vue 3、Composition API、TypeScript、Vite |
| 状态管理 | Pinia,只保存跨组件或跨页面状态 |
| UI 基础 | Reka UI / Headless Components |
| UI 基础 | 当前为项目公共组件、CSS Design Token 与 Element Plus 图标;复杂无障碍 Headless 组件后续按需引入 Reka UI |
| 编辑器 | Milkdown 为默认编辑模式,CodeMirror 6 为源码模式 |
| 桌面容器 | Tauri 2;文件、密钥、Sidecar 和系统能力通过 Rust Command |
| 本地 AI API | FastAPI;普通请求使用 HTTP JSON,流式数据使用 SSE |
@@ -611,18 +613,22 @@ Settings 使用分区导航,不把全部配置堆在一个表单中。
- 测试连接并显示耗时;
- 根据 Capability 标记是否支持 Chat、Tool Calling、Vision、Streaming 等。
API Key 输入后立即交给 Rust Stronghold,前端仅保存 `credential_id`。页面回显只能显示“已配置/未配置”,不得回显完整密钥。
当前 Web 联调阶段,API Key 通过 Credential API 交给 FastAPI,由 Fernet 加密保存;前端仅在提交期间持有明文,Provider 和 Store 只保留 `credential_id`。页面只能回显“已配置/未配置”,不得回显完整密钥。Tauri 集成后由 Stronghold 替换后端开发存储实现,接口边界保持不变。
接口:
```text
GET /api/providers
GET /api/providers/presets
POST /api/providers
GET /api/providers/{provider_id}
PATCH /api/providers/{provider_id}
DELETE /api/providers/{provider_id}
GET /api/providers/{provider_id}/models
POST /api/providers/test
GET /api/credentials/{credential_id}
PUT /api/credentials/{credential_id}
DELETE /api/credentials/{credential_id}
```
### 14.4 Index 与 Models
@@ -4,6 +4,8 @@
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
> 2026-08-30 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析和 Fernet 加密存储,当前完整后端回归基线为 71 项测试通过。
## 1. 审阅结论
审阅前的主链路已经能够运行,原有 50 项测试全部通过,但测试没有覆盖首次失败、畸形扩展包、代码型 Markdown、浏览器字符偏移和真实 Provider Streaming 等边界。
@@ -303,7 +305,7 @@ git diff --check
验证结果:
```text
62 passed
71 passed
compileall passed
uv lock --check passed
git diff --check passed
@@ -321,6 +323,8 @@ git diff --check passed
- Attachment 与 transcript Tool
- Provider PATCH 显式 null
- OpenAI SSE 与 Ollama JSONL 增量事件。
- Provider 预设、模型发现、凭据缺失/鉴权错误映射;
- 凭据密文落盘、API 不回显明文及 Provider 解密读取。
## 14. 后续工作
+10 -4
View File
@@ -1,6 +1,6 @@
# 后端接口契约(开发版)
> 本文档记录当前前后端联调使用的接口壳子。业务服务尚未实现,最终字段以 FastAPI 运行时生成的 OpenAPI 为准。
> 更新日期:2026-08-30。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。
## 契约入口
@@ -68,14 +68,18 @@
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/providers` | 获取 Provider 配置列表 |
| GET | `/api/providers/presets` | 获取 OpenAI、DeepSeek 与 Ollama 配置预设 |
| POST | `/api/providers` | 新建 Provider 配置 |
| GET | `/api/providers/{provider_id}` | 获取 Provider 配置 |
| PATCH | `/api/providers/{provider_id}` | 更新 Provider 配置 |
| DELETE | `/api/providers/{provider_id}` | 删除 Provider 配置 |
| GET | `/api/providers/{provider_id}/models` | 获取模型及 Capability 列表 |
| POST | `/api/providers/test` | 测试 Provider 连接 |
| GET | `/api/credentials/{credential_id}` | 查询凭据是否已配置,不返回明文 |
| PUT | `/api/credentials/{credential_id}` | 加密保存开发阶段 API Key |
| DELETE | `/api/credentials/{credential_id}` | 删除已保存凭据 |
Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`,不通过普通 JSON 接口传递明文 API Key
Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`。当前前后端开发阶段通过独立的 `PUT /api/credentials/{credential_id}` 接收 API Key,并立即加密落盘;该接口只返回配置状态,不返回密钥。Provider CRUD、模型列表和测试接口均不携带明文 API Key。Tauri 集成后由 Stronghold 接管存储实现
### Tasks、Media 与 Index
@@ -99,8 +103,8 @@ Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`
```json
{
"error": {
"code": "NOT_IMPLEMENTED",
"message": "The contract is available, but its business service is not implemented.",
"code": "RESOURCE_NOT_FOUND",
"message": "Resource was not found.",
"details": {}
}
}
@@ -155,6 +159,8 @@ RunCancelled
## 当前实现状态
更新至 2026-08-30:后端 71 项回归测试通过。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
@@ -0,0 +1,107 @@
# 模型提供商与模型发现开发说明
> 更新日期:2026-08-30。OpenAI、DeepSeek、Ollama 预设、模型自动发现、默认模型选择和开发阶段加密凭据存储均已实现并接入设置页。
## 1. 本次目标
本次完善设置页的模型提供商配置,不改变 Agent、Chat 和 Skill 对统一 Model Core 接口的依赖:
- 提供 OpenAI、DeepSeek 和 Ollama 配置预设;
- 保存 Provider 后自动获取该账号或服务当前可用的模型列表;
- 支持手动刷新模型列表和选择默认模型;
- 保留自定义 OpenAI-Compatible 服务入口;
- 不在 Vue、FastAPI 配置或仓库文件中保存、回显 API Key 明文。
## 2. 接口与实现
### 2.1 Provider 预设
新增接口:
```http
GET /api/providers/presets
```
预设由后端 `ProviderFactory` 提供,前端只消费名称、协议类型、Base URL 和是否需要凭据等配置元数据,不直接实现厂商协议。
当前预设:
| 提供商 | Provider Type | Base URL | 默认 Credential ID |
| --- | --- | --- | --- |
| OpenAI | `openai_chat` | `https://api.openai.com/v1` | `openai` |
| DeepSeek | `openai_compatible` | `https://api.deepseek.com` | `deepseek` |
| Ollama | `ollama` | `http://127.0.0.1:11434` | 无 |
OpenAI 和 DeepSeek 都通过项目已有的 `OpenAICompatibleProvider` 访问。模型发现分别请求 Base URL 下的 `/models`,不引入厂商 SDK。
### 2.2 自动获取模型
模型列表继续使用既有接口:
```http
GET /api/providers/{provider_id}/models
```
设置页在以下时机调用该接口:
- Provider 列表加载完成后,为所有已启用 Provider 自动刷新;
- 新增或编辑 Provider 保存成功后自动刷新;
- 用户点击“刷新模型”时手动刷新;
- 打开已有 Provider 的编辑窗口时刷新可选模型。
前端按模型名称排序并按 `model_id` 去重。获取结果保存在 `providerStore.modelsByProvider`,加载状态和错误按 Provider 隔离,单个外部服务失败不会阻止其他服务展示。
获取成功后,Provider 卡片展示模型数量和默认模型下拉框。更换默认模型会调用 Provider PATCH 接口写回配置;编辑窗口仍允许手动输入模型 ID,以兼容未出现在列表中的代理模型或部署别名。
### 2.3 错误处理
Provider Adapter 的错误在 FastAPI 路由转换为统一 API Error
| Provider Error | HTTP 状态 |
| --- | --- |
| `PROVIDER_AUTH_FAILED` | 401 |
| `MODEL_NOT_FOUND` | 404 |
| `PROVIDER_RATE_LIMITED` | 429 |
| `PROVIDER_TIMEOUT` | 504 |
| 其他 Provider 可用性错误 | 502 |
前端在对应 Provider 卡片内展示失败原因,并允许用户修正 Credential ID、Base URL 后重新获取。
## 3. 凭据边界
设置页选择 OpenAI 或 DeepSeek 预设后展示密码类型的 API Key 输入框,不再要求用户理解 Credential ID。输入值只存在于表单的临时 `ref`,不会写入 Pinia 或 localStorage;请求完成、取消表单或失败后都会清空。
API Key 通过独立接口写入:
```http
GET /api/credentials/{credential_id}
PUT /api/credentials/{credential_id}
DELETE /api/credentials/{credential_id}
```
PUT 请求使用 Pydantic `SecretStr` 接收密钥,响应仅包含 Credential ID 和 `configured` 状态。后端使用 Fernet 认证加密,将密文保存到 `data/credentials/credentials.json`,主密钥保存到 `data/credentials/master.key`;目录和文件尽可能设置为仅当前用户可访问并整体排除版本控制。写入采用临时文件替换,避免进程中断留下半写文件。Provider 发起请求时按 Credential ID 解密,解密失败转换为统一 Provider Error,任何读取接口均不返回明文。
本地开发存储的主密钥与密文仍位于同一用户数据目录,因此它解决的是仓库泄漏、普通配置误提交和静态明文暴露,不等同于操作系统安全硬件或 Stronghold。Tauri 集成后应以 Stronghold 实现替换 `EncryptedCredentialStore`。无界面环境仍兼容 `OPENAI_API_KEY``DEEPSEEK_API_KEY` 和 Host 注入的 `AINOTE_CREDENTIAL_<ID>`;设置页保存的本地密钥优先,环境变量仅作为回退。
自动化测试仅使用虚构测试值,验证磁盘文件不包含明文、加解密往返、API 响应不泄密,以及 Provider 能用解密后的值构造 Authorization Header。本次没有使用真实 OpenAI 或 DeepSeek Key,也没有向厂商发起真实请求。
## 4. 验证
后端:
```bash
cd backend
uv run pytest -q -p no:cacheprovider
```
前端:
```bash
cd frontend
pnpm test
pnpm build
```
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
当前完整回归基线:后端 71 项测试、前端 14 项测试通过,前端生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
+12
View File
@@ -1,5 +1,17 @@
# 第一阶段分工表
> 状态更新:2026-08-30。本文保留原始职责划分,同时记录当前交付状态。第一阶段后端目标已完成,前端 Web 联调页面已完成;尚未纳入本阶段完成项的是 Tauri Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。
## 当前交付状态
| 领域 | 当前状态 | 说明 |
| --- | --- | --- |
| Desktop Frontend / UI | Web 联调版已完成 | 全部业务路由、Markdown 写作/源码编辑、Chat、Search、Agent、扩展管理、设置页和 Provider 配置已落地 |
| Knowledge / Retrieval Core | 第一阶段已完成 | Markdown Block、SQLite/FTS5、sqlite-vec、Hybrid/RRF/Reranker、Citation 与事务修复均已覆盖测试 |
| AI / Agent Core | 第一阶段已完成 | Streaming、Agent Loop、Tool、Permission、Trace、Skill/Plugin 与 Knowledge/Retrieval 调用链已落地 |
| Model Core | 第一阶段已完成 | Mock、OpenAI Chat/OpenAI-Compatible、DeepSeek 预设、Ollama、模型发现和加密凭据链路可用 |
| Desktop Host / Sync | 后续阶段 | Tauri、Stronghold、系统文件访问、Sidecar 生命周期和云同步尚未实现 |
## 总分工表
| 成员 | 主要职责 | 第一阶段负责模块 | 具体工作内容 | 主要交付物 |
@@ -0,0 +1,376 @@
# 第一阶段测试验证操作手册
> 适用基线:2026-08-30 `main`
> 适用对象:开发、自测、代码审阅、合并验收和 Demo 前检查
> 验证范围:Vue Web 前端、FastAPI、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 与开发阶段凭据链路
## 1. 验证目标
本手册用于确认第一阶段已经形成可运行的本地知识工作流:
```text
启动前后端
→ 编辑 Markdown
→ 建立或更新索引
→ Search / RAG 返回 Citation
→ Chat 或 Agent 调用统一 Provider
→ Agent 展示 Trace、Tool 和 Permission
→ Skill / Plugin 完成生命周期与 Tool 注册
```
当前不作为第一阶段通过条件的内容:Tauri/Rust Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。
## 2. 环境准备
最低环境:
| 工具 | 要求 |
| --- | --- |
| Git | 较新稳定版 |
| Node.js | 22 或更高版本 |
| pnpm | 10 或更高版本 |
| Python | 3.11 或更高版本 |
| uv | 较新稳定版 |
在仓库根目录检查版本:
```powershell
git --version
node --version
pnpm --version
python --version
uv --version
```
同步依赖:
```powershell
cd backend
uv sync --frozen
cd ../frontend
pnpm install --frozen-lockfile
cd ..
```
`uv sync` 会自动创建和管理 `backend/.venv`,不需要手动创建或激活虚拟环境。
## 3. 自动化验收
### 3.1 后端测试
```powershell
cd backend
uv run pytest -q -p no:cacheprovider
```
当前基线:
```text
71 passed
```
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
### 3.2 前端测试
```powershell
cd frontend
pnpm test
```
当前基线:
```text
9 test files passed
23 tests passed
```
通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。
### 3.3 类型检查与生产构建
```powershell
cd frontend
pnpm build
```
通过标准:`vue-tsc -b``vite build` 均成功,生成 `frontend/dist`。当前较大的编辑器与 Markdown Chunk 会产生体积警告,该警告不等于构建失败,但应记录在验收结果中。
### 3.4 Git 与文档检查
```powershell
cd ..
git diff --check
git status --short
```
通过标准:`git diff --check` 没有错误。测试产生的 `.venv``node_modules``dist`、凭据和运行数据不得进入提交。
## 4. 启动联调环境
打开两个 PowerShell 终端。
终端 A
```powershell
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
终端 B
```powershell
cd frontend
pnpm dev
```
访问:
- 前端:<http://127.0.0.1:5173>
- 健康检查:<http://127.0.0.1:8000/health>
- 服务状态:<http://127.0.0.1:8000/api/status>
- Swagger UI<http://127.0.0.1:8000/docs>
- OpenAPI<http://127.0.0.1:8000/openapi.json>
快速检查:
```powershell
Invoke-RestMethod http://127.0.0.1:8000/health
Invoke-RestMethod http://127.0.0.1:8000/api/status
```
如果 `/docs` 返回 `RESOURCE_NOT_FOUND`,检查启动命令是否在 `backend` 目录执行、端口 8000 是否被其他程序占用,以及浏览器地址是否确实为 `http://127.0.0.1:8000/docs`
## 5. 后端主链路验证
以下命令在第三个 PowerShell 终端执行,保持后端运行。
### 5.1 Note 创建、读取与检索
```powershell
$apiBase = 'http://127.0.0.1:8000/api'
$noteBody = @{
title = '第一阶段验收笔记'
markdown = "# 第一阶段验收`n`nNotes Agent 支持混合检索和可定位引用。"
folder = '验收'
tags = @('phase-1', 'verification')
} | ConvertTo-Json
$note = Invoke-RestMethod -Method Post -Uri "$apiBase/notes" -ContentType 'application/json' -Body $noteBody
$noteId = $note.note_id
Invoke-RestMethod -Uri "$apiBase/notes/$noteId"
```
执行搜索:
```powershell
$searchBody = @{
query = '混合检索'
mode = 'hybrid'
note_ids = @($noteId)
limit = 10
offset = 0
include_snippet = $true
} | ConvertTo-Json
$search = Invoke-RestMethod -Method Post -Uri "$apiBase/search" -ContentType 'application/json' -Body $searchBody
$search.items | Format-Table title, file_path, snippet
```
通过标准:创建响应包含稳定 `note_id`;读取内容一致;搜索至少返回一项,并包含 `note_id``block_id`、文件路径、Snippet 和 Citation 定位信息。
### 5.2 Index 状态与重建
```powershell
Invoke-RestMethod -Uri "$apiBase/index/status"
$indexJob = Invoke-RestMethod -Method Post -Uri "$apiBase/index/rebuild" -ContentType 'application/json' -Body '{"scope":"all","force":false}'
$indexJob
Invoke-RestMethod -Uri "$apiBase/index/jobs/$($indexJob.job_id)"
```
通过标准:状态接口可访问;重建任务最终为 `completed`。重建失败时应返回统一错误并保留可恢复状态,不应留下半成品索引。
### 5.3 Chat SSE
使用内置 Mock Provider,不需要外部 API Key
```powershell
curl.exe --no-buffer -X POST "http://127.0.0.1:8000/api/chat" -H "Content-Type: application/json" --data-raw '{"provider_id":"mock","model":"mock-1","messages":[{"role":"user","content":"请回复第一阶段 Chat 验收成功"}],"use_rag":false}'
```
通过标准:响应类型为 `text/event-stream`,能看到递增 `sequence``TextDelta`,并以 `Done` 终止;不得一次性伪装为流式结果。
### 5.4 Agent Run 与 Trace
```powershell
$runBody = @{
input = '执行第一阶段 Agent 基础验证'
provider_id = 'mock'
model = 'mock-1'
allowed_tools = @('system.echo', 'math.add')
max_steps = 10
tool_timeout_seconds = 30
run_timeout_seconds = 300
max_concurrent_tools = 1
allow_network = $false
} | ConvertTo-Json
$run = Invoke-RestMethod -Method Post -Uri "$apiBase/agent/runs" -ContentType 'application/json' -Body $runBody
Start-Sleep -Milliseconds 300
$runResult = Invoke-RestMethod -Uri "$apiBase/agent/runs/$($run.run_id)"
$runResult
```
订阅事件也可以使用:
```powershell
curl.exe --no-buffer "http://127.0.0.1:8000/api/agent/runs/$($run.run_id)/events"
```
通过标准:Run 最终为 `completed`,Trace 至少包含开始、文本或工具事件和完成事件;事件序号递增。需要权限的 Tool 应进入 `waiting_permission`,用户允许、会话允许或拒绝后能正确恢复或终止。
### 5.5 Tool、Skill 与 Plugin
```powershell
Invoke-RestMethod -Uri "$apiBase/tools"
Invoke-RestMethod -Uri "$apiBase/skills"
Invoke-RestMethod -Uri "$apiBase/plugins"
```
通过标准:内置 Tool Definition 能被列出;内置知识助手 Skill 和示例 Plugin 状态可读取;未知权限、缺失依赖和畸形 Manifest 必须被拒绝,不能静默启用。
### 5.6 Provider 与模型发现
```powershell
Invoke-RestMethod -Uri "$apiBase/providers"
Invoke-RestMethod -Uri "$apiBase/providers/presets"
Invoke-RestMethod -Uri "$apiBase/providers/mock/models"
```
通过标准:预设至少包含 OpenAI、DeepSeek 和 OllamaMock Provider 能返回模型列表。OpenAI/DeepSeek 属于选测项,需要测试人员自己的有效 API Key,真实密钥不得写入命令历史、文档、Issue、截图或提交。
如需验证外部模型,优先在“设置 → 模型提供商”中选择预设并填写 API Key。页面不得回显明文;后端 `GET /api/credentials/{credential_id}` 只返回 `configured` 状态。测试完成后可在 Swagger 中调用对应 DELETE 接口删除测试凭据。
## 6. 前端人工验收
### 6.1 App Shell 与主题
- 主导航、辅助侧栏、标题栏和状态栏正常显示;
- `Ctrl+P` 能打开命令面板并跳转页面;
- 亮色、暗色和护眼主题切换后文字、表格线、列表序号和浮动工具栏均清晰;
- 导航使用统一图标,不出现无意义 Emoji;
- 窄窗口下主要操作仍可访问。
### 6.2 Workspace 与 Markdown
- 能新建、打开、重命名和删除 Web Mock 文件;
- 连续快速点击不同文件时,路径和正文始终一致;
- 文件切换前的未保存内容不会被错误写入新文件;
- 写作模式不展示 Markdown 源码,源码模式可以精确编辑;
- H1–H6、正文、粗体、斜体、有序/无序列表、行内代码、代码块、行内/块公式、链接和字号输入均能修改 Markdown;
- 选择文本后,顶部工具栏和浮动工具栏都对当前选区生效;
- 正文不默认加粗,标题默认加粗;
- 代码块默认展开编辑,不显示额外 Shiki 预览;
- 主题页可在跟随主题、GitHub Light 和 GitHub Dark 间切换代码块样式,刷新后偏好仍保留;
- Chat 等只读 Markdown 区域的代码高亮能跟随亮暗主题;
- 表格、公式和 Markdown HTML 渲染正常,危险 HTML 被 DOMPurify 清理。
### 6.3 Search、Chat 与 Citation
- FTS、Vector 和 Hybrid 查询可切换;
- 向量服务不可用时能降级到 FTS 并显示说明;
- Chat 能展示 Streaming、Thinking、Tool Call、Usage、错误与 Citation
- 点击 Citation 后能打开对应笔记并定位内容;
- 取消生成后页面状态恢复,不继续追加旧请求内容。
### 6.4 智能体页面
- 能新建、查看、切换和取消智能体运行;
- Provider、模型、Skill、Tool 和运行限制可配置;
- 运行状态、事件类型、工具说明、权限弹窗和常用详情字段显示中文;
- `notes.search` 等技术 ID 保留显示,便于与日志对应;
- Permission Request 不会跨 Run 残留;
- Completed、Failed、Cancelled 和连接中断状态均有明确反馈。
### 6.5 设置与扩展管理
- Provider 预设可选择,保存后自动获取模型,也能手动刷新和选择默认模型;
- 凭据缺失和 HTTP 401 会显示可理解的错误,不只显示笼统网络失败;
- Skill、Plugin 的安装、启用、停用、权限和删除操作状态一致;
- AI Core 诊断页能显示健康状态和开发 API 地址;
- 前端不会在 Store、Local Storage 或页面中保存、回显 API Key 明文。
## 7. 清理测试数据
删除本手册创建的验收笔记:
```powershell
Invoke-RestMethod -Method Delete -Uri "$apiBase/notes/$noteId"
```
如果测试了外部 Provider,还应删除临时 Provider 和不再使用的测试凭据。不要直接递归删除整个 `backend/data`,其中可能包含其他成员的本地 Vault、索引和任务数据。
## 8. 通过判定
第一阶段可以标记为“验证通过”需要同时满足:
- 后端测试零失败;
- 前端测试零失败;
- TypeScript 检查和生产构建成功;
- 健康检查、OpenAPI 和主要接口可访问;
- Note → Index/Search → Citation 主链路通过;
- Mock Chat 和 Agent Trace 主链路通过;
- Tool、Skill、Plugin 和 Provider 基础接口通过;
- 前端人工验收没有 P0/P1 缺陷;
- 没有真实密钥、生成目录或运行数据进入 Git;
- 已记录测试环境、提交、结果、警告和遗留问题。
外部 OpenAI/DeepSeek、Tauri、Stronghold、真实文件系统、真实音频模型与 Sync Server 失败或未测,不阻止当前第一阶段 Web 联调基线通过,但必须在验收记录中注明“未纳入本阶段”或“选测未执行”。
## 9. 验收记录模板
```markdown
# 第一阶段验收记录
- 日期:
- 验收人:
- 分支:main
- 提交:
- 操作系统:
- Node / pnpm
- Python / uv
## 自动化结果
- 后端 pytest:通过 / 失败,数量:
- 前端 Vitest:通过 / 失败,数量:
- 前端 build:通过 / 失败:
- git diff --check:通过 / 失败:
## 主链路
- Health / OpenAPI
- Note / Search / Citation
- Chat SSE
- Agent Run / Trace / Permission
- Tool / Skill / Plugin
- Provider / Models
- 前端页面人工验收:
## 选测项
- OpenAI:未测 / 通过 / 失败
- DeepSeek:未测 / 通过 / 失败
- Ollama:未测 / 通过 / 失败
## 警告与遗留问题
-
## 最终结论
- 通过 / 有条件通过 / 不通过
```
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171717" />
<title>Notes Agent</title>
<title>NotesAgent</title>
</head>
<body>
<div id="app"></div>
+12
View File
@@ -6,12 +6,16 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/theme-one-dark": "^6.1.0",
"@element-plus/icons-vue": "^2.3.2",
"@milkdown/crepe": "7.22.1",
"@milkdown/kit": "7.22.1",
"@milkdown/plugin-block": "^7.22.0",
"@milkdown/plugin-cursor": "^7.22.0",
"@milkdown/plugin-history": "^7.22.0",
@@ -21,18 +25,26 @@
"@milkdown/plugin-trailing": "^7.22.0",
"@milkdown/theme-nord": "^7.22.0",
"@milkdown/vue": "^7.22.0",
"@shikijs/engine-javascript": "4.4.3",
"@shikijs/langs": "4.4.3",
"@shikijs/themes": "4.4.3",
"@vueuse/core": "^14.0.0",
"codemirror": "^6.0.0",
"dompurify": "^3.4.14",
"marked": "^15.0.0",
"pinia": "^4.0.0",
"shiki": "^4.4.3",
"vue": "^3.5.0",
"vue-router": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vue/test-utils": "^2.5.0",
"happy-dom": "^20.11.15",
"typescript": "~5.9.3",
"vite": "^6.0.0",
"vitest": "^4.1.11",
"vue-tsc": "^2.0.0"
}
}
+712 -9
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { Component } from 'vue'
withDefaults(defineProps<{ icon: Component; size?: number }>(), { size: 18 })
</script>
<template>
<component :is="icon" class="app-icon" :style="{ width: `${size}px`, height: `${size}px` }" aria-hidden="true" />
</template>
<style scoped>
.app-icon { display: inline-block; flex: 0 0 auto; color: currentColor; }
</style>
+12 -5
View File
@@ -1,15 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useAgentStore } from '@/stores/agent'
import PrimarySidebar from './PrimarySidebar.vue'
import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue'
defineProps<{
showSecondarySidebar?: boolean
@@ -19,14 +19,19 @@ const workspaceStore = useWorkspaceStore()
const themeStore = useThemeStore()
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const agentStore = useAgentStore()
const route = useRoute()
const router = useRouter()
onMounted(() => { void settingsStore.loadDiagnostics() })
watch(() => settingsStore.defaultEditorMode, (mode) => editorStore.setMode(mode), { immediate: true })
watch(() => settingsStore.editorLineWidth, (width) => {
document.documentElement.style.setProperty('--editor-line-width', `${width}ch`)
}, { immediate: true })
const routeName = computed(() => route.name as string)
const secondaryComponent = computed(() => {
switch (routeName) {
switch (routeName.value) {
case 'workspace': return 'file-tree'
case 'search': return 'search-filters'
case 'chat': return 'conversation-list'
@@ -58,6 +63,7 @@ defineExpose({ openCitation })
</main>
</div>
<StatusBar />
<CommandPalette />
</div>
</template>
@@ -67,7 +73,7 @@ defineExpose({ openCitation })
flex-direction: column;
height: 100vh;
width: 100vw;
background: var(--color-background-primary);
background: var(--color-background-secondary);
color: var(--color-text-primary);
}
@@ -83,5 +89,6 @@ defineExpose({ openCitation })
min-width: 0;
overflow: hidden;
background: var(--color-background-primary);
isolation: isolate;
}
</style>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { useWorkspaceStore } from '@/stores/workspace'
import * as workspaceService from '@/services/workspaceService'
const router = useRouter()
const editorStore = useEditorStore()
const themeStore = useThemeStore()
const workspaceStore = useWorkspaceStore()
const open = ref(false)
const query = ref('')
const input = ref<HTMLInputElement | null>(null)
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
const commands = computed<Command[]>(() => [
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
{ id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') },
{ id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') },
{ id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() },
{ id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() },
{ id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() },
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote },
])
const filteredCommands = computed(() => {
const value = query.value.trim().toLocaleLowerCase()
return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value
})
function show() {
open.value = true
query.value = ''
void nextTick(() => input.value?.focus())
}
function hide() { open.value = false }
async function execute(command: Command | undefined) {
if (!command) return
hide()
await command.run()
}
async function createNote() {
const rawName = window.prompt('笔记名称')?.trim()
if (!rawName) return
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
workspaceStore.addFileToTree('/', file)
await editorStore.loadFile(file.path)
workspaceStore.openFile(file.path)
await router.push('/workspace')
}
function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
event.preventDefault()
open.value ? hide() : show()
} else if (event.key === 'Escape' && open.value) {
hide()
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script>
<template>
<Teleport to="body">
<div v-if="open" class="command-backdrop" @click.self="hide">
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板">
<input ref="input" v-model="query" class="command-input" placeholder="输入命令…" @keydown.enter.prevent="execute(filteredCommands[0])" />
<div class="command-list">
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
<span>{{ command.label }}</span><small>{{ command.hint }}</small>
</button>
<p v-if="!filteredCommands.length">没有匹配的命令</p>
</div>
<footer><span>Enter 执行</span><span>Esc 关闭</span></footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); animation: command-backdrop-in var(--motion-fast) both; }
.command-palette { width: min(620px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: command-palette-in var(--motion-normal) both; }
.command-input { width: 100%; padding: var(--space-xl); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; color: var(--color-text-primary); font-size: var(--font-size-xl); }
.command-list { max-height: 360px; overflow: auto; padding: var(--space-sm); }
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md) var(--space-lg); border-radius: var(--radius-md); text-align: left; transition: color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast); }
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
.command-list button:hover { transform: translateX(2px); }
.command-list small, .command-list p, footer { color: var(--color-text-tertiary); }
.command-list p { padding: var(--space-xl); text-align: center; }
footer { display: flex; gap: var(--space-lg); padding: var(--space-sm) var(--space-lg); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-xs); }
@keyframes command-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes command-palette-in { from { opacity: 0; transform: translateY(-8px) scale(.99); } to { opacity: 1; transform: translateY(0) scale(1); } }
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { Connection, Lightning } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { usePluginStore } from '@/stores/plugin'
import { useSkillStore } from '@/stores/skill'
const route = useRoute()
const skillStore = useSkillStore()
const pluginStore = usePluginStore()
const isPlugin = computed(() => route.name === 'plugins')
onMounted(() => { if (isPlugin.value) void pluginStore.loadPlugins(); else void skillStore.loadSkills() })
</script>
<template>
<div class="sidebar-panel">
<div v-if="isPlugin" class="sidebar-list">
<button v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="sidebar-list-item extension-item"
:class="{ active: pluginStore.selectedPluginId === plugin.plugin_id }" @click="pluginStore.selectPlugin(plugin.plugin_id)">
<AppIcon :icon="Connection" /><span><strong>{{ plugin.name }}</strong><small>{{ plugin.status }}</small></span>
</button>
</div>
<div v-else class="sidebar-list">
<button v-for="skill in skillStore.skills" :key="skill.skill_id" class="sidebar-list-item extension-item"
:class="{ active: skillStore.selectedSkillId === skill.skill_id }" @click="skillStore.selectSkill(skill.skill_id)">
<AppIcon :icon="Lightning" /><span><strong>{{ skill.name }}</strong><small>{{ skill.status }}</small></span>
</button>
</div>
</div>
</template>
<style scoped>
.extension-item { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: var(--space-sm); width: 100%; text-align: left; }
.extension-item strong, .extension-item small { display: block; }
.extension-item small { color: var(--color-text-tertiary); }
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
const props = defineProps<{ source: string }>()
const html = ref('')
let renderVersion = 0
watch(() => props.source, async (source) => {
const version = ++renderVersion
const result = await renderMarkdown(source)
if (version === renderVersion) html.value = result
}, { immediate: true })
</script>
<template>
<div class="markdown-content" v-html="html" />
</template>
<style>
.markdown-content { white-space: normal; user-select: text; }
.markdown-content p, .markdown-content ul, .markdown-content ol, .markdown-content pre, .markdown-content blockquote { margin: .65em 0; }
.markdown-content h1, .markdown-content h2, .markdown-content h3 { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
.markdown-content ul { padding-left: 1.5em; list-style: disc; }
.markdown-content ol { padding-left: 1.5em; list-style: decimal; }
.markdown-content li::marker { color: var(--color-markdown-marker); font-weight: 700; }
.markdown-content .shiki { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; }
.markdown-content code { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
.markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
.markdown-content .shiki .line { display: block; min-height: 1.45em; }
.markdown-content blockquote { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
.markdown-content table { width: 100%; margin: .65em 0; border-collapse: collapse; }
.markdown-content th, .markdown-content td { padding: .45em .65em; border: 1px solid var(--color-markdown-grid); text-align: left; }
.markdown-content th { background: var(--color-markdown-table-header); font-weight: 700; }
.markdown-content img { max-width: 100%; }
.markdown-content hr { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
[data-code-theme='github-light'] .markdown-content .shiki,
[data-code-theme='github-light'] .markdown-content .shiki span {
color: var(--shiki-light) !important;
font-style: var(--shiki-light-font-style) !important;
font-weight: var(--shiki-light-font-weight) !important;
text-decoration: var(--shiki-light-text-decoration) !important;
}
[data-code-theme='github-dark'] .markdown-content .shiki,
[data-code-theme='github-dark'] .markdown-content .shiki span {
color: var(--shiki-dark) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
</style>
@@ -1,35 +1,41 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Search, Setting } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
const route = useRoute()
const router = useRouter()
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
const navItems = [
{ name: 'workspace', icon: '📁', label: '工作区' },
{ name: 'search', icon: '🔍', label: '搜索' },
{ name: 'chat', icon: '💬', label: 'AI 对话' },
{ name: 'agent', icon: '🤖', label: 'Agent' },
{ name: 'tasks', icon: '✅', label: '任务' },
{ name: 'skills', icon: '⚡', label: 'Skill' },
{ name: 'plugins', icon: '🧩', label: 'Plugin' },
{ name: 'themes', icon: '🎨', label: '主题' },
{ name: 'settings', icon: '⚙️', label: '设置' },
{ name: 'workspace', icon: FolderOpened, label: '工作区' },
{ name: 'search', icon: Search, label: '搜索' },
{ name: 'chat', icon: ChatDotRound, label: 'AI 对话' },
{ name: 'agent', icon: Cpu, label: '智能体' },
{ name: 'tasks', icon: CircleCheck, label: '任务' },
{ name: 'skills', icon: Lightning, label: 'Skill' },
{ name: 'plugins', icon: Connection, label: 'Plugin' },
{ name: 'themes', icon: Brush, label: '主题' },
{ name: 'settings', icon: Setting, label: '设置' },
]
const currentName = computed(() => {
const name = route.name as string
if (name === 'skills' || name === 'plugins') return 'skills'
return name
return route.name as string
})
function navigate(name: string) {
router.push({ name })
}
function toggleExpanded() {
expanded.value = !expanded.value
localStorage.setItem('primary-sidebar-expanded', String(expanded.value))
}
</script>
<template>
<aside class="primary-sidebar">
<aside class="primary-sidebar" :class="{ expanded }">
<nav class="nav-list">
<div
v-for="item in navItems"
@@ -39,32 +45,39 @@ function navigate(name: string) {
@click="navigate(item.name)"
:title="item.label"
>
<span class="nav-icon">{{ item.icon }}</span>
<AppIcon class="nav-icon" :icon="item.icon" :size="20" />
<span class="nav-label">{{ item.label }}</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="nav-item" @click="navigate('settings')" title="设置">
<span class="nav-icon"></span>
</div>
<button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded">
<AppIcon class="nav-icon" :icon="expanded ? ArrowLeftBold : ArrowRightBold" />
<span class="nav-label">{{ expanded ? '收起' : '展开' }}</span>
</button>
</div>
</aside>
</template>
<style scoped>
.primary-sidebar {
width: 56px;
width: var(--sidebar-primary-width);
background: var(--color-background-secondary);
border-right: 1px solid var(--color-border-subtle);
display: flex;
flex-direction: column;
flex-shrink: 0;
z-index: var(--z-sidebar);
transition: width var(--motion-normal), background-color var(--motion-normal);
}
.primary-sidebar.expanded { width: var(--sidebar-primary-width-expanded); }
.primary-sidebar.expanded .nav-item { flex-direction: row; justify-content: flex-start; gap: var(--space-md); padding: 0 var(--space-lg); }
.primary-sidebar.expanded .nav-icon { margin-bottom: 0; }
.primary-sidebar.expanded .nav-label { font-size: var(--font-size-sm); }
.nav-list {
flex: 1;
padding: var(--space-sm) 0;
padding: var(--space-md) 0;
display: flex;
flex-direction: column;
gap: 2px;
@@ -75,31 +88,34 @@ function navigate(name: string) {
flex-direction: column;
align-items: center;
justify-content: center;
height: 50px;
margin: 0 4px;
height: 48px;
margin: 0 6px;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-secondary);
transition: all var(--motion-fast);
border: 1px solid transparent;
transition: color var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast);
position: relative;
&:hover {
background: var(--color-background-hover);
color: var(--color-text-primary);
transform: translateX(2px);
}
&.active {
background: var(--color-accent-soft);
color: var(--color-accent-primary);
border-color: color-mix(in srgb, var(--color-accent-primary) 16%, transparent);
&::before {
content: '';
position: absolute;
left: -4px;
left: -7px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
width: 4px;
height: 22px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--color-accent-primary);
}
@@ -115,10 +131,13 @@ function navigate(name: string) {
.nav-label {
font-size: 10px;
line-height: 1.2;
font-weight: 550;
}
.sidebar-footer {
padding: var(--space-sm) 0;
border-top: 1px solid var(--color-border-subtle);
}
.collapse-button { width: calc(100% - 12px); }
</style>
@@ -19,7 +19,7 @@ const sidebarTitle = computed(() => {
const titles: Record<string, string> = {
'file-tree': '文件',
'conversation-list': '对话',
'run-list': 'Agent Run',
'run-list': '智能体运行',
'search-filters': '搜索筛选',
'task-filters': '任务筛选',
'extension-list': '扩展',
@@ -27,7 +27,7 @@ const sidebarTitle = computed(() => {
return titles[props.component || ''] || ''
})
const showSkillToggle = computed(() => routeName === 'skills' || routeName === 'plugins')
const showSkillToggle = computed(() => routeName.value === 'skills' || routeName.value === 'plugins')
</script>
<template>
@@ -53,7 +53,7 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === '
<style scoped>
.secondary-sidebar {
width: var(--sidebar-secondary-width);
background: var(--color-background-primary);
background: var(--color-surface-secondary);
border-right: 1px solid var(--color-border-default);
display: flex;
flex-direction: column;
@@ -62,36 +62,36 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === '
}
.sidebar-header {
padding: var(--space-md) var(--space-lg);
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
.sidebar-title {
font-size: var(--font-size-sm);
font-weight: 600;
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-sm) 0;
margin: 0 0 var(--space-md) 0;
}
.sidebar-tabs {
display: flex;
gap: 2px;
background: var(--color-background-secondary);
padding: 2px;
padding: 3px;
border-radius: var(--radius-md);
}
.tab {
flex: 1;
text-align: center;
padding: 4px 8px;
padding: 6px 8px;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: none;
transition: all var(--motion-fast);
transition: color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast);
&.active {
background: var(--color-surface-primary);
@@ -108,5 +108,7 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === '
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
</style>
+13 -6
View File
@@ -75,16 +75,16 @@ const showEditorInfo = computed(() => route.name === 'workspace')
{{ saveStatusText }}
</span>
<span class="status-item" :title="indexStatusText">
<span class="status-dot" style="background: var(--color-success)" />
索引就绪
<span class="status-dot" :style="{ background: settingsStore.indexStatus.status === 'error' ? 'var(--color-error)' : settingsStore.indexStatus.status === 'indexing' ? 'var(--color-warning)' : 'var(--color-success)' }" />
{{ indexStatusText }}
</span>
<span class="status-item" :style="{ color: aiCoreColor }" @click>
<span class="status-item" :style="{ color: aiCoreColor }">
<span class="status-dot" :style="{ background: aiCoreColor }" />
{{ aiCoreStatusText }}
</span>
<span v-if="agentStore.isRunning" class="status-item agent-status">
<span class="spinner" />
Agent 运行中
智能体运行中
</span>
</div>
<div class="statusbar-right">
@@ -107,8 +107,8 @@ const showEditorInfo = computed(() => route.name === 'workspace')
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-md);
background: var(--color-background-secondary);
padding: 0 var(--space-lg);
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-subtle);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
@@ -129,6 +129,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
gap: 6px;
white-space: nowrap;
cursor: default;
transition: color var(--motion-fast);
&:hover {
color: var(--color-text-primary);
@@ -140,6 +141,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
height: 6px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px var(--color-surface-secondary);
}
.agent-status {
@@ -162,4 +164,9 @@ const showEditorInfo = computed(() => route.name === 'workspace')
.provider-info {
color: var(--color-text-tertiary);
}
@media (max-width: 760px) {
.statusbar-left, .statusbar-right { gap: var(--space-sm); }
.provider-info { display: none; }
}
</style>
+26 -11
View File
@@ -4,6 +4,8 @@ import { useRoute } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
const route = useRoute()
const workspaceStore = useWorkspaceStore()
@@ -16,14 +18,14 @@ const pageTitle = computed(() => {
workspace: '工作区',
search: '搜索',
chat: 'AI 对话',
agent: 'Agent Trace',
agent: '智能体执行轨迹',
tasks: '任务',
skills: 'Skill 管理',
plugins: 'Plugin 管理',
themes: '主题管理',
settings: '设置',
}
return titles[name] || '知笔知己'
return titles[name] || 'NotesAgent'
})
const currentFileName = computed(() => {
@@ -48,11 +50,11 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
</span>
</div>
<div class="titlebar-center">
<span class="app-name">知笔知己</span>
<span class="app-name">NotesAgent</span>
</div>
<div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'">
<span class="icon">{{ themeStore.isDark ? '☀️' : '🌙' }}</span>
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="16" />
</button>
<div class="window-controls">
<span class="win-btn minimize"></span>
@@ -69,8 +71,8 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-md);
background: var(--color-background-secondary);
padding: 0 var(--space-lg);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-subtle);
font-size: var(--font-size-sm);
flex-shrink: 0;
@@ -127,7 +129,13 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
}
.app-name {
font-weight: 500;
padding: 3px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-weight: 650;
letter-spacing: .04em;
}
.titlebar-right {
@@ -139,8 +147,8 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
}
.icon-btn {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
@@ -148,11 +156,12 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
color: var(--color-text-secondary);
font-size: 14px;
-webkit-app-region: no-drag;
transition: background var(--motion-fast);
transition: background-color var(--motion-fast), color var(--motion-fast), transform var(--motion-fast);
&:hover {
background: var(--color-background-hover);
color: var(--color-text-primary);
transform: rotate(8deg);
}
}
@@ -177,7 +186,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--motion-fast);
transition: background-color var(--motion-fast), color var(--motion-fast);
&:hover {
background: var(--color-background-hover);
@@ -188,4 +197,10 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
color: white;
}
}
@media (max-width: 760px) {
.titlebar-left, .titlebar-right { min-width: 0; }
.titlebar-center, .window-controls { display: none; }
.file-name { max-width: 42vw; }
}
</style>
+203 -5
View File
@@ -75,6 +75,8 @@ export interface ChatMessage {
created_at: string
citations?: Citation[]
tool_calls?: ToolCall[]
thinking?: string
usage?: TokenUsage
}
export interface Citation {
@@ -99,6 +101,7 @@ export type ModelEventType =
| 'ToolCallDelta'
| 'ToolCallEnd'
| 'Usage'
| 'Citation'
| 'Error'
| 'Done'
@@ -246,16 +249,17 @@ export interface Plugin {
status: PluginStatus
enabled: boolean
permissions: string[]
granted_permissions?: string[]
contributions: PluginContribution[]
backend_type?: 'mcp' | 'internal'
transport?: 'stdio' | 'websocket'
backend_type?: 'mcp' | 'internal_rpc' | 'none'
transport?: 'stdio' | 'http' | 'none'
last_error?: string
dependent_skills?: string[]
}
// ============ Provider ============
export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'openai-compatible' | 'mock'
export type ProviderType = ApiProviderType
export interface ModelCapability {
chat: boolean
@@ -286,6 +290,15 @@ export interface ProviderConfig {
has_credential: boolean
}
export interface ProviderPreset {
preset_id: string
name: string
provider_type: ProviderType
base_url: string
default_credential_id?: string | null
requires_credential: boolean
}
// ============ Tasks ============
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
@@ -316,6 +329,7 @@ export interface ThemeConfig {
is_dark: boolean
author?: string
builtin: boolean
code_theme?: 'github-light' | 'github-dark'
}
// ============ Index ============
@@ -346,10 +360,10 @@ export interface ErrorResponse {
}
export interface SystemStatus {
status: 'ok'
name: string
version: string
environment: 'development' | 'production' | 'test'
ai_core_available: boolean
environment: string
}
export type SaveStatus =
@@ -362,3 +376,187 @@ export type SaveStatus =
| 'conflict'
export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error'
// ============ FastAPI wire contracts ============
// UI view models above may contain presentation-only fields. Services must use
// these DTOs at the HTTP boundary and explicitly map them to view models.
export interface PageMeta {
total: number
limit: number
offset: number
}
export interface OperationResponse {
status: 'accepted' | 'completed'
resource_id?: string | null
message?: string | null
}
export interface ApiNoteBlock {
block_id: string
note_id: string
heading_path: string[]
start_offset: number
end_offset: number
content: string
content_hash: string
token_count: number
}
export interface ApiNoteSummary {
note_id: string
title: string
file_path: string
tags: string[]
created_at: string
updated_at: string
}
export interface ApiNote extends ApiNoteSummary {
markdown: string
blocks: ApiNoteBlock[]
}
export interface ApiSearchResult {
note_id: string
block_id: string
title: string
file_path: string
heading_path: string[]
snippet?: string | null
score: number
citation: ApiCitation
}
export interface ApiCitation {
citation_id: string
note_id: string
block_id: string
file_path: string
heading_path: string[]
start_offset?: number | null
end_offset?: number | null
source_audio?: string | null
start_time?: number | null
end_time?: number | null
speaker?: string | null
}
export interface ApiAgentRun {
run_id: string
status: AgentRunStatus
input: string
provider_id: string
model: string
skill_id?: string | null
current_step: number
max_steps: number
token_budget?: number | null
cancelled: boolean
output?: string | null
error_code?: string | null
error_message?: string | null
token_usage: number
created_at: string
updated_at: string
}
export interface ApiSkill {
manifest: {
skill_id: string
name: string
version: string
description: string
permissions: string[]
tools: string[]
retrieval: { top_k: number; rerank: boolean; citation: boolean }
model: { required_capabilities: string[] }
}
status: SkillStatus
enabled: boolean
missing_dependencies: string[]
}
export interface ApiPlugin {
manifest: {
plugin_id: string
name: string
version: string
description: string
permissions: string[]
contributes: {
tools: string[]
commands: string[]
importers: string[]
exporters: string[]
panels: string[]
settings_sections: string[]
}
backend: { type: 'mcp' | 'internal_rpc' | 'none'; transport: 'stdio' | 'http' | 'none' }
}
status: PluginStatus
enabled: boolean
granted_permissions: string[]
error_message?: string | null
}
export type ApiProviderType =
| 'mock'
| 'openai_responses'
| 'openai_chat'
| 'openai_compatible'
| 'anthropic_messages'
| 'ollama'
export interface ApiProviderConfig {
provider_id: string
provider_type: ApiProviderType
name: string
base_url?: string | null
default_model?: string | null
credential_id?: string | null
enabled: boolean
capabilities: string[]
}
export interface ApiProviderPreset {
preset_id: string
name: string
provider_type: ApiProviderType
base_url: string
default_credential_id?: string | null
requires_credential: boolean
}
export interface ApiModelInfo {
model: string
display_name: string
capabilities: string[]
}
export interface ApiTask {
task_id: string
title: string
description: string
status: TaskStatus
note_id?: string | null
due_at?: string | null
created_at: string
updated_at: string
}
export interface ApiIndexStatus {
status: 'idle' | 'queued' | 'running' | 'failed'
pending_jobs: number
active_job_id?: string | null
last_completed_at?: string | null
error_message?: string | null
}
export interface ApiIndexJob {
job_id: string
status: 'queued' | 'running' | 'completed' | 'failed'
scope: 'all' | 'notes' | 'vectors'
created_at: string
}
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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'
const route = useRoute()
const router = useRouter()
const agentStore = useAgentStore()
const providerStore = useProviderStore()
const skillStore = useSkillStore()
const pageError = ref('')
const form = reactive({
input: '', provider_id: 'mock', model: 'mock-1', 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[],
})
const models = computed(() => providerStore.modelsByProvider[form.provider_id] ?? [])
const isNewRun = computed(() => !route.params.runId)
onMounted(async () => {
try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
await providerStore.loadModels(form.provider_id)
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
})
watch(() => route.params.runId, async (runId) => {
if (typeof runId !== 'string') return
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : '运行记录加载失败' }
}, { 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 */ }
})
function toggleTool(name: string) {
const index = form.allowed_tools.indexOf(name)
if (index >= 0) form.allowed_tools.splice(index, 1)
else form.allowed_tools.push(name)
}
async function createRun() {
pageError.value = ''
try {
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,
max_steps: form.max_steps, tool_timeout_seconds: form.tool_timeout_seconds,
run_timeout_seconds: form.run_timeout_seconds, token_budget: form.token_budget,
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
})
await router.replace({ name: 'agent', params: { runId: run.run_id } })
} catch (error) { pageError.value = error instanceof Error ? error.message : '运行创建失败' }
}
function eventText(event: AgentEvent) {
if (event.event === 'RunCompleted') return '任务已成功完成。'
if (event.event === 'RunCancelled') return '任务已取消。'
const text = event.data.text ?? event.data.message ?? event.data.code
if (text) return String(text)
return ''
}
</script>
<template>
<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>
<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><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>
<div class="field"><label>运行超时</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
<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>
<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>
</form>
<div v-else class="trace-layout">
<div class="panel run-summary"><div><span class="badge info">{{ runStatusLabel(agentStore.activeRun?.status) }}</span><h2>{{ agentStore.activeRunId }}</h2></div><div class="inline-actions"><span>步骤 {{ agentStore.currentStep }} / {{ agentStore.activeRun?.max_steps }}</span><button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">取消运行</button></div></div>
<div class="timeline">
<article v-for="event in agentStore.events" :key="event.sequence" class="event-card item-card">
<div class="event-head"><span class="badge" :class="{ success: event.event === 'RunCompleted', error: event.event === 'RunFailed', warning: event.event === 'PermissionRequired' }">{{ eventLabel(event.event) }}</span><span> {{ event.sequence }} · {{ new Date(event.timestamp).toLocaleTimeString() }}</span></div>
<p v-if="eventText(event)" class="event-text">{{ eventText(event) }}</p>
<pre v-if="['ToolCall', 'ToolResult', 'Citation', 'Usage'].includes(event.event)">{{ JSON.stringify(localizeDetails(event.data), null, 2) }}</pre>
</article>
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>等待执行轨迹</strong><p>事件连接建立后将在这里实时显示</p></div></div>
</div>
</div>
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
<div class="modal"><span class="badge warning">权限确认</span><h2>{{ toolLabel(agentStore.permissionRequest.tool_name) }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">所需权限{{ permissionLabel(agentStore.permissionRequest.permission) }}{{ agentStore.permissionRequest.permission }}</p><pre>{{ JSON.stringify(localizeDetails(agentStore.permissionRequest.parameters), null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">仅本次允许</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">本次会话允许</button><button class="button-danger" @click="agentStore.respondPermission('deny')">拒绝</button></div></div>
</div>
</section>
</template>
<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); }
.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); }
.run-summary h2 { margin-top: var(--space-sm); font-family: var(--font-ui-mono); font-size: var(--font-size-lg); }
.timeline { position: relative; display: grid; gap: var(--space-md); padding-left: var(--space-md); }
.timeline::before { content: ''; position: absolute; top: 10px; bottom: 10px; left: 1px; width: 2px; border-radius: var(--radius-full); background: var(--color-border-default); }
.event-card { position: relative; }
.event-card::before { content: ''; position: absolute; top: 20px; left: calc(-1 * var(--space-md) - 5px); width: 8px; height: 8px; border: 2px solid var(--color-surface-primary); border-radius: var(--radius-full); background: var(--color-accent-primary); box-shadow: 0 0 0 1px var(--color-accent-secondary); }
.event-head { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.event-text { margin-top: var(--space-md); white-space: pre-wrap; line-height: var(--line-height-relaxed); }
pre { margin-top: var(--space-md); max-height: 260px; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); font-family: var(--font-ui-mono); font-size: var(--font-size-xs); white-space: pre-wrap; user-select: text; }
.permission-actions { margin-top: var(--space-lg); }
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent'
import { runStatusLabel } from './labels'
const agentStore = useAgentStore()
const router = useRouter()
const error = ref('')
onMounted(async () => {
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : '运行记录加载失败' }
})
function selectRun(runId: string) { void router.push({ name: 'agent', params: { runId } }) }
</script>
<template>
<div class="sidebar-panel">
<button class="button-primary new-button" @click="router.push({ name: 'agent' })"> 新建运行</button>
<p v-if="error" class="subtle error-text">{{ error }}</p>
<div class="sidebar-list">
<button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item"
:class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)">
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ runStatusLabel(run.status) }}</span>
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString() : '等待开始' }}</small>
</button>
</div>
</div>
</template>
<style scoped>
.new-button { width: 100%; margin-bottom: var(--space-md); }
.run-item { display: grid; gap: 3px; width: 100%; text-align: left; }
.run-item .badge { justify-self: start; }
.run-item small { color: var(--color-text-tertiary); }
.error-text { margin-bottom: var(--space-sm); color: var(--color-error); }
</style>
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
eventLabel,
localizeDetails,
permissionLabel,
runStatusLabel,
toolDescription,
toolLabel,
} from './labels'
describe('智能体页面中文标签', () => {
it('转换运行状态和事件名称', () => {
expect(runStatusLabel('waiting_permission')).toBe('等待授权')
expect(eventLabel('ToolCall')).toBe('调用工具')
})
it('转换工具、权限和工具说明', () => {
expect(toolLabel('notes.search')).toBe('搜索笔记')
expect(permissionLabel('notes.write')).toBe('修改笔记')
expect(toolDescription('math.add', 'fallback')).toContain('两个数')
expect(toolLabel('custom.tool')).toBe('custom.tool')
})
it('递归转换事件详情中的键名、状态和布尔值', () => {
expect(localizeDetails({
tool_call_id: 'call-1',
success: true,
result: { status: 'completed' },
})).toEqual({
'工具调用 ID': 'call-1',
'是否成功': '是',
'结果': { '状态': '已完成' },
})
})
})
+126
View File
@@ -0,0 +1,126 @@
import type { AgentEventType, AgentRunStatus } from '@/contracts'
const runStatusLabels: Record<AgentRunStatus, string> = {
queued: '排队中',
running: '运行中',
waiting_permission: '等待授权',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
}
const eventLabels: Record<AgentEventType, string> = {
RunStarted: '运行开始',
TextDelta: '回复内容',
ThinkingDelta: '思考过程',
ToolCall: '调用工具',
ToolResult: '工具结果',
PermissionRequired: '请求权限',
Usage: '用量统计',
Citation: '引用来源',
RunCompleted: '运行完成',
RunFailed: '运行失败',
RunCancelled: '运行取消',
}
const toolLabels: Record<string, string> = {
'system.echo': '回显测试',
'math.add': '数值相加',
'notes.search': '搜索笔记',
'rag.search': '知识检索',
'notes.read': '读取笔记',
'notes.create': '创建笔记',
'notes.update': '更新笔记',
'notes.list': '列出笔记',
'notes.move': '移动笔记',
'tasks.create': '创建任务',
'tasks.update': '更新任务',
'tasks.list': '列出任务',
'attachments.read': '读取附件',
'audio.transcribe': '音频转写',
}
const toolDescriptions: Record<string, string> = {
'system.echo': '回显文本,用于本地智能体集成测试。',
'math.add': '计算两个数的和,不产生外部副作用。',
'notes.search': '搜索已建立索引的笔记,并返回摘要和引用。',
'rag.search': '检索与当前任务相关的笔记内容块和引用。',
'notes.read': '根据笔记 ID 读取笔记及其内容块。',
'notes.create': '在当前知识库中创建 Markdown 笔记。',
'notes.update': '更新已有 Markdown 笔记。',
'notes.list': '按文件夹和标签筛选并列出笔记摘要。',
'notes.move': '移动笔记到其他文件夹并保留笔记 ID。',
'tasks.create': '创建并持久化任务。',
'tasks.update': '更新已有任务。',
'tasks.list': '列出已持久化的任务。',
'attachments.read': '读取由宿主管理的 UTF-8 附件。',
'audio.transcribe': '读取音频附件已有的宿主转写结果。',
}
const permissionLabels: Record<string, string> = {
'notes.search': '搜索笔记',
'notes.read': '读取笔记',
'notes.write': '修改笔记',
'tasks.read': '读取任务',
'tasks.write': '修改任务',
'attachments.read': '读取附件',
'network.request': '访问网络',
'secrets.use': '使用密钥',
}
const detailLabels: Record<string, string> = {
tool_call_id: '工具调用 ID',
name: '工具名称',
arguments: '参数',
parameters: '参数',
success: '是否成功',
output: '输出',
result: '结果',
error_code: '错误代码',
error_message: '错误信息',
note_id: '笔记 ID',
block_id: '内容块 ID',
heading_path: '标题路径',
input_tokens: '输入令牌',
output_tokens: '输出令牌',
total_tokens: '令牌总数',
status: '状态',
duration_ms: '耗时(毫秒)',
}
export function runStatusLabel(status?: AgentRunStatus): string {
return status ? runStatusLabels[status] : '未知状态'
}
export function eventLabel(event: AgentEventType): string {
return eventLabels[event]
}
export function toolLabel(name: string): string {
return toolLabels[name] ?? name
}
export function toolDescription(name: string, fallback: string): string {
return toolDescriptions[name] ?? fallback
}
export function permissionLabel(permission: string): string {
return permissionLabels[permission] ?? permission
}
function localizeValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(localizeValue)
if (value && typeof value === 'object') return localizeDetails(value as Record<string, unknown>)
if (value === true) return '是'
if (value === false) return '否'
if (typeof value === 'string' && value in runStatusLabels) {
return runStatusLabels[value as AgentRunStatus]
}
return value
}
export function localizeDetails(data: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [detailLabels[key] ?? key, localizeValue(value)])
)
}
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Citation } from '@/contracts'
import { useChatStore } from '@/stores/chat'
import { useEditorStore } from '@/stores/editor'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import { useWorkspaceStore } from '@/stores/workspace'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
const chatStore = useChatStore()
const providerStore = useProviderStore()
const skillStore = useSkillStore()
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
const router = useRouter()
const loadError = ref('')
const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? [])
onMounted(async () => {
try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
await providerStore.loadModels(chatStore.selectedProviderId)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,当前展示本地数据。'
}
})
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 : '模型列表加载失败'
}
})
function send() { void chatStore.sendMessage(chatStore.inputText) }
async function openCitation(citation: Citation) {
await editorStore.loadFile(citation.file_path)
workspaceStore.openFile(citation.file_path)
editorStore.highlightBlock(citation.block_id)
await router.push('/workspace')
}
</script>
<template>
<section class="chat-page">
<header class="chat-toolbar">
<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>
</header>
<div v-if="loadError" class="error-banner chat-error">{{ loadError }}</div>
<main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>可以直接提问也可以打开 RAG 让模型基于当前 Vault 回答</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">
<details v-if="message.thinking" class="thinking"><summary>思考过程</summary><p>{{ message.thinking }}</p></details>
<MarkdownContent v-if="message.content" class="message-content" :source="message.content" />
<div v-else-if="chatStore.isStreaming" class="message-content">正在思考</div>
<div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div>
<div v-if="message.citations?.length" class="citations">
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitation(citation)">
<span class="badge info">{{ index + 1 }}</span><span><strong>{{ citation.heading_path || citation.file_path }}</strong><small>{{ citation.content }}</small></span>
</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>
</div>
</article>
</main>
<footer class="composer">
<textarea v-model="chatStore.inputText" class="textarea" placeholder="输入问题,Ctrl + Enter 发送"
@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>
</div>
</footer>
</section>
</template>
<style scoped>
.chat-page { display: grid; grid-template-rows: auto auto 1fr auto; height: 100%; min-height: 0; background: radial-gradient(circle at 85% -10%, var(--color-accent-soft), transparent 30%), var(--color-background-primary); }
.chat-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); padding: var(--space-md) var(--space-xl); border-bottom: 1px solid var(--color-border-default); background: var(--color-surface-secondary); box-shadow: var(--shadow-sm); z-index: 1; }
.compact { min-width: 160px; }
.rag-toggle { display: flex; align-items: center; gap: var(--space-xs); min-height: 36px; color: var(--color-text-secondary); }
.chat-error { margin: var(--space-md) var(--space-xl) 0; }
.message-timeline { min-height: 0; overflow: auto; padding: var(--space-xl) max(var(--space-xl), calc((100% - 820px) / 2)); user-select: text; }
.message { display: grid; grid-template-columns: 36px 1fr; gap: var(--space-md); margin-bottom: var(--space-xl); animation: message-in var(--motion-normal) both; }
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--color-border-default); border-radius: var(--radius-full); background: var(--color-background-tertiary); box-shadow: var(--shadow-sm); font-weight: 700; }
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.message-body { min-width: 0; padding: var(--space-md) var(--space-lg); border: 1px solid var(--color-border-subtle); border-radius: 4px var(--radius-lg) var(--radius-lg) var(--radius-lg); background: color-mix(in srgb, var(--color-surface-primary) 88%, transparent); box-shadow: var(--shadow-sm); }
.user .message-body { background: var(--color-accent-soft); border-color: color-mix(in srgb, var(--color-accent-primary) 14%, transparent); }
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
.message time { display: block; margin-top: var(--space-sm); color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.citations { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }
.citation-card { display: flex; align-items: flex-start; 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); text-align: left; transition: border-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
.citation-card:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.citation-card small { display: block; margin-top: 2px; color: var(--color-text-secondary); }
.composer { padding: var(--space-md) max(var(--space-xl), calc((100% - 820px) / 2)); border-top: 1px solid var(--color-border-default); background: var(--color-surface-secondary); box-shadow: 0 -8px 24px color-mix(in srgb, var(--color-text-primary) 5%, transparent); }
.composer .textarea { min-height: 72px; }
.composer-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-top: var(--space-sm); }
@keyframes message-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
</script>
<template>
<div class="sidebar-panel">
<button class="button-primary new-button" @click="chatStore.createNewConversation"> 新对话</button>
<div class="sidebar-list conversation-list">
<div v-for="conversation in chatStore.sortedConversations" :key="conversation.conversation_id"
class="sidebar-list-item conversation" :class="{ active: chatStore.activeConversationId === conversation.conversation_id }"
@click="chatStore.setActiveConversation(conversation.conversation_id)">
<div><strong>{{ conversation.title }}</strong><p>{{ conversation.message_count }} 条消息</p></div>
<button class="delete" title="删除会话" @click.stop="chatStore.deleteConversation(conversation.conversation_id)">×</button>
</div>
</div>
</div>
</template>
<style scoped>
.new-button { width: 100%; margin-bottom: var(--space-md); }
.conversation-list { gap: var(--space-xs); }
.conversation { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
.conversation strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-sm); }
.conversation p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.delete { padding: var(--space-xs); color: var(--color-text-tertiary); }
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const statusText: Record<string, string> = {
idle: '空闲', dirty: '未保存', saving: '保存中…', saved: '已保存', save_failed: '保存失败',
external_changed: '外部文件已变化', conflict: '存在编辑冲突',
}
</script>
<template>
<header class="editor-header">
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<div class="mode-switch" aria-label="编辑模式">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">写作</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">源码</button>
</div>
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">保存</button>
</div>
</header>
</template>
<style scoped>
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
padding: 0 var(--space-lg);
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-secondary);
}
.file-identity { display: grid; min-width: 0; }
.file-identity strong, .file-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-identity small { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.editor-actions, .mode-switch { display: flex; align-items: center; gap: var(--space-sm); }
.save-status { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.save-status.dirty, .save-status.external_changed { color: var(--color-warning); }
.save-status.save_failed, .save-status.conflict { color: var(--color-error); }
.save-status.saved { color: var(--color-success); }
.mode-switch { gap: 2px; padding: 2px; border-radius: var(--radius-md); background: var(--color-background-secondary); }
.mode-switch button, .save-button { padding: 5px 9px; border-radius: var(--radius-sm); }
.mode-switch button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
.save-button { background: var(--color-accent-primary); color: var(--color-text-inverse); }
.save-button:disabled { opacity: .55; }
</style>
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import EditorPane from './EditorPane.vue'
import { useEditorStore } from '@/stores/editor'
let wrapper: VueWrapper | null = null
async function waitForText(text: string) {
for (let attempt = 0; attempt < 100; attempt++) {
if (wrapper?.text().includes(text)) return
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(`Editor did not render ${text}`)
}
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
describe('EditorPane file switching', () => {
it('recreates the visual editor with the newly loaded file content', async () => {
const store = useEditorStore()
await store.loadFile('/欢迎使用 NotesAgent.md')
wrapper = mount(EditorPane, { attachTo: document.body })
await waitForText('欢迎使用 NotesAgent')
await store.loadFile('/数据结构/红黑树.md')
await nextTick()
await waitForText('红黑树')
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
expect(wrapper.text()).not.toContain('祝你写作愉快')
})
})
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
function updateContent(event: Event) {
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
}
</script>
<template>
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}`"
:initial-content="editorStore.content" />
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
aria-label="Markdown 源码编辑器" @input="updateContent" />
</template>
<style scoped>
.editor-pane {
box-sizing: border-box;
flex: 1;
width: 100%;
resize: none;
border: 0;
outline: 0;
padding: var(--space-xl);
background: var(--color-background-primary);
color: var(--color-text-primary);
font: inherit;
line-height: 1.7;
user-select: text;
}
.editor-pane.source { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
</style>
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { getMarkdown } from '@milkdown/kit/utils'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
type EditorComponent = { getEditor: () => Editor | undefined }
const mounted: VueWrapper[] = []
async function waitForEditor(wrapper: VueWrapper): Promise<Editor> {
for (let attempt = 0; attempt < 100; attempt++) {
const editor = (wrapper.vm as unknown as EditorComponent).getEditor()
if (editor) {
try {
editor.action(getMarkdown())
return editor
} catch { /* editor is still creating */ }
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error('Milkdown editor did not become ready')
}
function selectText(editor: Editor, from: number, to: number) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to)))
view.focus()
})
}
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
afterEach(() => {
mounted.splice(0).forEach((wrapper) => wrapper.unmount())
document.body.innerHTML = ''
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it('applies bold from the top toolbar to the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
selectText(editor, 1, 6)
await wrapper.get('[aria-label="加粗"]').trigger('pointerdown')
expect(editor.action(getMarkdown())).toContain('**alpha** beta')
})
it('applies italic from the floating toolbar to the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
selectText(editor, 1, 6)
await new Promise((resolve) => setTimeout(resolve, 80))
const floatingItalic = document.querySelector<HTMLButtonElement>('.milkdown-toolbar [data-toolbar-item="italic"]')
expect(floatingItalic).not.toBeNull()
floatingItalic?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
expect(editor.action(getMarkdown())).toContain('*alpha* beta')
})
it('writes a custom input font size into markdown for the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
selectText(editor, 1, 6)
await wrapper.get('[aria-label="自定义字号"]').setValue(22)
await wrapper.get('[aria-label="应用自定义字号"]').trigger('pointerdown')
expect(editor.action(getMarkdown())).toContain('<span style="font-size: 22px">alpha</span> beta')
})
it('turns a heading back into a normal paragraph', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# alpha' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
await wrapper.get('[aria-label="标题级别"]').setValue('paragraph')
expect(editor.action(getMarkdown()).trim()).toBe('alpha')
})
})
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
import { oneDark } from '@codemirror/theme-one-dark'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
toggleInlineCodeCommand,
toggleLinkCommand,
toggleStrongCommand,
turnIntoTextCommand,
wrapInBulletListCommand,
wrapInHeadingCommand,
wrapInOrderedListCommand,
} from '@milkdown/kit/preset/commonmark'
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { callCommand } from '@milkdown/kit/utils'
import AppIcon from '@/components/common/AppIcon.vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
const props = defineProps<{ initialContent: string }>()
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true)
const fontSizeInput = ref(16)
let crepe: Crepe | null = null
type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block'
function runCommand(command: ToolbarCommand) {
const editor = crepe?.editor
if (!editor) return
// Milkdown
const actions = {
bold: callCommand(toggleStrongCommand.key),
italic: callCommand(toggleEmphasisCommand.key),
'ordered-list': callCommand(wrapInOrderedListCommand.key),
'bullet-list': callCommand(wrapInBulletListCommand.key),
'inline-code': callCommand(toggleInlineCodeCommand.key),
'code-block': callCommand(createCodeBlockCommand.key, ''),
'inline-math': callCommand('ToggleLatex'),
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
}
editor.action(actions[command])
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
}
function applyLink() {
if (!crepe) return
// TODO(editor): Element Plus prompt URL
const href = window.prompt('请输入链接地址', 'https://')?.trim()
if (!href) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const commands = ctx.get(commandsCtx)
if (view.state.selection.empty) {
const label = window.prompt('请输入链接文字', href)?.trim() || href
const from = view.state.selection.from
const transaction = view.state.tr.insertText(label, from)
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length))
view.dispatch(transaction)
}
return commands.call(toggleLinkCommand.key, { href })
})
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
}
function applyHeading(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (!value || !crepe) return
crepe.editor.action(value === 'paragraph'
? callCommand(turnIntoTextCommand.key)
: callCommand(wrapInHeadingCommand.key, Number(value)))
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
;(event.target as HTMLSelectElement).value = ''
}
function applyFontSize(event: Event) {
const size = Number((event.target as HTMLSelectElement).value)
if (!size || !crepe) return
fontSizeInput.value = size
applyFontSizeValue()
;(event.target as HTMLSelectElement).value = ''
}
function applyFontSizeValue() {
if (!crepe) return
const size = Math.min(96, Math.max(8, Math.round(Number(fontSizeInput.value))))
if (!Number.isFinite(size)) return
fontSizeInput.value = size
applyMarkdownFontSize(crepe.editor, size)
}
onMounted(async () => {
crepe = new Crepe({
root: editorRoot.value,
defaultValue: props.initialContent,
features: { [Crepe.Feature.TopBar]: false },
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
[Crepe.Feature.CodeMirror]: {
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
previewOnlyByDefault: false,
searchPlaceholder: '搜索语言',
noResultText: '没有匹配的语言',
copyText: '复制',
},
[Crepe.Feature.Latex]: {
inlineEditConfirm: '确认',
},
[Crepe.Feature.LinkTooltip]: {
editButton: '编辑',
removeButton: '移除',
confirmButton: '确认',
inputPlaceholder: '粘贴链接地址…',
},
[Crepe.Feature.Toolbar]: {
boldLabel: '加粗',
italicLabel: '斜体',
strikethroughLabel: '删除线',
codeLabel: '行内代码',
latexLabel: '行内公式',
linkLabel: '链接',
},
[Crepe.Feature.BlockEdit]: {
textGroup: {
label: '文本',
text: { label: '正文' },
h1: { label: '一级标题' },
h2: { label: '二级标题' },
h3: { label: '三级标题' },
h4: { label: '四级标题' },
h5: { label: '五级标题' },
h6: { label: '六级标题' },
quote: { label: '引用' },
divider: { label: '分割线' },
},
listGroup: {
label: '列表',
bulletList: { label: '无序列表' },
orderedList: { label: '有序列表' },
taskList: { label: '任务列表' },
},
advancedGroup: {
label: '插入',
image: { label: '图片' },
codeBlock: { label: '代码块' },
table: { label: '表格' },
math: { label: '公式块' },
},
},
},
})
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// /
if (markdown === previousMarkdown || markdown === editorStore.content) return
editorStore.updateContent(markdown)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
})
})
await crepe.create()
loading.value = false
})
onBeforeUnmount(() => { void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
<template>
<div class="visual-editor">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏">
<label class="toolbar-select heading-select" title="设置标题级别">
<span class="format-glyph heading-glyph">H</span>
<select aria-label="标题级别" @change="applyHeading">
<option value="" selected>标题</option>
<option value="paragraph">正文</option>
<option v-for="level in 6" :key="level" :value="level">H{{ level }}</option>
</select>
</label>
<button type="button" title="加粗 (Ctrl+B)" aria-label="加粗" @pointerdown.prevent="runCommand('bold')"><strong class="format-glyph">B</strong></button>
<button type="button" title="斜体 (Ctrl+I)" aria-label="斜体" @pointerdown.prevent="runCommand('italic')"><em class="format-glyph">I</em></button>
<span class="toolbar-divider" />
<button type="button" class="list-glyph" title="有序列表" aria-label="有序列表" @pointerdown.prevent="runCommand('ordered-list')"><span class="list-marker">1</span><span class="list-lines"></span></button>
<button type="button" class="list-glyph" title="无序列表" aria-label="无序列表" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker"></span><span class="list-lines"></span></button>
<span class="toolbar-divider" />
<label class="toolbar-select font-size-select" title="选择预设字号">
<span class="format-glyph font-size-glyph">A</span>
<select aria-label="文字字号" @change="applyFontSize">
<option value="" selected>字号</option>
<option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option>
</select>
</label>
<div class="font-size-input" title="输入字号后按 Enter 或点击应用">
<input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" aria-label="自定义字号"
@keydown.enter.prevent="applyFontSizeValue" />
<span>px</span>
<button type="button" aria-label="应用自定义字号" @pointerdown.prevent="applyFontSizeValue">应用</button>
</div>
<span class="toolbar-divider" />
<button type="button" title="行内代码" aria-label="行内代码" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</code></button>
<button type="button" title="代码块" aria-label="代码块" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
<button type="button" title="行内公式" aria-label="行内公式" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button type="button" title="公式块" aria-label="公式块" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button type="button" title="插入链接" aria-label="插入链接" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
</div>
<div v-if="loading" class="editor-loading">正在加载编辑器</div>
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
</div>
</template>
<style scoped>
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); }
.markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
.format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; }
.heading-glyph { font-weight: 800; }
.font-size-glyph { font-size: 18px; }
.list-glyph { grid-template-columns: 8px 14px; column-gap: 2px; font-weight: 700; }
.list-marker { font: 700 12px/1 var(--font-ui-sans); }
.list-lines { overflow: hidden; width: 14px; font-size: 15px; line-height: 1; transform: scaleX(1.2); }
.code-glyph, .block-glyph { padding: 0; background: transparent; color: inherit; font: 700 13px/1 var(--font-editor-mono); }
.math-glyph { font: italic 700 16px/1 Georgia, 'Times New Roman', serif; }
.toolbar-select { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 5px 3px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
.toolbar-select select { min-width: 58px; border: 0; outline: 0; background: transparent; color: inherit; cursor: pointer; font-size: var(--font-size-sm); }
.font-size-select select { min-width: 62px; }
.font-size-input { display: inline-flex; align-items: center; height: 30px; margin-left: 2px; overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-secondary); background: var(--color-background-primary); }
.font-size-input:focus-within { border-color: var(--color-border-focus); box-shadow: 0 0 0 1px var(--color-border-focus); }
.font-size-input input { width: 42px; height: 100%; padding-left: 7px; border: 0; outline: 0; background: transparent; color: var(--color-text-primary); }
.font-size-input span { font-size: var(--font-size-xs); }
.font-size-input button { min-width: auto; min-height: 100%; margin-left: 4px; padding: 3px 7px; border-left: 1px solid var(--color-border-default); border-radius: 0; font-size: var(--font-size-xs); }
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); }
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
.milkdown-host.loading { visibility: hidden; }
.editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); }
.milkdown-host :deep(.milkdown) {
min-height: 100%;
background: transparent;
color: inherit;
--crepe-color-background: var(--color-background-primary);
--crepe-color-on-background: var(--color-text-primary);
--crepe-color-surface: var(--color-surface-primary);
--crepe-color-surface-low: var(--color-background-secondary);
--crepe-color-on-surface: var(--color-text-primary);
--crepe-color-on-surface-variant: var(--color-text-secondary);
--crepe-color-outline: var(--color-markdown-grid);
--crepe-color-primary: var(--color-accent-primary);
--crepe-color-secondary: var(--color-accent-soft);
--crepe-color-on-secondary: var(--color-text-primary);
--crepe-color-inverse: var(--color-text-primary);
--crepe-color-on-inverse: var(--color-background-primary);
--crepe-color-inline-code: var(--color-error);
--crepe-color-error: var(--color-error);
--crepe-color-hover: var(--color-background-hover);
--crepe-color-selected: var(--color-accent-soft);
--crepe-color-inline-area: var(--color-background-tertiary);
--crepe-font-default: var(--font-editor-sans);
--crepe-font-code: var(--font-editor-mono);
}
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: hidden; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.milkdown-code-block .cm-editor),
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
.milkdown-host :deep(.milkdown-code-block .cm-content) { caret-color: var(--color-code-text); font-family: var(--font-editor-mono); }
.milkdown-host :deep(.milkdown-code-block .language-button) { color: var(--color-code-muted); }
:global(.milkdown-toolbar) { border: 1px solid var(--color-border-default) !important; background: var(--color-surface-elevated) !important; box-shadow: var(--shadow-md) !important; }
:global(.milkdown-toolbar .toolbar-item svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary) !important; fill: var(--color-text-primary) !important; opacity: 1 !important; }
:global(.milkdown-toolbar .toolbar-item:hover svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-accent-primary) !important; fill: var(--color-accent-primary) !important; }
.milkdown-host :deep(.milkdown-table-block th),
.milkdown-host :deep(.milkdown-table-block td) { border-color: var(--color-markdown-grid); }
.milkdown-host :deep(.milkdown-table-block th) { background: var(--color-markdown-table-header); font-weight: 700; }
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-markdown-marker); font-weight: 700; }
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-markdown-marker); }
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
:global([data-theme='dark'] .milkdown-host .milkdown) { color-scheme: dark; }
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
</style>
@@ -0,0 +1,64 @@
import { $prose } from '@milkdown/kit/utils'
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
const openingTag = /^<span style="font-size:\s*(\d+(?:\.\d+)?)px">$/i
const closingTag = /^<\/span>$/i
export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = []
const stack: Array<{ from: number; size: string }> = []
state.doc.descendants((node, position) => {
if (node.type.name !== 'html') return
const value = String(node.attrs.value ?? '')
const match = value.match(openingTag)
if (match) {
stack.push({ from: position + node.nodeSize, size: match[1] })
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
return
}
if (closingTag.test(value)) {
const opening = stack.pop()
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
if (opening && opening.from < position) {
decorations.push(Decoration.inline(opening.from, position, {
style: `font-size: ${opening.size}px`,
'data-font-size': opening.size,
}))
}
}
})
return DecorationSet.create(state.doc, decorations)
},
},
}))
export function applyMarkdownFontSize(editor: Editor, size: number): boolean {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { from, to, empty } = view.state.selection
if (empty) return false
const htmlNode = view.state.schema.nodes.html
if (!htmlNode) return false
const opening = htmlNode.create({ value: `<span style="font-size: ${size}px">` })
const closing = htmlNode.create({ value: '</span>' })
const transaction = view.state.tr
.insert(to, closing)
.insert(from, opening)
transaction.setSelection(TextSelection.create(transaction.doc, from + 1, to + 1))
view.dispatch(transaction)
view.focus()
return true
})
}
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { Connection } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { onMounted, ref } from 'vue'
import { usePluginStore } from '@/stores/plugin'
const pluginStore = usePluginStore()
const actionError = ref('')
onMounted(() => { void pluginStore.loadPlugins() })
async function install() { const path = prompt('请输入 Plugin Package 路径')?.trim(); if (!path) return; try { await pluginStore.installPlugin(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' } }
async function toggle(id: string, enabled: boolean) { try { enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' } }
async function grant(id: string, permissions: string[]) { if (!confirm(`将授权:${permissions.join('、')}。是否继续?`)) return; try { await pluginStore.grantPermissions(id, permissions) } catch (error) { actionError.value = error instanceof Error ? error.message : '授权失败' } }
async function uninstall(id: string, name: string) { if (!confirm(`卸载“${name}”将移除其全部 Contribution,是否继续?`)) return; try { await pluginStore.uninstallPlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' } }
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>Plugin 管理</h1><p>管理插件生命周期权限和受控 Contribution</p></div><button class="button-primary" @click="install">安装 Plugin</button></header>
<div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
<div v-if="pluginStore.selectedPlugin" class="panel">
<div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">授权权限</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">卸载</button></div></div>
<p class="description">{{ pluginStore.selectedPlugin.description }}</p>
<div class="detail-grid"><div><h3>权限</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div>
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div>
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill{{ pluginStore.selectedPlugin.dependent_skills.join('') }}</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>
<style scoped>
.detail-head, .extension-title { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); }
.detail-head h2 { margin-top: var(--space-sm); }
.description { margin: var(--space-xl) 0; line-height: var(--line-height-relaxed); }
.detail-grid { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr); gap: var(--space-xl); }
.detail-grid h3 { margin-bottom: var(--space-sm); }
.contribution-list { display: grid; gap: var(--space-sm); }
.contribution-list .item-card { display: grid; gap: var(--space-xs); }
.last-error { margin: var(--space-xl) 0 0; }
.extension-card { cursor: pointer; }
.extension-card > p { margin-top: var(--space-md); }
.extension-title { align-items: center; }
.extension-title .icon { font-size: 28px; }
.extension-title p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
@media (max-width: 800px) { .detail-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useSearchStore } from '@/stores/search'
const searchStore = useSearchStore()
const modes = [
{ value: 'hybrid', label: '混合检索' },
{ value: 'fts', label: '全文检索' },
{ value: 'vector', label: '向量检索' },
] as const
</script>
<template>
<div class="sidebar-panel">
<p class="subtle">检索模式</p>
<div class="sidebar-list mode-list">
<button v-for="item in modes" :key="item.value" class="sidebar-list-item"
:class="{ active: searchStore.mode === item.value }" @click="searchStore.setMode(item.value)">
{{ item.label }}
</button>
</div>
<p class="subtle section-title">最近搜索</p>
<div class="sidebar-list">
<button v-for="query in searchStore.recentQueries" :key="query" class="sidebar-list-item recent"
@click="searchStore.doSearch({ query, mode: searchStore.mode })">{{ query }}</button>
</div>
</div>
</template>
<style scoped>
.mode-list { margin-top: var(--space-sm); }
.section-title { margin-top: var(--space-xl); }
.sidebar-list-item { width: 100%; text-align: left; }
.recent { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); }
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { SearchResult } from '@/contracts'
import { useEditorStore } from '@/stores/editor'
import { useSearchStore } from '@/stores/search'
import { useWorkspaceStore } from '@/stores/workspace'
const searchStore = useSearchStore()
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
const router = useRouter()
const folder = ref('')
const tag = ref('')
function submitSearch() {
void searchStore.doSearch({
query: searchStore.query,
mode: searchStore.mode,
folder: folder.value || undefined,
tag: tag.value || undefined,
})
}
async function openResult(result: SearchResult) {
await editorStore.loadFile(result.file_path)
workspaceStore.openFile(result.file_path)
editorStore.highlightBlock(result.block_id)
await router.push('/workspace')
}
</script>
<template>
<section class="feature-page search-page">
<header class="feature-header">
<div><h1>搜索知识库</h1><p>在当前 Vault 中进行全文向量或混合检索</p></div>
</header>
<form class="search-form panel" @submit.prevent="submitSearch">
<input v-model="searchStore.query" class="input search-input" placeholder="搜索笔记内容、标题或标签" autofocus />
<button class="button-primary" :disabled="!searchStore.query.trim() || searchStore.isSearching">
{{ searchStore.isSearching ? '搜索中…' : '搜索' }}
</button>
<div class="form-grid advanced">
<div class="field"><label>文件夹范围</label><input v-model="folder" class="input" placeholder="例如 /数据结构" /></div>
<div class="field"><label>标签</label><input v-model="tag" class="input" placeholder="例如 算法" /></div>
</div>
</form>
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
<div v-if="searchStore.vectorUnavailable" class="notice-banner">向量索引不可用已保留全文检索能力</div>
<div v-if="searchStore.results.length" class="results-header">
<span>找到 {{ searchStore.total }} 条结果</span><span class="badge info">{{ searchStore.mode }}</span>
</div>
<div v-if="searchStore.results.length" class="result-list">
<article v-for="result in searchStore.results" :key="`${result.note_id}:${result.block_id}`"
class="item-card result-card" @click="openResult(result)">
<div class="result-title"><strong>{{ result.note_title }}</strong><span class="badge">{{ result.match_type }}</span></div>
<p class="subtle">{{ result.file_path }} · {{ result.heading_path }}</p>
<p class="snippet">{{ result.snippet }}</p>
<div class="result-meta"><span>相关度 {{ Math.round(result.score * 100) }}%</span><span>点击定位原文 </span></div>
</article>
</div>
<div v-else-if="!searchStore.isSearching" class="empty-state">
<div><strong>{{ searchStore.query ? '没有找到匹配内容' : '从你的知识库开始搜索' }}</strong><p>可切换检索模式或缩小文件夹标签范围</p></div>
</div>
</section>
</template>
<style scoped>
.search-page > * { width: min(100%, 1040px); margin-inline: auto; }
.search-form { display: grid; grid-template-columns: 1fr auto; gap: var(--space-md); margin-bottom: var(--space-lg); }
.search-input { height: 44px; font-size: var(--font-size-lg); }
.advanced { grid-column: 1 / -1; }
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
.result-list { display: grid; gap: var(--space-md); }
.result-card { position: relative; cursor: pointer; overflow: hidden; }
.result-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--color-accent-primary); opacity: 0; transform: scaleY(.45); transition: opacity var(--motion-fast), transform var(--motion-fast); }
.result-card:hover::before { opacity: 1; transform: scaleY(1); }
.snippet { margin: var(--space-md) 0; line-height: var(--line-height-relaxed); }
.result-meta { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
@media (max-width: 700px) { .search-form { grid-template-columns: 1fr; } .advanced { grid-column: auto; } }
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { ProviderConfig, ProviderType } from '@/contracts'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core'
const sections: Array<{ id: Section; label: string }> = [
{ id: 'general', label: '通用' }, { id: 'editor', label: '编辑器' }, { id: 'providers', label: '模型提供商' },
{ id: 'index', label: '索引与模型' }, { id: 'permissions', label: '权限' }, { id: 'ai-core', label: 'AI Core 诊断' },
]
const activeSection = ref<Section>('general')
const settingsStore = useSettingsStore()
const providerStore = useProviderStore()
const themeStore = useThemeStore()
const showProviderForm = ref(false)
const editingProviderId = ref<string | null>(null)
const providerAction = ref('')
const testResults = ref<Record<string, string>>({})
const providerApiKey = ref('')
const providerForm = reactive({ preset_id: '', provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
const formModels = computed(() => editingProviderId.value ? providerStore.modelsByProvider[editingProviderId.value] ?? [] : [])
const selectedPreset = computed(() => providerStore.presets.find((item) => item.preset_id === providerForm.preset_id) ?? null)
onMounted(async () => {
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
await providerStore.refreshEnabledModels()
})
function presetIdFor(provider?: ProviderConfig) {
if (!provider) return ''
return providerStore.presets.find((preset) =>
preset.provider_type === provider.provider_type && preset.base_url === provider.base_url
)?.preset_id ?? ''
}
function openProvider(provider?: ProviderConfig) {
editingProviderId.value = provider?.provider_id ?? null
const presetId = presetIdFor(provider)
const preset = providerStore.presets.find((item) => item.preset_id === presetId)
Object.assign(providerForm, { preset_id: presetId, provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? preset?.default_credential_id ?? '', enabled: provider?.enabled ?? true })
providerApiKey.value = ''
showProviderForm.value = true
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
function applyProviderPreset() {
const preset = providerStore.presets.find((item) => item.preset_id === providerForm.preset_id)
if (!preset) return
Object.assign(providerForm, {
provider_type: preset.provider_type,
name: preset.name,
base_url: preset.base_url,
credential_id: preset.default_credential_id ?? '',
})
providerApiKey.value = ''
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
}
function closeProvider() {
providerApiKey.value = ''
showProviderForm.value = false
}
async function saveProvider() {
providerAction.value = ''
const credentialId = providerForm.credential_id.trim()
const requiresApiKey = Boolean(selectedPreset.value?.requires_credential)
if (requiresApiKey && !providerApiKey.value && !providerStore.credentialConfiguredById[credentialId]) {
providerAction.value = '请输入 API Key。密钥将由后端加密保存。'
return
}
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
try {
if (providerApiKey.value) await providerStore.saveCredential(credentialId, providerApiKey.value)
const saved = editingProviderId.value
? await providerStore.updateProvider(editingProviderId.value, data)
: await providerStore.addProvider(data)
closeProvider()
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
} catch (error) {
providerApiKey.value = ''
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
}
}
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}”吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = '测试中…'; const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `连接成功${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `连接失败:${result.error}` }
async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
const defaultModel = (event.target as HTMLSelectElement).value
try { await providerStore.updateProvider(provider.provider_id, { default_model: defaultModel }) }
catch (error) { providerAction.value = error instanceof Error ? error.message : '默认模型更新失败' }
}
</script>
<template>
<section class="feature-page settings-page">
<header class="feature-header"><div><h1>设置</h1><p>管理应用偏好模型索引权限和本地 AI Core</p></div></header>
<nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav>
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>通用</h2><label class="setting-row"><span><strong>恢复上次 Vault</strong><small>启动后自动打开最近使用的知识库</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>自动保存间隔</strong><small>编辑停止后等待多久写入文件</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 秒</option><option :value="1500">1.5 秒</option><option :value="3000">3 秒</option></select></div><div class="setting-row"><span><strong>界面语言</strong><small>当前阶段支持中文和英文入口</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>版本</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>编辑器</h2><div class="setting-row"><span><strong>默认模式</strong><small>新打开文件使用的编辑器模式</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">写作与预览</option><option value="source">Markdown 源码</option></select></div><div class="setting-row"><span><strong>字号</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>行高</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>行宽</strong><small>Markdown 预览最大字符宽度</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>拼写检查</strong></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'providers'" class="settings-section">
<div class="section-head">
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAIDeepSeekOllama 和自定义兼容服务</p></div>
<button class="button-primary" @click="openProvider()">新增 Provider</button>
</div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main">
<div class="inline-actions"><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
<label :for="`default-model-${provider.provider_id}`">默认模型</label>
<select :id="`default-model-${provider.provider_id}`" class="select" :value="provider.default_model" @change="chooseDefaultModel(provider, $event)">
<option value="">未设置</option>
<option v-for="model in providerStore.modelsByProvider[provider.provider_id]" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
</select>
<span class="subtle">已获取 {{ providerStore.modelsByProvider[provider.provider_id].length }} 个模型</span>
</div>
<p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">模型获取失败{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p>
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
</div>
<div class="inline-actions provider-actions">
<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>
</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></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 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-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
<div class="modal">
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
<form @submit.prevent="saveProvider">
<div class="field">
<label>提供商预设</label>
<select v-model="providerForm.preset_id" class="select" @change="applyProviderPreset">
<option value="">自定义</option>
<option v-for="preset in providerStore.presets" :key="preset.preset_id" :value="preset.preset_id">{{ preset.name }}</option>
</select>
</div>
<div class="field"><label>接入协议</label><select v-model="providerForm.provider_type" class="select"><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></div>
<div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div>
<div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" required /></div>
<div class="field">
<label>默认模型</label>
<input v-model="providerForm.default_model" class="input" :list="editingProviderId ? 'provider-model-options' : undefined" placeholder="保存后自动获取,也可以手动输入" />
<datalist id="provider-model-options"><option v-for="model in formModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist>
</div>
<div v-if="selectedPreset?.requires_credential" class="field">
<label>API Key</label>
<input v-model="providerApiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="providerStore.credentialConfiguredById[providerForm.credential_id] ? '已配置,留空表示不修改' : '请输入 API Key'" />
<small class="subtle">提交后由本地 AI Core 加密保存页面不会回显已保存的密钥</small>
</div>
<div v-else-if="!selectedPreset" class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="自定义凭据标识" /><small class="subtle">自定义服务可以引用 Host 注入或后端已保存的凭据</small></div>
<label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label>
<div class="inline-actions"><button class="button-primary">保存并获取模型</button><button type="button" class="button-secondary" @click="closeProvider">取消</button></div>
</form>
</div>
</div>
</section>
</template>
<style scoped>
.settings-page { max-width: 1120px; margin: 0 auto; }
.settings-section { display: grid; gap: var(--space-md); }
.settings-section h2 { margin-bottom: var(--space-sm); }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 58px; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); transition: background-color var(--motion-fast); }
.setting-row:hover { background: var(--color-background-secondary); }
.setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); }
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-main { min-width: 0; flex: 1; }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-error); }
.test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); }
.section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); }
@media (max-width: 700px) { .provider-card, .setting-row, .model-picker { align-items: flex-start; flex-direction: column; }.short, .model-picker .select { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; }.provider-actions { justify-content: flex-start; } }
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Lightning } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { onMounted, ref } from 'vue'
import { useSkillStore } from '@/stores/skill'
const skillStore = useSkillStore()
const actionError = ref('')
onMounted(() => { void skillStore.loadSkills() })
async function install() {
const path = prompt('请输入 Skill Package 路径')?.trim()
if (!path) return
try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' }
}
async function toggle(skillId: string, enabled: boolean) {
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
}
async function uninstall(skillId: string, name: string) {
if (!confirm(`确定卸载 Skill“${name}”吗?`)) return
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' }
}
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>Skill 管理</h1><p>查看工作流使用的 Tool权限检索配置和模型要求</p></div><button class="button-primary" @click="install">安装 Skill</button></header>
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
<div v-if="skillStore.selectedSkill" class="panel detail-panel">
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || '未知作者' }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">卸载</button></div></div>
<p class="description">{{ skillStore.selectedSkill.description }}</p>
<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 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>
<style scoped>
.detail-head, .extension-title { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); }
.detail-head h2 { margin-top: var(--space-sm); }
.description { margin: var(--space-xl) 0; line-height: var(--line-height-relaxed); }
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-xl); }
.detail-grid h3 { margin-bottom: var(--space-sm); }
pre { padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.dependencies { margin: var(--space-xl) 0 0; }
.extension-card { cursor: pointer; }
.extension-card > p { margin: var(--space-md) 0; }
.extension-title { align-items: center; }
.extension-title .icon { font-size: 28px; }
.extension-title p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useTaskStore } from '@/stores/task'
const taskStore = useTaskStore()
</script>
<template>
<div class="sidebar-panel filters">
<div class="field"><label>状态</label><select v-model="taskStore.filterStatus" class="select"><option value="all">全部</option><option value="todo">待办</option><option value="in_progress">进行中</option><option value="done">已完成</option><option value="cancelled">已取消</option></select></div>
<div class="task-counts"><p><span>待办</span><strong>{{ taskStore.todoTasks.length }}</strong></p><p><span>进行中</span><strong>{{ taskStore.inProgressTasks.length }}</strong></p><p><span>已完成</span><strong>{{ taskStore.doneTasks.length }}</strong></p></div>
</div>
</template>
<style scoped>
.filters { display: grid; gap: var(--space-md); }
.task-counts { display: grid; gap: var(--space-xs); padding-top: var(--space-md); border-top: 1px solid var(--color-border-subtle); }
.task-counts p { display: flex; justify-content: space-between; color: var(--color-text-secondary); }
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts'
import { useTaskStore } from '@/stores/task'
const taskStore = useTaskStore()
const showForm = ref(false)
const editingId = ref<string | null>(null)
const actionError = ref('')
const form = reactive({ title: '', description: '', due_date: '', note_id: '' })
onMounted(() => { void taskStore.loadTasks() })
function resetForm() { editingId.value = null; form.title = ''; form.description = ''; form.due_date = ''; form.note_id = '' }
function editTask(task: TaskItem) { editingId.value = task.task_id; Object.assign(form, { title: task.title, description: task.description ?? '', due_date: task.due_date?.slice(0, 16) ?? '', note_id: task.note_id ?? '' }); showForm.value = true }
async function saveTask() {
actionError.value = ''
try {
if (editingId.value) await taskStore.updateTask(editingId.value, { ...form, due_date: form.due_date || undefined, note_id: form.note_id || null })
else await taskStore.createTask({ ...form, due_date: form.due_date || undefined, note_id: form.note_id || undefined })
showForm.value = false; resetForm()
} catch (error) { actionError.value = error instanceof Error ? error.message : '任务保存失败' }
}
async function setStatus(task: TaskItem, status: TaskStatus) {
try { await taskStore.updateTask(task.task_id, { status }) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
}
async function remove(task: TaskItem) {
if (!confirm(`确定删除任务“${task.title}”吗?`)) return
try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : '任务删除失败' }
}
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>任务</h1><p>管理用户笔记和 Agent 产生的行动项</p></div><button class="button-primary" @click="resetForm(); showForm = true"> 新建任务</button></header>
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
<div v-if="taskStore.filteredTasks.length" class="task-list">
<article v-for="task in taskStore.filteredTasks" :key="task.task_id" class="item-card task-card">
<button class="status-check" :class="{ done: task.status === 'done' }" title="切换完成状态" @click="setStatus(task, task.status === 'done' ? 'todo' : 'done')">{{ task.status === 'done' ? '' : '' }}</button>
<div class="task-content"><div class="task-title"><strong :class="{ completed: task.status === 'done' }">{{ task.title }}</strong></div><p v-if="task.description" class="muted">{{ task.description }}</p><div class="subtle"><span>{{ task.status }}</span><span v-if="task.due_date">截止 {{ new Date(task.due_date).toLocaleString() }}</span><span v-if="task.note_id">关联 Note{{ task.note_id }}</span></div></div>
<div class="inline-actions"><button class="icon-button" @click="editTask(task)">编辑</button><button class="button-danger" @click="remove(task)">删除</button></div>
</article>
</div>
<div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? '正在加载任务…' : '没有符合条件的任务' }}</strong><p>创建一项任务或调整左侧筛选条件</p></div></div>
<div v-if="showForm" class="modal-backdrop" @click.self="showForm = false"><div class="modal"><h2>{{ editingId ? '编辑任务' : '新建任务' }}</h2><form @submit.prevent="saveTask"><div class="field"><label>标题</label><input v-model="form.title" class="input" required /></div><div class="field"><label>描述</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>截止时间</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>关联 Note ID</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">保存</button><button type="button" class="button-secondary" @click="showForm = false">取消</button></div></form></div></div>
</section>
</template>
<style scoped>
.task-list { display: grid; gap: var(--space-md); width: min(100%, 980px); margin-inline: auto; }
.task-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-md); }
.status-check { width: 28px; height: 28px; border: 2px solid var(--color-border-default); border-radius: var(--radius-full); transition: border-color var(--motion-fast), background-color var(--motion-fast), color var(--motion-fast), transform var(--motion-fast); }
.status-check:hover { border-color: var(--color-success); transform: scale(1.06); }
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: white; box-shadow: 0 3px 10px color-mix(in srgb, var(--color-success) 24%, transparent); }
.task-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
.task-content p { margin: var(--space-xs) 0; }
.task-content .subtle { display: flex; flex-wrap: wrap; gap: var(--space-md); }
.completed { text-decoration: line-through; color: var(--color-text-tertiary); }
@media (max-width: 700px) { .task-card { grid-template-columns: auto 1fr; } .task-card > .inline-actions { grid-column: 2; } }
</style>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const shikiPreview = `\`\`\`typescript
const notes = await search('本地优先')
\`\`\``
const codeThemeLabel = computed(() => themeStore.resolvedCodeBlockTheme === 'github-dark'
? 'Shiki · GitHub Dark'
: 'Shiki · GitHub Light')
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>主题</h1><p>预览并切换 Design Token编辑器偏好会即时生效</p></div><button class="button-secondary" @click="themeStore.resetToDefault">恢复默认</button></header>
<div class="feature-grid themes">
<button v-for="theme in themeStore.themes" :key="theme.theme_id" class="item-card theme-card" :class="{ selected: themeStore.currentThemeId === theme.theme_id }" @click="themeStore.applyTheme(theme.theme_id)">
<div class="theme-preview" :class="`preview-${theme.theme_id}`"><span></span><span></span><span></span><div></div></div>
<div class="theme-info"><div><strong>{{ theme.name }}</strong><p class="subtle">{{ theme.description }}</p></div><span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">使用中</span></div>
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}</p>
</button>
</div>
<div class="panel preference-panel">
<h2 class="panel-title">编辑器外观</h2>
<div class="form-grid">
<div class="field"><label>字号{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div>
<div class="field"><label>行高{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div>
<div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div>
<div class="field"><label>代码块样式</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">跟随主题</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>Markdown 渲染使用对应的 Shiki GitHub 主题</small></div>
</div>
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }">
<div class="preview-heading"><h3>主题预览</h3><span class="badge info">{{ codeThemeLabel }}</span></div>
<p>知识的价值不只在于保存更在于被重新发现和使用</p>
<MarkdownContent class="code-theme-preview" :source="shikiPreview" />
</div>
</div>
</section>
</template>
<style scoped>
.themes { margin-bottom: var(--space-xl); }
.theme-card { display: grid; gap: var(--space-md); text-align: left; }
.theme-preview { display: grid; grid-template-columns: 30px 1fr; grid-template-rows: repeat(3, 18px); gap: 6px; height: 120px; padding: var(--space-md); border-radius: var(--radius-md); background: #fff; border: 1px solid #ddd; }
.theme-preview span { grid-column: 1; border-radius: 4px; background: #dfe3eb; }
.theme-preview div { grid-column: 2; grid-row: 1 / 4; border-radius: 6px; background: #f4f5f7; }
.preview-dark { background: #0d1117; border-color: #30363d; }.preview-dark span { background: #30363d; }.preview-dark div { background: #161b22; }
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }.preview-sepia span { background: #d8c69c; }.preview-sepia div { background: #f4e8ca; }
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); }
.preference-panel { display: grid; gap: var(--space-xl); }
.editor-preview { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.editor-preview p { margin: var(--space-sm) 0; }
.preview-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.field small { color: var(--color-text-tertiary); }
.code-theme-preview { margin-top: var(--space-md); }
</style>
+47 -14
View File
@@ -4,6 +4,8 @@ import { useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
import { useSettingsStore } from '@/stores/settings'
import { ArrowRight, Document, Folder, FolderOpened, Moon, Plus, Sunny } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
@@ -17,7 +19,12 @@ const newVaultPath = ref('')
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
onMounted(async () => {
await workspaceStore.loadRecentVaults()
await Promise.all([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
const lastVaultPath = localStorage.getItem('last-vault-path')
if (settingsStore.restoreLastVault && lastVaultPath) {
await openVault(lastVaultPath)
return
}
setTimeout(() => {
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}, 800)
@@ -60,8 +67,8 @@ async function createVault() {
<div class="bg-decoration" />
<div class="entry-container">
<div class="brand-section">
<div class="logo">📝</div>
<h1 class="app-title">知笔知己</h1>
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
<h1 class="app-title">NotesAgent</h1>
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
</div>
@@ -79,22 +86,22 @@ async function createVault() {
@click="openVault(vault.path)"
:disabled="isLoading"
>
<span class="vault-icon">📁</span>
<AppIcon class="vault-icon" :icon="Folder" :size="20" />
<div class="vault-info">
<div class="vault-name">{{ vault.name }}</div>
<div class="vault-path">{{ vault.path }}</div>
</div>
<span class="vault-arrow"></span>
<AppIcon class="vault-arrow" :icon="ArrowRight" :size="16" />
</button>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
<span>📂</span> 打开本地 Vault
<AppIcon :icon="FolderOpened" /> 打开本地 Vault
</button>
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
<span></span> 创建新 Vault
<AppIcon :icon="Plus" /> 创建新 Vault
</button>
</div>
@@ -109,7 +116,8 @@ async function createVault() {
<div class="footer-info">
<span>v0.1.0</span>
<button class="theme-toggle" @click="themeStore.toggleTheme()">
{{ themeStore.isDark ? '☀️ 浅色' : '🌙 深色' }}
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
{{ themeStore.isDark ? '浅色' : '深色' }}
</button>
</div>
</div>
@@ -153,7 +161,7 @@ async function createVault() {
background:
radial-gradient(circle at 20% 30%, var(--color-accent-soft) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, var(--color-info-soft) 0%, transparent 50%);
opacity: 0.5;
opacity: 0.62;
}
.entry-container {
@@ -165,6 +173,7 @@ async function createVault() {
gap: 32px;
max-width: 480px;
width: 90%;
animation: entry-in var(--motion-slow) both;
}
.brand-section {
@@ -172,8 +181,16 @@ async function createVault() {
}
.logo {
font-size: 64px;
margin-bottom: 12px;
display: inline-grid;
place-items: center;
width: 84px;
height: 84px;
margin-bottom: 14px;
border: 1px solid color-mix(in srgb, var(--color-accent-primary) 18%, transparent);
border-radius: 24px;
background: var(--color-surface-primary);
color: var(--color-accent-primary);
box-shadow: var(--shadow-lg);
}
.app-title {
@@ -198,7 +215,7 @@ async function createVault() {
border: 1px solid var(--color-border-default);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-xl);
}
.card-title {
@@ -239,11 +256,13 @@ async function createVault() {
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
transition: all var(--motion-fast);
transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
&:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent-secondary);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
&:disabled {
@@ -280,8 +299,11 @@ async function createVault() {
.vault-arrow {
color: var(--color-text-tertiary);
font-size: 20px;
transition: color var(--motion-fast), transform var(--motion-fast);
}
.vault-item:hover .vault-arrow { color: var(--color-accent-primary); transform: translateX(3px); }
.actions {
display: flex;
flex-direction: column;
@@ -299,7 +321,7 @@ async function createVault() {
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--motion-fast);
transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
border: 1px solid transparent;
&:disabled {
@@ -313,6 +335,8 @@ async function createVault() {
&:hover:not(:disabled) {
background: var(--color-accent-primary-hover);
transform: translateY(-1px);
box-shadow: 0 7px 18px color-mix(in srgb, var(--color-accent-primary) 25%, transparent);
}
}
@@ -369,6 +393,9 @@ async function createVault() {
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-text-secondary);
background: none;
border: none;
@@ -389,6 +416,7 @@ async function createVault() {
align-items: center;
justify-content: center;
z-index: var(--z-modal);
animation: dialog-backdrop-in var(--motion-fast) both;
}
.dialog {
@@ -398,8 +426,13 @@ async function createVault() {
width: 90%;
max-width: 400px;
box-shadow: var(--shadow-xl);
animation: dialog-in var(--motion-normal) both;
}
@keyframes entry-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes dialog-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
.dialog h3 {
margin: 0 0 var(--space-lg) 0;
font-size: 18px;
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { FileNode } from '@/contracts'
import { Document, Folder, FolderOpened } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
defineProps<{ node: FileNode; activePath: string | null }>()
const emit = defineEmits<{
open: [node: FileNode]
contextMenu: [event: MouseEvent, node: FileNode]
}>()
</script>
<template>
<div>
<div class="tree-node" :class="{ active: node.path === activePath }"
@click="emit('open', node)" @contextmenu="emit('contextMenu', $event, node)">
<AppIcon :icon="node.type === 'folder' ? (node.is_open ? FolderOpened : Folder) : Document" :size="16" />
<span class="name">{{ node.name }}</span>
<span v-if="node.is_dirty"></span>
</div>
<div v-if="node.type === 'folder' && node.is_open" class="children">
<FileTreeNode v-for="child in node.children ?? []" :key="child.id" :node="child" :active-path="activePath"
@open="emit('open', $event)" @context-menu="(event, target) => emit('contextMenu', event, target)" />
</div>
</div>
</template>
<style scoped>
.tree-node { display: flex; align-items: center; gap: var(--space-xs); min-height: 28px; padding: 0 var(--space-sm); border-radius: var(--radius-sm); cursor: pointer; }
.tree-node:hover, .tree-node.active { background: var(--color-background-secondary); }
.name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.children { padding-left: var(--space-md); }
</style>
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import FileTreePanel from './FileTreePanel.vue'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
let wrapper: VueWrapper | null = null
async function waitForPath(path: string) {
const editorStore = useEditorStore()
for (let attempt = 0; attempt < 100; attempt++) {
if (editorStore.currentFilePath === path) return
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(`Editor did not open ${path}`)
}
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
describe('FileTreePanel file switching', () => {
it('switches both workspace selection and editor content on consecutive clicks', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/workspace', component: { template: '<div />' } }],
})
await router.push('/workspace')
await router.isReady()
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
await workspaceStore.openVault('/mock-vault')
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))!
await findNode('红黑树.md').trigger('click')
await waitForPath('/数据结构/红黑树.md')
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
expect(editorStore.content).toContain('# 红黑树')
await findNode('二叉搜索树.md').trigger('click')
await waitForPath('/数据结构/二叉搜索树.md')
expect(workspaceStore.activeFilePath).toBe('/数据结构/二叉搜索树.md')
expect(editorStore.content).toContain('# 二叉搜索树')
})
})
+104 -373
View File
@@ -1,406 +1,137 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
import { useRouter } from 'vue-router'
import type { FileNode } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import FileTreeNode from './FileTreeNode.vue'
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
const router = useRouter()
const newItemType = ref<'file' | 'folder' | null>(null)
const newItemName = ref('')
const parentPath = ref('/')
const contextTarget = ref<FileNode | null>(null)
const contextMenuPosition = ref({ x: 0, y: 0 })
const showNewMenu = ref(false)
const newFileName = ref('')
const newFolderName = ref('')
const newFileParentPath = ref('')
const showNewFileInput = ref(false)
const showNewFolderInput = ref(false)
const contextMenuPath = ref<string | null>(null)
const showContextMenu = ref(false)
const contextMenuPos = ref({ x: 0, y: 0 })
const renamingPath = ref<string | null>(null)
const renameValue = ref('')
function toggleFolder(node: FileNode) {
workspaceStore.toggleFolder(node.path)
function beginCreate(type: 'file' | 'folder', parent = '/') {
newItemType.value = type
newItemName.value = ''
parentPath.value = parent
}
async function openFile(node: FileNode) {
if (node.type === 'folder') {
toggleFolder(node)
return
async function createItem() {
const rawName = newItemName.value.trim()
if (!rawName || !newItemType.value) return
if (newItemType.value === 'file') {
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile(parentPath.value, name, `# ${rawName}\n\n`)
workspaceStore.addFileToTree(parentPath.value, file)
await editorStore.loadFile(file.path)
workspaceStore.openFile(file.path)
await router.push('/workspace')
} else {
const folder = await workspaceService.createFolder(parentPath.value, rawName)
workspaceStore.addFileToTree(parentPath.value, folder)
}
newItemType.value = null
newItemName.value = ''
}
async function openNode(node: FileNode) {
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
//
const previousPath = workspaceStore.activeFilePath
const wasOpen = workspaceStore.openFiles.includes(node.path)
workspaceStore.openFile(node.path)
await editorStore.loadFile(node.path)
router.push('/workspace')
}
function startNewFile(parentPath = '') {
newFileParentPath.value = parentPath
showNewFileInput.value = true
showNewMenu.value = false
newFileName.value = ''
}
function startNewFolder(parentPath = '') {
newFileParentPath.value = parentPath
showNewFolderInput.value = true
showNewMenu.value = false
newFolderName.value = ''
}
async function createFile() {
if (!newFileName.value.trim()) return
const name = newFileName.value.endsWith('.md') ? newFileName.value : `${newFileName.value}.md`
const file = await workspaceService.createFile(newFileParentPath.value || '/', name, '# ' + newFileName.value + '\n\n')
workspaceStore.addFileToTree(newFileParentPath.value || '/', file)
workspaceStore.openFile(file.path)
await editorStore.loadFile(file.path)
showNewFileInput.value = false
newFileName.value = ''
}
async function createFolder() {
if (!newFolderName.value.trim()) return
const folder = await workspaceService.createFolder(newFileParentPath.value || '/', newFolderName.value)
workspaceStore.addFileToTree(newFileParentPath.value || '/', folder)
showNewFolderInput.value = false
newFolderName.value = ''
}
function onContextMenu(e: MouseEvent, node: FileNode) {
e.preventDefault()
contextMenuPath.value = node.path
contextMenuPos.value = { x: e.clientX, y: e.clientY }
showContextMenu.value = true
}
function closeContextMenu() {
showContextMenu.value = false
contextMenuPath.value = null
}
function startRename(node: FileNode) {
renamingPath.value = node.path
renameValue.value = node.name
showContextMenu.value = false
}
async function finishRename(node: FileNode) {
if (renameValue.value && renameValue.value !== node.name) {
await workspaceService.renameFile(node.path, renameValue.value)
node.name = renameValue.value
try {
await editorStore.loadFile(node.path)
await router.push('/workspace')
} catch (error) {
if (!wasOpen) workspaceStore.closeFile(node.path)
workspaceStore.setActiveFile(previousPath)
console.error(`打开文件失败:${node.path}`, error)
}
renamingPath.value = null
}
async function deleteNode(node: FileNode) {
const confirmMsg = node.type === 'folder' ? `确定要删除文件夹 "${node.name}" 吗?` : `确定要删除笔记 "${node.name}" 吗?`
if (confirm(confirmMsg)) {
await workspaceService.deleteFile(node.path)
workspaceStore.removeFromTree(node.path)
if (node.type === 'file') {
workspaceStore.closeFile(node.path)
}
function openContextMenu(event: MouseEvent, node: FileNode) {
event.preventDefault()
event.stopPropagation()
contextTarget.value = node
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
}
function closeContextMenu() { contextTarget.value = null }
async function renameTarget() {
const node = contextTarget.value
if (!node) return
const newName = window.prompt('新名称', node.name)?.trim()
if (newName && newName !== node.name) {
const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
const oldPath = node.path
const separator = oldPath.lastIndexOf('/')
const newPath = `${oldPath.slice(0, separator + 1)}${normalizedName}`
await workspaceService.renameFile(oldPath, normalizedName)
workspaceStore.renamePath(oldPath, newPath, normalizedName)
editorStore.renameFilePath(oldPath, newPath)
}
showContextMenu.value = false
closeContextMenu()
}
function getFileIcon(name: string) {
if (name.endsWith('.md')) return '📄'
return '📄'
async function deleteTarget() {
const node = contextTarget.value
if (!node) return
if (!window.confirm(`确定要删除“${node.name}”吗?`)) return closeContextMenu()
await workspaceService.deleteFile(node.path)
const activeWasRemoved = workspaceStore.closePath(node.path)
workspaceStore.removeFromTree(node.path)
if (activeWasRemoved) {
editorStore.closeFile()
if (workspaceStore.activeFilePath) await editorStore.loadFile(workspaceStore.activeFilePath)
}
closeContextMenu()
}
</script>
<template>
<div class="file-tree-panel" @click="closeContextMenu">
<div class="panel-toolbar">
<div class="toolbar-left">
<button class="tool-btn" @click="startNewFile" title="新建笔记">
<span></span>
</button>
<button class="tool-btn" @click="startNewFolder" title="新建文件夹">
<span>📁</span>
</button>
</div>
<button class="tool-btn" title="刷新">
<span>🔄</span>
</button>
<section class="file-tree-panel" @click="closeContextMenu">
<div class="toolbar">
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file')"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder')"><AppIcon :icon="FolderAdd" /></button>
</div>
<div class="new-input" v-if="showNewFileInput">
<input
v-model="newFileName"
type="text"
placeholder="笔记名称"
@keyup.enter="createFile"
@keyup.esc="showNewFileInput = false"
autofocus
/>
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
<button type="submit">创建</button>
<button type="button" @click="newItemType = null">取消</button>
</form>
<div class="tree">
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
:active-path="workspaceStore.activeFilePath" @open="openNode" @context-menu="openContextMenu" />
</div>
<div class="new-input" v-if="showNewFolderInput">
<input
v-model="newFolderName"
type="text"
placeholder="文件夹名称"
@keyup.enter="createFolder"
@keyup.esc="showNewFolderInput = false"
autofocus
/>
</div>
<div class="tree-container">
<template v-for="node in workspaceStore.fileTree" :key="node.id">
<div class="tree-node-wrapper">
<TreeNode :node="node" :depth="0" @open="openFile" @toggle="toggleFolder" @context-menu="onContextMenu"
:renaming-path="renamingPath" :rename-value="renameValue"
@rename-start="startRename" @rename-finish="finishRename"
@delete-node="deleteNode"
@new-file="startNewFile" @new-folder="startNewFolder" />
</div>
</template>
</div>
<Teleport to="body">
<div v-if="showContextMenu" class="context-menu"
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
@click.stop>
<button @click="() => { const n = workspaceStore.activeFile; if (n) startRename(n) }"> 重命名</button>
<button @click="() => { const n = workspaceStore.activeFile; if (n) deleteNode(n) }" class="danger">🗑 删除</button>
<div v-if="contextTarget" class="context-menu"
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
<button @click="renameTarget">重命名</button>
<button class="danger" @click="deleteTarget">删除</button>
</div>
</Teleport>
</div>
</section>
</template>
<script lang="ts">
import { defineComponent, h } from 'vue'
import type { FileNode } from '@/contracts'
const TreeNode = defineComponent({
name: 'TreeNode',
props: {
node: { type: Object as () => FileNode, required: true },
depth: { type: Number, default: 0 },
renamingPath: { type: String, default: null },
renameValue: { type: String, default: '' },
},
emits: ['open', 'toggle', 'context-menu', 'rename-start', 'rename-finish', 'delete-node', 'new-file', 'new-folder'],
setup(props, { emit }) {
const isActive = (path: string) => {
const { useWorkspaceStore } = require('@/stores/workspace')
return useWorkspaceStore().activeFilePath === path
}
const handleClick = () => {
emit('open', props.node)
}
const handleContextMenu = (e: MouseEvent) => {
emit('context-menu', e, props.node)
}
const finishRename = () => {
emit('rename-finish', props.node)
}
return () => {
const isFolder = props.node.type === 'folder'
const isOpen = props.node.is_open
const isRenaming = props.renamingPath === props.node.path
const active = isActive(props.node.path)
return h('div', { class: 'tree-node' }, [
h('div', {
class: ['node-row', { active, folder: isFolder, open: isOpen }],
style: { paddingLeft: `${props.depth * 16 + 8}px` },
onClick: handleClick,
onContextmenu: handleContextMenu,
}, [
h('span', { class: 'chevron' }, isFolder ? (isOpen ? '▼' : '▶') : ''),
h('span', { class: 'node-icon' }, isFolder ? (isOpen ? '📂' : '📁') : '📄'),
isRenaming
? h('input', {
class: 'rename-input',
value: props.renameValue,
autofocus: true,
onBlur: finishRename,
onKeyup: (e: KeyboardEvent) => {
if (e.key === 'Enter') finishRename()
if (e.key === 'Escape') emit('rename-finish', props.node)
},
})
: h('span', { class: 'node-name' }, props.node.name),
]),
isFolder && isOpen && props.node.children && props.node.children.length
? h('div', { class: 'node-children' },
props.node.children.map((child) =>
h(TreeNode, {
key: child.id,
node: child,
depth: props.depth + 1,
renamingPath: props.renamingPath,
renameValue: props.renameValue,
onOpen: (n: FileNode) => emit('open', n),
onToggle: (n: FileNode) => emit('toggle', n.path),
onContextmenu: (e: MouseEvent, n: FileNode) => emit('context-menu', e, n),
onRenameStart: (n: FileNode) => emit('rename-start', n),
onRenameFinish: (n: FileNode) => emit('rename-finish', n),
onDeleteNode: (n: FileNode) => emit('delete-node', n),
onNewFile: (p: string) => emit('new-file', p),
onNewFolder: (p: string) => emit('new-folder', p),
})
)
)
: null,
])
}
},
})
export default {}
</script>
<style scoped>
.file-tree-panel {
height: 100%;
display: flex;
flex-direction: column;
font-size: var(--font-size-sm);
}
.panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border-subtle);
}
.toolbar-left {
display: flex;
gap: 2px;
}
.tool-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: 14px;
transition: all var(--motion-fast);
&:hover {
background: var(--color-background-hover);
color: var(--color-text-primary);
}
}
.new-input {
padding: var(--space-sm) var(--space-md);
input {
width: 100%;
padding: 4px 8px;
background: var(--color-background-secondary);
border: 1px solid var(--color-border-focus);
border-radius: var(--radius-sm);
font-size: 13px;
outline: none;
}
}
.tree-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-xs) 0;
}
.tree-node {
user-select: none;
}
.node-row {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding-right: var(--space-md);
cursor: pointer;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
margin-right: 4px;
transition: background var(--motion-fast);
&:hover {
background: var(--color-background-hover);
}
&.active {
background: var(--color-accent-soft);
color: var(--color-accent-primary);
}
}
.chevron {
width: 14px;
font-size: 9px;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.node-icon {
font-size: 14px;
flex-shrink: 0;
}
.node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-primary);
}
.rename-input {
flex: 1;
padding: 2px 4px;
font-size: 13px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-focus);
border-radius: var(--radius-sm);
outline: none;
}
.context-menu {
position: fixed;
z-index: var(--z-dropdown);
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
padding: 4px;
box-shadow: var(--shadow-lg);
min-width: 140px;
display: flex;
flex-direction: column;
button {
text-align: left;
padding: 6px 10px;
font-size: 13px;
color: var(--color-text-primary);
border-radius: var(--radius-sm);
&:hover {
background: var(--color-background-hover);
}
&.danger {
color: var(--color-error);
}
}
}
.file-tree-panel { height: 100%; }
.toolbar { display: flex; gap: var(--space-xs); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
button { border: 0; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: inherit; cursor: pointer; }
button:hover { background: var(--color-background-secondary); }
.new-item { display: flex; gap: var(--space-xs); padding: var(--space-sm); }
.new-item input { min-width: 0; flex: 1; }
.tree { padding: var(--space-xs); }
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
.context-menu button { text-align: left; }
.context-menu .danger { color: var(--color-error); }
</style>
@@ -0,0 +1,32 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import WorkspaceView from './WorkspaceView.vue'
import { useWorkspaceStore } from '@/stores/workspace'
let wrapper: VueWrapper | null = null
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
})
describe('WorkspaceView initial file', () => {
it('does not overwrite a file selected while the welcome note is loading', async () => {
const workspaceStore = useWorkspaceStore()
wrapper = mount(WorkspaceView, {
global: { stubs: { EditorHeader: true, EditorPane: true } },
})
workspaceStore.openFile('/数据结构/红黑树.md')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
})
})
@@ -4,6 +4,8 @@ import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
import EditorHeader from '@/features/editor/EditorHeader.vue'
import EditorPane from '@/features/editor/EditorPane.vue'
import { EditPen } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
@@ -13,8 +15,12 @@ onMounted(() => {
// Already loaded
}
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
workspaceStore.openFile('/欢迎使用知笔知己.md')
editorStore.loadFile('/欢迎使用知笔知己.md')
void editorStore.loadFile('/欢迎使用 NotesAgent.md').then(() => {
//
if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用 NotesAgent.md') {
workspaceStore.openFile('/欢迎使用 NotesAgent.md')
}
})
}
})
</script>
@@ -27,7 +33,7 @@ onMounted(() => {
</template>
<div v-else class="empty-workspace">
<div class="empty-content">
<div class="empty-icon">📝</div>
<AppIcon class="empty-icon" :icon="EditPen" :size="48" />
<h2>开始写作</h2>
<p>从左侧文件树选择笔记或创建新的笔记</p>
</div>
@@ -53,6 +59,11 @@ onMounted(() => {
.empty-content {
text-align: center;
padding: var(--space-3xl);
border: 1px dashed var(--color-border-default);
border-radius: var(--radius-xl);
background: var(--color-background-secondary);
animation: workspace-empty-in var(--motion-normal) both;
h2 {
font-size: 18px;
@@ -69,4 +80,6 @@ onMounted(() => {
font-size: 48px;
opacity: 0.5;
}
@keyframes workspace-empty-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
</style>
+1
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles/tokens.css'
import './styles/features.css'
import { useThemeStore } from './stores/theme'
const app = createApp(App)
+1 -10
View File
@@ -61,15 +61,6 @@ const routes = [
name: 'settings',
component: () => import('@/features/settings/SettingsView.vue'),
meta: { title: '设置', requiresVault: true },
children: [
{ path: '', redirect: '/settings/general' },
{ path: 'general', component: () => import('@/features/settings/sections/GeneralSection.vue') },
{ path: 'editor', component: () => import('@/features/settings/sections/EditorSection.vue') },
{ path: 'providers', component: () => import('@/features/settings/sections/ProvidersSection.vue') },
{ path: 'index', component: () => import('@/features/settings/sections/IndexSection.vue') },
{ path: 'permissions', component: () => import('@/features/settings/sections/PermissionsSection.vue') },
{ path: 'ai-core', component: () => import('@/features/settings/sections/AiCoreSection.vue') },
],
},
]
@@ -92,7 +83,7 @@ router.beforeEach((to, _from, next) => {
})
router.afterEach((to) => {
const baseTitle = '知笔知己'
const baseTitle = 'NotesAgent'
const title = to.meta.title as string | undefined
document.title = title ? `${title} · ${baseTitle}` : baseTitle
})
+34 -19
View File
@@ -1,46 +1,62 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest } from '@/contracts'
import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
function toAgentRun(run: ApiAgentRun): AgentRun {
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
return {
run_id: run.run_id,
status: run.status,
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,
completed_at: ['completed', 'failed', 'cancelled'].includes(run.status) ? run.updated_at : undefined,
error: run.error_message ?? undefined,
}
}
export async function listAgentRuns(params?: {
limit?: number
offset?: number
}): Promise<{ items: AgentRun[]; total: number }> {
return apiClient.get('/api/agent/runs', { params })
const response = await apiClient.get<{ items: ApiAgentRun[]; page: PageMeta }>('/api/agent/runs', { params })
return { items: response.items.map(toAgentRun), total: response.page.total }
}
export async function getAgentRun(runId: string): Promise<AgentRun> {
return apiClient.get(`/api/agent/runs/${runId}`)
return toAgentRun(await apiClient.get<ApiAgentRun>(`/api/agent/runs/${runId}`))
}
export interface CreateAgentRunRequest {
task: string
provider_id?: string
model?: string
input: string
provider_id: string
model: string
skill_id?: string
allowed_tools?: string[]
max_steps?: number
tool_timeout?: number
run_timeout?: number
tool_timeout_seconds?: number
run_timeout_seconds?: number
token_budget?: number
allow_network?: boolean
max_concurrent_tools?: number
}
export async function createAgentRun(request: CreateAgentRunRequest): Promise<AgentRun> {
return apiClient.post('/api/agent/runs', request)
return toAgentRun(await apiClient.post<ApiAgentRun>('/api/agent/runs', request))
}
export async function cancelAgentRun(runId: string): Promise<void> {
export async function cancelAgentRun(runId: string): Promise<OperationResponse> {
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
}
export async function listTools(): Promise<ToolDefinition[]> {
try {
return await apiClient.get('/api/tools')
} catch {
return mockTools
}
const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
return response.items
}
export function streamAgentEvents(
@@ -52,6 +68,7 @@ export function streamAgentEvents(
onOpen?: () => void
}
): SseClient {
// 将通用 SSE 包装成领域事件,Store 无需了解传输层 envelope。
const client = new SseClient({
url: `/api/agent/runs/${runId}/events`,
method: 'GET',
@@ -75,12 +92,10 @@ export function streamAgentEvents(
export async function respondToPermission(
runId: string,
requestId: string,
decision: 'allow' | 'deny',
scope?: 'once' | 'session' | 'always'
): Promise<void> {
decision: 'allow_once' | 'allow_session' | 'deny'
): Promise<OperationResponse> {
return apiClient.post(`/api/agent/runs/${runId}/permissions/${requestId}`, {
decision,
scope,
})
}
+16 -2
View File
@@ -1,6 +1,12 @@
import type { ApiError, ErrorResponse } from '@/contracts'
const BASE_URL = import.meta.env.VITE_API_BASE || ''
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
export function resolveApiUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path
return `${BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
}
interface RequestOptions extends RequestInit {
params?: Record<string, string | number | boolean | undefined>
@@ -22,7 +28,7 @@ export class ApiErrorClass extends Error {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, token, headers, ...rest } = options
let url = path.startsWith('http') ? path : `${BASE_URL}${path}`
let url = resolveApiUrl(path)
if (params) {
const usp = new URLSearchParams()
@@ -58,6 +64,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
return resp as unknown as T
}
// 后端约定返回 ErrorResponse;代理或网关的非 JSON 错误仍降级为 HTTP 状态码。
let errBody: ErrorResponse | null = null
try {
errBody = (await resp.json()) as ErrorResponse
@@ -94,6 +101,13 @@ export const apiClient = {
body: body !== undefined ? JSON.stringify(body) : undefined,
})
},
put<T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) {
return request<T>(path, {
...options,
method: 'PUT',
body: body !== undefined ? JSON.stringify(body) : undefined,
})
},
delete<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'DELETE' })
},
+11 -17
View File
@@ -1,27 +1,21 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient'
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts'
export async function listConversations(): Promise<Conversation[]> {
return apiClient.get('/api/conversations')
}
export async function getConversation(conversationId: string): Promise<Conversation> {
return apiClient.get(`/api/conversations/${conversationId}`)
}
export async function getMessages(conversationId: string): Promise<ChatMessage[]> {
return apiClient.get(`/api/conversations/${conversationId}/messages`)
}
export interface ChatRequest {
provider_id: string
model: string
conversation_id?: string
message: string
provider_id?: string
model?: string
system?: string
messages: Array<{
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
name?: string
tool_call_id?: string
}>
use_rag?: boolean
skill_id?: string
attachments?: string[]
temperature?: number
max_tokens?: number
}
export function streamChat(
+1 -1
View File
@@ -1,5 +1,5 @@
export { apiClient, ApiErrorClass } from './apiClient'
export type { ApiError } from './apiClient'
export type { ApiError } from '@/contracts'
export { SseClient } from './sseClient'
export type { SseClientOptions, SseEventHandler } from './sseClient'
export * as noteService from './noteService'
+19 -15
View File
@@ -1,25 +1,29 @@
import apiClient from './apiClient'
import type { IndexStatus } from '@/contracts'
import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
export async function getIndexStatus(): Promise<IndexStatus> {
try {
return await apiClient.get('/api/index/status')
} catch {
return mockIndexStatus
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,
last_indexed_at: status.last_completed_at ?? undefined,
error: status.error_message ?? undefined,
}
}
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<{ job_id: string }> {
return apiClient.post('/api/index/rebuild', { scope })
export async function getIndexStatus(): Promise<IndexStatus> {
return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status'))
}
export async function getIndexJob(jobId: string): Promise<{
job_id: string
status: 'queued' | 'running' | 'completed' | 'failed'
progress: number
total: number
error?: string
}> {
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<ApiIndexJob> {
const apiScope = scope === 'full' ? 'all' : scope === 'fts' ? 'notes' : 'vectors'
return apiClient.post<ApiIndexJob>('/api/index/rebuild', { scope: apiScope })
}
export async function getIndexJob(jobId: string): Promise<ApiIndexJob> {
return apiClient.get(`/api/index/jobs/${jobId}`)
}
+12 -11
View File
@@ -1,38 +1,39 @@
import apiClient from './apiClient'
import type { Note, NoteBlock } from '@/contracts'
import type { ApiNote, ApiNoteSummary, OperationResponse, PageMeta } from '@/contracts'
export async function listNotes(params?: {
folder?: string
tag?: string
limit?: number
offset?: number
}): Promise<{ items: Note[]; total: number }> {
}): Promise<{ items: ApiNoteSummary[]; page: PageMeta }> {
return apiClient.get('/api/notes', { params })
}
export async function getNote(noteId: string): Promise<{ note: Note; blocks: NoteBlock[] }> {
export async function getNote(noteId: string): Promise<ApiNote> {
return apiClient.get(`/api/notes/${noteId}`)
}
export async function createNote(data: {
title: string
folder_path?: string
content?: string
}): Promise<Note> {
folder?: string
markdown?: string
tags?: string[]
}): Promise<ApiNote> {
return apiClient.post('/api/notes', data)
}
export async function updateNote(
noteId: string,
data: { title?: string; content?: string; tags?: string[] }
): Promise<Note> {
data: { title?: string; markdown?: string; tags?: string[] }
): Promise<ApiNote> {
return apiClient.patch(`/api/notes/${noteId}`, data)
}
export async function deleteNote(noteId: string): Promise<void> {
export async function deleteNote(noteId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/notes/${noteId}`)
}
export async function moveNote(noteId: string, target_folder: string): Promise<Note> {
return apiClient.post(`/api/notes/${noteId}/move`, { target_folder })
export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
return apiClient.post(`/api/notes/${noteId}/move`, { folder })
}
+51 -22
View File
@@ -1,31 +1,60 @@
import apiClient from './apiClient'
import type { Plugin } from '@/contracts'
import type { ApiPlugin, OperationResponse, Plugin, PluginContribution } from '@/contracts'
export async function listPlugins(): Promise<Plugin[]> {
try {
return await apiClient.get('/api/plugins')
} catch {
return mockPlugins
function toPlugin(plugin: ApiPlugin): Plugin {
const { manifest } = plugin
const contributions: PluginContribution[] = []
const append = (type: PluginContribution['type'], values: string[]) => {
values.forEach((id) => contributions.push({ type, id, name: id }))
}
append('tool', manifest.contributes.tools)
append('command', manifest.contributes.commands)
append('importer', manifest.contributes.importers)
append('exporter', manifest.contributes.exporters)
append('sidebar_panel', manifest.contributes.panels)
append('settings_section', manifest.contributes.settings_sections)
return {
plugin_id: manifest.plugin_id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
status: plugin.status,
enabled: plugin.enabled,
permissions: manifest.permissions,
granted_permissions: plugin.granted_permissions,
contributions,
backend_type: manifest.backend.type,
transport: manifest.backend.transport,
last_error: plugin.error_message ?? undefined,
}
}
export async function getPlugin(pluginId: string): Promise<Plugin> {
return apiClient.get(`/api/plugins/${pluginId}`)
export async function listPlugins(): Promise<Plugin[]> {
const response = await apiClient.get<{ items: ApiPlugin[] }>('/api/plugins')
return response.items.map(toPlugin)
}
export async function installPlugin(pluginId: string): Promise<Plugin> {
return apiClient.post('/api/plugins/install', { plugin_id: pluginId })
export async function getPlugin(pluginId: string): Promise<Plugin> {
return toPlugin(await apiClient.get<ApiPlugin>(`/api/plugins/${pluginId}`))
}
export async function installPlugin(packagePath: string): Promise<Plugin> {
return toPlugin(await apiClient.post<ApiPlugin>('/api/plugins/install', { package_path: packagePath }))
}
export async function enablePlugin(pluginId: string): Promise<Plugin> {
return apiClient.post(`/api/plugins/${pluginId}/enable`)
return toPlugin(await apiClient.post<ApiPlugin>(`/api/plugins/${pluginId}/enable`))
}
export async function disablePlugin(pluginId: string): Promise<Plugin> {
return apiClient.post(`/api/plugins/${pluginId}/disable`)
return toPlugin(await apiClient.post<ApiPlugin>(`/api/plugins/${pluginId}/disable`))
}
export async function uninstallPlugin(pluginId: string): Promise<void> {
export async function grantPluginPermissions(pluginId: string, permissions: string[]): Promise<Plugin> {
return toPlugin(await apiClient.put<ApiPlugin>(`/api/plugins/${pluginId}/permissions`, { permissions }))
}
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`)
}
@@ -35,8 +64,8 @@ export const mockPlugins: Plugin[] = [
name: 'GitHub 集成',
version: '1.3.2',
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
icon: '🐙',
author: '知笔知己团队',
icon: '',
author: 'NotesAgent 团队',
status: 'ready',
enabled: true,
permissions: ['notes.read', 'network.request'],
@@ -54,7 +83,7 @@ export const mockPlugins: Plugin[] = [
name: '翻译助手',
version: '1.0.0',
description: '提供多语言翻译能力,支持文档批量翻译',
icon: '🌐',
icon: '',
author: '社区贡献',
status: 'ready',
enabled: false,
@@ -72,7 +101,7 @@ export const mockPlugins: Plugin[] = [
name: '看板视图',
version: '0.8.0',
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
icon: '📋',
icon: '',
author: '社区贡献',
status: 'installed',
enabled: false,
@@ -80,15 +109,15 @@ export const mockPlugins: Plugin[] = [
contributions: [
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
],
backend_type: 'internal',
backend_type: 'internal_rpc',
},
{
plugin_id: 'pdf-importer',
name: 'PDF 导入',
version: '2.1.0',
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
icon: '📄',
author: '知笔知己团队',
icon: '',
author: 'NotesAgent 团队',
status: 'error',
enabled: false,
permissions: ['notes.write', 'attachments.read'],
@@ -104,7 +133,7 @@ export const mockPlugins: Plugin[] = [
name: '日历同步',
version: '0.5.0',
description: '同步日历事件,自动生成相关笔记和任务提醒',
icon: '📅',
icon: '',
author: '社区贡献',
status: 'dependency_missing',
enabled: false,
@@ -114,6 +143,6 @@ export const mockPlugins: Plugin[] = [
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
],
backend_type: 'mcp',
transport: 'websocket',
transport: 'http',
},
]
+70 -22
View File
@@ -1,36 +1,84 @@
import apiClient from './apiClient'
import type { ProviderConfig, ModelInfo } from '@/contracts'
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
export async function listProviders(): Promise<ProviderConfig[]> {
try {
return await apiClient.get('/api/providers')
} catch {
return mockProviders
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
}
function toProvider(provider: ApiProviderConfig): ProviderConfig {
return {
provider_id: provider.provider_id,
provider_type: provider.provider_type,
name: provider.name,
base_url: provider.base_url ?? undefined,
default_model: provider.default_model ?? '',
enabled: provider.enabled,
capabilities: capabilityMap(provider.capabilities),
credential_id: provider.credential_id ?? undefined,
has_credential: Boolean(provider.credential_id) || provider.provider_type === 'mock',
}
}
function toModel(model: ApiModelInfo): ModelInfo {
return { model_id: model.model, name: model.display_name, capabilities: capabilityMap(model.capabilities) }
}
export async function listProviders(): Promise<ProviderConfig[]> {
const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers')
return response.items.map(toProvider)
}
export async function getProvider(providerId: string): Promise<ProviderConfig> {
return apiClient.get(`/api/providers/${providerId}`)
return toProvider(await apiClient.get<ApiProviderConfig>(`/api/providers/${providerId}`))
}
export async function createProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }): Promise<ProviderConfig> {
return apiClient.post('/api/providers', data)
export async function createProvider(data: Omit<ProviderConfig, 'provider_id'>): Promise<ProviderConfig> {
const response = await apiClient.post<ApiProviderConfig>('/api/providers', {
provider_type: data.provider_type,
name: data.name,
base_url: data.base_url,
default_model: data.default_model || null,
credential_id: data.credential_id,
enabled: data.enabled,
})
return toProvider(response)
}
export async function updateProvider(providerId: string, data: Partial<ProviderConfig> & { api_key?: string }): Promise<ProviderConfig> {
return apiClient.patch(`/api/providers/${providerId}`, data)
export async function listProviderPresets(): Promise<ProviderPreset[]> {
const response = await apiClient.get<{ items: ApiProviderPreset[] }>('/api/providers/presets')
return response.items
}
export async function deleteProvider(providerId: string): Promise<void> {
export async function getCredentialStatus(credentialId: string): Promise<boolean> {
const response = await apiClient.get<{ credential_id: string; configured: boolean }>(`/api/credentials/${encodeURIComponent(credentialId)}`)
return response.configured
}
export async function putCredential(credentialId: string, apiKey: string): Promise<void> {
await apiClient.put<{ credential_id: string; configured: boolean }>(
`/api/credentials/${encodeURIComponent(credentialId)}`,
{ api_key: apiKey },
)
}
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
name: data.name,
base_url: data.base_url,
default_model: data.default_model,
credential_id: data.credential_id,
enabled: data.enabled,
})
return toProvider(response)
}
export async function deleteProvider(providerId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/providers/${providerId}`)
}
export async function listModels(providerId: string): Promise<ModelInfo[]> {
try {
return await apiClient.get(`/api/providers/${providerId}/models`)
} catch {
return mockModels[providerId] || []
}
const response = await apiClient.get<{ provider_id: string; items: ApiModelInfo[] }>(`/api/providers/${providerId}/models`)
return response.items.map(toModel)
}
export interface TestResult {
@@ -42,8 +90,8 @@ export interface TestResult {
export async function testProvider(providerId: string): Promise<TestResult> {
try {
const result = await apiClient.post<{ success: boolean; latency_ms: number }>('/api/providers/test', { provider_id: providerId })
return { success: result.success, latency_ms: result.latency_ms }
const result = await apiClient.post<{ success: boolean; latency_ms?: number | null; message: string }>('/api/providers/test', { provider_id: providerId })
return { success: result.success, latency_ms: result.latency_ms ?? undefined, error_message: result.success ? undefined : result.message }
} catch (e: any) {
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
}
@@ -51,7 +99,7 @@ export async function testProvider(providerId: string): Promise<TestResult> {
export const mockProviders: ProviderConfig[] = [
{
provider_id: 'mock-provider',
provider_id: 'mock',
provider_type: 'mock',
name: 'Mock Provider (测试)',
default_model: 'mock-1',
@@ -69,7 +117,7 @@ export const mockProviders: ProviderConfig[] = [
},
{
provider_id: 'openai-compat-1',
provider_type: 'openai-compatible',
provider_type: 'openai_compatible',
name: 'OpenAI 兼容服务',
base_url: 'https://api.openai.com/v1',
default_model: 'gpt-4o-mini',
@@ -106,7 +154,7 @@ export const mockProviders: ProviderConfig[] = [
]
export const mockModels: Record<string, ModelInfo[]> = {
'mock-provider': [
mock: [
{
model_id: 'mock-1',
name: 'Mock Model v1',
+33 -3
View File
@@ -1,15 +1,45 @@
import apiClient from './apiClient'
import type { SearchRequest, SearchResult } from '@/contracts'
import type { ApiSearchResult, PageMeta, SearchRequest, SearchResult } from '@/contracts'
export async function search(request: SearchRequest): Promise<{
results: SearchResult[]
total: number
mode: SearchRequest['mode']
}> {
return apiClient.post('/api/search', request)
const response = await apiClient.post<{
query: string
mode: 'fts' | 'vector' | 'hybrid'
items: ApiSearchResult[]
page: PageMeta
}>('/api/search', {
query: request.query,
mode: request.mode ?? 'hybrid',
folders: request.folder ? [request.folder] : [],
note_ids: request.note_id ? [request.note_id] : [],
tags: request.tag ? [request.tag] : [],
limit: request.limit ?? 20,
offset: request.offset ?? 0,
})
return {
results: response.items.map((item) => ({
block_id: item.block_id,
note_id: item.note_id,
note_title: item.title,
file_path: item.file_path,
heading_path: item.heading_path.join(' / '),
snippet: item.snippet ?? '',
score: item.score,
match_type: response.mode,
})),
total: response.page.total,
mode: response.mode,
}
}
export async function searchMock(query: string, mode = 'hybrid' as const): Promise<{
export async function searchMock(
query: string,
mode: 'fts' | 'vector' | 'hybrid' = 'hybrid'
): Promise<{
results: SearchResult[]
total: number
mode: 'fts' | 'vector' | 'hybrid'
+34 -20
View File
@@ -1,31 +1,45 @@
import apiClient from './apiClient'
import type { Skill } from '@/contracts'
import type { ApiSkill, OperationResponse, Skill } from '@/contracts'
export async function listSkills(): Promise<Skill[]> {
try {
return await apiClient.get('/api/skills')
} catch {
return mockSkills
function toSkill(skill: ApiSkill): Skill {
const { manifest } = skill
return {
skill_id: manifest.skill_id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
permissions: manifest.permissions,
tools: manifest.tools,
retrieval_config: manifest.retrieval,
model_requirements: { capabilities: manifest.model.required_capabilities },
status: skill.status,
missing_dependencies: skill.missing_dependencies,
enabled: skill.enabled,
}
}
export async function getSkill(skillId: string): Promise<Skill> {
return apiClient.get(`/api/skills/${skillId}`)
export async function listSkills(): Promise<Skill[]> {
const response = await apiClient.get<{ items: ApiSkill[] }>('/api/skills')
return response.items.map(toSkill)
}
export async function installSkill(skillId: string): Promise<Skill> {
return apiClient.post('/api/skills/install', { skill_id: skillId })
export async function getSkill(skillId: string): Promise<Skill> {
return toSkill(await apiClient.get<ApiSkill>(`/api/skills/${skillId}`))
}
export async function installSkill(packagePath: string): Promise<Skill> {
return toSkill(await apiClient.post<ApiSkill>('/api/skills/install', { package_path: packagePath }))
}
export async function enableSkill(skillId: string): Promise<Skill> {
return apiClient.post(`/api/skills/${skillId}/enable`)
return toSkill(await apiClient.post<ApiSkill>(`/api/skills/${skillId}/enable`))
}
export async function disableSkill(skillId: string): Promise<Skill> {
return apiClient.post(`/api/skills/${skillId}/disable`)
return toSkill(await apiClient.post<ApiSkill>(`/api/skills/${skillId}/disable`))
}
export async function uninstallSkill(skillId: string): Promise<void> {
export async function uninstallSkill(skillId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/skills/${skillId}`)
}
@@ -35,8 +49,8 @@ export const mockSkills: Skill[] = [
name: '期末复习助手',
version: '1.0.0',
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
icon: '📚',
author: '知笔知己团队',
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 },
@@ -49,8 +63,8 @@ export const mockSkills: Skill[] = [
name: '会议纪要生成',
version: '1.1.0',
description: '从音频或文本中提取会议要点、行动项和待办任务',
icon: '📝',
author: '知笔知己团队',
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 },
@@ -63,7 +77,7 @@ export const mockSkills: Skill[] = [
name: '代码解读助手',
version: '0.9.0',
description: '分析代码片段,解释功能、复杂度和优化建议',
icon: '💻',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read'],
tools: ['notes.search', 'notes.read', 'rag.search'],
@@ -77,7 +91,7 @@ export const mockSkills: Skill[] = [
name: '文献研究助手',
version: '1.2.0',
description: '自动整理文献笔记,生成研究综述和引用关系图',
icon: '🔬',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'notes.write'],
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
@@ -92,7 +106,7 @@ export const mockSkills: Skill[] = [
name: '语言学习助手',
version: '0.5.0',
description: '基于你的学习笔记生成语言练习和记忆卡片',
icon: '🌍',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
+46 -32
View File
@@ -1,3 +1,5 @@
import { resolveApiUrl } from './apiClient'
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
export interface SseClientOptions {
@@ -37,7 +39,7 @@ export class SseClient {
headers['Authorization'] = `Bearer ${token}`
}
const resp = await fetch(url, {
const resp = await fetch(resolveApiUrl(url), {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -52,7 +54,41 @@ export class SseClient {
this.connected = true
onOpen?.()
// 一个 UTF-8 字符或 SSE 行可能横跨多个网络分片,必须累积后再按空行派发。
const decoder = new TextDecoder('utf-8')
let eventName = 'message'
let dataLines: string[] = []
let doneNotified = false
const dispatchEvent = () => {
if (!dataLines.length) {
eventName = 'message'
return
}
try {
const data = JSON.parse(dataLines.join('\n')) as Record<string, unknown>
onEvent?.(eventName, data)
if (!doneNotified && ['Done', 'RunCompleted', 'RunFailed', 'RunCancelled'].includes(eventName)) {
doneNotified = true
onDone?.()
}
} catch (error) {
onError?.(error instanceof Error ? error : new Error('Malformed SSE data'))
}
eventName = 'message'
dataLines = []
}
const consumeLine = (line: string) => {
if (line === '') return dispatchEvent()
if (line.startsWith(':')) return
const separator = line.indexOf(':')
const field = separator === -1 ? line : line.slice(0, separator)
let fieldValue = separator === -1 ? '' : line.slice(separator + 1)
if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1)
if (field === 'event') eventName = fieldValue
if (field === 'data') dataLines.push(fieldValue)
}
while (true) {
const { value, done } = await this.reader.read()
@@ -60,39 +96,15 @@ export class SseClient {
this.buffer += decoder.decode(value, { stream: true })
const lines = this.buffer.split('\n')
const lines = this.buffer.split(/\r?\n/)
this.buffer = lines.pop() || ''
let eventName = 'message'
let dataStr = ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
if (dataStr) {
try {
const data = JSON.parse(dataStr)
onEvent?.(eventName, data)
if (eventName === 'Done' || eventName === 'RunCompleted' || eventName === 'RunFailed' || eventName === 'RunCancelled') {
onDone?.()
}
} catch {
/* ignore malformed json */
}
eventName = 'message'
dataStr = ''
}
continue
}
if (trimmed.startsWith('event:')) {
eventName = trimmed.slice(6).trim()
} else if (trimmed.startsWith('data:')) {
const d = trimmed.slice(5).trim()
dataStr += dataStr ? '\n' + d : d
}
}
lines.forEach(consumeLine)
}
this.buffer += decoder.decode()
if (this.buffer) consumeLine(this.buffer.replace(/\r$/, ''))
dispatchEvent()
if (!doneNotified) onDone?.()
} catch (e) {
if ((e as Error).name === 'AbortError') return
onError?.(e as Error)
@@ -106,6 +118,8 @@ export class SseClient {
this.controller.abort()
}
// TODO(streaming): Agent 事件持久化后,增加 Last-Event-ID 与指数退避重连。
isConnected() {
return this.connected
}
+1 -1
View File
@@ -14,10 +14,10 @@ export async function getStatus(): Promise<SystemStatus> {
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',
ai_core_available: false,
}
}
}
+39 -18
View File
@@ -1,42 +1,63 @@
import apiClient from './apiClient'
import type { TaskItem, TaskStatus, TaskPriority } from '@/contracts'
import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus, TaskPriority } from '@/contracts'
export async function listTasks(params?: {
status?: TaskStatus
priority?: TaskPriority
source?: 'user' | 'note' | 'agent'
limit?: number
offset?: number
}): Promise<{ items: TaskItem[]; total: number }> {
try {
return await apiClient.get('/api/tasks', { params })
} catch {
return { items: mockTasks, total: mockTasks.length }
function toTask(task: ApiTask): TaskItem {
return {
task_id: task.task_id,
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,
}
}
export async function listTasks(params?: {
limit?: number
offset?: number
}): Promise<{ items: TaskItem[]; total: number }> {
const response = await apiClient.get<{ items: ApiTask[]; page: PageMeta }>('/api/tasks', {
params: { limit: params?.limit, offset: params?.offset },
})
return { items: response.items.map(toTask), total: response.page.total }
}
export async function getTask(taskId: string): Promise<TaskItem> {
return apiClient.get(`/api/tasks/${taskId}`)
return toTask(await apiClient.get<ApiTask>(`/api/tasks/${taskId}`))
}
export async function createTask(data: {
title: string
description?: string
priority?: TaskPriority
due_date?: string
note_id?: string
}): Promise<TaskItem> {
return apiClient.post('/api/tasks', data)
return toTask(await apiClient.post<ApiTask>('/api/tasks', {
title: data.title,
description: data.description ?? '',
due_at: data.due_date,
note_id: data.note_id,
}))
}
export async function updateTask(
taskId: string,
data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'priority' | 'due_date'>>
data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'due_date'>> & { note_id?: string | null }
): Promise<TaskItem> {
return apiClient.patch(`/api/tasks/${taskId}`, data)
return toTask(await apiClient.patch<ApiTask>(`/api/tasks/${taskId}`, {
title: data.title,
description: data.description,
status: data.status,
due_at: data.due_date,
note_id: data.note_id,
}))
}
export async function deleteTask(taskId: string): Promise<void> {
export async function deleteTask(taskId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/tasks/${taskId}`)
}
+35 -10
View File
@@ -1,7 +1,7 @@
import type { FileNode } from '@/contracts'
// Mock workspace service for web dev mode
// In Tauri environment this will use Tauri IPC commands
// Web 开发模式使用内存实现,服务签名保持与未来桌面文件系统适配器一致。
// TODO(desktop): Tauri Host 就绪后通过 IPC 替换 Mock,并保留路径规范化与错误映射。
export interface VaultInfo {
path: string
@@ -58,9 +58,16 @@ const MOCK_FILE_TREE: FileNode[] = [
{ id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' },
],
},
{ id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.md', type: 'file' },
{ id: 'n-welcome', name: '欢迎使用 NotesAgent.md', path: '/欢迎使用 NotesAgent.md', type: 'file' },
]
const mockFileContents = new Map<string, string>()
function rememberContent(path: string, content: string): Promise<string> {
mockFileContents.set(path, content)
return Promise.resolve(content)
}
export function getRecentVaults(): Promise<VaultInfo[]> {
return Promise.resolve(MOCK_VAULTS)
}
@@ -79,9 +86,11 @@ export function getFileTree(): Promise<FileNode[]> {
}
export function readFileContent(filePath: string): Promise<string> {
const saved = mockFileContents.get(filePath)
if (saved !== undefined) return Promise.resolve(saved)
const name = filePath.split('/').pop() || 'Untitled'
if (name === '欢迎使用知笔知己.md') {
return Promise.resolve(`# 欢迎使用知笔知己
if (name === '欢迎使用 NotesAgent.md') {
return rememberContent(filePath, `# 欢迎使用 NotesAgent
AI Markdown RAG Agent
@@ -137,7 +146,7 @@ def quick_sort(arr):
`)
}
if (name === '红黑树.md') {
return Promise.resolve(`# 红黑树
return rememberContent(filePath, `# 红黑树
Red-Black Tree
@@ -183,7 +192,7 @@ def quick_sort(arr):
- Linux
`)
}
return Promise.resolve(`# ${name.replace('.md', '')}
return rememberContent(filePath, `# ${name.replace('.md', '')}
@@ -198,36 +207,52 @@ def quick_sort(arr):
>
\`\`\`javascript
console.log('Hello, Notes Agent!');
console.log('Hello, NotesAgent!');
\`\`\`
`)
}
export function saveFileContent(filePath: string, content: string): Promise<void> {
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`)
mockFileContents.set(filePath, content)
return Promise.resolve()
}
export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> {
const path = `${folderPath}/${name}`
const path = `${folderPath === '/' ? '' : folderPath}/${name}`
const id = `n-${Date.now()}`
mockFileContents.set(path, content)
return Promise.resolve({ id, name, path, type: 'file' })
}
export function createFolder(parentPath: string, name: string): Promise<FileNode> {
const path = `${parentPath}/${name}`
const path = `${parentPath === '/' ? '' : parentPath}/${name}`
const id = `f-${Date.now()}`
return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] })
}
export function renameFile(oldPath: string, newName: string): Promise<void> {
const separator = oldPath.lastIndexOf('/')
const newPath = `${oldPath.slice(0, separator + 1)}${newName}`
for (const [path, content] of [...mockFileContents]) {
if (path === oldPath || path.startsWith(`${oldPath}/`)) {
mockFileContents.delete(path)
mockFileContents.set(`${newPath}${path.slice(oldPath.length)}`, content)
}
}
return Promise.resolve()
}
export function deleteFile(path: string): Promise<void> {
for (const filePath of [...mockFileContents.keys()]) {
if (filePath === path || filePath.startsWith(`${path}/`)) mockFileContents.delete(filePath)
}
return Promise.resolve()
}
export function moveFile(sourcePath: string, targetPath: string): Promise<void> {
// Mock 文件树由 Store 同步更新;真实实现必须在 Host 侧执行原子移动。
void sourcePath
void targetPath
return Promise.resolve()
}
+78 -52
View File
@@ -3,6 +3,7 @@ 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)
@@ -13,6 +14,8 @@ export const useAgentStore = defineStore('agent', () => {
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
const activeRun = computed(() =>
runs.value.find((r) => r.run_id === activeRunId.value) || null
@@ -37,84 +40,106 @@ export const useAgentStore = defineStore('agent', () => {
}
async function loadRun(runId: string) {
eventStream?.cancel()
activeRunId.value = runId
events.value = mockAgentEvents.filter((e) => e.run_id === runId)
const run = await agentService.getAgentRun(runId)
const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
if (existingIndex >= 0) runs.value[existingIndex] = run
else runs.value.unshift(run)
events.value = []
toolCalls.value = []
for (const evt of events.value) {
if (evt.event === 'ToolCall') {
const data = evt.data as any
toolCalls.value.push({
tool_call_id: data.tool_call_id,
name: data.name,
parameters: data.parameters,
status: data.status || 'completed',
started_at: evt.timestamp,
})
} else if (evt.event === 'ToolResult') {
const data = evt.data as any
const tc = toolCalls.value.find((t) => t.tool_call_id === data.tool_call_id)
if (tc) {
tc.status = data.status
tc.result = data.result
tc.completed_at = evt.timestamp
}
permissionRequest.value = null
subscribe(runId)
}
function processEvent(event: AgentEvent) {
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
events.value.push(event)
events.value.sort((a, b) => a.sequence - b.sequence)
const data = event.data
const run = runs.value.find((item) => item.run_id === event.run_id)
if (event.event === 'RunStarted' && run) run.status = 'running'
if (event.event === 'ToolCall') {
toolCalls.value.push({
tool_call_id: String(data.tool_call_id ?? ''),
name: String(data.name ?? 'unknown'),
parameters: (data.arguments ?? {}) as Record<string, unknown>,
status: 'running',
started_at: event.timestamp,
})
} else if (event.event === 'ToolResult') {
const toolCall = toolCalls.value.find((item) => item.tool_call_id === data.tool_call_id)
if (toolCall) {
toolCall.status = data.success ? 'completed' : 'error'
toolCall.result = data.output == null ? undefined : JSON.stringify(data.output)
toolCall.error_code = data.error_code == null ? undefined : String(data.error_code)
toolCall.error_message = data.error_message == null ? undefined : String(data.error_message)
toolCall.completed_at = event.timestamp
}
permissionRequest.value = null
if (run?.status === 'waiting_permission') run.status = 'running'
} else if (event.event === 'PermissionRequired') {
const call = (data.tool_call ?? {}) as Record<string, unknown>
permissionRequest.value = {
request_id: String(data.request_id ?? ''),
run_id: event.run_id,
tool_name: String(call.name ?? 'unknown'),
permission: String(data.permission ?? ''),
parameters: (call.arguments ?? {}) as Record<string, unknown>,
impact: '该工具需要获得权限后才能继续执行。',
}
if (run) run.status = 'waiting_permission'
} else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) {
isRunning.value = false
permissionRequest.value = null
if (run) {
run.status = event.event === 'RunCompleted' ? 'completed' : event.event === 'RunFailed' ? 'failed' : 'cancelled'
run.completed_at = event.timestamp
}
}
}
function subscribe(runId: string) {
// 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。
eventStream?.cancel()
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 },
})
}
async function createRun(request: agentService.CreateAgentRunRequest) {
isCreating.value = true
try {
const run = await agentService.createAgentRun(request)
runs.value.unshift(run)
activeRunId.value = run.run_id
events.value = [{
event: 'RunStarted',
sequence: 1,
run_id: run.run_id,
data: { task: request.task },
timestamp: new Date().toISOString(),
}]
isRunning.value = true
// Mock events streaming
simulateRun(run.run_id)
events.value = []
toolCalls.value = []
subscribe(run.run_id)
return run
} finally {
isCreating.value = false
}
}
function simulateRun(runId: string) {
const runEvents: AgentEvent[] = [
{ event: 'ThinkingDelta', sequence: 2, run_id: runId, data: { text: '我需要先搜索相关笔记...' }, timestamp: new Date().toISOString() },
{ event: 'ToolCall', sequence: 3, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', parameters: { query: '红黑树', limit: 5 }, status: 'running' }, timestamp: new Date().toISOString() },
{ event: 'ToolResult', sequence: 4, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', status: 'completed', result: '找到 5 条相关结果' }, timestamp: new Date().toISOString() },
{ event: 'TextDelta', sequence: 5, run_id: runId, data: { text: '根据你的笔记,以下是...' }, timestamp: new Date().toISOString() },
{ event: 'RunCompleted', sequence: 6, run_id: runId, data: { message: 'Task completed successfully' }, timestamp: new Date().toISOString() },
]
let idx = 0
const push = () => {
if (idx >= runEvents.length) {
isRunning.value = false
return
}
events.value.push(runEvents[idx])
idx++
setTimeout(push, 800)
}
setTimeout(push, 500)
}
async function cancelRun(runId: string) {
await agentService.cancelAgentRun(runId)
const run = runs.value.find((r) => r.run_id === runId)
if (run) run.status = 'cancelled'
isRunning.value = false
eventStream?.cancel()
eventStream = null
}
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' | 'always' = 'once') {
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' = 'once') {
if (!activeRunId.value || !permissionRequest.value) return
await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, decision, scope)
const apiDecision = decision === 'deny' ? 'deny' : scope === 'session' ? 'allow_session' : 'allow_once'
await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, apiDecision)
permissionRequest.value = null
}
@@ -133,6 +158,7 @@ export const useAgentStore = defineStore('agent', () => {
isRunning,
permissionRequest,
toolCalls,
error,
currentStep,
loadTools,
loadRuns,
+65 -27
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ChatMessage, Conversation, Citation } from '@/contracts'
import { mockConversations, mockMessages } from '@/services/chatService'
import type { ChatMessage, Conversation } from '@/contracts'
import { mockConversations, mockMessages, streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
export const useChatStore = defineStore('chat', () => {
@@ -12,10 +12,12 @@ export const useChatStore = defineStore('chat', () => {
const inputText = ref('')
const useRag = ref(true)
const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('mock-provider')
const selectedProviderId = ref('mock')
const selectedModel = ref('mock-1')
let sseClient: SseClient | null = null
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
const activeConversation = computed(() =>
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
)
@@ -35,7 +37,7 @@ export const useChatStore = defineStore('chat', () => {
if (!activeConversationId.value) {
const newConv: Conversation = {
conversation_id,
conversation_id: conversationId,
title: text.slice(0, 30),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -56,6 +58,7 @@ export const useChatStore = defineStore('chat', () => {
inputText.value = ''
isStreaming.value = true
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg: ChatMessage = {
message_id: `msg-${Date.now() + 1}`,
conversation_id: conversationId,
@@ -67,31 +70,66 @@ export const useChatStore = defineStore('chat', () => {
}
messages.value.push(aiMsg)
// Mock streaming
const fullText =
'这是一个模拟的 AI 回复。在实际环境中,这里会通过 SSE 接收后端 AI Core 的流式输出,基于 RAG 引擎和你的知识库生成回答,并附带来源引用。\n\n**要点总结:**\n1. 这是演示用的流式输出\n2. 实际会调用 ModelEvent SSE\n3. 支持 Citation、Tool Call 等事件\n\n你可以在设置中配置真实的模型 Provider 来启用完整功能。'
const citations: Citation[] = [
{
note_id: 'n-rbt',
block_id: 'b1',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 概述',
content: '红黑树是一种自平衡二叉搜索树...',
sseClient = streamChat({
provider_id: selectedProviderId.value,
model: selectedModel.value,
conversation_id: conversationId,
use_rag: useRag.value,
messages: messages.value
.filter((message) => message !== aiMsg)
.map((message) => ({ role: message.role, content: message.content })),
}, {
onEvent(event) {
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') {
aiMsg.tool_calls?.push({
tool_call_id: String(event.data.tool_call_id ?? ''),
name: String(event.data.name ?? 'unknown'),
parameters: (event.data.arguments ?? {}) as Record<string, unknown>,
status: 'running',
})
}
if (event.event === 'ToolCallDelta') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
Object.assign(call.parameters, event.data.arguments)
}
}
if (event.event === 'ToolCallEnd') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
if (call) call.status = 'completed'
}
if (event.event === 'Usage') {
const input = Number(event.data.input_tokens ?? 0)
const output = Number(event.data.output_tokens ?? 0)
aiMsg.usage = { input_tokens: input, output_tokens: output, total_tokens: input + output }
}
if (event.event === 'Citation') {
aiMsg.citations?.push({
note_id: String(event.data.note_id ?? ''), block_id: String(event.data.block_id ?? ''),
file_path: String(event.data.file_path ?? ''),
heading_path: Array.isArray(event.data.heading_path) ? event.data.heading_path.join(' / ') : String(event.data.heading_path ?? ''),
content: String(event.data.content ?? event.data.snippet ?? ''),
})
}
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
},
]
let i = 0
const interval = setInterval(() => {
if (i >= fullText.length) {
clearInterval(interval)
onError(error) {
aiMsg.content += `\n\n连接失败:${error.message}`
isStreaming.value = false
aiMsg.citations = citations
return
}
const chunk = fullText.slice(i, i + 3)
aiMsg.content += chunk
i += 3
}, 20)
sseClient = null
},
onDone() {
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
if (conversation) {
conversation.message_count = messages.value.length
conversation.updated_at = new Date().toISOString()
}
isStreaming.value = false
sseClient = null
},
})
}
function stopGeneration() {
+57 -16
View File
@@ -30,43 +30,74 @@ export const useEditorStore = defineStore('editor', () => {
function updateContent(newContent: string) {
content.value = newContent
if (saveStatus.value === 'saved' || saveStatus.value === 'idle') {
saveStatus.value = 'dirty'
}
saveStatus.value = 'dirty'
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
let pendingSave: Promise<void> | null = null
function scheduleAutoSave(delay = 1500) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
saveTimer = null
void save()
}, delay)
}
async function save() {
if (!currentFilePath.value) return
if (saveStatus.value === 'saving') return
if (pendingSave) return pendingSave
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
const targetPath = currentFilePath.value
const snapshot = content.value
saveStatus.value = 'saving'
try {
await workspaceService.saveFileContent(currentFilePath.value, content.value)
saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString()
} catch {
saveStatus.value = 'save_failed'
}
pendingSave = (async () => {
try {
await workspaceService.saveFileContent(targetPath, snapshot)
if (currentFilePath.value === targetPath) {
saveStatus.value = content.value === snapshot ? 'saved' : 'dirty'
lastSavedAt.value = new Date().toISOString()
}
} catch {
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed'
} finally {
pendingSave = null
}
})()
return pendingSave
}
let loadVersion = 0
async function loadFile(filePath: string) {
currentFilePath.value = filePath
if (currentFilePath.value === filePath) return
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
if (saveStatus.value === 'conflict') {
throw new Error('当前文件存在编辑冲突,请处理后再切换文件。')
}
if (pendingSave) await pendingSave
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save()
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
}
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
const version = ++loadVersion
const previousStatus = saveStatus.value
saveStatus.value = 'saving'
try {
content.value = await workspaceService.readFileContent(filePath)
const loadedContent = await workspaceService.readFileContent(filePath)
if (version !== loadVersion) return
currentFilePath.value = filePath
content.value = loadedContent
saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString()
} catch {
content.value = ''
saveStatus.value = 'idle'
} catch (error) {
if (version !== loadVersion) return
saveStatus.value = previousStatus
throw error
}
highlightBlockId.value = null
}
@@ -88,7 +119,10 @@ export const useEditorStore = defineStore('editor', () => {
}
}
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
function closeFile() {
loadVersion++
if (saveTimer) clearTimeout(saveTimer)
currentFilePath.value = null
currentNoteId.value = null
@@ -98,6 +132,12 @@ export const useEditorStore = defineStore('editor', () => {
highlightBlockId.value = null
}
function renameFilePath(oldPath: string, newPath: string) {
if (currentFilePath.value === oldPath || currentFilePath.value?.startsWith(`${oldPath}/`)) {
currentFilePath.value = `${newPath}${currentFilePath.value.slice(oldPath.length)}`
}
}
return {
mode,
content,
@@ -118,5 +158,6 @@ export const useEditorStore = defineStore('editor', () => {
highlightBlock,
setExternalChanged,
closeFile,
renameFilePath,
}
})
+31 -14
View File
@@ -1,12 +1,13 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Plugin } from '@/contracts'
import { mockPlugins } from '@/services/pluginService'
import * as pluginService from '@/services/pluginService'
export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>(mockPlugins)
const plugins = ref<Plugin[]>(pluginService.mockPlugins)
const selectedPluginId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedPlugin = computed(() =>
plugins.value.find((p) => p.plugin_id === selectedPluginId.value) || null
@@ -19,8 +20,10 @@ export const usePluginStore = defineStore('plugin', () => {
async function loadPlugins() {
isLoading.value = true
try {
const { listPlugins } = await import('@/services/pluginService')
plugins.value = await listPlugins()
plugins.value = await pluginService.listPlugins()
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Plugin 加载失败'
} finally {
isLoading.value = false
}
@@ -30,23 +33,34 @@ export const usePluginStore = defineStore('plugin', () => {
selectedPluginId.value = pluginId
}
async function installPlugin(packagePath: string) {
const installed = await pluginService.installPlugin(packagePath)
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === installed.plugin_id)
if (index >= 0) plugins.value[index] = installed
else plugins.value.unshift(installed)
selectedPluginId.value = installed.plugin_id
}
async function grantPermissions(pluginId: string, permissions: string[]) {
const updated = await pluginService.grantPluginPermissions(pluginId, permissions)
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
if (index >= 0) plugins.value[index] = updated
}
async function enablePlugin(pluginId: string) {
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
if (plugin) {
plugin.enabled = true
plugin.status = 'ready'
}
const updated = await pluginService.enablePlugin(pluginId)
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
if (index >= 0) plugins.value[index] = updated
}
async function disablePlugin(pluginId: string) {
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
if (plugin) {
plugin.enabled = false
plugin.status = 'disabled'
}
const updated = await pluginService.disablePlugin(pluginId)
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
if (index >= 0) plugins.value[index] = updated
}
async function uninstallPlugin(pluginId: string) {
await pluginService.uninstallPlugin(pluginId)
const idx = plugins.value.findIndex((p) => p.plugin_id === pluginId)
if (idx > -1) plugins.value.splice(idx, 1)
if (selectedPluginId.value === pluginId) selectedPluginId.value = null
@@ -60,8 +74,11 @@ export const usePluginStore = defineStore('plugin', () => {
readyPlugins,
errorPlugins,
isLoading,
error,
loadPlugins,
selectPlugin,
installPlugin,
grantPermissions,
enablePlugin,
disablePlugin,
uninstallPlugin,
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
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(),
putCredential: vi.fn(),
listModels: vi.fn(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
testProvider: vi.fn(),
}))
import { useProviderStore } from './provider'
import { getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient'
const providers: ProviderConfig[] = [
{
provider_id: 'openai',
provider_type: 'openai_chat',
name: 'OpenAI',
base_url: 'https://api.openai.com/v1',
default_model: '',
enabled: true,
capabilities: { chat: true },
credential_id: 'deepseek',
has_credential: true,
},
]
const presets: ProviderPreset[] = [
{
preset_id: 'deepseek',
name: 'DeepSeek',
provider_type: 'openai_compatible',
base_url: 'https://api.deepseek.com',
default_credential_id: 'deepseek',
requires_credential: true,
},
]
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(listProviders).mockResolvedValue(providers)
vi.mocked(listProviderPresets).mockResolvedValue(presets)
vi.mocked(getCredentialStatus).mockResolvedValue(false)
vi.mocked(putCredential).mockResolvedValue(undefined)
})
describe('provider store model discovery', () => {
it('loads provider presets and automatically refreshes enabled providers', async () => {
vi.mocked(listModels).mockResolvedValue([
{ model_id: 'model-z', name: 'Zulu', capabilities: { chat: true } },
{ model_id: 'model-a', name: 'Alpha', capabilities: { chat: true } },
{ model_id: 'model-a', name: 'Alpha duplicate', capabilities: { chat: true } },
])
const store = useProviderStore()
await store.loadProviders()
await store.loadPresets()
await store.refreshEnabledModels()
expect(store.presets[0].preset_id).toBe('deepseek')
expect(listModels).toHaveBeenCalledWith('openai')
expect(store.modelsByProvider.openai.map((model) => model.model_id)).toEqual(['model-a', 'model-z'])
expect(store.modelErrorsByProvider.openai).toBeUndefined()
})
it('records a provider-specific error when model discovery fails', async () => {
vi.mocked(listModels).mockRejectedValue(new Error('认证失败'))
const store = useProviderStore()
await expect(store.loadModels('openai')).rejects.toThrow('认证失败')
expect(store.modelLoadingByProvider.openai).toBe(false)
expect(store.modelErrorsByProvider.openai).toBe('认证失败')
})
it('explains how to inject a missing DeepSeek credential', async () => {
vi.mocked(listModels).mockRejectedValue(
new ApiErrorClass('PROVIDER_CREDENTIAL_MISSING', 'Credential is unavailable')
)
const store = useProviderStore()
await store.loadProviders()
await expect(store.loadModels('openai')).rejects.toThrow('Credential is unavailable')
expect(store.modelErrorsByProvider.openai).toContain('填写并保存')
})
it('sends an API key to the credential endpoint without storing it in Pinia', async () => {
const store = useProviderStore()
await store.saveCredential('deepseek', 'sk-test-sensitive-value')
expect(putCredential).toHaveBeenCalledWith('deepseek', 'sk-test-sensitive-value')
expect(store.credentialConfiguredById.deepseek).toBe(true)
expect(JSON.stringify(store.$state)).not.toContain('sk-test-sensitive-value')
})
})
+77 -19
View File
@@ -1,13 +1,19 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo } from '@/contracts'
import { mockProviders, mockModels } from '@/services/providerService'
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 { ApiErrorClass } from '@/services/apiClient'
export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders)
const presets = ref<ProviderPreset[]>([])
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
const defaultProviderId = ref('mock-provider')
const modelLoadingByProvider = ref<Record<string, boolean>>({})
const modelErrorsByProvider = ref<Record<string, string>>({})
const credentialConfiguredById = ref<Record<string, boolean>>({})
const defaultProviderId = ref('mock')
const isLoading = ref(false)
const error = ref<string | null>(null)
const enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
const defaultProvider = computed(() =>
@@ -17,45 +23,88 @@ export const useProviderStore = defineStore('provider', () => {
async function loadProviders() {
isLoading.value = true
try {
const { listProviders } = await import('@/services/providerService')
providers.value = await listProviders()
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
} finally {
isLoading.value = false
}
}
async function loadModels(providerId: string) {
const { listModels } = await import('@/services/providerService')
modelsByProvider.value[providerId] = await listModels(providerId)
async function loadPresets() {
try {
presets.value = await listProviderPresets()
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败'
}
}
async function addProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }) {
const newProvider: ProviderConfig = {
...data,
provider_id: `prov-${Date.now()}`,
async function loadModels(providerId: string): Promise<ModelInfo[]> {
modelLoadingByProvider.value[providerId] = true
delete modelErrorsByProvider.value[providerId]
try {
const models = await listModels(providerId)
const uniqueModels = [...new Map(models.map((model) => [model.model_id, model])).values()]
.sort((left, right) => left.name.localeCompare(right.name))
modelsByProvider.value[providerId] = uniqueModels
return uniqueModels
} catch (reason) {
const provider = providers.value.find((item) => item.provider_id === providerId)
const credentialId = provider?.credential_id
let message = reason instanceof Error ? reason.message : '模型列表获取失败'
if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') {
message = '尚未配置 API Key,请编辑该 Provider 后填写并保存。'
} else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') {
message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`
}
modelErrorsByProvider.value[providerId] = message
throw reason
} finally {
modelLoadingByProvider.value[providerId] = false
}
}
async function refreshEnabledModels() {
await Promise.allSettled(
providers.value.filter((provider) => provider.enabled).map((provider) => loadModels(provider.provider_id))
)
}
async function loadCredentialStatus(credentialId: string): Promise<boolean> {
const configured = await getCredentialStatus(credentialId)
credentialConfiguredById.value[credentialId] = configured
return configured
}
async function saveCredential(credentialId: string, apiKey: string) {
await putCredential(credentialId, apiKey)
credentialConfiguredById.value[credentialId] = true
}
async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) {
const newProvider = await createProvider(data)
providers.value.push(newProvider)
return newProvider
}
async function updateProvider(providerId: string, data: Partial<ProviderConfig>) {
const p = providers.value.find((p) => p.provider_id === providerId)
if (p) Object.assign(p, data)
const updated = await updateProviderRequest(providerId, data)
const index = providers.value.findIndex((provider) => provider.provider_id === providerId)
if (index >= 0) providers.value[index] = updated
return updated
}
async function deleteProvider(providerId: string) {
await deleteProviderRequest(providerId)
const idx = providers.value.findIndex((p) => p.provider_id === providerId)
if (idx > -1) providers.value.splice(idx, 1)
delete modelsByProvider.value[providerId]
}
async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> {
await new Promise((r) => setTimeout(r, 1000))
const p = providers.value.find((p) => p.provider_id === providerId)
if (p?.enabled && p.has_credential) {
return { success: true, latency_ms: 230 + Math.floor(Math.random() * 200) }
}
return { success: false, error: '认证失败,请检查 API Key' }
const result = await testProviderRequest(providerId)
return { success: result.success, latency_ms: result.latency_ms, error: result.error_message }
}
function setDefaultProvider(providerId: string) {
@@ -64,13 +113,22 @@ export const useProviderStore = defineStore('provider', () => {
return {
providers,
presets,
modelsByProvider,
modelLoadingByProvider,
modelErrorsByProvider,
credentialConfiguredById,
defaultProviderId,
enabledProviders,
defaultProvider,
isLoading,
error,
loadProviders,
loadPresets,
loadModels,
refreshEnabledModels,
loadCredentialStatus,
saveCredential,
addProvider,
updateProvider,
deleteProvider,
+28 -5
View File
@@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { SearchResult, SearchRequest } from '@/contracts'
import * as searchService from '@/services/searchService'
import { ApiErrorClass } from '@/services/apiClient'
const VECTOR_ERROR_CODES = new Set([
'VECTOR_UNAVAILABLE', 'EMBEDDING_UNAVAILABLE', 'INDEX_UNAVAILABLE',
'MODEL_NOT_FOUND', 'MODEL_CAPABILITY_MISMATCH', 'PROVIDER_UNAVAILABLE',
])
export const useSearchStore = defineStore('search', () => {
const query = ref('')
@@ -19,16 +25,33 @@ export const useSearchStore = defineStore('search', () => {
mode.value = request.mode || 'hybrid'
isSearching.value = true
error.value = null
vectorUnavailable.value = false
try {
const resp = await searchService.searchMock(request.query, request.mode || 'hybrid')
const resp = await searchService.search(request)
results.value = resp.results
total.value = resp.total
selectedIndex.value = 0
} catch (e: any) {
error.value = e.message || '搜索失败'
results.value = []
total.value = 0
} catch (reason) {
const canFallback = mode.value !== 'fts' && reason instanceof ApiErrorClass && VECTOR_ERROR_CODES.has(reason.code)
if (canFallback) {
try {
const fallback = await searchService.search({ ...request, mode: 'fts' })
results.value = fallback.results
total.value = fallback.total
mode.value = 'fts'
vectorUnavailable.value = true
selectedIndex.value = 0
} catch (fallbackError) {
error.value = fallbackError instanceof Error ? fallbackError.message : '全文检索降级失败'
results.value = []
total.value = 0
}
} else {
error.value = reason instanceof Error ? reason.message : '搜索失败'
results.value = []
total.value = 0
}
} finally {
isSearching.value = false
}
+44 -14
View File
@@ -1,22 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { mockIndexStatus } from '@/services/indexService'
import * as indexService from '@/services/indexService'
import * as systemService from '@/services/systemService'
export const useSettingsStore = defineStore('settings', () => {
const saved = (() => {
try { return JSON.parse(localStorage.getItem('app-settings') ?? '{}') as Record<string, unknown> }
catch { localStorage.removeItem('app-settings'); return {} }
})()
// General
const restoreLastVault = ref(true)
const autoSaveInterval = ref(1500)
const language = ref<'zh-CN' | 'en'>('zh-CN')
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')
// Editor
const defaultEditorMode = ref<'wysiwyg' | 'source'>('wysiwyg')
const editorFontSize = ref(15)
const editorLineHeight = ref(1.7)
const editorLineWidth = ref(80)
const spellCheck = ref(false)
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
const editorLineWidth = ref(typeof saved.editorLineWidth === 'number' ? saved.editorLineWidth : 80)
const spellCheck = ref(saved.spellCheck === true)
// AI Core
const aiCoreStatus = ref<AiCoreStatus>('running')
@@ -37,6 +41,28 @@ export const useSettingsStore = defineStore('settings', () => {
'network.request': 'confirm',
'secrets.use': 'confirm',
})
const diagnosticsError = ref<string | null>(null)
watch(() => ({
restoreLastVault: restoreLastVault.value, autoSaveInterval: autoSaveInterval.value,
language: language.value, defaultEditorMode: defaultEditorMode.value,
editorLineWidth: editorLineWidth.value, spellCheck: spellCheck.value,
}), (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 : '诊断信息加载失败'
}
}
function setAutoSaveInterval(ms: number) {
autoSaveInterval.value = ms
@@ -63,9 +89,13 @@ export const useSettingsStore = defineStore('settings', () => {
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
indexStatus.value.status = 'indexing'
setTimeout(() => {
indexStatus.value.status = 'idle'
}, 3000)
try {
await indexService.rebuildIndex(scope)
indexStatus.value = await indexService.getIndexStatus()
} catch (reason) {
indexStatus.value.status = 'error'
indexStatus.value.error = reason instanceof Error ? reason.message : '索引重建失败'
}
}
return {
@@ -75,14 +105,14 @@ export const useSettingsStore = defineStore('settings', () => {
appVersion,
aiCoreVersion,
defaultEditorMode,
editorFontSize,
editorLineHeight,
editorLineWidth,
spellCheck,
aiCoreStatus,
aiCoreAddress,
indexStatus,
permissionPolicy,
diagnosticsError,
loadDiagnostics,
setAutoSaveInterval,
setDefaultEditorMode,
setPermission,
+24 -14
View File
@@ -1,12 +1,13 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Skill } from '@/contracts'
import { mockSkills } from '@/services/skillService'
import * as skillService from '@/services/skillService'
export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>(mockSkills)
const skills = ref<Skill[]>(skillService.mockSkills)
const selectedSkillId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedSkill = computed(() =>
skills.value.find((s) => s.skill_id === selectedSkillId.value) || null
@@ -19,8 +20,10 @@ export const useSkillStore = defineStore('skill', () => {
async function loadSkills() {
isLoading.value = true
try {
const { listSkills } = await import('@/services/skillService')
skills.value = await listSkills()
skills.value = await skillService.listSkills()
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Skill 加载失败'
} finally {
isLoading.value = false
}
@@ -30,23 +33,28 @@ export const useSkillStore = defineStore('skill', () => {
selectedSkillId.value = skillId
}
async function installSkill(packagePath: string) {
const installed = await skillService.installSkill(packagePath)
const index = skills.value.findIndex((skill) => skill.skill_id === installed.skill_id)
if (index >= 0) skills.value[index] = installed
else skills.value.unshift(installed)
selectedSkillId.value = installed.skill_id
}
async function enableSkill(skillId: string) {
const skill = skills.value.find((s) => s.skill_id === skillId)
if (skill) {
skill.enabled = true
skill.status = 'ready'
}
const updated = await skillService.enableSkill(skillId)
const index = skills.value.findIndex((skill) => skill.skill_id === skillId)
if (index >= 0) skills.value[index] = updated
}
async function disableSkill(skillId: string) {
const skill = skills.value.find((s) => s.skill_id === skillId)
if (skill) {
skill.enabled = false
skill.status = 'disabled'
}
const updated = await skillService.disableSkill(skillId)
const index = skills.value.findIndex((skill) => skill.skill_id === skillId)
if (index >= 0) skills.value[index] = updated
}
async function uninstallSkill(skillId: string) {
await skillService.uninstallSkill(skillId)
const idx = skills.value.findIndex((s) => s.skill_id === skillId)
if (idx > -1) skills.value.splice(idx, 1)
if (selectedSkillId.value === skillId) selectedSkillId.value = null
@@ -60,8 +68,10 @@ export const useSkillStore = defineStore('skill', () => {
installedSkills,
readySkills,
isLoading,
error,
loadSkills,
selectSkill,
installSkill,
enableSkill,
disableSkill,
uninstallSkill,
+11 -17
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
import { mockTasks } from '@/services/taskService'
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, mockTasks, updateTask as updateTaskRequest } from '@/services/taskService'
export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>(mockTasks)
@@ -9,6 +9,7 @@ export const useTaskStore = defineStore('task', () => {
const filterPriority = ref<TaskPriority | 'all'>('all')
const filterSource = ref<TaskSource | 'all'>('all')
const isLoading = ref(false)
const error = ref<string | null>(null)
const filteredTasks = computed(() => {
return tasks.value.filter((t) => {
@@ -26,40 +27,32 @@ export const useTaskStore = defineStore('task', () => {
async function loadTasks() {
isLoading.value = true
try {
const { listTasks } = await import('@/services/taskService')
const resp = await listTasks()
tasks.value = resp.items
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '任务加载失败'
} finally {
isLoading.value = false
}
}
async function createTask(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string; note_id?: string }) {
const newTask: TaskItem = {
task_id: `t-${Date.now()}`,
title: data.title,
description: data.description,
status: 'todo',
priority: data.priority || 'medium',
due_date: data.due_date,
note_id: data.note_id,
source: 'user',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
const newTask = await createTaskRequest(data)
tasks.value.unshift(newTask)
return newTask
}
async function updateTask(taskId: string, data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'priority' | 'due_date'>>) {
async function updateTask(taskId: string, data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'due_date'>> & { note_id?: string | null }) {
const task = tasks.value.find((t) => t.task_id === taskId)
if (task) {
Object.assign(task, data)
task.updated_at = new Date().toISOString()
const updated = await updateTaskRequest(taskId, data)
Object.assign(task, updated, data)
}
}
async function deleteTask(taskId: string) {
await deleteTaskRequest(taskId)
const idx = tasks.value.findIndex((t) => t.task_id === taskId)
if (idx > -1) tasks.value.splice(idx, 1)
}
@@ -78,6 +71,7 @@ export const useTaskStore = defineStore('task', () => {
inProgressTasks,
doneTasks,
isLoading,
error,
loadTasks,
createTask,
updateTask,

Some files were not shown because too many files have changed in this diff Show More