diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index bcbf629..e5ea40c 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -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"); diff --git a/docs/Knowledge-Docs-Refactor-Plan.md b/docs/Knowledge-Docs-Refactor-Plan.md new file mode 100644 index 0000000..49e8051 --- /dev/null +++ b/docs/Knowledge-Docs-Refactor-Plan.md @@ -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 架构。 diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 36d218a..f6405af 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -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。 \ No newline at end of file +- 在ClwaX项目中深度分析消息频道功能包括渲染层视觉UI、主进程等实现思路,用于迁移到zn-ai项目,输出迁移开发计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工分析消息频道功能实现思路,期望迁移功能对齐ClawX。 + +- 重构KnowLedge/index.tsx渲染层、主进程,产品需求如下:上传文件按钮,上传到zn-ai/docs目录,文件列表显示内容:文件名称,文件大小、修改日期、文件类型,操作:删除,视觉UI沿用当前的风格。规划重构计划,估算sub-agent数量,安排sub-agent分工推进工作 \ No newline at end of file diff --git a/electron/api/router.ts b/electron/api/router.ts index 3e7f1d4..f54327c 100644 --- a/electron/api/router.ts +++ b/electron/api/router.ts @@ -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, ]; diff --git a/electron/api/routes/knowledge.ts b/electron/api/routes/knowledge.ts new file mode 100644 index 0000000..6757f62 --- /dev/null +++ b/electron/api/routes/knowledge.ts @@ -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; +} diff --git a/electron/utils/knowledge-docs.ts b/electron/utils/knowledge-docs.ts new file mode 100644 index 0000000..29e51ed --- /dev/null +++ b/electron/utils/knowledge-docs.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await ensureKnowledgeDocsDir(); + const filePath = resolveKnowledgeDocPath(fileName); + await rm(filePath, { force: true }); +} diff --git a/src/lib/knowledge-docs-api.ts b/src/lib/knowledge-docs-api.ts new file mode 100644 index 0000000..cd04f09 --- /dev/null +++ b/src/lib/knowledge-docs-api.ts @@ -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 { + 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 { + const response = await hostApiFetch('/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 { + const response = await hostApiFetch('/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 { + const trimmedName = String(name ?? '').trim(); + if (!trimmedName) { + throw new Error('Document name is required'); + } + + const response = await hostApiFetch(`/api/knowledge/docs/${encodeURIComponent(trimmedName)}`, { + method: 'DELETE', + }); + ensureSuccess(isRecord(response) ? response : undefined, 'Failed to delete knowledge doc'); + }, +}; diff --git a/src/pages/Knowledge/components/KnowledgeDocsTable.tsx b/src/pages/Knowledge/components/KnowledgeDocsTable.tsx new file mode 100644 index 0000000..ba33910 --- /dev/null +++ b/src/pages/Knowledge/components/KnowledgeDocsTable.tsx @@ -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 ( +
+
+ + + + + + + + + + + + {docs.map((doc) => { + const pending = deletingId === doc.id; + return ( + + + + + + + + ); + })} + +
{fileNameLabel}{sizeLabel}{modifiedLabel}{typeLabel}{actionLabel}
+
{doc.fileName}
+
{formatBytes(doc.size)}{formatDateTime(doc.modifiedAt)} + + {doc.fileType} + + + +
+
+
+ ); +} diff --git a/src/pages/Knowledge/components/KnowledgeEmptyState.tsx b/src/pages/Knowledge/components/KnowledgeEmptyState.tsx new file mode 100644 index 0000000..72f6fe2 --- /dev/null +++ b/src/pages/Knowledge/components/KnowledgeEmptyState.tsx @@ -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 ( +
+
{icon}
+

{title}

+

{description}

+ +
+ ); +} diff --git a/src/pages/Knowledge/components/KnowledgeFeedbackBanner.tsx b/src/pages/Knowledge/components/KnowledgeFeedbackBanner.tsx new file mode 100644 index 0000000..0a532c3 --- /dev/null +++ b/src/pages/Knowledge/components/KnowledgeFeedbackBanner.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/src/pages/Knowledge/components/KnowledgePageHeader.tsx b/src/pages/Knowledge/components/KnowledgePageHeader.tsx new file mode 100644 index 0000000..79d7a4f --- /dev/null +++ b/src/pages/Knowledge/components/KnowledgePageHeader.tsx @@ -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 ( +
+
+

+ {title} +

+

{subtitle}

+
+ +
+
+
{documentsLabel}
+
{totalCount}
+
+
+
{storageLabel}
+
{totalSize}
+
+
+
+ ); +} diff --git a/src/pages/Knowledge/components/KnowledgeToolbar.tsx b/src/pages/Knowledge/components/KnowledgeToolbar.tsx new file mode 100644 index 0000000..16882ba --- /dev/null +++ b/src/pages/Knowledge/components/KnowledgeToolbar.tsx @@ -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 ( +
+

+ {description} +

+ +
+ + +
+
+ ); +} diff --git a/src/pages/Knowledge/components/index.ts b/src/pages/Knowledge/components/index.ts new file mode 100644 index 0000000..0fc2c43 --- /dev/null +++ b/src/pages/Knowledge/components/index.ts @@ -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'; diff --git a/src/pages/Knowledge/copy.ts b/src/pages/Knowledge/copy.ts index 29f960e..6e8100d 100644 --- a/src/pages/Knowledge/copy.ts +++ b/src/pages/Knowledge/copy.ts @@ -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: 'キャンセル', diff --git a/src/pages/Knowledge/index.tsx b/src/pages/Knowledge/index.tsx index 6fcd693..729d6cc 100644 --- a/src/pages/Knowledge/index.tsx +++ b/src/pages/Knowledge/index.tsx @@ -1,15 +1,11 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type SVGProps } from 'react'; -import { hotelStaffTypeMappingPageListUsingPost } from '../../api/typeMapping'; +import { knowledgeDocsApi } from '../../lib/knowledge-docs-api'; import { useKnowledgeCopy } from './copy'; -import type { - AddEventInput, - FeedbackState, - FeedbackTone, - KnowledgeEvent, - KnowledgeImage, - KnowledgeTabKey, - RoomTypeRow, -} from './types'; +import KnowledgeDocsTable, { type KnowledgeDocRow } from './components/KnowledgeDocsTable'; +import KnowledgeEmptyState from './components/KnowledgeEmptyState'; +import KnowledgeFeedbackBanner from './components/KnowledgeFeedbackBanner'; +import KnowledgePageHeader from './components/KnowledgePageHeader'; +import KnowledgeToolbar from './components/KnowledgeToolbar'; function cn(...tokens: Array): string { return tokens.filter(Boolean).join(' '); @@ -36,15 +32,6 @@ function IconBase({ ); } -function SearchIcon(props: SVGProps) { - return ( - - - - - ); -} - function RefreshIcon(props: SVGProps) { return ( @@ -54,40 +41,12 @@ function RefreshIcon(props: SVGProps) { ); } -function PlusIcon(props: SVGProps) { +function UploadIcon(props: SVGProps) { return ( - - - - ); -} - -function CloseIcon(props: SVGProps) { - return ( - - - - - ); -} - -function ImageIcon(props: SVGProps) { - return ( - - - - - - - ); -} - -function CopyIcon(props: SVGProps) { - return ( - - - + + + ); } @@ -104,15 +63,6 @@ function TrashIcon(props: SVGProps) { ); } -function EditIcon(props: SVGProps) { - return ( - - - - - ); -} - function EmptyIcon(props: SVGProps) { return ( @@ -123,608 +73,155 @@ function EmptyIcon(props: SVGProps) { ); } -function toneClasses(tone: FeedbackTone): string { - if (tone === 'success') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; +type KnowledgeDocsApiItem = { + id?: string; + fileName?: string; + name?: string; + size?: number; + updatedAt?: string; + modifiedAt?: string; + fileType?: string; + mimeType?: string; +}; + +type KnowledgeDocsApiResponse = + | KnowledgeDocsApiItem[] + | { + success?: boolean; + docs?: KnowledgeDocsApiItem[]; + data?: KnowledgeDocsApiItem[]; + items?: KnowledgeDocsApiItem[]; + message?: string; + }; + +type KnowledgeFeedback = { + id: number; + kind: 'success' | 'warning' | 'error' | 'info'; + message: string; +} | null; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function formatBytes(bytes: number | null | undefined): string { + const size = Number(bytes ?? 0); + if (!Number.isFinite(size) || size <= 0) return '-'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let currentSize = size; + let unitIndex = 0; + + while (currentSize >= 1024 && unitIndex < units.length - 1) { + currentSize /= 1024; + unitIndex += 1; } - if (tone === 'warning') { - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'; - } - - if (tone === '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'; + return `${currentSize >= 10 || unitIndex === 0 ? Math.round(currentSize) : currentSize.toFixed(1)} ${units[unitIndex]}`; } -function createPreviewDataUrl(): string { - const svg = ` - - - - - - - - - - - - - Knowledge Asset Preview - - `; - - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +function formatDateTime(value: string | null | undefined): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); } -function createAssetPreview(kind: KnowledgeImage['kind'], label: string): string { - if (kind === 'image') { - return createPreviewDataUrl(); - } +function inferFileType(item: KnowledgeDocsApiItem): string { + const explicit = String(item.fileType ?? '').trim(); + if (explicit) return explicit; - const svg = ` - - - - ${label} - ${kind.toUpperCase()} - - `; + const mimeType = String(item.mimeType ?? '').trim(); + if (mimeType) return mimeType; - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + const name = String(item.fileName ?? item.name ?? '').trim(); + const index = name.lastIndexOf('.'); + return index > -1 ? name.slice(index + 1).toUpperCase() : 'FILE'; } -function normalizeRoomTypes(rows: RoomTypeRow[] | undefined): RoomTypeRow[] { - if (!Array.isArray(rows) || rows.length === 0) return []; +function normalizeDocs(payload: KnowledgeDocsApiResponse | unknown): KnowledgeDocRow[] { + const source = Array.isArray(payload) + ? payload + : isRecord(payload) + ? (Array.isArray(payload.docs) + ? payload.docs + : Array.isArray(payload.data) + ? payload.data + : Array.isArray(payload.items) + ? payload.items + : []) + : []; - return rows.map((row, index) => ({ - ...row, - id: row.id ?? `room-type-${index}`, - dyHotSpringName: row.dyHotSpringName ?? row.dyHotSrpingName ?? '', - dyHotSrpingName: row.dyHotSrpingName ?? row.dyHotSpringName ?? '', - })); + return source + .filter((item): item is KnowledgeDocsApiItem => isRecord(item)) + .map((item, index) => { + const name = String(item.fileName ?? item.name ?? '').trim() || `Document ${index + 1}`; + return { + id: String(item.id ?? name), + fileName: name, + size: Number(item.size ?? 0), + modifiedAt: String(item.updatedAt ?? item.modifiedAt ?? ''), + fileType: inferFileType(item), + }; + }) + .sort((left, right) => { + const leftTime = new Date(left.modifiedAt).getTime() || 0; + const rightTime = new Date(right.modifiedAt).getTime() || 0; + return rightTime - leftTime; + }); } -function normalizeImageKind(file: File): KnowledgeImage['kind'] { - if (file.type.startsWith('video/')) return 'video'; - if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) return 'document'; - return 'image'; -} - -function formatDateRange(startAt: string, endAt: string): string { - return `${startAt} - ${endAt}`; -} - -function buildImageFromFile(file: File): KnowledgeImage { - const kind = normalizeImageKind(file); - const objectUrl = URL.createObjectURL(file); - - return { - id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name: file.name, - url: kind === 'image' ? objectUrl : createAssetPreview(kind, file.name), - description: '', - sourceUrl: objectUrl, - createdAt: new Date().toLocaleString(), - kind, - objectUrl: kind === 'image', - }; -} - -function EventDialog({ - open, - event, - t, - onClose, - onSave, -}: { - open: boolean; - event: KnowledgeEvent | null; - t: ReturnType; - onClose: () => void; - onSave: (input: AddEventInput) => void; -}) { - const [form, setForm] = useState({ - name: '', - description: '', - startAt: '', - endAt: '', +async function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error('Failed to read file')); + reader.onloadend = () => { + const dataUrl = String(reader.result || ''); + resolve(dataUrl.split(',')[1] || ''); + }; + reader.readAsDataURL(file); }); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open) return; - - setForm({ - name: event?.name ?? '', - description: event?.description ?? '', - startAt: event?.startAt ?? '', - endAt: event?.endAt ?? '', - }); - setError(null); - }, [event, open]); - - if (!open) return null; - - function updateField(key: K, value: AddEventInput[K]) { - setForm((current) => ({ ...current, [key]: value })); - } - - function handleSubmit() { - const name = form.name.trim(); - const description = form.description.trim(); - - if (!name) { - setError(t('knowledge.event.pleaseEnterEventName')); - return; - } - - if (name.length < 3 || name.length > 50) { - setError(t('knowledge.event.lengthValidation')); - return; - } - - if (!description) { - setError(t('knowledge.event.pleaseEnterEventDesc')); - return; - } - - if (!form.startAt || !form.endAt || form.startAt > form.endAt) { - setError(t('knowledge.event.pleaseSelectTimeRange')); - return; - } - - onSave({ - name, - description, - startAt: form.startAt, - endAt: form.endAt, - }); - } - - return ( -
-
-
-
-

- {event ? `${t('knowledge.event.addEvent')} / Edit` : t('knowledge.event.addEvent')} -

-

- {t('knowledge.event.effectiveTimeRange')} -

-
- -
- -
-
- - updateField('name', eventValue.target.value)} - placeholder={t('knowledge.event.pleaseEnterEventName')} - className="h-12 w-full rounded-2xl border border-black/10 bg-[#f8fafc] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#2B7FFF] dark:border-[#2a2a2d] dark:bg-[#222225] dark:text-[#f3f4f6]" - /> -
- -
- -