Fix/frontend review findings #3

Merged
Kronecker merged 16 commits from fix/frontend-review-findings into main 2026-08-30 00:16:35 +08:00
60 changed files with 2676 additions and 760 deletions
@@ -0,0 +1,276 @@
# 前端合并审阅问题与修复复盘
> 审阅与修复日期:2026-08-29
> 涉及提交:`f9efc4f`,合并提交 `c6c28e4`。
> 文档用途:记录前端分支合并后暴露的问题域、形成原因、实际后果、修复思路和落地方案,供后续技术文档、比赛材料与博客写作使用。
## 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
62 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 时也必须保留清洗步骤。
@@ -0,0 +1,201 @@
# 前端壳子与接口层开发说明
> 更新日期:2026-08-29
> 适用范围: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` 命令面板。
原统一占位页已经删除,所有已注册业务路由均指向真实页面。当前 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
uv run pytest 62 passed
preview smoke HTTP 200
git diff --check passed
```
当前前端没有单独的单元测试脚本,`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过一次 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 62 项测试结果,也不涉及产品代码。
浏览器可视化冒烟在本次执行环境中因浏览器运行资源缺失未能启动;HTTP 冒烟已确认前端入口、后端健康检查与 OpenAPI 均能访问。进入合并验收前,仍建议团队在本机打开各路由完成一次人工视觉检查。
## 10. 后续开发要求
- 新页面文件与路由修改必须在同一提交中出现;
- 新增或修改接口时同步更新 FastAPI DTO、Service 映射和接口文档;
- 不允许用 `as any` 或错误返回类型掩盖 Contract 差异;
- SSE 相关变更需要覆盖跨 Chunk、CRLF、多行 data、终态事件和取消;
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
- Workspace 的写作/源码模式目前共享同一 Markdown 数据源,后续接入 Milkdown 与 CodeMirror 6 时不得改变 Store/Service 边界或造成切换丢稿。
+1
View File
@@ -23,6 +23,7 @@
"@milkdown/vue": "^7.22.0", "@milkdown/vue": "^7.22.0",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.0.0",
"codemirror": "^6.0.0", "codemirror": "^6.0.0",
"dompurify": "^3.4.14",
"marked": "^15.0.0", "marked": "^15.0.0",
"pinia": "^4.0.0", "pinia": "^4.0.0",
"vue": "^3.5.0", "vue": "^3.5.0",
+5 -9
View File
@@ -47,6 +47,9 @@ importers:
codemirror: codemirror:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.2 version: 6.0.2
dompurify:
specifier: ^3.4.14
version: 3.4.14
marked: marked:
specifier: ^15.0.0 specifier: ^15.0.0
version: 15.0.12 version: 15.0.12
@@ -829,11 +832,6 @@ packages:
'@volar/typescript@2.4.15': '@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue-macros/common@3.1.4': '@vue-macros/common@3.1.4':
resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==}
@@ -2608,13 +2606,11 @@ snapshots:
'@volar/source-map@2.4.15': {} '@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15(typescript@5.9.3)': '@volar/typescript@2.4.15':
dependencies: dependencies:
'@volar/language-core': 2.4.15 '@volar/language-core': 2.4.15
path-browserify: 1.0.1 path-browserify: 1.0.1
vscode-uri: 3.2.0 vscode-uri: 3.2.0
optionalDependencies:
typescript: 5.9.3
'@vue-macros/common@3.1.4(vue@3.5.41(typescript@5.9.3))': '@vue-macros/common@3.1.4(vue@3.5.41(typescript@5.9.3))':
dependencies: dependencies:
@@ -3652,7 +3648,7 @@ snapshots:
vue-tsc@2.2.12(typescript@5.9.3): vue-tsc@2.2.12(typescript@5.9.3):
dependencies: dependencies:
'@volar/typescript': 2.4.15(typescript@5.9.3) '@volar/typescript': 2.4.15
'@vue/language-core': 2.2.12(typescript@5.9.3) '@vue/language-core': 2.2.12(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
+10 -4
View File
@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useAgentStore } from '@/stores/agent'
import PrimarySidebar from './PrimarySidebar.vue' import PrimarySidebar from './PrimarySidebar.vue'
import SecondarySidebar from './SecondarySidebar.vue' import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue' import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue' import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue'
defineProps<{ defineProps<{
showSecondarySidebar?: boolean showSecondarySidebar?: boolean
@@ -19,14 +19,19 @@ const workspaceStore = useWorkspaceStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const agentStore = useAgentStore()
const route = useRoute() const route = useRoute()
const router = useRouter() 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 routeName = computed(() => route.name as string)
const secondaryComponent = computed(() => { const secondaryComponent = computed(() => {
switch (routeName) { switch (routeName.value) {
case 'workspace': return 'file-tree' case 'workspace': return 'file-tree'
case 'search': return 'search-filters' case 'search': return 'search-filters'
case 'chat': return 'conversation-list' case 'chat': return 'conversation-list'
@@ -58,6 +63,7 @@ defineExpose({ openCitation })
</main> </main>
</div> </div>
<StatusBar /> <StatusBar />
<CommandPalette />
</div> </div>
</template> </template>
@@ -0,0 +1,101 @@
<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: '创建 Agent Run', 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); }
.command-palette { width: min(600px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
.command-input { width: 100%; padding: var(--space-lg); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; 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); border-radius: var(--radius-md); text-align: left; }
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
.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); }
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
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)">
<span>{{ plugin.icon || '🧩' }}</span><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)">
<span>{{ skill.icon || '⚡' }}</span><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>
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue' import { computed, ref } from 'vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
const navItems = [ const navItems = [
{ name: 'workspace', icon: '📁', label: '工作区' }, { name: 'workspace', icon: '📁', label: '工作区' },
@@ -18,18 +19,21 @@ const navItems = [
] ]
const currentName = computed(() => { const currentName = computed(() => {
const name = route.name as string return route.name as string
if (name === 'skills' || name === 'plugins') return 'skills'
return name
}) })
function navigate(name: string) { function navigate(name: string) {
router.push({ name }) router.push({ name })
} }
function toggleExpanded() {
expanded.value = !expanded.value
localStorage.setItem('primary-sidebar-expanded', String(expanded.value))
}
</script> </script>
<template> <template>
<aside class="primary-sidebar"> <aside class="primary-sidebar" :class="{ expanded }">
<nav class="nav-list"> <nav class="nav-list">
<div <div
v-for="item in navItems" v-for="item in navItems"
@@ -44,9 +48,10 @@ function navigate(name: string) {
</div> </div>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="nav-item" @click="navigate('settings')" title="设置"> <button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded">
<span class="nav-icon"></span> <span class="nav-icon">{{ expanded ? '«' : '»' }}</span>
</div> <span class="nav-label">{{ expanded ? '收起' : '展开' }}</span>
</button>
</div> </div>
</aside> </aside>
</template> </template>
@@ -62,6 +67,11 @@ function navigate(name: string) {
z-index: var(--z-sidebar); z-index: var(--z-sidebar);
} }
.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 { .nav-list {
flex: 1; flex: 1;
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
@@ -121,4 +131,6 @@ function navigate(name: string) {
padding: var(--space-sm) 0; padding: var(--space-sm) 0;
border-top: 1px solid var(--color-border-subtle); border-top: 1px solid var(--color-border-subtle);
} }
.collapse-button { width: calc(100% - 8px); }
</style> </style>
@@ -27,7 +27,7 @@ const sidebarTitle = computed(() => {
return titles[props.component || ''] || '' return titles[props.component || ''] || ''
}) })
const showSkillToggle = computed(() => routeName === 'skills' || routeName === 'plugins') const showSkillToggle = computed(() => routeName.value === 'skills' || routeName.value === 'plugins')
</script> </script>
<template> <template>
@@ -109,4 +109,5 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === '
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
</style> </style>
+3 -3
View File
@@ -75,10 +75,10 @@ const showEditorInfo = computed(() => route.name === 'workspace')
{{ saveStatusText }} {{ saveStatusText }}
</span> </span>
<span class="status-item" :title="indexStatusText"> <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>
<span class="status-item" :style="{ color: aiCoreColor }" @click> <span class="status-item" :style="{ color: aiCoreColor }">
<span class="status-dot" :style="{ background: aiCoreColor }" /> <span class="status-dot" :style="{ background: aiCoreColor }" />
{{ aiCoreStatusText }} {{ aiCoreStatusText }}
</span> </span>
+184 -5
View File
@@ -75,6 +75,8 @@ export interface ChatMessage {
created_at: string created_at: string
citations?: Citation[] citations?: Citation[]
tool_calls?: ToolCall[] tool_calls?: ToolCall[]
thinking?: string
usage?: TokenUsage
} }
export interface Citation { export interface Citation {
@@ -99,6 +101,7 @@ export type ModelEventType =
| 'ToolCallDelta' | 'ToolCallDelta'
| 'ToolCallEnd' | 'ToolCallEnd'
| 'Usage' | 'Usage'
| 'Citation'
| 'Error' | 'Error'
| 'Done' | 'Done'
@@ -246,16 +249,17 @@ export interface Plugin {
status: PluginStatus status: PluginStatus
enabled: boolean enabled: boolean
permissions: string[] permissions: string[]
granted_permissions?: string[]
contributions: PluginContribution[] contributions: PluginContribution[]
backend_type?: 'mcp' | 'internal' backend_type?: 'mcp' | 'internal_rpc' | 'none'
transport?: 'stdio' | 'websocket' transport?: 'stdio' | 'http' | 'none'
last_error?: string last_error?: string
dependent_skills?: string[] dependent_skills?: string[]
} }
// ============ Provider ============ // ============ Provider ============
export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'openai-compatible' | 'mock' export type ProviderType = ApiProviderType
export interface ModelCapability { export interface ModelCapability {
chat: boolean chat: boolean
@@ -346,10 +350,10 @@ export interface ErrorResponse {
} }
export interface SystemStatus { export interface SystemStatus {
status: 'ok'
name: string name: string
version: string version: string
environment: 'development' | 'production' | 'test' environment: string
ai_core_available: boolean
} }
export type SaveStatus = export type SaveStatus =
@@ -362,3 +366,178 @@ export type SaveStatus =
| 'conflict' | 'conflict'
export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error' 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 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
}
+118
View File
@@ -0,0 +1,118 @@
<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'
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 : 'Agent 配置加载失败' }
})
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 : 'Run 加载失败' }
}, { 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 : 'Run 创建失败' }
}
function eventText(data: Record<string, unknown>) {
return String(data.text ?? data.message ?? data.code ?? '')
}
</script>
<template>
<section class="feature-page agent-page">
<header class="feature-header"><div><h1>{{ isNewRun ? '创建 Agent Run' : 'Agent Trace' }}</h1><p>配置执行边界并实时查看模型工具和权限事件</p></div>
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建 Run</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="描述希望 Agent 完成的任务" /></div>
<div class="form-grid">
<div class="field"><label>Provider</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>Model</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>Skill</label><select v-model="form.skill_id" class="select"><option value="">不使用 Skill</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>Tool Timeout</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>Run Timeout</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>Token Budget</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>允许的 Tool</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>{{ tool.name }}</strong><small>{{ tool.description }}</small></span></label></div></div>
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次 Run 调用网络工具</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">{{ 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' }">{{ event.event }}</span><span>#{{ event.sequence }} · {{ new Date(event.timestamp).toLocaleTimeString() }}</span></div>
<p v-if="eventText(event.data)" class="event-text">{{ eventText(event.data) }}</p>
<pre v-if="['ToolCall', 'ToolResult', 'Citation'].includes(event.event)">{{ JSON.stringify(event.data, null, 2) }}</pre>
</article>
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>等待 Trace</strong><p>事件连接建立后将在这里实时显示</p></div></div>
</div>
</div>
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
<div class="modal"><span class="badge warning">权限确认</span><h2>{{ agentStore.permissionRequest.tool_name }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">权限{{ agentStore.permissionRequest.permission }}</p><pre>{{ JSON.stringify(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>
.run-form { display: grid; gap: var(--space-xl); max-width: 980px; }
.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-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
.tool-option small { display: block; color: var(--color-text-secondary); }
.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 { display: grid; gap: var(--space-md); }
.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,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent'
const agentStore = useAgentStore()
const router = useRouter()
const error = ref('')
onMounted(async () => {
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : 'Run 列表加载失败' }
})
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' })"> 新建 Run</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' }">{{ 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>
+126
View File
@@ -0,0 +1,126 @@
<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 { renderMarkdown } from '@/utils/markdown'
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>
<div v-if="message.content" class="message-content markdown-content" v-html="renderMarkdown(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: 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); }
.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); }
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: var(--radius-full); background: var(--color-background-tertiary); font-weight: 700; }
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
.markdown-content { white-space: normal; user-select: text; }
.markdown-content :deep(p), .markdown-content :deep(ul), .markdown-content :deep(ol), .markdown-content :deep(pre), .markdown-content :deep(blockquote) { margin: .65em 0; }
.markdown-content :deep(h1), .markdown-content :deep(h2), .markdown-content :deep(h3) { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
.markdown-content :deep(pre) { overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }.markdown-content :deep(pre code) { padding: 0; background: transparent; }
.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-border-default); text-align: left; }
.markdown-content :deep(img) { max-width: 100%; }.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
.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-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); text-align: left; }
.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-primary); }
.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); }
</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,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { renderMarkdown } from '@/utils/markdown'
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const renderedContent = computed(() => renderMarkdown(editorStore.content))
function updateContent(event: Event) {
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
}
</script>
<template>
<div v-if="editorStore.mode === 'wysiwyg'" class="writing-layout">
<textarea class="editor-pane writing-input" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
aria-label="Markdown 写作编辑器" @input="updateContent" />
<article class="markdown-preview" aria-label="Markdown 实时预览" v-html="renderedContent" />
</div>
<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;
}
.writing-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); flex: 1; min-height: 0; }
.writing-input { border-right: 1px solid var(--color-border-default); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
.markdown-preview { width: min(100%, var(--editor-line-width, 80ch)); overflow: auto; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); user-select: text; }
.markdown-preview :deep(h1), .markdown-preview :deep(h2), .markdown-preview :deep(h3) { margin: 1.4em 0 .6em; line-height: var(--line-height-tight); color: var(--color-text-primary); }
.markdown-preview :deep(h1:first-child), .markdown-preview :deep(h2:first-child) { margin-top: 0; }
.markdown-preview :deep(p), .markdown-preview :deep(ul), .markdown-preview :deep(ol), .markdown-preview :deep(blockquote), .markdown-preview :deep(pre), .markdown-preview :deep(table) { margin: .8em 0; }
.markdown-preview :deep(ul), .markdown-preview :deep(ol) { padding-left: 1.6em; }
.markdown-preview :deep(ul) { list-style: disc; }.markdown-preview :deep(ol) { list-style: decimal; }
.markdown-preview :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
.markdown-preview :deep(code) { padding: .15em .35em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-editor-mono); }
.markdown-preview :deep(pre) { overflow: auto; padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.markdown-preview :deep(pre code) { padding: 0; background: transparent; }
.markdown-preview :deep(table) { width: 100%; border-collapse: collapse; }.markdown-preview :deep(th), .markdown-preview :deep(td) { padding: .5em .7em; border: 1px solid var(--color-border-default); text-align: left; }
.markdown-preview :deep(img) { max-width: 100%; }.markdown-preview :deep(a) { color: var(--color-text-link); }
.markdown-preview :deep(hr) { margin: 1.5em 0; border: 0; border-top: 1px solid var(--color-border-default); }
.editor-pane.source { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
@media (max-width: 900px) { .writing-layout { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr); overflow: auto; }.writing-input { min-height: 220px; border-right: 0; border-bottom: 1px solid var(--color-border-default); }.markdown-preview { min-height: 220px; } }
</style>
@@ -0,0 +1,45 @@
<script setup lang="ts">
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"><span class="icon">{{ plugin.icon || '🧩' }}</span><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,79 @@
<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-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 { cursor: pointer; }
.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,73 @@
<script setup lang="ts">
import { 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 providerForm = reactive({ provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
onMounted(() => { void providerStore.loadProviders(); void settingsStore.loadDiagnostics() })
function openProvider(provider?: ProviderConfig) {
editingProviderId.value = provider?.provider_id ?? null
Object.assign(providerForm, { 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 ?? '', enabled: provider?.enabled ?? true })
showProviderForm.value = true
}
async function saveProvider() {
providerAction.value = ''
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
try { if (editingProviderId.value) await providerStore.updateProvider(editingProviderId.value, data); else await providerStore.addProvider(data); showProviderForm.value = false } catch (error) { 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}` }
</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"><h2>模型提供商</h2><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><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><p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p></div><div class="inline-actions"><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="showProviderForm = false"><div class="modal"><h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2><form @submit.prevent="saveProvider"><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" /></div><div class="field"><label>默认模型</label><input v-model="providerForm.default_model" class="input" /></div><div class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="密钥明文由 Stronghold 保存" /><small class="subtle">此处不输入或回显 API Key。</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="showProviderForm = false">取消</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: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); }
.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-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
.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 { align-items: flex-start; flex-direction: column; }.short { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
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"><span class="icon">{{ skill.icon || '⚡' }}</span><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>
+62
View File
@@ -0,0 +1,62 @@
<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); }
.task-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-md); }
.status-check { width: 26px; height: 26px; border: 2px solid var(--color-border-default); border-radius: var(--radius-full); }
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: white; }
.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,32 @@
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
</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><div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"><h3>主题预览</h3><p>知识的价值不只在于保存更在于被重新发现和使用</p><code>const notes = await search('本地优先')</code></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; }.editor-preview code { color: var(--color-accent-primary); }
</style>
+6 -1
View File
@@ -17,7 +17,12 @@ const newVaultPath = ref('')
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking') const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
onMounted(async () => { 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(() => { setTimeout(() => {
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped' aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}, 800) }, 800)
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { FileNode } from '@/contracts'
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)">
<span>{{ node.type === 'folder' ? (node.is_open ? '📂' : '📁') : '📄' }}</span>
<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>
+90 -370
View File
@@ -1,406 +1,126 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { FileNode } from '@/contracts' import type { FileNode } from '@/contracts'
import * as workspaceService from '@/services/workspaceService' import * as workspaceService from '@/services/workspaceService'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import FileTreeNode from './FileTreeNode.vue'
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const router = useRouter() 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) function beginCreate(type: 'file' | 'folder', parent = '/') {
const newFileName = ref('') newItemType.value = type
const newFolderName = ref('') newItemName.value = ''
const newFileParentPath = ref('') parentPath.value = parent
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)
} }
async function openFile(node: FileNode) { async function createItem() {
if (node.type === 'folder') { const rawName = newItemName.value.trim()
toggleFolder(node) if (!rawName || !newItemType.value) return
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)
} }
workspaceStore.openFile(node.path) newItemType.value = null
newItemName.value = ''
}
async function openNode(node: FileNode) {
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
await editorStore.loadFile(node.path) await editorStore.loadFile(node.path)
router.push('/workspace') workspaceStore.openFile(node.path)
await router.push('/workspace')
} }
function startNewFile(parentPath = '') { function openContextMenu(event: MouseEvent, node: FileNode) {
newFileParentPath.value = parentPath event.preventDefault()
showNewFileInput.value = true event.stopPropagation()
showNewMenu.value = false contextTarget.value = node
newFileName.value = '' contextMenuPosition.value = { x: event.clientX, y: event.clientY }
} }
function startNewFolder(parentPath = '') { function closeContextMenu() { contextTarget.value = null }
newFileParentPath.value = parentPath
showNewFolderInput.value = true
showNewMenu.value = false
newFolderName.value = ''
}
async function createFile() { async function renameTarget() {
if (!newFileName.value.trim()) return const node = contextTarget.value
const name = newFileName.value.endsWith('.md') ? newFileName.value : `${newFileName.value}.md` if (!node) return
const file = await workspaceService.createFile(newFileParentPath.value || '/', name, '# ' + newFileName.value + '\n\n') const newName = window.prompt('新名称', node.name)?.trim()
workspaceStore.addFileToTree(newFileParentPath.value || '/', file) if (newName && newName !== node.name) {
workspaceStore.openFile(file.path) const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
await editorStore.loadFile(file.path) const oldPath = node.path
showNewFileInput.value = false const separator = oldPath.lastIndexOf('/')
newFileName.value = '' const newPath = `${oldPath.slice(0, separator + 1)}${normalizedName}`
} await workspaceService.renameFile(oldPath, normalizedName)
workspaceStore.renamePath(oldPath, newPath, normalizedName)
async function createFolder() { editorStore.renameFilePath(oldPath, newPath)
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
} }
renamingPath.value = null closeContextMenu()
} }
async function deleteNode(node: FileNode) { async function deleteTarget() {
const confirmMsg = node.type === 'folder' ? `确定要删除文件夹 "${node.name}" 吗?` : `确定要删除笔记 "${node.name}" 吗?` const node = contextTarget.value
if (confirm(confirmMsg)) { if (!node) return
await workspaceService.deleteFile(node.path) if (!window.confirm(`确定要删除“${node.name}”吗?`)) return closeContextMenu()
workspaceStore.removeFromTree(node.path) await workspaceService.deleteFile(node.path)
if (node.type === 'file') { const activeWasRemoved = workspaceStore.closePath(node.path)
workspaceStore.closeFile(node.path) workspaceStore.removeFromTree(node.path)
} if (activeWasRemoved) {
editorStore.closeFile()
if (workspaceStore.activeFilePath) await editorStore.loadFile(workspaceStore.activeFilePath)
} }
showContextMenu.value = false closeContextMenu()
}
function getFileIcon(name: string) {
if (name.endsWith('.md')) return '📄'
return '📄'
} }
</script> </script>
<template> <template>
<div class="file-tree-panel" @click="closeContextMenu"> <section class="file-tree-panel" @click="closeContextMenu">
<div class="panel-toolbar"> <div class="toolbar">
<div class="toolbar-left"> <button type="button" title="新建笔记" @click.stop="beginCreate('file')">📄</button>
<button class="tool-btn" @click="startNewFile" title="新建笔记"> <button type="button" title="新建文件夹" @click.stop="beginCreate('folder')">📁</button>
<span></span>
</button>
<button class="tool-btn" @click="startNewFolder" title="新建文件夹">
<span>📁</span>
</button>
</div>
<button class="tool-btn" title="刷新">
<span>🔄</span>
</button>
</div> </div>
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
<div class="new-input" v-if="showNewFileInput"> <input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
<input <button type="submit">创建</button>
v-model="newFileName" <button type="button" @click="newItemType = null">取消</button>
type="text" </form>
placeholder="笔记名称" <div class="tree">
@keyup.enter="createFile" <FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
@keyup.esc="showNewFileInput = false" :active-path="workspaceStore.activeFilePath" @open="openNode" @context-menu="openContextMenu" />
autofocus
/>
</div> </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"> <Teleport to="body">
<div v-if="showContextMenu" class="context-menu" <div v-if="contextTarget" class="context-menu"
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }" :style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
@click.stop> <button @click="renameTarget">重命名</button>
<button @click="() => { const n = workspaceStore.activeFile; if (n) startRename(n) }"> 重命名</button> <button class="danger" @click="deleteTarget">删除</button>
<button @click="() => { const n = workspaceStore.activeFile; if (n) deleteNode(n) }" class="danger">🗑 删除</button>
</div> </div>
</Teleport> </Teleport>
</div> </section>
</template> </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> <style scoped>
.file-tree-panel { .file-tree-panel { height: 100%; }
height: 100%; .toolbar { display: flex; gap: var(--space-xs); padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
display: flex; button { border: 0; border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); background: transparent; color: inherit; cursor: pointer; }
flex-direction: column; button:hover { background: var(--color-background-secondary); }
font-size: var(--font-size-sm); .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); }
.panel-toolbar { .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); }
display: flex; .context-menu button { text-align: left; }
align-items: center; .context-menu .danger { color: var(--color-danger, #d33); }
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);
}
}
}
</style> </style>
@@ -13,8 +13,9 @@ onMounted(() => {
// Already loaded // Already loaded
} }
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) { if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
workspaceStore.openFile('/欢迎使用知笔知己.md') void editorStore.loadFile('/欢迎使用知笔知己.md').then(() => {
editorStore.loadFile('/欢迎使用知笔知己.md') workspaceStore.openFile('/欢迎使用知笔知己.md')
})
} }
}) })
</script> </script>
+1
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './styles/tokens.css' import './styles/tokens.css'
import './styles/features.css'
import { useThemeStore } from './stores/theme' import { useThemeStore } from './stores/theme'
const app = createApp(App) const app = createApp(App)
-9
View File
@@ -61,15 +61,6 @@ const routes = [
name: 'settings', name: 'settings',
component: () => import('@/features/settings/SettingsView.vue'), component: () => import('@/features/settings/SettingsView.vue'),
meta: { title: '设置', requiresVault: true }, 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') },
],
}, },
] ]
+32 -19
View File
@@ -1,46 +1,61 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import { SseClient } from './sseClient' 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 {
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?: { export async function listAgentRuns(params?: {
limit?: number limit?: number
offset?: number offset?: number
}): Promise<{ items: AgentRun[]; total: 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> { 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 { export interface CreateAgentRunRequest {
task: string input: string
provider_id?: string provider_id: string
model?: string model: string
skill_id?: string skill_id?: string
allowed_tools?: string[] allowed_tools?: string[]
max_steps?: number max_steps?: number
tool_timeout?: number tool_timeout_seconds?: number
run_timeout?: number run_timeout_seconds?: number
token_budget?: number token_budget?: number
allow_network?: boolean allow_network?: boolean
max_concurrent_tools?: number max_concurrent_tools?: number
} }
export async function createAgentRun(request: CreateAgentRunRequest): Promise<AgentRun> { 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`) return apiClient.post(`/api/agent/runs/${runId}/cancel`)
} }
export async function listTools(): Promise<ToolDefinition[]> { export async function listTools(): Promise<ToolDefinition[]> {
try { const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
return await apiClient.get('/api/tools') return response.items
} catch {
return mockTools
}
} }
export function streamAgentEvents( export function streamAgentEvents(
@@ -75,12 +90,10 @@ export function streamAgentEvents(
export async function respondToPermission( export async function respondToPermission(
runId: string, runId: string,
requestId: string, requestId: string,
decision: 'allow' | 'deny', decision: 'allow_once' | 'allow_session' | 'deny'
scope?: 'once' | 'session' | 'always' ): Promise<OperationResponse> {
): Promise<void> {
return apiClient.post(`/api/agent/runs/${runId}/permissions/${requestId}`, { return apiClient.post(`/api/agent/runs/${runId}/permissions/${requestId}`, {
decision, decision,
scope,
}) })
} }
+14 -2
View File
@@ -1,6 +1,11 @@
import type { ApiError, ErrorResponse } from '@/contracts' import type { ApiError, ErrorResponse } from '@/contracts'
const BASE_URL = import.meta.env.VITE_API_BASE || '' 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 { interface RequestOptions extends RequestInit {
params?: Record<string, string | number | boolean | undefined> params?: Record<string, string | number | boolean | undefined>
@@ -22,7 +27,7 @@ export class ApiErrorClass extends Error {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, token, headers, ...rest } = options const { params, token, headers, ...rest } = options
let url = path.startsWith('http') ? path : `${BASE_URL}${path}` let url = resolveApiUrl(path)
if (params) { if (params) {
const usp = new URLSearchParams() const usp = new URLSearchParams()
@@ -94,6 +99,13 @@ export const apiClient = {
body: body !== undefined ? JSON.stringify(body) : undefined, 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'>) { delete<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'DELETE' }) return request<T>(path, { ...options, method: 'DELETE' })
}, },
+11 -17
View File
@@ -1,27 +1,21 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient' import { SseClient } from './sseClient'
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts' 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 { export interface ChatRequest {
provider_id: string
model: string
conversation_id?: string conversation_id?: string
message: string system?: string
provider_id?: string messages: Array<{
model?: string role: 'system' | 'user' | 'assistant' | 'tool'
content: string
name?: string
tool_call_id?: string
}>
use_rag?: boolean use_rag?: boolean
skill_id?: string
attachments?: string[] attachments?: string[]
temperature?: number
max_tokens?: number
} }
export function streamChat( export function streamChat(
+1 -1
View File
@@ -1,5 +1,5 @@
export { apiClient, ApiErrorClass } from './apiClient' export { apiClient, ApiErrorClass } from './apiClient'
export type { ApiError } from './apiClient' export type { ApiError } from '@/contracts'
export { SseClient } from './sseClient' export { SseClient } from './sseClient'
export type { SseClientOptions, SseEventHandler } from './sseClient' export type { SseClientOptions, SseEventHandler } from './sseClient'
export * as noteService from './noteService' export * as noteService from './noteService'
+19 -15
View File
@@ -1,25 +1,29 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { IndexStatus } from '@/contracts' import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
export async function getIndexStatus(): Promise<IndexStatus> { function toIndexStatus(status: ApiIndexStatus): IndexStatus {
try { return {
return await apiClient.get('/api/index/status') status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
} catch { pending_jobs: status.pending_jobs,
return mockIndexStatus 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 }> { export async function getIndexStatus(): Promise<IndexStatus> {
return apiClient.post('/api/index/rebuild', { scope }) return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status'))
} }
export async function getIndexJob(jobId: string): Promise<{ export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<ApiIndexJob> {
job_id: string const apiScope = scope === 'full' ? 'all' : scope === 'fts' ? 'notes' : 'vectors'
status: 'queued' | 'running' | 'completed' | 'failed' return apiClient.post<ApiIndexJob>('/api/index/rebuild', { scope: apiScope })
progress: number }
total: number
error?: string export async function getIndexJob(jobId: string): Promise<ApiIndexJob> {
}> {
return apiClient.get(`/api/index/jobs/${jobId}`) return apiClient.get(`/api/index/jobs/${jobId}`)
} }
+12 -11
View File
@@ -1,38 +1,39 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { Note, NoteBlock } from '@/contracts' import type { ApiNote, ApiNoteSummary, OperationResponse, PageMeta } from '@/contracts'
export async function listNotes(params?: { export async function listNotes(params?: {
folder?: string folder?: string
tag?: string tag?: string
limit?: number limit?: number
offset?: number offset?: number
}): Promise<{ items: Note[]; total: number }> { }): Promise<{ items: ApiNoteSummary[]; page: PageMeta }> {
return apiClient.get('/api/notes', { params }) 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}`) return apiClient.get(`/api/notes/${noteId}`)
} }
export async function createNote(data: { export async function createNote(data: {
title: string title: string
folder_path?: string folder?: string
content?: string markdown?: string
}): Promise<Note> { tags?: string[]
}): Promise<ApiNote> {
return apiClient.post('/api/notes', data) return apiClient.post('/api/notes', data)
} }
export async function updateNote( export async function updateNote(
noteId: string, noteId: string,
data: { title?: string; content?: string; tags?: string[] } data: { title?: string; markdown?: string; tags?: string[] }
): Promise<Note> { ): Promise<ApiNote> {
return apiClient.patch(`/api/notes/${noteId}`, data) 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}`) return apiClient.delete(`/api/notes/${noteId}`)
} }
export async function moveNote(noteId: string, target_folder: string): Promise<Note> { export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
return apiClient.post(`/api/notes/${noteId}/move`, { target_folder }) return apiClient.post(`/api/notes/${noteId}/move`, { folder })
} }
+44 -15
View File
@@ -1,31 +1,60 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { Plugin } from '@/contracts' import type { ApiPlugin, OperationResponse, Plugin, PluginContribution } from '@/contracts'
export async function listPlugins(): Promise<Plugin[]> { function toPlugin(plugin: ApiPlugin): Plugin {
try { const { manifest } = plugin
return await apiClient.get('/api/plugins') const contributions: PluginContribution[] = []
} catch { const append = (type: PluginContribution['type'], values: string[]) => {
return mockPlugins 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> { export async function listPlugins(): Promise<Plugin[]> {
return apiClient.get(`/api/plugins/${pluginId}`) const response = await apiClient.get<{ items: ApiPlugin[] }>('/api/plugins')
return response.items.map(toPlugin)
} }
export async function installPlugin(pluginId: string): Promise<Plugin> { export async function getPlugin(pluginId: string): Promise<Plugin> {
return apiClient.post('/api/plugins/install', { plugin_id: pluginId }) 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> { 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> { 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}`) return apiClient.delete(`/api/plugins/${pluginId}`)
} }
@@ -80,7 +109,7 @@ export const mockPlugins: Plugin[] = [
contributions: [ contributions: [
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' }, { type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
], ],
backend_type: 'internal', backend_type: 'internal_rpc',
}, },
{ {
plugin_id: 'pdf-importer', plugin_id: 'pdf-importer',
@@ -114,6 +143,6 @@ export const mockPlugins: Plugin[] = [
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' }, { type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
], ],
backend_type: 'mcp', backend_type: 'mcp',
transport: 'websocket', transport: 'http',
}, },
] ]
+53 -22
View File
@@ -1,36 +1,67 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { ProviderConfig, ModelInfo } from '@/contracts' import type { ApiModelInfo, ApiProviderConfig, ModelCapability, ModelInfo, OperationResponse, ProviderConfig } from '@/contracts'
export async function listProviders(): Promise<ProviderConfig[]> { function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
try { return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
return await apiClient.get('/api/providers') }
} catch {
return mockProviders 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> { 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> { export async function createProvider(data: Omit<ProviderConfig, 'provider_id'>): Promise<ProviderConfig> {
return apiClient.post('/api/providers', data) 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> { export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
return apiClient.patch(`/api/providers/${providerId}`, data) 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<void> { export async function deleteProvider(providerId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/providers/${providerId}`) return apiClient.delete(`/api/providers/${providerId}`)
} }
export async function listModels(providerId: string): Promise<ModelInfo[]> { export async function listModels(providerId: string): Promise<ModelInfo[]> {
try { const response = await apiClient.get<{ provider_id: string; items: ApiModelInfo[] }>(`/api/providers/${providerId}/models`)
return await apiClient.get(`/api/providers/${providerId}/models`) return response.items.map(toModel)
} catch {
return mockModels[providerId] || []
}
} }
export interface TestResult { export interface TestResult {
@@ -42,8 +73,8 @@ export interface TestResult {
export async function testProvider(providerId: string): Promise<TestResult> { export async function testProvider(providerId: string): Promise<TestResult> {
try { try {
const result = await apiClient.post<{ success: boolean; latency_ms: number }>('/api/providers/test', { provider_id: providerId }) 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 } return { success: result.success, latency_ms: result.latency_ms ?? undefined, error_message: result.success ? undefined : result.message }
} catch (e: any) { } catch (e: any) {
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message } return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
} }
@@ -51,7 +82,7 @@ export async function testProvider(providerId: string): Promise<TestResult> {
export const mockProviders: ProviderConfig[] = [ export const mockProviders: ProviderConfig[] = [
{ {
provider_id: 'mock-provider', provider_id: 'mock',
provider_type: 'mock', provider_type: 'mock',
name: 'Mock Provider (测试)', name: 'Mock Provider (测试)',
default_model: 'mock-1', default_model: 'mock-1',
@@ -69,7 +100,7 @@ export const mockProviders: ProviderConfig[] = [
}, },
{ {
provider_id: 'openai-compat-1', provider_id: 'openai-compat-1',
provider_type: 'openai-compatible', provider_type: 'openai_compatible',
name: 'OpenAI 兼容服务', name: 'OpenAI 兼容服务',
base_url: 'https://api.openai.com/v1', base_url: 'https://api.openai.com/v1',
default_model: 'gpt-4o-mini', default_model: 'gpt-4o-mini',
@@ -106,7 +137,7 @@ export const mockProviders: ProviderConfig[] = [
] ]
export const mockModels: Record<string, ModelInfo[]> = { export const mockModels: Record<string, ModelInfo[]> = {
'mock-provider': [ mock: [
{ {
model_id: 'mock-1', model_id: 'mock-1',
name: 'Mock Model v1', name: 'Mock Model v1',
+33 -3
View File
@@ -1,15 +1,45 @@
import apiClient from './apiClient' 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<{ export async function search(request: SearchRequest): Promise<{
results: SearchResult[] results: SearchResult[]
total: number total: number
mode: SearchRequest['mode'] 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[] results: SearchResult[]
total: number total: number
mode: 'fts' | 'vector' | 'hybrid' mode: 'fts' | 'vector' | 'hybrid'
+27 -13
View File
@@ -1,31 +1,45 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { Skill } from '@/contracts' import type { ApiSkill, OperationResponse, Skill } from '@/contracts'
export async function listSkills(): Promise<Skill[]> { function toSkill(skill: ApiSkill): Skill {
try { const { manifest } = skill
return await apiClient.get('/api/skills') return {
} catch { skill_id: manifest.skill_id,
return mockSkills 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> { export async function listSkills(): Promise<Skill[]> {
return apiClient.get(`/api/skills/${skillId}`) const response = await apiClient.get<{ items: ApiSkill[] }>('/api/skills')
return response.items.map(toSkill)
} }
export async function installSkill(skillId: string): Promise<Skill> { export async function getSkill(skillId: string): Promise<Skill> {
return apiClient.post('/api/skills/install', { skill_id: skillId }) 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> { 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> { 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}`) return apiClient.delete(`/api/skills/${skillId}`)
} }
+43 -32
View File
@@ -1,3 +1,5 @@
import { resolveApiUrl } from './apiClient'
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
export interface SseClientOptions { export interface SseClientOptions {
@@ -37,7 +39,7 @@ export class SseClient {
headers['Authorization'] = `Bearer ${token}` headers['Authorization'] = `Bearer ${token}`
} }
const resp = await fetch(url, { const resp = await fetch(resolveApiUrl(url), {
method, method,
headers, headers,
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -53,6 +55,39 @@ export class SseClient {
onOpen?.() onOpen?.()
const decoder = new TextDecoder('utf-8') 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) { while (true) {
const { value, done } = await this.reader.read() const { value, done } = await this.reader.read()
@@ -60,39 +95,15 @@ export class SseClient {
this.buffer += decoder.decode(value, { stream: true }) this.buffer += decoder.decode(value, { stream: true })
const lines = this.buffer.split('\n') const lines = this.buffer.split(/\r?\n/)
this.buffer = lines.pop() || '' this.buffer = lines.pop() || ''
lines.forEach(consumeLine)
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
}
}
} }
this.buffer += decoder.decode()
if (this.buffer) consumeLine(this.buffer.replace(/\r$/, ''))
dispatchEvent()
if (!doneNotified) onDone?.()
} catch (e) { } catch (e) {
if ((e as Error).name === 'AbortError') return if ((e as Error).name === 'AbortError') return
onError?.(e as Error) onError?.(e as Error)
+1 -1
View File
@@ -14,10 +14,10 @@ export async function getStatus(): Promise<SystemStatus> {
return await apiClient.get<SystemStatus>('/api/status') return await apiClient.get<SystemStatus>('/api/status')
} catch { } catch {
return { return {
status: 'ok',
name: 'notes-agent', name: 'notes-agent',
version: '0.1.0', version: '0.1.0',
environment: import.meta.env.DEV ? 'development' : 'production', environment: import.meta.env.DEV ? 'development' : 'production',
ai_core_available: false,
} }
} }
} }
+39 -18
View File
@@ -1,42 +1,63 @@
import apiClient from './apiClient' 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?: { function toTask(task: ApiTask): TaskItem {
status?: TaskStatus return {
priority?: TaskPriority task_id: task.task_id,
source?: 'user' | 'note' | 'agent' title: task.title,
limit?: number description: task.description,
offset?: number status: task.status,
}): Promise<{ items: TaskItem[]; total: number }> { priority: 'medium',
try { due_date: task.due_at ?? undefined,
return await apiClient.get('/api/tasks', { params }) note_id: task.note_id ?? undefined,
} catch { source: 'user',
return { items: mockTasks, total: mockTasks.length } 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> { 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: { export async function createTask(data: {
title: string title: string
description?: string description?: string
priority?: TaskPriority
due_date?: string due_date?: string
note_id?: string note_id?: string
}): Promise<TaskItem> { }): 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( export async function updateTask(
taskId: string, 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> { ): 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}`) return apiClient.delete(`/api/tasks/${taskId}`)
} }
+27 -5
View File
@@ -61,6 +61,13 @@ const MOCK_FILE_TREE: FileNode[] = [
{ id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.md', type: 'file' }, { id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.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[]> { export function getRecentVaults(): Promise<VaultInfo[]> {
return Promise.resolve(MOCK_VAULTS) return Promise.resolve(MOCK_VAULTS)
} }
@@ -79,9 +86,11 @@ export function getFileTree(): Promise<FileNode[]> {
} }
export function readFileContent(filePath: string): Promise<string> { 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' const name = filePath.split('/').pop() || 'Untitled'
if (name === '欢迎使用知笔知己.md') { if (name === '欢迎使用知笔知己.md') {
return Promise.resolve(`# 欢迎使用知笔知己 return rememberContent(filePath, `# 欢迎使用知笔知己
AI Markdown RAG Agent AI Markdown RAG Agent
@@ -137,7 +146,7 @@ def quick_sort(arr):
`) `)
} }
if (name === '红黑树.md') { if (name === '红黑树.md') {
return Promise.resolve(`# 红黑树 return rememberContent(filePath, `# 红黑树
Red-Black Tree Red-Black Tree
@@ -183,7 +192,7 @@ def quick_sort(arr):
- Linux - Linux
`) `)
} }
return Promise.resolve(`# ${name.replace('.md', '')} return rememberContent(filePath, `# ${name.replace('.md', '')}
@@ -205,26 +214,39 @@ console.log('Hello, Notes Agent!');
export function saveFileContent(filePath: string, content: string): Promise<void> { export function saveFileContent(filePath: string, content: string): Promise<void> {
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`) console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`)
mockFileContents.set(filePath, content)
return Promise.resolve() return Promise.resolve()
} }
export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> { export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> {
const path = `${folderPath}/${name}` const path = `${folderPath === '/' ? '' : folderPath}/${name}`
const id = `n-${Date.now()}` const id = `n-${Date.now()}`
mockFileContents.set(path, content)
return Promise.resolve({ id, name, path, type: 'file' }) return Promise.resolve({ id, name, path, type: 'file' })
} }
export function createFolder(parentPath: string, name: string): Promise<FileNode> { export function createFolder(parentPath: string, name: string): Promise<FileNode> {
const path = `${parentPath}/${name}` const path = `${parentPath === '/' ? '' : parentPath}/${name}`
const id = `f-${Date.now()}` const id = `f-${Date.now()}`
return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] }) return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] })
} }
export function renameFile(oldPath: string, newName: string): Promise<void> { 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() return Promise.resolve()
} }
export function deleteFile(path: string): Promise<void> { 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() return Promise.resolve()
} }
+76 -52
View File
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts' import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService' import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
import * as agentService from '@/services/agentService' import * as agentService from '@/services/agentService'
import type { SseClient } from '@/services/sseClient'
export const useAgentStore = defineStore('agent', () => { export const useAgentStore = defineStore('agent', () => {
const runs = ref<AgentRun[]>(mockAgentRuns) const runs = ref<AgentRun[]>(mockAgentRuns)
@@ -13,6 +14,8 @@ export const useAgentStore = defineStore('agent', () => {
const isRunning = ref(false) const isRunning = ref(false)
const permissionRequest = ref<PermissionRequest | null>(null) const permissionRequest = ref<PermissionRequest | null>(null)
const toolCalls = ref<ToolCall[]>([]) const toolCalls = ref<ToolCall[]>([])
const error = ref<string | null>(null)
let eventStream: SseClient | null = null
const activeRun = computed(() => const activeRun = computed(() =>
runs.value.find((r) => r.run_id === activeRunId.value) || null runs.value.find((r) => r.run_id === activeRunId.value) || null
@@ -37,84 +40,104 @@ export const useAgentStore = defineStore('agent', () => {
} }
async function loadRun(runId: string) { async function loadRun(runId: string) {
eventStream?.cancel()
activeRunId.value = runId 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 = [] toolCalls.value = []
for (const evt of events.value) { permissionRequest.value = null
if (evt.event === 'ToolCall') { subscribe(runId)
const data = evt.data as any }
toolCalls.value.push({
tool_call_id: data.tool_call_id, function processEvent(event: AgentEvent) {
name: data.name, if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
parameters: data.parameters, events.value.push(event)
status: data.status || 'completed', events.value.sort((a, b) => a.sequence - b.sequence)
started_at: evt.timestamp, const data = event.data
}) const run = runs.value.find((item) => item.run_id === event.run_id)
} else if (evt.event === 'ToolResult') { if (event.event === 'RunStarted' && run) run.status = 'running'
const data = evt.data as any if (event.event === 'ToolCall') {
const tc = toolCalls.value.find((t) => t.tool_call_id === data.tool_call_id) toolCalls.value.push({
if (tc) { tool_call_id: String(data.tool_call_id ?? ''),
tc.status = data.status name: String(data.name ?? 'unknown'),
tc.result = data.result parameters: (data.arguments ?? {}) as Record<string, unknown>,
tc.completed_at = evt.timestamp 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) { async function createRun(request: agentService.CreateAgentRunRequest) {
isCreating.value = true isCreating.value = true
try { try {
const run = await agentService.createAgentRun(request) const run = await agentService.createAgentRun(request)
runs.value.unshift(run) runs.value.unshift(run)
activeRunId.value = run.run_id activeRunId.value = run.run_id
events.value = [{ events.value = []
event: 'RunStarted', toolCalls.value = []
sequence: 1, subscribe(run.run_id)
run_id: run.run_id,
data: { task: request.task },
timestamp: new Date().toISOString(),
}]
isRunning.value = true
// Mock events streaming
simulateRun(run.run_id)
return run return run
} finally { } finally {
isCreating.value = false 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) { async function cancelRun(runId: string) {
await agentService.cancelAgentRun(runId) await agentService.cancelAgentRun(runId)
const run = runs.value.find((r) => r.run_id === runId) const run = runs.value.find((r) => r.run_id === runId)
if (run) run.status = 'cancelled' if (run) run.status = 'cancelled'
isRunning.value = false 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 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 permissionRequest.value = null
} }
@@ -133,6 +156,7 @@ export const useAgentStore = defineStore('agent', () => {
isRunning, isRunning,
permissionRequest, permissionRequest,
toolCalls, toolCalls,
error,
currentStep, currentStep,
loadTools, loadTools,
loadRuns, loadRuns,
+62 -27
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ChatMessage, Conversation, Citation } from '@/contracts' import type { ChatMessage, Conversation } from '@/contracts'
import { mockConversations, mockMessages } from '@/services/chatService' import { mockConversations, mockMessages, streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
@@ -12,7 +12,7 @@ export const useChatStore = defineStore('chat', () => {
const inputText = ref('') const inputText = ref('')
const useRag = ref(true) const useRag = ref(true)
const selectedSkillId = ref<string | null>(null) const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('mock-provider') const selectedProviderId = ref('mock')
const selectedModel = ref('mock-1') const selectedModel = ref('mock-1')
let sseClient: SseClient | null = null let sseClient: SseClient | null = null
@@ -35,7 +35,7 @@ export const useChatStore = defineStore('chat', () => {
if (!activeConversationId.value) { if (!activeConversationId.value) {
const newConv: Conversation = { const newConv: Conversation = {
conversation_id, conversation_id: conversationId,
title: text.slice(0, 30), title: text.slice(0, 30),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@@ -67,31 +67,66 @@ export const useChatStore = defineStore('chat', () => {
} }
messages.value.push(aiMsg) messages.value.push(aiMsg)
// Mock streaming sseClient = streamChat({
const fullText = provider_id: selectedProviderId.value,
'这是一个模拟的 AI 回复。在实际环境中,这里会通过 SSE 接收后端 AI Core 的流式输出,基于 RAG 引擎和你的知识库生成回答,并附带来源引用。\n\n**要点总结:**\n1. 这是演示用的流式输出\n2. 实际会调用 ModelEvent SSE\n3. 支持 Citation、Tool Call 等事件\n\n你可以在设置中配置真实的模型 Provider 来启用完整功能。' model: selectedModel.value,
const citations: Citation[] = [ conversation_id: conversationId,
{ use_rag: useRag.value,
note_id: 'n-rbt', messages: messages.value
block_id: 'b1', .filter((message) => message !== aiMsg)
file_path: '/数据结构/红黑树.md', .map((message) => ({ role: message.role, content: message.content })),
heading_path: '数据结构 / 红黑树 / 概述', }, {
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 ?? '未知错误')}`
}, },
] onError(error) {
aiMsg.content += `\n\n连接失败:${error.message}`
let i = 0
const interval = setInterval(() => {
if (i >= fullText.length) {
clearInterval(interval)
isStreaming.value = false isStreaming.value = false
aiMsg.citations = citations sseClient = null
return },
} onDone() {
const chunk = fullText.slice(i, i + 3) const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
aiMsg.content += chunk if (conversation) {
i += 3 conversation.message_count = messages.value.length
}, 20) conversation.updated_at = new Date().toISOString()
}
isStreaming.value = false
sseClient = null
},
})
} }
function stopGeneration() { function stopGeneration() {
+48 -12
View File
@@ -30,41 +30,69 @@ export const useEditorStore = defineStore('editor', () => {
function updateContent(newContent: string) { function updateContent(newContent: string) {
content.value = newContent content.value = newContent
if (saveStatus.value === 'saved' || saveStatus.value === 'idle') { saveStatus.value = 'dirty'
saveStatus.value = 'dirty'
}
} }
let saveTimer: ReturnType<typeof setTimeout> | null = null let saveTimer: ReturnType<typeof setTimeout> | null = null
let pendingSave: Promise<void> | null = null
function scheduleAutoSave(delay = 1500) { function scheduleAutoSave(delay = 1500) {
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
saveTimer = null
void save() void save()
}, delay) }, delay)
} }
async function save() { async function save() {
if (!currentFilePath.value) return if (!currentFilePath.value) return
if (saveStatus.value === 'saving') return if (pendingSave) return pendingSave
const targetPath = currentFilePath.value
const snapshot = content.value
saveStatus.value = 'saving' saveStatus.value = 'saving'
try { pendingSave = (async () => {
await workspaceService.saveFileContent(currentFilePath.value, content.value) try {
saveStatus.value = 'saved' await workspaceService.saveFileContent(targetPath, snapshot)
lastSavedAt.value = new Date().toISOString() if (currentFilePath.value === targetPath) {
} catch { saveStatus.value = content.value === snapshot ? 'saved' : 'dirty'
saveStatus.value = 'save_failed' 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) { async function loadFile(filePath: string) {
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
currentFilePath.value = filePath currentFilePath.value = filePath
saveStatus.value = 'saving' saveStatus.value = 'saving'
try { try {
content.value = await workspaceService.readFileContent(filePath) const loadedContent = await workspaceService.readFileContent(filePath)
if (version !== loadVersion || currentFilePath.value !== filePath) return
content.value = loadedContent
saveStatus.value = 'saved' saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString() lastSavedAt.value = new Date().toISOString()
} catch { } catch {
if (version !== loadVersion || currentFilePath.value !== filePath) return
content.value = '' content.value = ''
saveStatus.value = 'idle' saveStatus.value = 'idle'
} }
@@ -89,6 +117,7 @@ export const useEditorStore = defineStore('editor', () => {
} }
function closeFile() { function closeFile() {
loadVersion++
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
currentFilePath.value = null currentFilePath.value = null
currentNoteId.value = null currentNoteId.value = null
@@ -98,6 +127,12 @@ export const useEditorStore = defineStore('editor', () => {
highlightBlockId.value = null 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 { return {
mode, mode,
content, content,
@@ -118,5 +153,6 @@ export const useEditorStore = defineStore('editor', () => {
highlightBlock, highlightBlock,
setExternalChanged, setExternalChanged,
closeFile, closeFile,
renameFilePath,
} }
}) })
+31 -14
View File
@@ -1,12 +1,13 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Plugin } from '@/contracts' import type { Plugin } from '@/contracts'
import { mockPlugins } from '@/services/pluginService' import * as pluginService from '@/services/pluginService'
export const usePluginStore = defineStore('plugin', () => { export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>(mockPlugins) const plugins = ref<Plugin[]>(pluginService.mockPlugins)
const selectedPluginId = ref<string | null>(null) const selectedPluginId = ref<string | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedPlugin = computed(() => const selectedPlugin = computed(() =>
plugins.value.find((p) => p.plugin_id === selectedPluginId.value) || null plugins.value.find((p) => p.plugin_id === selectedPluginId.value) || null
@@ -19,8 +20,10 @@ export const usePluginStore = defineStore('plugin', () => {
async function loadPlugins() { async function loadPlugins() {
isLoading.value = true isLoading.value = true
try { try {
const { listPlugins } = await import('@/services/pluginService') plugins.value = await pluginService.listPlugins()
plugins.value = await listPlugins() error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Plugin 加载失败'
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -30,23 +33,34 @@ export const usePluginStore = defineStore('plugin', () => {
selectedPluginId.value = pluginId 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) { async function enablePlugin(pluginId: string) {
const plugin = plugins.value.find((p) => p.plugin_id === pluginId) const updated = await pluginService.enablePlugin(pluginId)
if (plugin) { const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
plugin.enabled = true if (index >= 0) plugins.value[index] = updated
plugin.status = 'ready'
}
} }
async function disablePlugin(pluginId: string) { async function disablePlugin(pluginId: string) {
const plugin = plugins.value.find((p) => p.plugin_id === pluginId) const updated = await pluginService.disablePlugin(pluginId)
if (plugin) { const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
plugin.enabled = false if (index >= 0) plugins.value[index] = updated
plugin.status = 'disabled'
}
} }
async function uninstallPlugin(pluginId: string) { async function uninstallPlugin(pluginId: string) {
await pluginService.uninstallPlugin(pluginId)
const idx = plugins.value.findIndex((p) => p.plugin_id === pluginId) const idx = plugins.value.findIndex((p) => p.plugin_id === pluginId)
if (idx > -1) plugins.value.splice(idx, 1) if (idx > -1) plugins.value.splice(idx, 1)
if (selectedPluginId.value === pluginId) selectedPluginId.value = null if (selectedPluginId.value === pluginId) selectedPluginId.value = null
@@ -60,8 +74,11 @@ export const usePluginStore = defineStore('plugin', () => {
readyPlugins, readyPlugins,
errorPlugins, errorPlugins,
isLoading, isLoading,
error,
loadPlugins, loadPlugins,
selectPlugin, selectPlugin,
installPlugin,
grantPermissions,
enablePlugin, enablePlugin,
disablePlugin, disablePlugin,
uninstallPlugin, uninstallPlugin,
+15 -17
View File
@@ -1,13 +1,14 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo } from '@/contracts' import type { ProviderConfig, ModelInfo } from '@/contracts'
import { mockProviders, mockModels } from '@/services/providerService' import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
export const useProviderStore = defineStore('provider', () => { export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders) const providers = ref<ProviderConfig[]>(mockProviders)
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels) const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
const defaultProviderId = ref('mock-provider') const defaultProviderId = ref('mock')
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null)
const enabledProviders = computed(() => providers.value.filter((p) => p.enabled)) const enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
const defaultProvider = computed(() => const defaultProvider = computed(() =>
@@ -17,45 +18,41 @@ export const useProviderStore = defineStore('provider', () => {
async function loadProviders() { async function loadProviders() {
isLoading.value = true isLoading.value = true
try { try {
const { listProviders } = await import('@/services/providerService')
providers.value = await listProviders() providers.value = await listProviders()
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
async function loadModels(providerId: string) { async function loadModels(providerId: string) {
const { listModels } = await import('@/services/providerService')
modelsByProvider.value[providerId] = await listModels(providerId) modelsByProvider.value[providerId] = await listModels(providerId)
} }
async function addProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }) { async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) {
const newProvider: ProviderConfig = { const newProvider = await createProvider(data)
...data,
provider_id: `prov-${Date.now()}`,
}
providers.value.push(newProvider) providers.value.push(newProvider)
return newProvider return newProvider
} }
async function updateProvider(providerId: string, data: Partial<ProviderConfig>) { async function updateProvider(providerId: string, data: Partial<ProviderConfig>) {
const p = providers.value.find((p) => p.provider_id === providerId) const updated = await updateProviderRequest(providerId, data)
if (p) Object.assign(p, data) const index = providers.value.findIndex((provider) => provider.provider_id === providerId)
if (index >= 0) providers.value[index] = updated
} }
async function deleteProvider(providerId: string) { async function deleteProvider(providerId: string) {
await deleteProviderRequest(providerId)
const idx = providers.value.findIndex((p) => p.provider_id === providerId) const idx = providers.value.findIndex((p) => p.provider_id === providerId)
if (idx > -1) providers.value.splice(idx, 1) if (idx > -1) providers.value.splice(idx, 1)
delete modelsByProvider.value[providerId] delete modelsByProvider.value[providerId]
} }
async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> { async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> {
await new Promise((r) => setTimeout(r, 1000)) const result = await testProviderRequest(providerId)
const p = providers.value.find((p) => p.provider_id === providerId) return { success: result.success, latency_ms: result.latency_ms, error: result.error_message }
if (p?.enabled && p.has_credential) {
return { success: true, latency_ms: 230 + Math.floor(Math.random() * 200) }
}
return { success: false, error: '认证失败,请检查 API Key' }
} }
function setDefaultProvider(providerId: string) { function setDefaultProvider(providerId: string) {
@@ -69,6 +66,7 @@ export const useProviderStore = defineStore('provider', () => {
enabledProviders, enabledProviders,
defaultProvider, defaultProvider,
isLoading, isLoading,
error,
loadProviders, loadProviders,
loadModels, loadModels,
addProvider, addProvider,
+28 -5
View File
@@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { SearchResult, SearchRequest } from '@/contracts' import type { SearchResult, SearchRequest } from '@/contracts'
import * as searchService from '@/services/searchService' 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', () => { export const useSearchStore = defineStore('search', () => {
const query = ref('') const query = ref('')
@@ -19,16 +25,33 @@ export const useSearchStore = defineStore('search', () => {
mode.value = request.mode || 'hybrid' mode.value = request.mode || 'hybrid'
isSearching.value = true isSearching.value = true
error.value = null error.value = null
vectorUnavailable.value = false
try { try {
const resp = await searchService.searchMock(request.query, request.mode || 'hybrid') const resp = await searchService.search(request)
results.value = resp.results results.value = resp.results
total.value = resp.total total.value = resp.total
selectedIndex.value = 0 selectedIndex.value = 0
} catch (e: any) { } catch (reason) {
error.value = e.message || '搜索失败' const canFallback = mode.value !== 'fts' && reason instanceof ApiErrorClass && VECTOR_ERROR_CODES.has(reason.code)
results.value = [] if (canFallback) {
total.value = 0 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 { } finally {
isSearching.value = false isSearching.value = false
} }
+44 -14
View File
@@ -1,22 +1,26 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, watch } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts' import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { mockIndexStatus } from '@/services/indexService' import { mockIndexStatus } from '@/services/indexService'
import * as indexService from '@/services/indexService'
import * as systemService from '@/services/systemService'
export const useSettingsStore = defineStore('settings', () => { 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 // General
const restoreLastVault = ref(true) const restoreLastVault = ref(saved.restoreLastVault !== false)
const autoSaveInterval = ref(1500) const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
const language = ref<'zh-CN' | 'en'>('zh-CN') const language = ref<'zh-CN' | 'en'>(saved.language === 'en' ? 'en' : 'zh-CN')
const appVersion = ref('0.1.0') const appVersion = ref('0.1.0')
const aiCoreVersion = ref('0.1.0') const aiCoreVersion = ref('0.1.0')
// Editor // Editor
const defaultEditorMode = ref<'wysiwyg' | 'source'>('wysiwyg') const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
const editorFontSize = ref(15) const editorLineWidth = ref(typeof saved.editorLineWidth === 'number' ? saved.editorLineWidth : 80)
const editorLineHeight = ref(1.7) const spellCheck = ref(saved.spellCheck === true)
const editorLineWidth = ref(80)
const spellCheck = ref(false)
// AI Core // AI Core
const aiCoreStatus = ref<AiCoreStatus>('running') const aiCoreStatus = ref<AiCoreStatus>('running')
@@ -37,6 +41,28 @@ export const useSettingsStore = defineStore('settings', () => {
'network.request': 'confirm', 'network.request': 'confirm',
'secrets.use': '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) { function setAutoSaveInterval(ms: number) {
autoSaveInterval.value = ms autoSaveInterval.value = ms
@@ -63,9 +89,13 @@ export const useSettingsStore = defineStore('settings', () => {
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') { async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
indexStatus.value.status = 'indexing' indexStatus.value.status = 'indexing'
setTimeout(() => { try {
indexStatus.value.status = 'idle' await indexService.rebuildIndex(scope)
}, 3000) indexStatus.value = await indexService.getIndexStatus()
} catch (reason) {
indexStatus.value.status = 'error'
indexStatus.value.error = reason instanceof Error ? reason.message : '索引重建失败'
}
} }
return { return {
@@ -75,14 +105,14 @@ export const useSettingsStore = defineStore('settings', () => {
appVersion, appVersion,
aiCoreVersion, aiCoreVersion,
defaultEditorMode, defaultEditorMode,
editorFontSize,
editorLineHeight,
editorLineWidth, editorLineWidth,
spellCheck, spellCheck,
aiCoreStatus, aiCoreStatus,
aiCoreAddress, aiCoreAddress,
indexStatus, indexStatus,
permissionPolicy, permissionPolicy,
diagnosticsError,
loadDiagnostics,
setAutoSaveInterval, setAutoSaveInterval,
setDefaultEditorMode, setDefaultEditorMode,
setPermission, setPermission,
+24 -14
View File
@@ -1,12 +1,13 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Skill } from '@/contracts' import type { Skill } from '@/contracts'
import { mockSkills } from '@/services/skillService' import * as skillService from '@/services/skillService'
export const useSkillStore = defineStore('skill', () => { export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>(mockSkills) const skills = ref<Skill[]>(skillService.mockSkills)
const selectedSkillId = ref<string | null>(null) const selectedSkillId = ref<string | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedSkill = computed(() => const selectedSkill = computed(() =>
skills.value.find((s) => s.skill_id === selectedSkillId.value) || null skills.value.find((s) => s.skill_id === selectedSkillId.value) || null
@@ -19,8 +20,10 @@ export const useSkillStore = defineStore('skill', () => {
async function loadSkills() { async function loadSkills() {
isLoading.value = true isLoading.value = true
try { try {
const { listSkills } = await import('@/services/skillService') skills.value = await skillService.listSkills()
skills.value = await listSkills() error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Skill 加载失败'
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -30,23 +33,28 @@ export const useSkillStore = defineStore('skill', () => {
selectedSkillId.value = skillId 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) { async function enableSkill(skillId: string) {
const skill = skills.value.find((s) => s.skill_id === skillId) const updated = await skillService.enableSkill(skillId)
if (skill) { const index = skills.value.findIndex((skill) => skill.skill_id === skillId)
skill.enabled = true if (index >= 0) skills.value[index] = updated
skill.status = 'ready'
}
} }
async function disableSkill(skillId: string) { async function disableSkill(skillId: string) {
const skill = skills.value.find((s) => s.skill_id === skillId) const updated = await skillService.disableSkill(skillId)
if (skill) { const index = skills.value.findIndex((skill) => skill.skill_id === skillId)
skill.enabled = false if (index >= 0) skills.value[index] = updated
skill.status = 'disabled'
}
} }
async function uninstallSkill(skillId: string) { async function uninstallSkill(skillId: string) {
await skillService.uninstallSkill(skillId)
const idx = skills.value.findIndex((s) => s.skill_id === skillId) const idx = skills.value.findIndex((s) => s.skill_id === skillId)
if (idx > -1) skills.value.splice(idx, 1) if (idx > -1) skills.value.splice(idx, 1)
if (selectedSkillId.value === skillId) selectedSkillId.value = null if (selectedSkillId.value === skillId) selectedSkillId.value = null
@@ -60,8 +68,10 @@ export const useSkillStore = defineStore('skill', () => {
installedSkills, installedSkills,
readySkills, readySkills,
isLoading, isLoading,
error,
loadSkills, loadSkills,
selectSkill, selectSkill,
installSkill,
enableSkill, enableSkill,
disableSkill, disableSkill,
uninstallSkill, uninstallSkill,
+11 -17
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts' 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', () => { export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>(mockTasks) const tasks = ref<TaskItem[]>(mockTasks)
@@ -9,6 +9,7 @@ export const useTaskStore = defineStore('task', () => {
const filterPriority = ref<TaskPriority | 'all'>('all') const filterPriority = ref<TaskPriority | 'all'>('all')
const filterSource = ref<TaskSource | 'all'>('all') const filterSource = ref<TaskSource | 'all'>('all')
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null)
const filteredTasks = computed(() => { const filteredTasks = computed(() => {
return tasks.value.filter((t) => { return tasks.value.filter((t) => {
@@ -26,40 +27,32 @@ export const useTaskStore = defineStore('task', () => {
async function loadTasks() { async function loadTasks() {
isLoading.value = true isLoading.value = true
try { try {
const { listTasks } = await import('@/services/taskService')
const resp = await listTasks() const resp = await listTasks()
tasks.value = resp.items tasks.value = resp.items
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '任务加载失败'
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
async function createTask(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string; note_id?: string }) { async function createTask(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string; note_id?: string }) {
const newTask: TaskItem = { const newTask = await createTaskRequest(data)
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(),
}
tasks.value.unshift(newTask) tasks.value.unshift(newTask)
return 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) const task = tasks.value.find((t) => t.task_id === taskId)
if (task) { if (task) {
Object.assign(task, data) const updated = await updateTaskRequest(taskId, data)
task.updated_at = new Date().toISOString() Object.assign(task, updated, data)
} }
} }
async function deleteTask(taskId: string) { async function deleteTask(taskId: string) {
await deleteTaskRequest(taskId)
const idx = tasks.value.findIndex((t) => t.task_id === taskId) const idx = tasks.value.findIndex((t) => t.task_id === taskId)
if (idx > -1) tasks.value.splice(idx, 1) if (idx > -1) tasks.value.splice(idx, 1)
} }
@@ -78,6 +71,7 @@ export const useTaskStore = defineStore('task', () => {
inProgressTasks, inProgressTasks,
doneTasks, doneTasks,
isLoading, isLoading,
error,
loadTasks, loadTasks,
createTask, createTask,
updateTask, updateTask,
+25 -2
View File
@@ -14,6 +14,7 @@ export const useThemeStore = defineStore('theme', () => {
const fontEditorSize = ref(15) const fontEditorSize = ref(15)
const fontEditorFamily = ref('system-ui') const fontEditorFamily = ref('system-ui')
const lineHeight = ref(1.7) const lineHeight = ref(1.7)
let appearanceHydrated = false
const currentTheme = computed(() => const currentTheme = computed(() =>
themes.value.find((t) => t.theme_id === currentThemeId.value) || themes.value[0] themes.value.find((t) => t.theme_id === currentThemeId.value) || themes.value[0]
@@ -37,7 +38,18 @@ export const useThemeStore = defineStore('theme', () => {
} }
function initTheme() { function initTheme() {
const savedAppearance = localStorage.getItem('editor-appearance')
if (savedAppearance) {
try {
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number }
if (value.size) fontEditorSize.value = value.size
if (value.family) fontEditorFamily.value = value.family
if (value.lineHeight) lineHeight.value = value.lineHeight
} catch { localStorage.removeItem('editor-appearance') }
}
const saved = localStorage.getItem('theme') const saved = localStorage.getItem('theme')
appearanceHydrated = true
persistAppearance()
if (saved && themes.value.find((t) => t.theme_id === saved)) { if (saved && themes.value.find((t) => t.theme_id === saved)) {
applyTheme(saved) applyTheme(saved)
return return
@@ -57,13 +69,24 @@ export const useThemeStore = defineStore('theme', () => {
lineHeight.value = 1.7 lineHeight.value = 1.7
} }
const persistAppearance = () => localStorage.setItem('editor-appearance', JSON.stringify({
size: fontEditorSize.value, family: fontEditorFamily.value, lineHeight: lineHeight.value,
}))
watch(fontEditorSize, (v) => { watch(fontEditorSize, (v) => {
document.documentElement.style.setProperty('--font-editor-size', `${v}px`) document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
}) if (appearanceHydrated) persistAppearance()
}, { immediate: true })
watch(lineHeight, (v) => { watch(lineHeight, (v) => {
document.documentElement.style.setProperty('--font-editor-line-height', String(v)) document.documentElement.style.setProperty('--font-editor-line-height', String(v))
}) if (appearanceHydrated) persistAppearance()
}, { immediate: true })
watch(fontEditorFamily, (v) => {
document.documentElement.style.setProperty('--font-editor-sans', v)
if (appearanceHydrated) persistAppearance()
}, { immediate: true })
return { return {
themes, themes,
+34
View File
@@ -69,6 +69,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
vaultName.value = info.name vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree() fileTree.value = await workspaceService.getFileTree()
hasVault.value = true hasVault.value = true
localStorage.setItem('last-vault-path', info.path)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -82,12 +83,17 @@ export const useWorkspaceStore = defineStore('workspace', () => {
vaultName.value = info.name vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree() fileTree.value = await workspaceService.getFileTree()
hasVault.value = true hasVault.value = true
localStorage.setItem('last-vault-path', info.path)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
function addFileToTree(parentPath: string, file: FileNode) { function addFileToTree(parentPath: string, file: FileNode) {
if (parentPath === '/' || parentPath === '') {
fileTree.value.push(file)
return
}
const parent = findNodeByPath(fileTree.value, parentPath) const parent = findNodeByPath(fileTree.value, parentPath)
if (parent?.children) { if (parent?.children) {
parent.children.push(file) parent.children.push(file)
@@ -109,6 +115,32 @@ export const useWorkspaceStore = defineStore('workspace', () => {
remove(fileTree.value) remove(fileTree.value)
} }
function renamePath(oldPath: string, newPath: string, newName: string) {
const node = findNodeByPath(fileTree.value, oldPath)
if (!node) return
const updateNodePath = (current: FileNode) => {
if (current.path === oldPath) current.name = newName
if (current.path === oldPath || current.path.startsWith(`${oldPath}/`)) {
current.path = `${newPath}${current.path.slice(oldPath.length)}`
}
current.children?.forEach(updateNodePath)
}
updateNodePath(node)
openFiles.value = openFiles.value.map((path) =>
path === oldPath || path.startsWith(`${oldPath}/`) ? `${newPath}${path.slice(oldPath.length)}` : path
)
if (activeFilePath.value && (activeFilePath.value === oldPath || activeFilePath.value.startsWith(`${oldPath}/`))) {
activeFilePath.value = `${newPath}${activeFilePath.value.slice(oldPath.length)}`
}
}
function closePath(path: string) {
const activeWasRemoved = Boolean(activeFilePath.value && (activeFilePath.value === path || activeFilePath.value.startsWith(`${path}/`)))
openFiles.value = openFiles.value.filter((openPath) => openPath !== path && !openPath.startsWith(`${path}/`))
if (activeWasRemoved) activeFilePath.value = openFiles.value.at(-1) ?? null
return activeWasRemoved
}
return { return {
vaultPath, vaultPath,
vaultName, vaultName,
@@ -128,5 +160,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
createVault, createVault,
addFileToTree, addFileToTree,
removeFromTree, removeFromTree,
renamePath,
closePath,
} }
}) })
+108
View File
@@ -0,0 +1,108 @@
.feature-page {
height: 100%;
overflow: auto;
padding: var(--space-2xl);
background: var(--color-background-primary);
user-select: text;
}
.feature-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
.feature-header h1 { font-size: var(--font-size-3xl); line-height: 1.2; }
.feature-header p { margin-top: var(--space-xs); color: var(--color-text-secondary); }
.feature-actions, .inline-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-lg); }
.split-view { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(360px, 1.7fr); gap: var(--space-lg); min-height: 0; }
.panel, .item-card {
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
box-shadow: var(--shadow-sm);
}
.panel { padding: var(--space-xl); }
.item-card { padding: var(--space-lg); transition: border-color var(--motion-fast), transform var(--motion-fast); }
.item-card:hover { border-color: var(--color-accent-secondary); }
.item-card.selected { border-color: var(--color-accent-primary); box-shadow: 0 0 0 2px var(--color-accent-soft); }
.panel-title { margin-bottom: var(--space-md); font-size: var(--font-size-xl); }
.muted { color: var(--color-text-secondary); }
.subtle { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
.button-primary, .button-secondary, .button-danger, .icon-button {
min-height: 34px;
padding: 0 var(--space-md);
border: 1px solid transparent;
border-radius: var(--radius-md);
font-weight: 600;
}
.button-primary { background: var(--color-accent-primary); color: var(--color-text-inverse); }
.button-primary:hover { background: var(--color-accent-primary-hover); }
.button-secondary { border-color: var(--color-border-default); background: var(--color-surface-primary); }
.button-secondary:hover, .icon-button:hover { background: var(--color-background-hover); }
.button-danger { background: var(--color-error-soft); color: var(--color-error); }
button:disabled { cursor: not-allowed; opacity: .55; }
.field { display: grid; gap: var(--space-xs); }
.field label { color: var(--color-text-secondary); font-size: var(--font-size-sm); font-weight: 600; }
.input, .select, .textarea {
width: 100%;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
outline: none;
background: var(--color-background-primary);
color: var(--color-text-primary);
}
.input, .select { height: 36px; padding: 0 var(--space-md); }
.textarea { min-height: 100px; padding: var(--space-md); resize: vertical; }
.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 2px var(--color-accent-soft); }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
.badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 var(--space-sm);
border-radius: var(--radius-full);
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 600;
}
.badge.success { background: var(--color-success-soft); color: var(--color-success); }
.badge.warning { background: var(--color-warning-soft); color: var(--color-warning); }
.badge.error { background: var(--color-error-soft); color: var(--color-error); }
.badge.info { background: var(--color-info-soft); color: var(--color-info); }
.tag-list { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
.empty-state { display: grid; place-items: center; min-height: 220px; padding: var(--space-2xl); text-align: center; color: var(--color-text-secondary); }
.empty-state strong { display: block; margin-bottom: var(--space-xs); color: var(--color-text-primary); font-size: var(--font-size-xl); }
.error-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); }
.notice-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-info-soft); color: var(--color-info); }
.sidebar-panel { padding: var(--space-md); }
.sidebar-panel .input, .sidebar-panel .select { margin-bottom: var(--space-sm); }
.sidebar-list { display: grid; gap: var(--space-xs); }
.sidebar-list-item { padding: var(--space-sm); border-radius: var(--radius-md); cursor: pointer; }
.sidebar-list-item:hover, .sidebar-list-item.active { background: var(--color-background-hover); }
.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); }
.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-xl); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
.modal h2 { margin-bottom: var(--space-lg); }
.modal form { display: grid; gap: var(--space-md); }
.settings-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-bottom: var(--space-xl); border-bottom: 1px solid var(--color-border-default); }
.settings-nav button { padding: var(--space-sm) var(--space-md); border-bottom: 2px solid transparent; color: var(--color-text-secondary); }
.settings-nav button.active { border-color: var(--color-accent-primary); color: var(--color-accent-primary); }
@media (max-width: 900px) {
.feature-page { padding: var(--space-lg); }
.split-view { grid-template-columns: 1fr; }
}
+20
View File
@@ -158,6 +158,26 @@
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.6); --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.6);
} }
[data-theme='sepia'] {
--color-background-primary: #fbf3df;
--color-background-secondary: #f4e8ca;
--color-background-tertiary: #eadbb8;
--color-background-hover: #eee0bf;
--color-background-active: #e3d1aa;
--color-surface-primary: #fff8e8;
--color-surface-secondary: #f8edd3;
--color-surface-elevated: #fffaf0;
--color-text-primary: #40372b;
--color-text-secondary: #746653;
--color-text-tertiary: #9a8a72;
--color-border-default: #ddcfad;
--color-border-subtle: #eadfc4;
--color-accent-primary: #8a5b32;
--color-accent-primary-hover: #704724;
--color-accent-soft: #edddbd;
--color-text-link: #7b512e;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
+9
View File
@@ -0,0 +1,9 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
marked.setOptions({ gfm: true, breaks: true })
export function renderMarkdown(source: string): string {
const html = marked.parse(source, { async: false }) as string
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
}
+4
View File
@@ -11,6 +11,10 @@
"esModuleInterop": true, "esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"], "types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"noEmit": true "noEmit": true
}, },
"include": ["src/**/*.ts", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.vue"]