refactor: update knowledge document types and API client interfaces
- Refactored types in `Knowledge/types.ts` to introduce new interfaces for document handling. - Added `KnowledgeDocItem`, `KnowledgeDocsListResponse`, `KnowledgeDocsUploadInput`, `KnowledgeDocsUploadResponse`, and `KnowledgeDocsDeleteResponse` for better structure and clarity. - Updated `KnowledgeDocsApiClient` interface to include methods for listing, uploading, and deleting documents. fix: replace deprecated icons in AccountSettingsPanel and SettingMenu - Replaced `CheckCircleIcon` with `CheckCircle` from `lucide-react` in `AccountSettingsPanel.tsx`. - Updated `SettingMenu.tsx` to use `Settings` and `User` from `lucide-react` instead of custom icons. test: add tests for knowledge docs routes and KnowledgePage - Created `knowledge-docs-routes.test.ts` to test API routes for listing, uploading, and deleting knowledge documents. - Added `knowledge-page.test.tsx` to test the rendering and functionality of the KnowledgePage component, including document loading and deletion.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
require("electron");
|
||||
require("./main-lgujyV0Y.js");
|
||||
require("./main-UxAT51-x.js");
|
||||
require("electron-squirrel-startup");
|
||||
require("electron-log");
|
||||
require("bytenode");
|
||||
|
||||
555
docs/Knowledge-Docs-Refactor-Plan.md
Normal file
555
docs/Knowledge-Docs-Refactor-Plan.md
Normal file
@@ -0,0 +1,555 @@
|
||||
# Knowledge 页面文档管理重构计划
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本次重构不是继续扩展当前 `Knowledge/index.tsx` 里的“房型管理 / 事件管理”演示逻辑,也不是在旧页面里增加一个“文档 Tab”。
|
||||
|
||||
本次方案明确采用替换式重构:
|
||||
|
||||
1. 直接抛弃 `Knowledge` 现有功能
|
||||
2. 直接删除 `roomType` / `event` 双业务模型
|
||||
3. 直接把 `Knowledge` 页面改造成面向本地文档目录的管理页
|
||||
4. 不做旧交互兼容,不保留旧状态,不做混合页过渡
|
||||
|
||||
产品需求:
|
||||
|
||||
1. 提供上传文件按钮
|
||||
2. 上传文件保存到 `zn-ai/docs` 目录
|
||||
3. 文件列表展示:
|
||||
- 文件名称
|
||||
- 文件大小
|
||||
- 修改日期
|
||||
- 文件类型
|
||||
4. 每条文件支持删除
|
||||
5. 视觉 UI 沿用当前 `Knowledge` 页和站内已有风格
|
||||
|
||||
一句话目标:
|
||||
|
||||
- 把 `Knowledge` 页面从“重业务 demo 页”直接替换成“本地 docs 目录文件管理页”。
|
||||
|
||||
## 2. 当前现状
|
||||
|
||||
## 2.1 渲染层现状
|
||||
|
||||
当前 [`src/pages/Knowledge/index.tsx`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/src/pages/Knowledge/index.tsx:1) 是一个单文件重页面,主要问题有:
|
||||
|
||||
1. 页面职责和产品目标不一致
|
||||
- 当前是 `roomType` / `event` 双 Tab
|
||||
- 上传行为只是 `ImageManagerDialog` 中的本地临时对象 URL
|
||||
- 没有真正写入主进程或磁盘
|
||||
2. 页面状态过重
|
||||
- 房型列表、事件列表、图片管理、反馈提示都堆在一个文件里
|
||||
3. 当前上传能力只是前端临时态
|
||||
- 使用 `File` + `URL.createObjectURL`
|
||||
- 刷新后数据丢失
|
||||
- 没有真正的文件列表 API
|
||||
|
||||
结论:
|
||||
|
||||
- 当前 `Knowledge` 页需要被整体替换,而不是增量补按钮。
|
||||
|
||||
## 2.2 主进程现状
|
||||
|
||||
当前主进程已经有本地 Host API 分发能力:
|
||||
|
||||
- [`electron/main.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/electron/main.ts:1)
|
||||
- [`electron/api/router.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/electron/api/router.ts:1)
|
||||
|
||||
其中已有可复用的文件路由:
|
||||
|
||||
- [`electron/api/routes/files.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/electron/api/routes/files.ts:1)
|
||||
|
||||
已有能力主要是:
|
||||
|
||||
1. `/api/files/stage-buffer`
|
||||
- 接收 `base64`
|
||||
- 写入 `userData/openclaw-media/outbound`
|
||||
2. `/api/files/stage-paths`
|
||||
- 拷贝已有路径文件到暂存目录
|
||||
|
||||
这说明仓库已经接受“渲染层读文件 -> base64 -> Host API 落盘”的模式,Knowledge 重构可以沿用这条链路。
|
||||
|
||||
## 2.3 风格现状
|
||||
|
||||
当前 `Knowledge` 页可复用的视觉资产很明确:
|
||||
|
||||
1. 顶部大标题 + 副标题
|
||||
2. 圆角胶囊按钮
|
||||
3. 圆角卡片 / 表格容器
|
||||
4. 浅色背景 + serif 标题
|
||||
5. 轻量 feedback 提示条
|
||||
|
||||
所以 UI 不需要另起视觉体系,但只复用视觉语言,不复用旧业务结构。
|
||||
|
||||
## 2.4 迁移策略结论
|
||||
|
||||
这次不采用“兼容式迁移”,而采用“一刀切替换”:
|
||||
|
||||
1. `/knowledge` 路由保持不变
|
||||
2. `Knowledge/index.tsx` 内部逻辑整体重写
|
||||
3. 旧的 `roomType` / `event` / `ImageManagerDialog` 相关状态、组件、文案直接移除
|
||||
4. 不保留旧功能入口
|
||||
5. 不做旧数据迁移
|
||||
|
||||
## 3. 重构范围
|
||||
|
||||
## 3.1 渲染层范围
|
||||
|
||||
建议重构目标文件:
|
||||
|
||||
1. [`src/pages/Knowledge/index.tsx`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/src/pages/Knowledge/index.tsx:1)
|
||||
- 从单文件重业务页直接替换成 docs 文件管理页
|
||||
2. [`src/pages/Knowledge/copy.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/src/pages/Knowledge/copy.ts:1)
|
||||
- 新增文档管理相关文案
|
||||
3. [`src/pages/Knowledge/types.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/src/pages/Knowledge/types.ts:1)
|
||||
- 删除旧 room/event 类型
|
||||
- 视情况改成 docs 列表类型,或直接移除
|
||||
4. 新增 `src/pages/Knowledge/components/*`
|
||||
- `KnowledgeDocsToolbar.tsx`
|
||||
- `KnowledgeDocsTable.tsx`
|
||||
- `KnowledgeDocsEmpty.tsx`
|
||||
- `KnowledgeDocDeleteButton.tsx`
|
||||
|
||||
## 3.2 主进程范围
|
||||
|
||||
建议新增 / 修改:
|
||||
|
||||
1. 新增 `electron/utils/knowledge-docs.ts`
|
||||
- docs 根目录解析
|
||||
- 文件列表读取
|
||||
- 上传写入
|
||||
- 删除
|
||||
- 文件名安全校验
|
||||
2. 新增 `electron/api/routes/knowledge.ts`
|
||||
- 暴露 Knowledge 专用 Host API
|
||||
3. 修改 `electron/api/router.ts`
|
||||
- 注册 `handleKnowledgeRoutes`
|
||||
|
||||
## 3.3 测试范围
|
||||
|
||||
建议新增:
|
||||
|
||||
1. `tests/knowledge-docs-routes.test.ts`
|
||||
2. `tests/knowledge-page.test.tsx`
|
||||
|
||||
## 4. 推荐目标结构
|
||||
|
||||
## 4.1 页面 IA
|
||||
|
||||
重构后的 `Knowledge` 页面建议只有一层核心任务流:
|
||||
|
||||
1. 顶部标题区
|
||||
- `Knowledge Management`
|
||||
- 副标题
|
||||
- `刷新`
|
||||
- `上传文件`
|
||||
2. 列表区
|
||||
- 文件总数卡片或摘要
|
||||
- 文档表格
|
||||
3. 空态 / 错误态
|
||||
4. 删除确认
|
||||
|
||||
明确删除:
|
||||
|
||||
1. `roomType` / `event` Tab
|
||||
2. `EventDialog`
|
||||
3. `ImageManagerDialog`
|
||||
4. 事件图片本地临时态
|
||||
5. 房型映射查询能力
|
||||
|
||||
## 4.2 前端组件拆分建议
|
||||
|
||||
推荐拆法:
|
||||
|
||||
1. `KnowledgePage`
|
||||
- 页面壳
|
||||
- 拉取列表
|
||||
- 调度上传 / 删除 / 刷新
|
||||
2. `KnowledgeDocsToolbar`
|
||||
- 上传按钮
|
||||
- 刷新按钮
|
||||
- 简要统计
|
||||
3. `KnowledgeDocsTable`
|
||||
- 表头
|
||||
- 行渲染
|
||||
4. `KnowledgeDocsEmpty`
|
||||
- 空态
|
||||
5. `useKnowledgeCopy`
|
||||
- 补齐新文案,不另起 i18n 体系
|
||||
|
||||
注意:
|
||||
|
||||
- 这里的组件拆分是为了替代旧页面,不是为了与旧功能并存。
|
||||
|
||||
## 4.3 主进程模块拆分建议
|
||||
|
||||
推荐拆法:
|
||||
|
||||
1. `knowledge-docs.ts`
|
||||
- `getKnowledgeDocsDir()`
|
||||
- `listKnowledgeDocsFiles()`
|
||||
- `saveKnowledgeDocsFile()`
|
||||
- `deleteKnowledgeDocsFile()`
|
||||
2. `routes/knowledge.ts`
|
||||
- `GET /api/knowledge/docs`
|
||||
- `POST /api/knowledge/docs`
|
||||
- `DELETE /api/knowledge/docs/:name`
|
||||
|
||||
这样可以避免把 Knowledge 领域继续塞进通用 `files.ts`,后续如果增加“重命名 / 下载 / 打开目录”,也更好扩展。
|
||||
|
||||
## 5. 推荐数据契约
|
||||
|
||||
## 5.1 列表项结构
|
||||
|
||||
建议前端使用以下结构:
|
||||
|
||||
```ts
|
||||
interface KnowledgeDocItem {
|
||||
name: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
type: string;
|
||||
}
|
||||
```
|
||||
|
||||
字段解释:
|
||||
|
||||
1. `name`
|
||||
- 文件名,作为主键展示
|
||||
2. `size`
|
||||
- 字节数,前端转成 `KB / MB`
|
||||
3. `modifiedAt`
|
||||
- ISO 时间字符串
|
||||
4. `type`
|
||||
- 优先用扩展名,例如 `md` / `pdf` / `json`
|
||||
- 无扩展名时回退为 `unknown`
|
||||
|
||||
## 5.2 Host API 设计
|
||||
|
||||
推荐 API:
|
||||
|
||||
### `GET /api/knowledge/docs`
|
||||
|
||||
返回:
|
||||
|
||||
```ts
|
||||
{
|
||||
success: true,
|
||||
files: KnowledgeDocItem[]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/knowledge/docs`
|
||||
|
||||
请求体:
|
||||
|
||||
```ts
|
||||
{
|
||||
fileName: string;
|
||||
base64: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```ts
|
||||
{
|
||||
success: true,
|
||||
file: KnowledgeDocItem
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/knowledge/docs/:name`
|
||||
|
||||
返回:
|
||||
|
||||
```ts
|
||||
{
|
||||
success: true
|
||||
}
|
||||
```
|
||||
|
||||
## 5.3 上传链路建议
|
||||
|
||||
推荐复用当前 chat store 已采用的模式:
|
||||
|
||||
1. 渲染层通过 `input[type=file]` 选中文件
|
||||
2. `FileReader.readAsDataURL`
|
||||
3. 取出 `base64`
|
||||
4. 调用 `POST /api/knowledge/docs`
|
||||
5. 主进程写入 `zn-ai/docs`
|
||||
6. 刷新列表
|
||||
|
||||
原因:
|
||||
|
||||
1. 与现有 [`src/stores/chat.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/src/stores/chat.ts:943) 的上传思路一致
|
||||
2. 不依赖浏览器侧暴露绝对路径
|
||||
3. 更适配现在的 `hostapi:fetch` JSON body 结构
|
||||
|
||||
## 6. 文件系统策略
|
||||
|
||||
## 6.1 目标目录
|
||||
|
||||
产品要求是写入 `zn-ai/docs`,建议在开发阶段明确采用仓库目录:
|
||||
|
||||
```ts
|
||||
path.join(process.cwd(), 'docs')
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
1. 这满足当前本地开发诉求
|
||||
2. 但在打包态里,`app.getAppPath()` 目录通常不适合作为可写目录
|
||||
|
||||
所以建议把这条约束写死在 Phase 0 决策里:
|
||||
|
||||
1. 当前版本按“开发工作区 docs 目录”实现
|
||||
2. 若未来进入打包态,再增加 fallback:
|
||||
- `userData/knowledge-docs`
|
||||
|
||||
## 6.2 安全约束
|
||||
|
||||
主进程必须加的边界:
|
||||
|
||||
1. 禁止路径穿越
|
||||
- 只允许文件名,不允许相对路径
|
||||
2. 删除时二次校验目标文件真实路径仍在 `docs` 根目录内
|
||||
3. 文件名规范化
|
||||
- 去掉控制字符
|
||||
- 过滤危险分隔符
|
||||
4. 默认不覆盖已有文件
|
||||
- 推荐同名自动追加后缀
|
||||
- 例如 `foo.md` -> `foo-1.md`
|
||||
|
||||
## 6.3 排序与展示规则
|
||||
|
||||
建议默认按 `modifiedAt` 倒序展示,让最新上传或最近修改的文件排最前。
|
||||
|
||||
## 7. 具体实施计划
|
||||
|
||||
## Phase 0:替换边界冻结
|
||||
|
||||
目标:
|
||||
|
||||
- 先冻结“替换式重构”的边界,避免后续又回到兼容旧功能的路线。
|
||||
|
||||
产出:
|
||||
|
||||
1. 确定页面只保留“文档上传 + 列表 + 删除”
|
||||
2. 确定 `docs` 目录路径策略
|
||||
3. 确定列表项结构
|
||||
4. 确定上传冲突策略
|
||||
5. 确定旧 `roomType/event` 代码直接下线
|
||||
|
||||
完成标准:
|
||||
|
||||
- 渲染层、主进程、测试都基于同一套字段命名
|
||||
- 不再保留任何旧业务能力的开发任务
|
||||
|
||||
## Phase 1:主进程与文件能力
|
||||
|
||||
目标:
|
||||
|
||||
- 先让本地 `docs` 目录具备可读、可写、可删能力。
|
||||
|
||||
交付:
|
||||
|
||||
1. 新增 `electron/utils/knowledge-docs.ts`
|
||||
2. 新增 `electron/api/routes/knowledge.ts`
|
||||
3. 在 `electron/api/router.ts` 接入新路由
|
||||
4. 支持:
|
||||
- list
|
||||
- upload
|
||||
- delete
|
||||
|
||||
完成标准:
|
||||
|
||||
- 不依赖前端 mock,即可从主进程拿到真实 docs 列表
|
||||
|
||||
## Phase 2:渲染层替换
|
||||
|
||||
目标:
|
||||
|
||||
- 把 `Knowledge/index.tsx` 从 demo 页整体替换成文件管理页。
|
||||
|
||||
交付:
|
||||
|
||||
1. 删除旧的 room/event 双 Tab 结构与相关状态
|
||||
2. 新增上传按钮与隐藏文件输入
|
||||
3. 新增文件表格
|
||||
4. 新增删除操作和确认
|
||||
5. 保留当前视觉语言
|
||||
|
||||
完成标准:
|
||||
|
||||
- 页面刷新后仍能看到真实文件列表
|
||||
- 上传与删除可以闭环
|
||||
|
||||
## Phase 3:验证与收口
|
||||
|
||||
目标:
|
||||
|
||||
- 补齐最基本的回归保护。
|
||||
|
||||
交付:
|
||||
|
||||
1. 主进程 route / util 测试
|
||||
2. 页面交互测试
|
||||
3. 手动 smoke checklist
|
||||
|
||||
完成标准:
|
||||
|
||||
- 上传、刷新、删除、空态、异常态都可验证
|
||||
|
||||
## 8. sub-agent 数量估算
|
||||
|
||||
由于这次采用“直接抛弃旧功能”的替换式重构,复杂度比“兼容旧页面并逐步迁移”明显更低,sub-agent 编制也可以相应收缩。
|
||||
|
||||
## 8.1 最小编制:3 个 sub-agent
|
||||
|
||||
适合当前任务,也已经足够覆盖主链路:
|
||||
|
||||
1. 渲染层 owner
|
||||
2. 主进程 / 文件系统 owner
|
||||
3. 测试 / 联调 owner
|
||||
|
||||
推荐原因:
|
||||
|
||||
1. 已经没有旧功能兼容成本
|
||||
2. 页面职责单一
|
||||
3. API 面很小
|
||||
4. 测试面可控
|
||||
|
||||
## 8.2 推荐编制:3 个 sub-agent
|
||||
|
||||
推荐采用:
|
||||
|
||||
1. Agent A:Knowledge 页面壳与视觉对齐
|
||||
2. Agent B:主进程 docs 文件服务与 Host API
|
||||
3. Agent C:测试、copy、前后端收口
|
||||
|
||||
结论:
|
||||
|
||||
- 对这次 Knowledge 替换式重构,推荐 `3` 个 sub-agent + `1` 个主控 Codex。
|
||||
|
||||
## 8.3 峰值编制:4 个 sub-agent
|
||||
|
||||
仅在想压缩工期时启用:
|
||||
|
||||
1. Agent A:页面壳
|
||||
2. Agent B:文件服务
|
||||
3. Agent C:Host API 路由
|
||||
4. Agent D:测试 / copy / 收口
|
||||
|
||||
不建议长期保持 `4` 个,因为当前任务已经不再需要兼容旧功能,继续加人收益很有限。
|
||||
|
||||
## 9. 推荐 sub-agent 分工
|
||||
|
||||
## Agent A:渲染层 Owner
|
||||
|
||||
负责文件:
|
||||
|
||||
1. [`src/pages/Knowledge/index.tsx`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/src/pages/Knowledge/index.tsx:1)
|
||||
2. `src/pages/Knowledge/components/*`
|
||||
|
||||
职责:
|
||||
|
||||
1. 页面布局重构
|
||||
2. 上传按钮与刷新按钮
|
||||
3. 文件表格与空态
|
||||
4. 删除确认
|
||||
5. 保持当前视觉风格
|
||||
|
||||
## Agent B:主进程 docs 文件服务 Owner
|
||||
|
||||
负责文件:
|
||||
|
||||
1. `electron/utils/knowledge-docs.ts`
|
||||
2. `electron/api/routes/knowledge.ts`
|
||||
3. [`electron/api/router.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/electron/api/router.ts:1)
|
||||
|
||||
职责:
|
||||
|
||||
1. 解析 `zn-ai/docs` 路径
|
||||
2. 列出文件元数据
|
||||
3. 保存文件
|
||||
4. 删除文件
|
||||
5. 文件名安全与根目录校验
|
||||
6. 暴露 list / upload / delete 的本地 Host API
|
||||
|
||||
## Agent C:测试 / copy / 前后端收口 Owner
|
||||
|
||||
负责文件:
|
||||
|
||||
1. [`src/pages/Knowledge/copy.ts`](/Users/duanshuwen/Documents/workspace/electron/zn-ai/src/pages/Knowledge/copy.ts:1)
|
||||
2. 可选新增 `src/lib/knowledge-docs-api.ts`
|
||||
3. `tests/knowledge-docs-routes.test.ts`
|
||||
4. `tests/knowledge-page.test.tsx`
|
||||
5. `docs/*` 中的 smoke checklist
|
||||
|
||||
职责:
|
||||
|
||||
1. 补齐文案
|
||||
2. 连接 `FileReader -> base64 -> hostApiFetch`
|
||||
3. 校验上传
|
||||
4. 校验删除
|
||||
5. 校验空态 / 错误态
|
||||
6. 校验文件元数据展示
|
||||
7. 保证前后端字段一致
|
||||
|
||||
## 10. 并行推进顺序
|
||||
|
||||
建议按下面顺序推进:
|
||||
|
||||
1. Agent B 先完成文件服务
|
||||
2. 主控 Codex / Agent B 接上 Host API 契约
|
||||
3. Agent A 在 API 稳定后整体替换页面
|
||||
4. Agent C 最后补测试、copy 与回归
|
||||
|
||||
原因:
|
||||
|
||||
- 这次需求本质上是“主进程文件能力先行,渲染层消费其结果”,而且旧页面不再需要兼容。
|
||||
|
||||
## 11. 风险与决策点
|
||||
|
||||
1. `zn-ai/docs` 目录写入策略
|
||||
- 当前需求明确指向仓库目录
|
||||
- 打包态写入需要后续额外决策
|
||||
2. 同名文件冲突
|
||||
- 建议先采用“自动追加后缀,不覆盖原文件”
|
||||
3. 大文件上传
|
||||
- 走 `base64` 会增加体积
|
||||
- 本期只要满足常规文档文件即可
|
||||
4. 页面是否保留旧房型 / 事件能力
|
||||
- 本计划已明确直接移除,不做混合页
|
||||
|
||||
## 12. 验收标准
|
||||
|
||||
重构完成后,至少满足:
|
||||
|
||||
1. `Knowledge` 页能从主进程读取 `zn-ai/docs` 真实文件
|
||||
2. 用户可上传文件到 `zn-ai/docs`
|
||||
3. 列表展示:
|
||||
- 文件名称
|
||||
- 文件大小
|
||||
- 修改日期
|
||||
- 文件类型
|
||||
4. 用户可删除文件
|
||||
5. 页面风格与当前 `Knowledge` 页一致
|
||||
6. 页面刷新后状态不丢失
|
||||
7. 主进程具备基本路径安全校验
|
||||
|
||||
## 13. 当前建议结论
|
||||
|
||||
这次任务不适合在现有 `Knowledge/index.tsx` 上继续堆条件分支,也不适合保留旧功能做过渡。
|
||||
|
||||
最合理的推进方式是:
|
||||
|
||||
1. 直接下线 `Knowledge` 现有房型 / 事件功能
|
||||
2. 把 `Knowledge` 页面整体替换成单一职责的 docs 文件管理页
|
||||
3. 新增专门的 `knowledge-docs` 主进程文件服务
|
||||
4. 用本地 Host API 打通上传、列表、删除
|
||||
5. 用 `3` 个 sub-agent 作为推荐编制推进开发
|
||||
|
||||
这样改动面最小、职责边界最清晰,也最符合当前仓库已经建立起来的本地 Host API 架构。
|
||||
@@ -14,4 +14,6 @@
|
||||
|
||||
- 替我确认真实 UI 操作流和 gateway 行为已经全部跑通。离“完全对齐 ClawX”还差两块更值钱的尾项:真实远端 target discovery,不只是本地候选合成;以及 provider/runtime 变更后的显式前端刷新事件,而不只是主进程侧同步。估算sub-agent数量,安排sub-agent分工推进开发工作,期望完全对齐ClawX。
|
||||
|
||||
- 在ClwaX项目中深度分析消息频道功能包括渲染层视觉UI、主进程等实现思路,用于迁移到zn-ai项目,输出迁移开发计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工分析消息频道功能实现思路,期望迁移功能对齐ClawX。
|
||||
- 在ClwaX项目中深度分析消息频道功能包括渲染层视觉UI、主进程等实现思路,用于迁移到zn-ai项目,输出迁移开发计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工分析消息频道功能实现思路,期望迁移功能对齐ClawX。
|
||||
|
||||
- 重构KnowLedge/index.tsx渲染层、主进程,产品需求如下:上传文件按钮,上传到zn-ai/docs目录,文件列表显示内容:文件名称,文件大小、修改日期、文件类型,操作:删除,视觉UI沿用当前的风格。规划重构计划,估算sub-agent数量,安排sub-agent分工推进工作
|
||||
@@ -9,6 +9,7 @@ import { handleChannelRoutes } from './routes/channels';
|
||||
import { handleCronRoutes } from './routes/cron';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
import { handleKnowledgeRoutes } from './routes/knowledge';
|
||||
import { handleModelRoutes } from './routes/models';
|
||||
import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
@@ -25,6 +26,7 @@ const routeHandlers: RouteHandler[] = [
|
||||
handleModelRoutes,
|
||||
handleCronRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleKnowledgeRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
];
|
||||
|
||||
86
electron/api/routes/knowledge.ts
Normal file
86
electron/api/routes/knowledge.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { fail, ok, parseJsonBody } from '../route-utils';
|
||||
import {
|
||||
deleteKnowledgeDoc,
|
||||
listKnowledgeDocs,
|
||||
uploadKnowledgeDoc,
|
||||
isSafeKnowledgeDocName,
|
||||
} from '../../utils/knowledge-docs';
|
||||
|
||||
function getDeleteTarget(request: NormalizedHostApiRequest): string | null {
|
||||
const fromQuery = request.url.searchParams.get('name')?.trim() || '';
|
||||
if (fromQuery) return fromQuery;
|
||||
|
||||
const body = request.body && typeof request.body === 'string'
|
||||
? parseJsonBody<{ fileName?: string; name?: string }>(request.body)
|
||||
: (request.body as { fileName?: string; name?: string } | null);
|
||||
|
||||
const candidate = String(body?.fileName || body?.name || '').trim();
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
export async function handleKnowledgeRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
_ctx: HostApiContext,
|
||||
) {
|
||||
const { pathname, method } = request;
|
||||
|
||||
if (pathname === '/api/knowledge/docs' && method === 'GET') {
|
||||
try {
|
||||
return ok({
|
||||
success: true,
|
||||
files: await listKnowledgeDocs(),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/knowledge/docs' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<{ base64?: string; fileName?: string }>(request.body);
|
||||
const fileName = String(body?.fileName || '').trim();
|
||||
const base64 = String(body?.base64 || '').trim();
|
||||
|
||||
if (!fileName) {
|
||||
return fail(400, 'fileName is required');
|
||||
}
|
||||
if (!base64) {
|
||||
return fail(400, 'base64 is required');
|
||||
}
|
||||
if (!isSafeKnowledgeDocName(fileName)) {
|
||||
return fail(400, 'Invalid file name');
|
||||
}
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
file: await uploadKnowledgeDoc({ fileName, base64 }),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && (pathname === '/api/knowledge/docs' || pathname.startsWith('/api/knowledge/docs/'))) {
|
||||
try {
|
||||
const fileName = pathname.startsWith('/api/knowledge/docs/')
|
||||
? decodeURIComponent(pathname.slice('/api/knowledge/docs/'.length))
|
||||
: getDeleteTarget(request);
|
||||
|
||||
if (!fileName) {
|
||||
return fail(400, 'fileName is required');
|
||||
}
|
||||
if (!isSafeKnowledgeDocName(fileName)) {
|
||||
return fail(400, 'Invalid file name');
|
||||
}
|
||||
|
||||
await deleteKnowledgeDoc(fileName);
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
145
electron/utils/knowledge-docs.ts
Normal file
145
electron/utils/knowledge-docs.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { access, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { extname, isAbsolute, join, normalize, parse, resolve, sep } from 'node:path';
|
||||
|
||||
export interface KnowledgeDocMetadata {
|
||||
name: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocUploadInput {
|
||||
base64: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_DOCS_DIR_ENV_NAME = 'ZN_AI_KNOWLEDGE_DOCS_DIR';
|
||||
|
||||
function getConfiguredDocsDir(): string {
|
||||
const override = process.env[KNOWLEDGE_DOCS_DIR_ENV_NAME]?.trim();
|
||||
if (override) {
|
||||
return resolve(override);
|
||||
}
|
||||
|
||||
return resolve(process.cwd(), 'docs');
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getKnowledgeDocsDir(): string {
|
||||
return getConfiguredDocsDir();
|
||||
}
|
||||
|
||||
export function isSafeKnowledgeDocName(fileName: string): boolean {
|
||||
const trimmed = fileName.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === '.' || trimmed === '..') return false;
|
||||
if (isAbsolute(trimmed)) return false;
|
||||
return normalize(trimmed) === trimmed && parse(trimmed).base === trimmed;
|
||||
}
|
||||
|
||||
export function resolveKnowledgeDocPath(fileName: string): string {
|
||||
if (!isSafeKnowledgeDocName(fileName)) {
|
||||
throw new Error('Invalid file name');
|
||||
}
|
||||
|
||||
const docsDir = getKnowledgeDocsDir();
|
||||
const docsRoot = resolve(docsDir);
|
||||
const candidate = resolve(docsDir, fileName.trim());
|
||||
const rootWithSep = `${docsRoot}${docsRoot.endsWith(sep) ? '' : sep}`;
|
||||
|
||||
if (candidate !== docsRoot && !candidate.startsWith(rootWithSep)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function ensureKnowledgeDocsDir(): Promise<void> {
|
||||
await mkdir(getKnowledgeDocsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
function getDocType(fileName: string, isDirectory: boolean): string {
|
||||
if (isDirectory) return 'directory';
|
||||
return extname(fileName).slice(1).toLowerCase() || 'file';
|
||||
}
|
||||
|
||||
async function resolveAvailableFileName(fileName: string): Promise<string> {
|
||||
const docsDir = getKnowledgeDocsDir();
|
||||
const parsed = parse(fileName);
|
||||
let candidate = fileName;
|
||||
let counter = 1;
|
||||
|
||||
while (await fileExists(join(docsDir, candidate))) {
|
||||
candidate = `${parsed.name}-${counter}${parsed.ext}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function listKnowledgeDocs(): Promise<KnowledgeDocMetadata[]> {
|
||||
await ensureKnowledgeDocsDir();
|
||||
const entries = await readdir(getKnowledgeDocsDir(), { withFileTypes: true });
|
||||
const results: KnowledgeDocMetadata[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() && !entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(getKnowledgeDocsDir(), entry.name);
|
||||
const info = await stat(entryPath);
|
||||
results.push({
|
||||
name: entry.name,
|
||||
size: info.size,
|
||||
modifiedAt: info.mtime.toISOString(),
|
||||
type: getDocType(entry.name, entry.isDirectory()),
|
||||
});
|
||||
}
|
||||
|
||||
return results.sort((left, right) => {
|
||||
const leftTime = new Date(left.modifiedAt).getTime() || 0;
|
||||
const rightTime = new Date(right.modifiedAt).getTime() || 0;
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadKnowledgeDoc(input: KnowledgeDocUploadInput): Promise<KnowledgeDocMetadata> {
|
||||
await ensureKnowledgeDocsDir();
|
||||
|
||||
if (!isSafeKnowledgeDocName(input.fileName)) {
|
||||
throw new Error('Invalid file name');
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(String(input.base64 || ''), 'base64');
|
||||
if (!buffer.length && String(input.base64 || '').trim()) {
|
||||
throw new Error('Invalid base64 payload');
|
||||
}
|
||||
|
||||
const fileName = await resolveAvailableFileName(input.fileName.trim());
|
||||
const filePath = join(getKnowledgeDocsDir(), fileName);
|
||||
await writeFile(filePath, buffer);
|
||||
const info = await stat(filePath);
|
||||
|
||||
return {
|
||||
name: fileName,
|
||||
size: info.size,
|
||||
modifiedAt: info.mtime.toISOString(),
|
||||
type: getDocType(fileName, false),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeDoc(fileName: string): Promise<void> {
|
||||
await ensureKnowledgeDocsDir();
|
||||
const filePath = resolveKnowledgeDocPath(fileName);
|
||||
await rm(filePath, { force: true });
|
||||
}
|
||||
132
src/lib/knowledge-docs-api.ts
Normal file
132
src/lib/knowledge-docs-api.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { hostApiFetch } from './host-api';
|
||||
import type {
|
||||
KnowledgeDocItem,
|
||||
KnowledgeDocsDeleteResponse,
|
||||
KnowledgeDocsListResponse,
|
||||
KnowledgeDocsUploadInput,
|
||||
KnowledgeDocsUploadResponse,
|
||||
} from '../pages/Knowledge/types';
|
||||
|
||||
type KnowledgeDocsListPayload = KnowledgeDocsListResponse | KnowledgeDocItem[] | { success?: boolean; files?: unknown; error?: string };
|
||||
type KnowledgeDocsUploadPayload = KnowledgeDocsUploadResponse | { success?: boolean; file?: unknown; files?: unknown; error?: string };
|
||||
type KnowledgeDocsDeletePayload = KnowledgeDocsDeleteResponse | { success?: boolean; error?: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeName(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeSize(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeModifiedAt(value: unknown): string {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
return new Date(0).toISOString();
|
||||
}
|
||||
|
||||
function normalizeType(value: unknown, name: string): string {
|
||||
const rawType = typeof value === 'string' ? value.trim() : '';
|
||||
if (rawType) return rawType;
|
||||
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
if (dotIndex > 0 && dotIndex < name.length - 1) {
|
||||
return name.slice(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function normalizeDocItem(value: unknown): KnowledgeDocItem | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = normalizeName(value.name ?? value.fileName ?? value.file ?? value.path);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
size: normalizeSize(value.size ?? value.bytes ?? value.length),
|
||||
modifiedAt: normalizeModifiedAt(value.modifiedAt ?? value.updatedAt ?? value.mtime ?? value.lastModified),
|
||||
type: normalizeType(value.type ?? value.mimeType, name),
|
||||
};
|
||||
}
|
||||
|
||||
function extractListItems(payload: KnowledgeDocsListPayload): KnowledgeDocItem[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map(normalizeDocItem).filter((item): item is KnowledgeDocItem => Boolean(item));
|
||||
}
|
||||
|
||||
if (isRecord(payload)) {
|
||||
const files = Array.isArray(payload.files) ? payload.files : [];
|
||||
return files.map(normalizeDocItem).filter((item): item is KnowledgeDocItem => Boolean(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractSingleItem(payload: KnowledgeDocsUploadPayload): KnowledgeDocItem {
|
||||
if (isRecord(payload)) {
|
||||
const candidate = normalizeDocItem(payload.file ?? payload);
|
||||
if (candidate) return candidate;
|
||||
|
||||
const files = Array.isArray(payload.files) ? payload.files : [];
|
||||
for (const item of files) {
|
||||
const normalized = normalizeDocItem(item);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid knowledge docs upload response');
|
||||
}
|
||||
|
||||
function ensureSuccess(payload: { success?: boolean; error?: string } | null | undefined, fallbackError: string): void {
|
||||
if (payload && payload.success === false) {
|
||||
throw new Error(payload.error || fallbackError);
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeDocsApi = {
|
||||
async list(): Promise<KnowledgeDocItem[]> {
|
||||
const response = await hostApiFetch<KnowledgeDocsListPayload>('/api/knowledge/docs');
|
||||
ensureSuccess(isRecord(response) ? response : undefined, 'Failed to load knowledge docs');
|
||||
return extractListItems(response).sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt));
|
||||
},
|
||||
|
||||
async upload(input: KnowledgeDocsUploadInput): Promise<KnowledgeDocItem> {
|
||||
const response = await hostApiFetch<KnowledgeDocsUploadPayload>('/api/knowledge/docs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
ensureSuccess(isRecord(response) ? response : undefined, 'Failed to upload knowledge doc');
|
||||
return extractSingleItem(response);
|
||||
},
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
const trimmedName = String(name ?? '').trim();
|
||||
if (!trimmedName) {
|
||||
throw new Error('Document name is required');
|
||||
}
|
||||
|
||||
const response = await hostApiFetch<KnowledgeDocsDeletePayload>(`/api/knowledge/docs/${encodeURIComponent(trimmedName)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
ensureSuccess(isRecord(response) ? response : undefined, 'Failed to delete knowledge doc');
|
||||
},
|
||||
};
|
||||
89
src/pages/Knowledge/components/KnowledgeDocsTable.tsx
Normal file
89
src/pages/Knowledge/components/KnowledgeDocsTable.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type KnowledgeDocRow = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
fileType: string;
|
||||
};
|
||||
|
||||
type KnowledgeDocsTableProps = {
|
||||
docs: KnowledgeDocRow[];
|
||||
deletingId: string | null;
|
||||
onDelete: (doc: KnowledgeDocRow) => void;
|
||||
deleteLabel: string;
|
||||
deletingLabel: string;
|
||||
fileNameLabel: string;
|
||||
sizeLabel: string;
|
||||
modifiedLabel: string;
|
||||
typeLabel: string;
|
||||
actionLabel: string;
|
||||
formatBytes: (value: number | null | undefined) => string;
|
||||
formatDateTime: (value: string | null | undefined) => string;
|
||||
trashIcon: ReactNode;
|
||||
};
|
||||
|
||||
export default function KnowledgeDocsTable({
|
||||
docs,
|
||||
deletingId,
|
||||
onDelete,
|
||||
deleteLabel,
|
||||
deletingLabel,
|
||||
fileNameLabel,
|
||||
sizeLabel,
|
||||
modifiedLabel,
|
||||
typeLabel,
|
||||
actionLabel,
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
trashIcon,
|
||||
}: KnowledgeDocsTableProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-black/5 bg-[#fcfcfd] dark:border-[#2a2a2d] dark:bg-[#222225]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-black/5 text-left text-xs uppercase tracking-[0.18em] text-[#99A0AE] dark:border-[#2a2a2d] dark:text-gray-500">
|
||||
<th className="px-5 py-4">{fileNameLabel}</th>
|
||||
<th className="px-5 py-4">{sizeLabel}</th>
|
||||
<th className="px-5 py-4">{modifiedLabel}</th>
|
||||
<th className="px-5 py-4">{typeLabel}</th>
|
||||
<th className="px-5 py-4">{actionLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((doc) => {
|
||||
const pending = deletingId === doc.id;
|
||||
return (
|
||||
<tr key={doc.id} className="border-b border-black/5 align-top last:border-b-0 dark:border-[#2a2a2d]">
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-semibold text-[#171717] dark:text-[#f3f4f6]">{doc.fileName}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-[#525866] dark:text-gray-300">{formatBytes(doc.size)}</td>
|
||||
<td className="px-5 py-4 text-sm text-[#525866] dark:text-gray-300">{formatDateTime(doc.modifiedAt)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="inline-flex rounded-xl border border-[#E5E8EE] bg-white px-3 py-2 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1b1b1d] dark:text-gray-300">
|
||||
{doc.fileType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center rounded-full border border-red-500/20 px-4 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-red-300 dark:hover:bg-red-900/20"
|
||||
onClick={() => onDelete(doc)}
|
||||
disabled={pending}
|
||||
>
|
||||
{trashIcon}
|
||||
<span className="ml-2">{pending ? deletingLabel : deleteLabel}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/pages/Knowledge/components/KnowledgeEmptyState.tsx
Normal file
26
src/pages/Knowledge/components/KnowledgeEmptyState.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type KnowledgeEmptyStateProps = {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
};
|
||||
|
||||
export default function KnowledgeEmptyState({ icon, title, description, actionLabel, onAction }: KnowledgeEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex min-h-[420px] flex-col items-center justify-center rounded-[24px] border border-dashed border-black/10 bg-[#f8fafc] px-6 text-center dark:border-[#2a2a2d] dark:bg-[#222225]">
|
||||
<div className="mb-4 text-[#99A0AE] dark:text-gray-500">{icon}</div>
|
||||
<p className="mb-3 text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{title}</p>
|
||||
<p className="max-w-lg text-sm leading-6 text-[#525866] dark:text-gray-400">{description}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 inline-flex h-10 items-center rounded-full bg-[#2B7FFF] px-4 text-sm font-medium text-white transition-colors hover:bg-[#2369db]"
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/pages/Knowledge/components/KnowledgeFeedbackBanner.tsx
Normal file
22
src/pages/Knowledge/components/KnowledgeFeedbackBanner.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type KnowledgeFeedbackBannerProps = {
|
||||
kind: 'success' | 'warning' | 'error' | 'info';
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function toneClasses(kind: KnowledgeFeedbackBannerProps['kind']): string {
|
||||
if (kind === 'success') return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
|
||||
if (kind === 'warning') return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
||||
if (kind === 'error') return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
|
||||
return 'border-black/10 bg-white text-[#525866] dark:border-gray-700 dark:bg-[#222225] dark:text-gray-300';
|
||||
}
|
||||
|
||||
export default function KnowledgeFeedbackBanner({ kind, className, children }: KnowledgeFeedbackBannerProps) {
|
||||
return (
|
||||
<div className={[className, 'max-w-[360px] rounded-xl border px-4 py-3 text-sm shadow-[0_12px_30px_rgba(15,23,42,0.14)]', toneClasses(kind)].filter(Boolean).join(' ')}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/pages/Knowledge/components/KnowledgePageHeader.tsx
Normal file
42
src/pages/Knowledge/components/KnowledgePageHeader.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
export type KnowledgePageHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
totalCount: number;
|
||||
totalSize: string;
|
||||
documentsLabel: string;
|
||||
storageLabel: string;
|
||||
};
|
||||
|
||||
export default function KnowledgePageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
totalCount,
|
||||
totalSize,
|
||||
documentsLabel,
|
||||
storageLabel,
|
||||
}: KnowledgePageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6 flex shrink-0 flex-col justify-between gap-4 md:flex-row md:items-start">
|
||||
<div>
|
||||
<h1
|
||||
className="mb-3 text-5xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6] md:text-6xl"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-[17px] font-medium text-[#171717]/70 dark:text-gray-400">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 md:mt-2 md:min-w-[320px]">
|
||||
<div className="rounded-[24px] bg-[#f4f7fb] px-5 py-4 dark:bg-[#222225]">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-[#99A0AE] dark:text-gray-500">{documentsLabel}</div>
|
||||
<div className="mt-3 text-3xl font-semibold text-[#171717] dark:text-[#f3f4f6]">{totalCount}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] bg-[#fff7ed] px-5 py-4 dark:bg-[#31251a]">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-[#6b7280] dark:text-gray-400">{storageLabel}</div>
|
||||
<div className="mt-3 text-3xl font-semibold text-[#171717] dark:text-[#f3f4f6]">{totalSize}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/pages/Knowledge/components/KnowledgeToolbar.tsx
Normal file
58
src/pages/Knowledge/components/KnowledgeToolbar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type KnowledgeToolbarProps = {
|
||||
description: string;
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
uploading: boolean;
|
||||
onRefresh: () => void;
|
||||
onUploadClick: () => void;
|
||||
refreshLabel: string;
|
||||
uploadLabel: string;
|
||||
uploadingLabel: string;
|
||||
refreshIcon: ReactNode;
|
||||
uploadIcon: ReactNode;
|
||||
};
|
||||
|
||||
export default function KnowledgeToolbar({
|
||||
description,
|
||||
loading,
|
||||
refreshing,
|
||||
uploading,
|
||||
onRefresh,
|
||||
onUploadClick,
|
||||
refreshLabel,
|
||||
uploadLabel,
|
||||
uploadingLabel,
|
||||
refreshIcon,
|
||||
uploadIcon,
|
||||
}: KnowledgeToolbarProps) {
|
||||
return (
|
||||
<div className="mb-5 flex shrink-0 flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="max-w-2xl text-sm leading-6 text-[#525866] dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 md:mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center rounded-full border border-black/10 px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
|
||||
onClick={onRefresh}
|
||||
disabled={loading || refreshing}
|
||||
>
|
||||
{refreshIcon}
|
||||
<span className="ml-2">{refreshLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center rounded-full bg-[#2B7FFF] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#2369db] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onUploadClick}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploadIcon}
|
||||
<span className="ml-2">{uploading ? uploadingLabel : uploadLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/pages/Knowledge/components/index.ts
Normal file
6
src/pages/Knowledge/components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as KnowledgeDocsTable } from './KnowledgeDocsTable';
|
||||
export type { KnowledgeDocRow } from './KnowledgeDocsTable';
|
||||
export { default as KnowledgeEmptyState } from './KnowledgeEmptyState';
|
||||
export { default as KnowledgeFeedbackBanner } from './KnowledgeFeedbackBanner';
|
||||
export { default as KnowledgePageHeader } from './KnowledgePageHeader';
|
||||
export { default as KnowledgeToolbar } from './KnowledgeToolbar';
|
||||
@@ -14,53 +14,39 @@ function normalizeKnowledgePath(path: string): string {
|
||||
}
|
||||
|
||||
const EN_KNOWLEDGE_MESSAGES: MessageTree = {
|
||||
title: 'Knowledge Management',
|
||||
desc: 'Content Management',
|
||||
roomTypeManager: 'Room Type Management',
|
||||
eventManager: 'Event Management',
|
||||
event: {
|
||||
addEvent: 'Add Event',
|
||||
eventName: 'Event Name',
|
||||
eventDesc: 'Event Description',
|
||||
effectiveTime: 'Effective Time',
|
||||
endTime: 'End Time',
|
||||
relatedImage: 'Related Image',
|
||||
enableDisable: 'Enable/Disable',
|
||||
operation: 'Operation',
|
||||
viewImage: 'View Image',
|
||||
uploadImage: 'Upload Image',
|
||||
pleaseEnter: 'Please enter',
|
||||
effectiveTimeRange: 'Effective Time Range',
|
||||
to: 'to',
|
||||
startDate: 'Start Date',
|
||||
endDate: 'End Date',
|
||||
pleaseEnterEventName: 'Please enter the event name',
|
||||
lengthValidation: 'Length between 3 and 50 characters',
|
||||
pleaseEnterEventDesc: 'Please enter the event description',
|
||||
pleaseSelectTimeRange: 'Please select the effective time range',
|
||||
title: 'Knowledge Docs',
|
||||
subtitle: 'Upload, review, and delete local documentation files stored in the knowledge directory.',
|
||||
refresh: 'Refresh',
|
||||
upload: 'Upload document',
|
||||
uploadHint: 'This page fully replaces the previous Knowledge demo and now focuses on local docs only.',
|
||||
documentsLabel: 'Documents',
|
||||
storageLabel: 'Storage',
|
||||
emptyTitle: 'No documents yet',
|
||||
emptyDescription: 'Upload a file to start managing the local knowledge docs directory.',
|
||||
deleteConfirm: 'Delete this document?',
|
||||
table: {
|
||||
name: 'Name',
|
||||
size: 'Size',
|
||||
modifiedAt: 'Modified At',
|
||||
type: 'Type',
|
||||
actions: 'Actions',
|
||||
delete: 'Delete',
|
||||
},
|
||||
upload: {
|
||||
title: 'Upload Image',
|
||||
step1: 'Upload Image 1',
|
||||
step2: 'Image Description 2',
|
||||
dragText: 'Choose a file or drag and drop it here',
|
||||
formatDesc: 'JPEG, PNG, PDF, and MP4 formats up to 50MB.',
|
||||
tipText: "If you don't want to enter prompt words for now, click confirm and operate in the list",
|
||||
pleaseUploadFirst: 'Please upload an image first',
|
||||
uploadSuccess: 'Image uploaded successfully',
|
||||
pleaseEnter: 'Please enter',
|
||||
dialog: {
|
||||
title: 'Upload document',
|
||||
description: 'Choose a file and save it into the local knowledge docs directory.',
|
||||
fileLabel: 'File',
|
||||
fileHint: 'The uploaded file will be written with its file name and metadata preserved.',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Upload',
|
||||
},
|
||||
eventPic: {
|
||||
copySuccess: 'Copied successfully',
|
||||
copyFail: 'Copy failed',
|
||||
backToEvent: 'Back to Event Management',
|
||||
},
|
||||
roomType: {
|
||||
ctrip: 'Ctrip',
|
||||
fliggy: 'Fliggy',
|
||||
douyinHotel: 'Douyin (Xifeng Nanshan Tianmu Hot Spring Hotel)',
|
||||
douyinHotSpring: 'Douyin (Xifeng Nanshan Tianmu Hot Spring)',
|
||||
meituan: 'Meituan',
|
||||
status: {
|
||||
loading: 'Loading documents...',
|
||||
uploading: 'Uploading...',
|
||||
deleting: 'Deleting...',
|
||||
uploadSuccess: 'Document uploaded successfully',
|
||||
deleteSuccess: 'Document deleted successfully',
|
||||
failed: 'Knowledge docs request failed: {error}',
|
||||
},
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
@@ -69,53 +55,39 @@ const EN_KNOWLEDGE_MESSAGES: MessageTree = {
|
||||
};
|
||||
|
||||
const ZH_KNOWLEDGE_MESSAGES: MessageTree = {
|
||||
title: '知识库管理',
|
||||
desc: '内容管理',
|
||||
roomTypeManager: '房型管理',
|
||||
eventManager: '事件管理',
|
||||
event: {
|
||||
addEvent: '添加事件',
|
||||
eventName: '事件名称',
|
||||
eventDesc: '事件描述',
|
||||
effectiveTime: '生效时间',
|
||||
endTime: '结束时间',
|
||||
relatedImage: '关联图片',
|
||||
enableDisable: '启用/停用',
|
||||
operation: '操作',
|
||||
viewImage: '查看图片',
|
||||
uploadImage: '上传图片',
|
||||
pleaseEnter: '请输入',
|
||||
effectiveTimeRange: '生效时间段',
|
||||
to: '至',
|
||||
startDate: '开始日期',
|
||||
endDate: '结束日期',
|
||||
pleaseEnterEventName: '请输入活动名称',
|
||||
lengthValidation: '长度在 3 到 50 个字符之间',
|
||||
pleaseEnterEventDesc: '请输入活动描述',
|
||||
pleaseSelectTimeRange: '请选择生效时间段',
|
||||
title: '知识文档管理',
|
||||
subtitle: '上传、查看和删除保存在知识目录中的本地文档文件。',
|
||||
refresh: '刷新',
|
||||
upload: '上传文档',
|
||||
uploadHint: '此页面已完全替换旧 Knowledge demo,只保留本地文档管理能力。',
|
||||
documentsLabel: '文档数量',
|
||||
storageLabel: '占用空间',
|
||||
emptyTitle: '暂无文档',
|
||||
emptyDescription: '先上传一个文件,开始管理本地知识文档目录。',
|
||||
deleteConfirm: '确定要删除此文档吗?',
|
||||
table: {
|
||||
name: '名称',
|
||||
size: '大小',
|
||||
modifiedAt: '修改时间',
|
||||
type: '类型',
|
||||
actions: '操作',
|
||||
delete: '删除',
|
||||
},
|
||||
upload: {
|
||||
title: '上传图片',
|
||||
step1: '上传图片 1',
|
||||
step2: '图片描述 2',
|
||||
dragText: '选择一个文件或将其拖放到此处',
|
||||
formatDesc: '支持 JPEG、PNG、PDF 和 MP4,大小不超过 50MB。',
|
||||
tipText: '如果暂时不想输入提示词,可点击确认后到列表操作。',
|
||||
pleaseUploadFirst: '请先上传图片',
|
||||
uploadSuccess: '图片上传成功',
|
||||
pleaseEnter: '请输入',
|
||||
dialog: {
|
||||
title: '上传文档',
|
||||
description: '选择一个文件并保存到本地知识文档目录中。',
|
||||
fileLabel: '文件',
|
||||
fileHint: '上传后的文件会保留文件名和元信息。',
|
||||
cancel: '取消',
|
||||
confirm: '上传',
|
||||
},
|
||||
eventPic: {
|
||||
copySuccess: '复制成功',
|
||||
copyFail: '复制失败',
|
||||
backToEvent: '返回事件管理',
|
||||
},
|
||||
roomType: {
|
||||
ctrip: '携程',
|
||||
fliggy: '飞猪',
|
||||
douyinHotel: '抖音(息烽南山天沐温泉酒店)',
|
||||
douyinHotSpring: '抖音(息烽南山天沐温泉)',
|
||||
meituan: '美团',
|
||||
status: {
|
||||
loading: '正在加载文档...',
|
||||
uploading: '上传中...',
|
||||
deleting: '删除中...',
|
||||
uploadSuccess: '文档上传成功',
|
||||
deleteSuccess: '文档删除成功',
|
||||
failed: '知识文档请求失败:{error}',
|
||||
},
|
||||
common: {
|
||||
cancel: '取消',
|
||||
@@ -124,53 +96,39 @@ const ZH_KNOWLEDGE_MESSAGES: MessageTree = {
|
||||
};
|
||||
|
||||
const JA_KNOWLEDGE_MESSAGES: MessageTree = {
|
||||
title: 'ナレッジ管理',
|
||||
desc: 'コンテンツ管理',
|
||||
roomTypeManager: '部屋タイプ管理',
|
||||
eventManager: 'イベント管理',
|
||||
event: {
|
||||
addEvent: 'イベントを追加',
|
||||
eventName: 'イベント名',
|
||||
eventDesc: 'イベントの説明',
|
||||
effectiveTime: '有効時間',
|
||||
endTime: '終了時間',
|
||||
relatedImage: '関連画像',
|
||||
enableDisable: '有効/無効',
|
||||
operation: '操作',
|
||||
viewImage: '画像を表示',
|
||||
uploadImage: '画像をアップロード',
|
||||
pleaseEnter: '入力してください',
|
||||
effectiveTimeRange: '有効期間',
|
||||
to: 'から',
|
||||
startDate: '開始日',
|
||||
endDate: '終了日',
|
||||
pleaseEnterEventName: 'イベント名を入力してください',
|
||||
lengthValidation: '3〜50文字で入力してください',
|
||||
pleaseEnterEventDesc: 'イベントの説明を入力してください',
|
||||
pleaseSelectTimeRange: '有効期間を選択してください',
|
||||
title: 'Knowledge Docs',
|
||||
subtitle: 'ローカルのナレッジ文書をアップロード、確認、削除できます。',
|
||||
refresh: '更新',
|
||||
upload: '文書をアップロード',
|
||||
uploadHint: 'このページは旧 Knowledge デモを置き換え、ローカル文書管理に専念します。',
|
||||
documentsLabel: 'Documents',
|
||||
storageLabel: 'Storage',
|
||||
emptyTitle: '文書がありません',
|
||||
emptyDescription: 'まずファイルをアップロードして、ローカルの knowledge docs を管理してください。',
|
||||
deleteConfirm: 'この文書を削除しますか?',
|
||||
table: {
|
||||
name: '名前',
|
||||
size: 'サイズ',
|
||||
modifiedAt: '更新日時',
|
||||
type: '種類',
|
||||
actions: '操作',
|
||||
delete: '削除',
|
||||
},
|
||||
upload: {
|
||||
title: '画像をアップロード',
|
||||
step1: '画像のアップロード 1',
|
||||
step2: '画像の説明 2',
|
||||
dragText: 'ファイルを選択するか、ここにドラッグ&ドロップしてください',
|
||||
formatDesc: 'JPEG、PNG、PDF、MP4 形式に対応し、最大 50MB です。',
|
||||
tipText: '今はプロンプトを入力しない場合でも、確認後に一覧で操作できます。',
|
||||
pleaseUploadFirst: '先に画像をアップロードしてください',
|
||||
uploadSuccess: '画像のアップロードに成功しました',
|
||||
pleaseEnter: '入力してください',
|
||||
dialog: {
|
||||
title: '文書をアップロード',
|
||||
description: 'ファイルを選んでローカルの knowledge docs ディレクトリに保存します。',
|
||||
fileLabel: 'ファイル',
|
||||
fileHint: 'アップロードしたファイルはファイル名とメタデータを保持します。',
|
||||
cancel: 'キャンセル',
|
||||
confirm: 'アップロード',
|
||||
},
|
||||
eventPic: {
|
||||
copySuccess: 'コピーに成功しました',
|
||||
copyFail: 'コピーに失敗しました',
|
||||
backToEvent: 'イベント管理に戻る',
|
||||
},
|
||||
roomType: {
|
||||
ctrip: 'Ctrip',
|
||||
fliggy: 'Fliggy',
|
||||
douyinHotel: 'Douyin (Xifeng Nanshan Tianmu Hot Spring Hotel)',
|
||||
douyinHotSpring: 'Douyin (Xifeng Nanshan Tianmu Hot Spring)',
|
||||
meituan: 'Meituan',
|
||||
status: {
|
||||
loading: '文書を読み込み中...',
|
||||
uploading: 'アップロード中...',
|
||||
deleting: '削除中...',
|
||||
uploadSuccess: '文書をアップロードしました',
|
||||
deleteSuccess: '文書を削除しました',
|
||||
failed: 'Knowledge docs のリクエストに失敗しました: {error}',
|
||||
},
|
||||
common: {
|
||||
cancel: 'キャンセル',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,35 @@
|
||||
import type { RoomTypeMapping } from '../../api/types';
|
||||
|
||||
export type KnowledgeTabKey = 'roomType' | 'event';
|
||||
|
||||
export type FeedbackTone = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
export type FeedbackState = {
|
||||
id: number;
|
||||
tone: FeedbackTone;
|
||||
message: string;
|
||||
} | null;
|
||||
|
||||
export type RoomTypeRow = RoomTypeMapping & {
|
||||
dyHotSpringName?: string;
|
||||
dyHotSrpingName?: string;
|
||||
};
|
||||
|
||||
export type KnowledgeImageKind = 'image' | 'document' | 'video';
|
||||
|
||||
export type KnowledgeImage = {
|
||||
id: string;
|
||||
export interface KnowledgeDocItem {
|
||||
name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
sourceUrl: string;
|
||||
createdAt: string;
|
||||
kind: KnowledgeImageKind;
|
||||
objectUrl?: boolean;
|
||||
};
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type KnowledgeEvent = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
enabled: boolean;
|
||||
images: KnowledgeImage[];
|
||||
};
|
||||
export interface KnowledgeDocsListResponse {
|
||||
success?: boolean;
|
||||
files?: KnowledgeDocItem[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type AddEventInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
};
|
||||
export interface KnowledgeDocsUploadInput {
|
||||
fileName: string;
|
||||
base64: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocsUploadResponse {
|
||||
success?: boolean;
|
||||
file?: KnowledgeDocItem;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocsDeleteResponse {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocsApiClient {
|
||||
list: () => Promise<KnowledgeDocItem[]>;
|
||||
upload: (input: KnowledgeDocsUploadInput) => Promise<KnowledgeDocItem>;
|
||||
delete: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useI18n } from '../../../i18n';
|
||||
import SectionHeader from './SectionHeader';
|
||||
import { CheckCircleIcon } from './SettingIcons';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
|
||||
const ACCOUNT_ID = '1234567890';
|
||||
const LAST_LOGIN_TIME = '2022-11-09 16:24:30';
|
||||
@@ -31,7 +31,7 @@ export default function AccountSettingsPanel() {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center rounded-[6px] border border-[#E5E8EE] px-[6px] py-[4px] dark:border-gray-700">
|
||||
<CheckCircleIcon className="h-[16px] w-[16px] text-[#1FC16B]" />
|
||||
<CheckCircle className="h-[16px] w-[16px] text-[#1FC16B]" />
|
||||
<span className="ml-[2px] whitespace-nowrap text-[12px] text-[#525866] dark:text-gray-400">
|
||||
{t('settings.account.configured')}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { SettingsIcon, UserIcon } from './SettingIcons';
|
||||
import { Settings, User } from 'lucide-react';
|
||||
|
||||
export type SettingView = 'account' | 'general';
|
||||
|
||||
@@ -11,17 +11,17 @@ type SettingMenuProps = {
|
||||
const MENU_ITEMS: Array<{
|
||||
id: SettingView;
|
||||
labelKey: 'settings.menu.account' | 'settings.menu.general';
|
||||
Icon: typeof UserIcon;
|
||||
Icon: typeof User | typeof Settings;
|
||||
}> = [
|
||||
{
|
||||
id: 'general',
|
||||
labelKey: 'settings.menu.general',
|
||||
Icon: SettingsIcon,
|
||||
Icon: Settings,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
labelKey: 'settings.menu.account',
|
||||
Icon: UserIcon,
|
||||
Icon: User,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
112
tests/knowledge-docs-routes.test.ts
Normal file
112
tests/knowledge-docs-routes.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listKnowledgeDocs: vi.fn(),
|
||||
uploadKnowledgeDoc: vi.fn(),
|
||||
deleteKnowledgeDoc: vi.fn(),
|
||||
isSafeKnowledgeDocName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../electron/utils/knowledge-docs', () => ({
|
||||
listKnowledgeDocs: mocks.listKnowledgeDocs,
|
||||
uploadKnowledgeDoc: mocks.uploadKnowledgeDoc,
|
||||
deleteKnowledgeDoc: mocks.deleteKnowledgeDoc,
|
||||
isSafeKnowledgeDocName: mocks.isSafeKnowledgeDocName,
|
||||
}));
|
||||
|
||||
import { normalizeRequest } from '../electron/api/route-utils';
|
||||
import { handleKnowledgeRoutes } from '../electron/api/routes/knowledge';
|
||||
|
||||
const ctx = {
|
||||
gatewayManager: null,
|
||||
providerApiService: null,
|
||||
mainWindow: null,
|
||||
} as any;
|
||||
|
||||
describe('knowledge docs routes', () => {
|
||||
beforeEach(() => {
|
||||
mocks.listKnowledgeDocs.mockReset();
|
||||
mocks.uploadKnowledgeDoc.mockReset();
|
||||
mocks.deleteKnowledgeDoc.mockReset();
|
||||
mocks.isSafeKnowledgeDocName.mockReset();
|
||||
mocks.isSafeKnowledgeDocName.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('returns listed knowledge docs from the local route', async () => {
|
||||
mocks.listKnowledgeDocs.mockResolvedValue([
|
||||
{
|
||||
name: 'guide.md',
|
||||
size: 128,
|
||||
modifiedAt: '2026-04-18T10:00:00.000Z',
|
||||
type: 'md',
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await handleKnowledgeRoutes(normalizeRequest({
|
||||
path: '/api/knowledge/docs',
|
||||
method: 'GET',
|
||||
}), ctx);
|
||||
|
||||
expect(response?.ok).toBe(true);
|
||||
expect(response?.json).toMatchObject({
|
||||
success: true,
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
name: 'guide.md',
|
||||
type: 'md',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads and deletes docs through the route contract', async () => {
|
||||
mocks.uploadKnowledgeDoc.mockResolvedValue({
|
||||
name: 'guide.md',
|
||||
size: 128,
|
||||
modifiedAt: '2026-04-18T10:00:00.000Z',
|
||||
type: 'md',
|
||||
});
|
||||
|
||||
const upload = await handleKnowledgeRoutes(normalizeRequest({
|
||||
path: '/api/knowledge/docs',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'guide.md',
|
||||
base64: Buffer.from('hello').toString('base64'),
|
||||
}),
|
||||
}), ctx);
|
||||
|
||||
expect(upload?.ok).toBe(true);
|
||||
expect(upload?.json).toMatchObject({
|
||||
success: true,
|
||||
file: expect.objectContaining({
|
||||
name: 'guide.md',
|
||||
}),
|
||||
});
|
||||
|
||||
const remove = await handleKnowledgeRoutes(normalizeRequest({
|
||||
path: '/api/knowledge/docs/guide.md',
|
||||
method: 'DELETE',
|
||||
}), ctx);
|
||||
|
||||
expect(remove?.ok).toBe(true);
|
||||
expect(remove?.json).toMatchObject({ success: true });
|
||||
expect(mocks.deleteKnowledgeDoc).toHaveBeenCalledWith('guide.md');
|
||||
});
|
||||
|
||||
it('rejects unsafe file names', async () => {
|
||||
mocks.isSafeKnowledgeDocName.mockReturnValue(false);
|
||||
|
||||
const response = await handleKnowledgeRoutes(normalizeRequest({
|
||||
path: '/api/knowledge/docs',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: '../escape.md',
|
||||
base64: Buffer.from('bad').toString('base64'),
|
||||
}),
|
||||
}), ctx);
|
||||
|
||||
expect(response?.ok).toBe(false);
|
||||
expect(response?.status).toBe(400);
|
||||
});
|
||||
});
|
||||
95
tests/knowledge-page.test.tsx
Normal file
95
tests/knowledge-page.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/lib/knowledge-docs-api', () => ({
|
||||
knowledgeDocsApi: apiMocks,
|
||||
}));
|
||||
|
||||
vi.mock('../src/pages/Knowledge/copy', () => ({
|
||||
useKnowledgeCopy: () => (path: string, params?: Record<string, string>, fallback?: string) => {
|
||||
const dictionary: Record<string, string> = {
|
||||
'knowledge.title': 'Knowledge Docs',
|
||||
'knowledge.subtitle': 'Manage docs',
|
||||
'knowledge.documentsLabel': 'Documents',
|
||||
'knowledge.storageLabel': 'Storage',
|
||||
'knowledge.refresh': 'Refresh',
|
||||
'knowledge.upload': 'Upload',
|
||||
'knowledge.uploadHint': 'Knowledge docs only',
|
||||
'knowledge.status.loading': 'Loading documents...',
|
||||
'knowledge.status.uploading': 'Uploading...',
|
||||
'knowledge.status.deleting': 'Deleting...',
|
||||
'knowledge.status.uploadSuccess': 'Uploaded',
|
||||
'knowledge.status.deleteSuccess': 'Deleted',
|
||||
'knowledge.emptyTitle': 'No documents yet',
|
||||
'knowledge.emptyDescription': 'Upload a file',
|
||||
'knowledge.deleteConfirm': 'Delete this document?',
|
||||
'knowledge.table.name': 'Name',
|
||||
'knowledge.table.size': 'Size',
|
||||
'knowledge.table.modifiedAt': 'Modified At',
|
||||
'knowledge.table.type': 'Type',
|
||||
'knowledge.table.actions': 'Actions',
|
||||
'knowledge.table.delete': 'Delete',
|
||||
};
|
||||
|
||||
if (path === 'knowledge.status.failed') {
|
||||
return `Knowledge docs request failed: ${params?.error ?? ''}`;
|
||||
}
|
||||
|
||||
return dictionary[path] ?? fallback ?? path;
|
||||
},
|
||||
}));
|
||||
|
||||
import KnowledgePage from '../src/pages/Knowledge';
|
||||
|
||||
describe('KnowledgePage', () => {
|
||||
beforeEach(() => {
|
||||
apiMocks.list.mockReset();
|
||||
apiMocks.upload.mockReset();
|
||||
apiMocks.delete.mockReset();
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
});
|
||||
|
||||
it('loads and renders docs from the knowledge docs api', async () => {
|
||||
apiMocks.list.mockResolvedValue([
|
||||
{
|
||||
name: 'guide.md',
|
||||
size: 1024,
|
||||
modifiedAt: '2026-04-18T10:00:00.000Z',
|
||||
type: 'md',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<KnowledgePage />);
|
||||
|
||||
expect(await screen.findByText('guide.md')).toBeTruthy();
|
||||
expect(screen.getByText('MD')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deletes a doc after confirmation', async () => {
|
||||
apiMocks.list.mockResolvedValue([
|
||||
{
|
||||
name: 'guide.md',
|
||||
size: 1024,
|
||||
modifiedAt: '2026-04-18T10:00:00.000Z',
|
||||
type: 'md',
|
||||
},
|
||||
]);
|
||||
apiMocks.delete.mockResolvedValue(undefined);
|
||||
|
||||
render(<KnowledgePage />);
|
||||
|
||||
const deleteButton = await screen.findByRole('button', { name: /delete/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.delete).toHaveBeenCalledWith('guide.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user