fix(frontend): 修复合并审阅发现的构建与契约问题
恢复 Vue TypeScript 生产构建,补齐可运行页面壳子,并修复文件树与 SSE 状态问题。 按 FastAPI Wire Contract 统一 Service DTO 映射,同时补充前端开发说明和问题修复复盘。
This commit is contained in:
@@ -0,0 +1,257 @@
|
|||||||
|
# 前端合并审阅问题与修复复盘
|
||||||
|
|
||||||
|
> 审阅与修复日期: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-01:TypeScript 与生产构建不可用
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
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-04:SSE 跨网络分片丢失事件
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
旧解析器把 `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-07:Chat 使用模拟流式输出
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
Chat Store 已经存在 SSE Service,但发送消息后仍通过 `setInterval` 拼接固定文本,没有调用后端。
|
||||||
|
|
||||||
|
### 后果
|
||||||
|
|
||||||
|
- 后端 Provider、RAG、错误事件和取消无法通过前端验证;
|
||||||
|
- 页面看似工作,实际没有形成前后端链路;
|
||||||
|
- SSE 解析缺陷长期被 Mock 掩盖。
|
||||||
|
|
||||||
|
### 解决思路与方案
|
||||||
|
|
||||||
|
保留初始展示数据,但用户主动发送消息时调用真实 `/api/chat`。请求使用当前 Provider、Model、RAG 开关和消息历史;TextDelta 追加到 Assistant Message;Error、网络失败、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. 当前边界与后续事项
|
||||||
|
|
||||||
|
本轮修复解决了前端壳子的工程正确性和接口边界,不代表全部前端页面已经完成。后续仍需要:
|
||||||
|
|
||||||
|
- 实现 Search、Chat、Agent、Task、Skill、Plugin、Theme 和 Settings 页面;
|
||||||
|
- 为 Service DTO 映射增加自动化契约测试;
|
||||||
|
- 为 SSE Parser 增加跨 Chunk 单元测试;
|
||||||
|
- 用 Tauri Command 替换 Mock Workspace Service;
|
||||||
|
- 完成 Citation 定位、Agent Permission Dialog 和 Trace 可视化;
|
||||||
|
- 在 CI 中加入前端构建和后端测试两个必需检查。
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# 前端壳子与接口层开发说明
|
||||||
|
|
||||||
|
> 更新日期: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、Chat、Agent、Task、Skill、Plugin、Theme 和 Settings 已保留稳定路由,但除公共 Store 与 Service 外,具体业务页面仍待后续实现。占位页用于保证主分支可构建、导航目标可识别,不代表对应功能页面已经验收。
|
||||||
|
|
||||||
|
## 2. 目录与职责
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/src/
|
||||||
|
├── components/common/ App Shell 与公共导航组件
|
||||||
|
├── contracts/index.ts UI View Model 与 FastAPI Wire DTO
|
||||||
|
├── features/common/ 未实现功能的统一占位页
|
||||||
|
├── 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。尚未完成的页面统一加载 `PlaceholderView.vue`,后续开发时应逐个替换为真实页面组件,不要在路由中提前引用尚未提交的文件。
|
||||||
|
|
||||||
|
## 4. Workspace 与编辑器
|
||||||
|
|
||||||
|
Workspace 当前由以下组件构成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WorkspaceView
|
||||||
|
├── EditorHeader
|
||||||
|
└── EditorPane
|
||||||
|
|
||||||
|
SecondarySidebar
|
||||||
|
└── FileTreePanel
|
||||||
|
└── FileTreeNode(递归)
|
||||||
|
```
|
||||||
|
|
||||||
|
文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。
|
||||||
|
|
||||||
|
当前 `workspaceService` 仍是 Web 开发模式下的 Mock Adapter。保存、重命名和删除只保留调用边界,尚未接入 Tauri 文件系统命令。进入桌面端阶段后,应替换 Service 内部实现,不改变 Component 和 Store 的调用方式。
|
||||||
|
|
||||||
|
## 5. 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。
|
||||||
|
|
||||||
|
## 6. 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 环境和启动
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 当前验证基线
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm build passed
|
||||||
|
uv run pytest 62 passed
|
||||||
|
preview smoke HTTP 200
|
||||||
|
git diff --check passed
|
||||||
|
```
|
||||||
|
|
||||||
|
后端测试出现过一次 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 62 项测试结果,也不涉及产品代码。
|
||||||
|
|
||||||
|
## 9. 后续开发要求
|
||||||
|
|
||||||
|
- 新页面文件与路由修改必须在同一提交中出现;
|
||||||
|
- 新增或修改接口时同步更新 FastAPI DTO、Service 映射和接口文档;
|
||||||
|
- 不允许用 `as any` 或错误返回类型掩盖 Contract 差异;
|
||||||
|
- SSE 相关变更需要覆盖跨 Chunk、CRLF、多行 data、终态事件和取消;
|
||||||
|
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
|
||||||
|
- Search、Agent、Skill、Plugin 等占位页应按功能逐个替换,不一次提交大量空页面。
|
||||||
@@ -26,7 +26,7 @@ const router = useRouter()
|
|||||||
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'
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import FileTreePanel from '@/features/workspace/FileTreePanel.vue'
|
import FileTreePanel from '@/features/workspace/FileTreePanel.vue'
|
||||||
import ConversationListPanel from '@/features/chat/ConversationListPanel.vue'
|
|
||||||
import RunListPanel from '@/features/agent/RunListPanel.vue'
|
|
||||||
import SearchFiltersPanel from '@/features/search/SearchFiltersPanel.vue'
|
|
||||||
import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue'
|
|
||||||
import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -27,7 +22,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>
|
||||||
@@ -41,11 +36,7 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === '
|
|||||||
</div>
|
</div>
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<FileTreePanel v-if="component === 'file-tree'" />
|
<FileTreePanel v-if="component === 'file-tree'" />
|
||||||
<ConversationListPanel v-else-if="component === 'conversation-list'" />
|
<p v-else class="sidebar-placeholder">该功能将在对应页面实现时补充。</p>
|
||||||
<RunListPanel v-else-if="component === 'run-list'" />
|
|
||||||
<SearchFiltersPanel v-else-if="component === 'search-filters'" />
|
|
||||||
<TaskFiltersPanel v-else-if="component === 'task-filters'" />
|
|
||||||
<ExtensionListPanel v-else-if="component === 'extension-list'" />
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@@ -109,4 +100,10 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === '
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-placeholder {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
|
|||||||
<span class="status-dot" style="background: var(--color-success)" />
|
<span class="status-dot" style="background: var(--color-success)" />
|
||||||
索引就绪
|
索引就绪
|
||||||
</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>
|
||||||
|
|||||||
@@ -247,15 +247,15 @@ export interface Plugin {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
permissions: string[]
|
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 +346,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 +362,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const title = computed(() => String(route.meta.title ?? '功能开发中'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="placeholder-view">
|
||||||
|
<div>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>基础路由已经就绪,具体页面将在后续功能开发中实现。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.placeholder-view {
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-view div {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-view h1 {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
|
||||||
|
const editorStore = useEditorStore()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="editor-header">
|
||||||
|
<strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong>
|
||||||
|
<span>{{ editorStore.saveStatus }}</span>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
|
const editorStore = useEditorStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
class="editor-pane"
|
||||||
|
:value="editorStore.content"
|
||||||
|
spellcheck="false"
|
||||||
|
@input="editorStore.updateContent(($event.target as HTMLTextAreaElement).value); editorStore.scheduleAutoSave()"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -1,406 +1,117 @@
|
|||||||
<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)
|
||||||
|
workspaceStore.openFile(file.path)
|
||||||
|
await editorStore.loadFile(file.path)
|
||||||
|
await router.push('/workspace')
|
||||||
|
} else {
|
||||||
|
const folder = await workspaceService.createFolder(parentPath.value, rawName)
|
||||||
|
workspaceStore.addFileToTree(parentPath.value, folder)
|
||||||
}
|
}
|
||||||
|
newItemType.value = null
|
||||||
|
newItemName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNode(node: FileNode) {
|
||||||
|
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
|
||||||
workspaceStore.openFile(node.path)
|
workspaceStore.openFile(node.path)
|
||||||
await editorStore.loadFile(node.path)
|
await editorStore.loadFile(node.path)
|
||||||
router.push('/workspace')
|
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)
|
await workspaceService.renameFile(node.path, newName)
|
||||||
await editorStore.loadFile(file.path)
|
node.name = newName
|
||||||
showNewFileInput.value = false
|
|
||||||
newFileName.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFolder() {
|
|
||||||
if (!newFolderName.value.trim()) return
|
|
||||||
const folder = await workspaceService.createFolder(newFileParentPath.value || '/', newFolderName.value)
|
|
||||||
workspaceStore.addFileToTree(newFileParentPath.value || '/', folder)
|
|
||||||
showNewFolderInput.value = false
|
|
||||||
newFolderName.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function onContextMenu(e: MouseEvent, node: FileNode) {
|
|
||||||
e.preventDefault()
|
|
||||||
contextMenuPath.value = node.path
|
|
||||||
contextMenuPos.value = { x: e.clientX, y: e.clientY }
|
|
||||||
showContextMenu.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeContextMenu() {
|
|
||||||
showContextMenu.value = false
|
|
||||||
contextMenuPath.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRename(node: FileNode) {
|
|
||||||
renamingPath.value = node.path
|
|
||||||
renameValue.value = node.name
|
|
||||||
showContextMenu.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishRename(node: FileNode) {
|
|
||||||
if (renameValue.value && renameValue.value !== node.name) {
|
|
||||||
await workspaceService.renameFile(node.path, renameValue.value)
|
|
||||||
node.name = renameValue.value
|
|
||||||
}
|
}
|
||||||
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') {
|
workspaceStore.removeFromTree(node.path)
|
||||||
workspaceStore.closeFile(node.path)
|
if (node.type === 'file') workspaceStore.closeFile(node.path)
|
||||||
}
|
closeContextMenu()
|
||||||
}
|
|
||||||
showContextMenu.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import { useWorkspaceStore } from '@/stores/workspace'
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import PlaceholderView from '@/features/common/PlaceholderView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -17,59 +18,50 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/search',
|
path: '/search',
|
||||||
name: 'search',
|
name: 'search',
|
||||||
component: () => import('@/features/search/SearchView.vue'),
|
component: PlaceholderView,
|
||||||
meta: { title: '搜索', requiresVault: true },
|
meta: { title: '搜索', requiresVault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/chat',
|
path: '/chat',
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
component: () => import('@/features/chat/ChatView.vue'),
|
component: PlaceholderView,
|
||||||
meta: { title: 'AI 对话', requiresVault: true },
|
meta: { title: 'AI 对话', requiresVault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/agent/runs/:runId?',
|
path: '/agent/runs/:runId?',
|
||||||
name: 'agent',
|
name: 'agent',
|
||||||
component: () => import('@/features/agent/AgentView.vue'),
|
component: PlaceholderView,
|
||||||
meta: { title: 'Agent Trace', requiresVault: true },
|
meta: { title: 'Agent Trace', requiresVault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tasks',
|
path: '/tasks',
|
||||||
name: 'tasks',
|
name: 'tasks',
|
||||||
component: () => import('@/features/tasks/TasksView.vue'),
|
component: PlaceholderView,
|
||||||
meta: { title: '任务', requiresVault: true },
|
meta: { title: '任务', requiresVault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/extensions/skills',
|
path: '/extensions/skills',
|
||||||
name: 'skills',
|
name: 'skills',
|
||||||
component: () => import('@/features/skills/SkillsView.vue'),
|
component: PlaceholderView,
|
||||||
meta: { title: 'Skill 管理', requiresVault: true },
|
meta: { title: 'Skill 管理', requiresVault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/extensions/plugins',
|
path: '/extensions/plugins',
|
||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
component: () => import('@/features/plugins/PluginsView.vue'),
|
component: PlaceholderView,
|
||||||
meta: { title: 'Plugin 管理', requiresVault: true },
|
meta: { title: 'Plugin 管理', requiresVault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/themes',
|
path: '/themes',
|
||||||
name: 'themes',
|
name: 'themes',
|
||||||
component: () => import('@/features/themes/ThemesView.vue'),
|
component: PlaceholderView,
|
||||||
meta: { title: '主题管理', requiresVault: true },
|
meta: { title: '主题管理', requiresVault: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('@/features/settings/SettingsView.vue'),
|
component: PlaceholderView,
|
||||||
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') },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,59 @@
|
|||||||
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: 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 +108,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 +142,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',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,62 @@
|
|||||||
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' | 'priority' | 'due_date'>>
|
||||||
): 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,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,13 +209,13 @@ export function saveFileContent(filePath: string, content: string): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()}`
|
||||||
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: [] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
event: 'RunStarted',
|
event: 'RunStarted',
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
run_id: run.run_id,
|
run_id: run.run_id,
|
||||||
data: { task: request.task },
|
data: { input: request.input },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}]
|
}]
|
||||||
isRunning.value = true
|
isRunning.value = true
|
||||||
@@ -112,9 +112,10 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
isRunning.value = false
|
isRunning.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-27
@@ -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,29 @@ 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 === '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)
|
isStreaming.value = false
|
||||||
aiMsg.content += chunk
|
sseClient = null
|
||||||
i += 3
|
},
|
||||||
}, 20)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopGeneration() {
|
function stopGeneration() {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
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 { listModels, listProviders, mockProviders, mockModels } 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 enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
|
const enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
|
||||||
@@ -17,7 +17,6 @@ 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()
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -25,11 +24,10 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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: ProviderConfig = {
|
||||||
...data,
|
...data,
|
||||||
provider_id: `prov-${Date.now()}`,
|
provider_id: `prov-${Date.now()}`,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const useSearchStore = defineStore('search', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user