diff --git a/.project-docs/30-worklog/tasks/20261013-merge-home-settings-9b6e1d47.md b/.project-docs/30-worklog/tasks/20261013-merge-home-settings-9b6e1d47.md index 08e81e2f..c724f31c 100644 --- a/.project-docs/30-worklog/tasks/20261013-merge-home-settings-9b6e1d47.md +++ b/.project-docs/30-worklog/tasks/20261013-merge-home-settings-9b6e1d47.md @@ -20,15 +20,15 @@ ## Outcome -- Not completed. +- Candidate merged in this isolated worktree. All product/tests/README/configuration blobs exactly match the tested published source fafdb826 while retaining every bf39 product change. One knowledge E2E conflict reconciled the old Vite dev fixture with the already tested static build/HTTP fixture; main normalizePath portability remains. Source task records remain unchanged in source branch/merge parent; target canonical/project docs unchanged except this owned merge record. Final push pending. ## Verification -- Not run. +- Independent read-only reviewer confirmed exact source equivalence and main message/process/resource-label updates retained. Typecheck, 15 focused unit files / 154 tests and conflicted-fixture ESLint passed; source tree/whitespace and task ownership boundaries checked. Complete production Renderer/Main/preload/utility build and all five knowledge fixture cases passed (18.8s). The newly compiled dist/dist-electron/resources/package are byte-identical to the active 2cc542f snapshot, so no new runtime refresh is necessary. Existing original source verification covers combined Main/recovery/channel and eight UI scenarios; no historical unrelated Code assertion altered. ## Follow-ups -- None recorded. +- Primary local main checkout is owned by a different task and remains unchanged; main publication will be performed directly from this reviewed candidate. Running temporary application needs no refresh because merged product is byte-equivalent to its verified source. ## Promotion Candidates @@ -47,3 +47,5 @@ 1. Merge exact published source without committing; resolve only compatible overlaps by retaining the tested assembled product and current main changes. Restore any merge-produced project-document delta to main's committed state, except this task's own record. 2. Check all product/test/config blobs against tested source, independently review resolution and run appropriate focused verification if any product differences remain. 3. Commit the merge/handoff, freshly verify remote main is still the candidate parent, push main without force and prove remote HEAD/source ancestry. Complete and clean this new temporary merge worktree under the existing cleanup instruction, preserving commits and running app. + +- Final merge verification: exact source equivalence, independent semantic review, 154 focused unit tests, 5 conflicted knowledge UI fixtures, typecheck, relevant lint and full reproducible build passed; no unresolved merge markers or documentation drift. Existing active runtime remains intact. diff --git a/README.md b/README.md index 8ec3314b..73d833bd 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,19 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。我的知识库以服务端本人管理目录为准,提供「上传自己的文件」入口,可新建库或追加到已有个人库,上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份,用户可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 + + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 + Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 @@ -27,6 +29,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 +创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 + ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 diff --git a/electron/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index b703e62b..1e888bec 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -73,9 +73,10 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR sendJson(res, 200, data); } catch (error) { const failure = error instanceof CloudAgentsError ? error - : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable'); + : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable', + error instanceof SyntaxError ? 'definitive_failure' : 'unknown'); if (res.headersSent) res.end(); - else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code }); + else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code, commandOutcome: failure.commandOutcome }); } return true; } diff --git a/electron/services/cloud-agent-journal.ts b/electron/services/cloud-agent-journal.ts index 103863a5..0a21ef9c 100644 --- a/electron/services/cloud-agent-journal.ts +++ b/electron/services/cloud-agent-journal.ts @@ -26,14 +26,17 @@ export class CloudAgentJournal { return this.update(accountKey, validate, value => ({ ...value, recent })); } - put(accountKey: string, entry: CloudPendingOperation, validate: () => void) { - return this.update(accountKey, validate, value => { + async put(accountKey: string, entry: CloudPendingOperation, validate: () => void): Promise { + let alreadyPending = false; + await this.update(accountKey, validate, value => { const existing = value.pending.find(item => item.id === entry.id); if (existing && JSON.stringify(existing.input) !== JSON.stringify(entry.input)) { throw new Error('同一次待确认操作不能更改输入'); } + alreadyPending = Boolean(existing); return { ...value, pending: existing ? value.pending : [...value.pending, entry] }; }); + return alreadyPending; } remove(accountKey: string, id: string, validate: () => void) { diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index ee055322..1be67387 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -29,7 +29,12 @@ const MESSAGES: Record = { draft_revision_conflict: '草稿已在其他设备更新,你的输入已保留', operation_conflict: '创建操作的内容发生冲突,请刷新列表确认', cloud_service_unavailable: '智能体服务暂时不可用,请重试', - invalid_input: '请检查名称、用途和配置内容', + invalid_input: '提交的信息未被接受,请检查后再试', + invalid_name: '请填写名称,长度不要超过 100 个字', + invalid_introduction: '一句话介绍不符合要求,长度不要超过 2000 个字', + introduction_too_short: '一句话介绍不符合要求,请修改后再试', + introduction_required: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试', + invalid_icon: '头像设置未被接受,请重新选择头像', model_required: '请先选择模型并保存', model_unavailable: '所选模型当前不可用,请刷新模型目录', resource_unavailable: '所选资源已不可用,请刷新配置', @@ -73,11 +78,39 @@ const MESSAGES: Record = { }; export class CloudAgentsError extends Error { - constructor(readonly status: number, readonly code: string) { + constructor(readonly status: number, readonly code: string, + readonly commandOutcome: 'definitive_failure' | 'unknown' = 'unknown') { super(MESSAGES[code] ?? MESSAGES.cloud_service_unavailable); } } +const validationTypes = new Set(['missing', 'string_type', 'string_too_short', 'string_too_long', + 'string_pattern_mismatch', 'uuid_parsing', 'uuid_type', 'value_error']); + +/** Only structural request-validation metadata is trusted; never expose msg, input or ctx. */ +function requestValidation(detail: unknown): boolean { + return Array.isArray(detail) && detail.length > 0 && detail.length <= 100 && detail.every(value => { + const item = record(value); + return Array.isArray(item.loc) && item.loc[0] === 'body' && item.loc.length >= 2 + && typeof item.type === 'string' && validationTypes.has(item.type); + }); +} + +function validationMessageCode(detail: unknown): string { + if (!requestValidation(detail)) return 'invalid_input'; + const codes = (detail as unknown[]).map(value => { + const item = record(value); + if (!Array.isArray(item.loc) || item.loc.length !== 2) return 'invalid_input'; + switch (item.loc[1]) { + case 'name': return 'invalid_name'; + case 'purpose': return item.type === 'string_too_short' ? 'introduction_too_short' : 'invalid_introduction'; + case 'icon': return 'invalid_icon'; + default: return 'invalid_input'; + } + }); + return codes.every(code => code === codes[0]) ? codes[0] : 'invalid_input'; +} + function record(value: unknown): Record { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; @@ -154,6 +187,7 @@ export class CloudAgentsModule { private activityTimer: ReturnType; private activityPolling = false; private activitySeen: Map | null = null; + private readonly creationAttempts = new Map }>(); constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise = proxyAwareFetch, private readonly journal = new CloudAgentJournal()) { @@ -196,17 +230,49 @@ export class CloudAgentsModule { async create(value: unknown): Promise { const input = record(value); - const operationId = textField(input.operation_id, 36); - if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { - throw new CloudAgentsError(422, 'invalid_input'); + let body: CreateCloudAgent; + try { + const operationId = textField(input.operation_id, 36); + if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { + throw new CloudAgentsError(422, 'invalid_input'); + } + body = { + operation_id: operationId, + name: textField(input.name, 100).trim(), + purpose: textField(input.purpose, 2000, false).trim(), + ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), + }; + } catch (error) { + if (error instanceof CloudAgentsError) { + const binding = getWorksSquareAccountBinding(); + const id = typeof input.operation_id === 'string' ? input.operation_id : ''; + let alreadyPending = Boolean(binding && this.creationAttempts.has(`${binding.accountKey}:${binding.epoch}:${id}`)); + if (binding) { + alreadyPending ||= (await this.journal.read(binding.accountKey)).pending.some(item => item.id === `createAgent::${id}`); + this.requireCurrent(binding); + } + throw new CloudAgentsError(error.status, error.code, alreadyPending ? 'unknown' : 'definitive_failure'); + } + throw error; } - const body: CreateCloudAgent = { - operation_id: operationId, - name: textField(input.name, 100).trim(), - purpose: textField(input.purpose, 2000, false).trim(), - ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), - }; - return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(result => this.withAvatar(projectDraft(result)))); + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const key = `${binding.accountKey}:${binding.epoch}:${body.operation_id}`; + const serialized = JSON.stringify(body); + const existing = this.creationAttempts.get(key); + if (existing) { + if (existing.input !== serialized) throw new CloudAgentsError(409, 'operation_conflict'); + return existing.promise; + } + const promise = this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body) + .then(result => this.withAvatar(projectDraft(result))).catch(error => { + if (body.purpose === '' && error instanceof CloudAgentsError && error.code === 'introduction_too_short') { + throw new CloudAgentsError(error.status, 'introduction_required', error.commandOutcome); + } + throw error; + })).finally(() => { this.creationAttempts.delete(key); }); + this.creationAttempts.set(key, { input: serialized, promise }); + return promise; } private async withRecovery(operation: string, input: Record, execute: () => Promise): Promise { @@ -217,9 +283,23 @@ export class CloudAgentsModule { .map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':') : input.request_id ?? input.operation_id; const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':'); - await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); + const alreadyPending = await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); this.requireCurrent(binding); - const result = await execute(); + let result: T; + try { + result = await execute(); + } catch (error) { + this.requireCurrent(binding); + if (error instanceof CloudAgentsError) { + // A retry rejection cannot disprove an earlier acceptance whose response was lost. + if (operation === 'createAgent' && !alreadyPending && error.commandOutcome === 'definitive_failure') { + await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); + throw error; + } + throw new CloudAgentsError(error.status, error.code, 'unknown'); + } + throw error; + } this.requireCurrent(binding); await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); return result; @@ -671,11 +751,17 @@ export class CloudAgentsModule { if (raw.length > 4_000_000) throw new CloudAgentsError(502, 'cloud_service_unavailable'); const body: unknown = JSON.parse(raw); if (!response.ok) { - const code = record(record(body).detail).code; + const detail = record(body).detail; + const code = record(detail).code; + const rejected = ([400, 422].includes(response.status) && (code === 'invalid_input' || requestValidation(detail))) + || (response.status === 401 && code === 'session_expired') + || (response.status === 403 && ['access_revoked', 'module_unavailable'].includes(String(code))) + || (response.status === 423 && code === 'account_locked'); throw new CloudAgentsError( response.status, typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code - : response.status === 422 ? 'invalid_input' : response.status === 401 ? 'session_expired' + : response.status === 422 ? validationMessageCode(detail) : response.status === 401 ? 'session_expired' : response.status === 403 ? 'access_revoked' : 'cloud_service_unavailable', + rejected ? 'definitive_failure' : 'unknown', ); } return body; diff --git a/src/components/account/AccountMenuTrigger.tsx b/src/components/account/AccountMenuTrigger.tsx new file mode 100644 index 00000000..88ca7bd8 --- /dev/null +++ b/src/components/account/AccountMenuTrigger.tsx @@ -0,0 +1,38 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { cn } from '@/lib/utils'; + +export interface AccountMenuTriggerProps extends ButtonHTMLAttributes { + accountName: string; + avatarUrl?: string | null; + collapsed?: boolean; + expandedWidth?: number; + open?: boolean; + avatarTestId?: string; + surfaceTestId?: string; +} + +/** Shared Code account appearance; the containing menu owns behavior and width preferences. */ +export const AccountMenuTrigger = forwardRef( + function AccountMenuTrigger({ + accountName, avatarUrl, collapsed = false, expandedWidth = 150, open = false, + avatarTestId, surfaceTestId, className, style, type = 'button', ...props + }, ref) { + return ; + }, +); diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx new file mode 100644 index 00000000..e2507b33 --- /dev/null +++ b/src/components/account/HomeAccountMenu.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import { LogOut, Settings, UserCircle2 } from 'lucide-react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { DisclosureContent } from '@/components/ui/disclosure'; +import { useAuthStore } from '@/stores/auth'; +import { useSettingsStore } from '@/stores/settings'; +import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; +import { canLeaveCurrentPage, getSettingsReturnState } from '@/lib/settings-navigation'; +import { PointWallet } from './PointWallet'; +import { AccountMenuTrigger } from './AccountMenuTrigger'; + +export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { + accountName: string; + avatarUrl?: string | null; + onOpenProfile: () => void; +}) { + const user = useAuthStore(state => state.user); + const logout = useAuthStore(state => state.logout); + const expandedWidth = useSettingsStore(state => normalizeClassroomSidebarWidth(state.classroomSidebarWidth) - 21); + const navigate = useNavigate(); + const location = useLocation(); + const [open, setOpen] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [error, setError] = useState(''); + const trigger = useRef(null); + const menu = useRef(null); + const initial = getAccountInitial(accountName); + + useEffect(() => { + if (!open) return; + const outside = (event: MouseEvent) => { + if (event.target instanceof Node && !trigger.current?.contains(event.target) && !menu.current?.contains(event.target)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('mousedown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + const focusItem = (last = false) => requestAnimationFrame(() => { + if (trigger.current?.getAttribute('aria-expanded') !== 'true') return; + const items = menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)'); + if (items?.length) items[last ? items.length - 1 : 0].focus(); + }); + + if (!user) return null; + + return
+ setOpen(value => !value)} + onKeyDown={event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); setOpen(true); focusItem(event.key === 'ArrowUp'); + } + }} /> + { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const items = [...(menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? [])]; + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; + items[index]?.focus(); + }}> +
+ +

{accountName}

词元点数账户

+
+ +
+ + + +
+ {error &&

{error}

} +
+
; +} diff --git a/src/components/account/StandaloneAccountDock.tsx b/src/components/account/StandaloneAccountDock.tsx new file mode 100644 index 00000000..9f584029 --- /dev/null +++ b/src/components/account/StandaloneAccountDock.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; +import { HomeAccountMenu } from './HomeAccountMenu'; +import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; +import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; +import { getAuthUserDisplayName } from '@/lib/auth-user-display'; +import { useAuthStore } from '@/stores/auth'; +import { getUserProfileDisplayName } from '@/stores/user-profile'; + +export function StandaloneAccountDock({ enforceProfileCompletion = true }: { enforceProfileCompletion?: boolean }) { + const user = useAuthStore(state => state.user); + const authenticated = useAuthStore(state => state.isAuthenticated()); + const { userProfile, profileRequired, profileSyncError, syncProfileNow } = useCurrentUserProfile(authenticated); + const [profileOpen, setProfileOpen] = useState(false); + const [dismissedError, setDismissedError] = useState(null); + if (!user || !authenticated) return null; + const name = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(user) || user.username; + return <> +
+ { setDismissedError(null); setProfileOpen(true); void syncProfileNow().catch(() => undefined); }} /> +
+ { + setProfileOpen(open); + if (open) setDismissedError(null); + else if (profileSyncError) setDismissedError(profileSyncError); + }} /> + ; +} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 94712efb..7d1a5def 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -6,6 +6,8 @@ import { Outlet, useLocation } from 'react-router-dom'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Sidebar } from './Sidebar'; import { TitleBar } from './TitleBar'; +import { SettingsLayout } from './SettingsLayout'; +import { StandaloneAccountDock } from '@/components/account/StandaloneAccountDock'; import { getAiModuleForPath } from '@/lib/ai-modules'; import { useSettingsStore } from '@/stores/settings'; import { cn } from '@/lib/utils'; @@ -74,6 +76,8 @@ export function MainLayout() { if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current); }, []); + if (location.pathname === '/settings' || location.pathname.startsWith('/settings/')) return ; + return (
{/* Title bar: drag region on macOS, icon + controls on Windows */} @@ -103,11 +107,13 @@ export function MainLayout() { 'relative min-h-0 min-w-0 flex-1 overflow-auto bg-background', (isPaintingModule || isCloudAgents || isChatWorkspace) ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6', (isChatWorkspace || isCloudAgents) && !isPaintingModule && 'basis-0 overflow-hidden p-0', + isCanvasWorkspace && 'pb-16 lg:pb-0', )} >
+ {isCanvasWorkspace &&
} ); } diff --git a/src/components/layout/SettingsLayout.tsx b/src/components/layout/SettingsLayout.tsx new file mode 100644 index 00000000..16daa8e7 --- /dev/null +++ b/src/components/layout/SettingsLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; +import { TitleBar } from './TitleBar'; +import { StandaloneAccountDock } from '@/components/account/StandaloneAccountDock'; + +export function SettingsLayout() { + return
+ +
+ +
; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index dcd14316..a4cdc38c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -36,6 +36,8 @@ import { subscribeCodingProjectOpenRequest, } from '@/lib/coding-project-entry'; import { PointWallet } from '@/components/account/PointWallet'; +import { AccountMenuTrigger } from '@/components/account/AccountMenuTrigger'; +import { canLeaveCurrentPage, getSettingsReturnState } from '@/lib/settings-navigation'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; @@ -361,8 +363,9 @@ export function Sidebar({ }; const navigateFromAccountMenu = (path: string) => { + if (path === '/settings' && !canLeaveCurrentPage()) { toast.info('请先保存当前修改,再打开设置。'); return; } setAccountMenuOpen(false); - navigate(path); + navigate(path, path === '/settings' ? { state: getSettingsReturnState(location) } : undefined); }; const openProfileDialog = () => { @@ -810,42 +813,26 @@ export function Sidebar({ onOpenChange={setProfileDialogOpen} /> -
+
-
- +
+
+ setAccountMenuOpen((open) => !open)} + /> +
void handleClick()} className={cn( - 'motion-press flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', + 'motion-press flex h-10 w-10 max-w-full shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', collapsed && 'shadow-none', )} > diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 00a8ad64..043df9b1 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -212,6 +212,7 @@ function ProductTitleBar({ data-testid="titlebar-main-surface" className={cn( 'relative flex min-w-0 flex-1 bg-background', + nativeTrafficLights && !nativeFullscreen && !showSidebarControls && !overlay && 'pl-[88px]', overlay && 'pointer-events-none border-b-0 bg-transparent', nativeTrafficLights && 'drag-region', !overlay && (workspaceLayout || sidebarTitlebarHidden ? 'border-b-0' : 'border-b border-border/80'), diff --git a/src/lib/settings-navigation.ts b/src/lib/settings-navigation.ts new file mode 100644 index 00000000..cd54837b --- /dev/null +++ b/src/lib/settings-navigation.ts @@ -0,0 +1,30 @@ +import { AI_MODULE_SELECTION_PATH, getGuardedAiModuleForPath } from './ai-modules'; + +type SettingsLocation = { pathname: string; search?: string; hash?: string; state?: unknown }; + +function localReturnPath(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return null; + try { + const target = new URL(value, 'https://makelore.local'); + if (target.origin !== 'https://makelore.local') return null; + if (target.pathname !== AI_MODULE_SELECTION_PATH && !getGuardedAiModuleForPath(target.pathname)) return null; + return `${target.pathname}${target.search}${target.hash}`; + } catch { return null; } +} + +export function getSettingsReturnPath(state: unknown): string { + const candidate = state && typeof state === 'object' && 'settingsReturnTo' in state + ? (state as { settingsReturnTo?: unknown }).settingsReturnTo : null; + return localReturnPath(candidate) ?? AI_MODULE_SELECTION_PATH; +} + +export function getSettingsReturnState(location: SettingsLocation): { settingsReturnTo: string } { + const inSettings = location.pathname === '/settings' || location.pathname.startsWith('/settings/'); + return { settingsReturnTo: inSettings ? getSettingsReturnPath(location.state) + : localReturnPath(`${location.pathname}${location.search ?? ''}${location.hash ?? ''}`) ?? AI_MODULE_SELECTION_PATH }; +} + +/** Honor the current page's existing unsaved-input protection before internal navigation. */ +export function canLeaveCurrentPage(): boolean { + return typeof window === 'undefined' || window.dispatchEvent(new Event('beforeunload', { cancelable: true })); +} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index ec6e0d00..a562e52c 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -36,8 +36,7 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row[data-selected="true"] { background: #e0ecfa; border-color: #cedef0; color: #304f75; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child { min-height: 35px; gap: 7px; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child > svg { color: #7891af; } -[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { padding: 10px 10px 12px; border-top: 1px solid #dde5ee; background: #edf3f9; } -[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-member-menu-trigger"] { min-height: 42px; border: 1px solid #d9e3ee; border-radius: 11px; background: #fff; font-size: 12px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { border-top: 1px solid #dde5ee; background: #edf3f9; } .classroom-workspace [data-testid="coding-conversation-header"] { border-bottom-color: #e8edf3; background: #fff; color: #304968; } .classroom-tabs { display: flex; flex-shrink: 0; height: 46px; gap: 9px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } .classroom-workspace-toolbar { display: flex; flex-wrap: wrap; flex-shrink: 0; align-items: center; gap: 6px 12px; min-height: 46px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 95dde3f9..10c3b269 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -251,6 +251,11 @@ export function ChannelAccounts() { {flow?.target && !targetAgent && selected?.target_agent_slug === flow.target && } {agents.map(agent => )} ; + const agentRequiredHint =
+

还没有可连接的智能体

+

先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。

+ +
; return
@@ -266,12 +271,13 @@ export function ChannelAccounts() {
{!flow && messages} + {!loading && loaded && !agents.length && !flow && agentRequiredHint} {loading ?
正在查看你的微信绑定…
: !loaded ?

暂时没能读到微信绑定,请刷新后再试。

- : !accounts.length ?
+ : !accounts.length ? (agents.length > 0 &&

把你的智能体带到微信吧

绑定后,就可以回到微信和它聊聊。每个微信绑定一个智能体,也可以把多个微信交给同一个智能体。

-
:
+
) :
{accounts.map(account => { const agent = agents.find(item => item.slug === account.target_agent_slug); const state = accountState(account); @@ -298,7 +304,7 @@ export function ChannelAccounts() { {recoveryBlocked &&

有一次绑定操作的结果需要确认,请先关闭此窗口并处理页面上的恢复提醒。

} {flow.step === 'choose' && <> {flow.mode === 'manage' && selectedState &&
微信连接与绑定{selectedState.text}
} - {!agents.length ?

先准备一个可以使用的智能体,再把它带到微信里。

+ {!agents.length ? agentRequiredHint : } {flow.mode === 'new' && !flow.accountId && } {flow.mode === 'manage' && selected?.blockers.length ?

{selected.blockers.map(item => blockerText[item] ?? '连接暂时不可用,请刷新后重试').join(';')}

: null} diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index 2e95e870..f3edb6cd 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { Library, Upload } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -9,11 +10,10 @@ import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments'; export interface CloudOwnedKnowledgeCatalog { databases: CloudKnowledge[] | null; loading: boolean; error: string } -export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, onCatalog, refreshToken = 0, disabled = false, selectionFull = false, selectedIds = [], selectableIds }: { +export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, refreshToken = 0, disabled = false, selectableIds }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void; onCatalog?: (value: CloudOwnedKnowledgeCatalog) => void; refreshToken?: number; disabled?: boolean; - selectionFull?: boolean; selectedIds?: string[]; - selectableIds?: string[] | null; onSelected?: (kb: CloudKnowledge) => void; + selectableIds?: string[] | null; }) { type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number]; const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null); @@ -21,6 +21,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const [name, setName] = useState(''); const [model, setModel] = useState(''); const [error, setError] = useState(''); + const [errorTarget, setErrorTarget] = useState<'creation' | 'management'>('management'); const [catalogError, setCatalogError] = useState(''); const [catalogLoading, setCatalogLoading] = useState(true); const [busy, setBusy] = useState(false); @@ -49,10 +50,10 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on .finally(() => { if (current) setCatalogLoading(false); }); return () => { current = false; }; }, [slug, refresh, refreshToken]); - const act = async (operation: () => Promise) => { + const act = async (operation: () => Promise, target: 'creation' | 'management' = 'management') => { if (disabled || operationInFlight.current) return; operationInFlight.current = true; - setBusy(true); setError(''); + setBusy(true); setError(''); setErrorTarget(target); try { await operation(); } catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); } finally { operationInFlight.current = false; if (alive.current) setBusy(false); } @@ -62,63 +63,71 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId); const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError); const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id)); - return
- 上传自己的文件 -
-

选择或新建自己的知识库,再上传文件。选用只会加入当前配置,保存并应用后生效。

-

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

- {error &&

{error}

} + const creationControls =
+ + {creating &&
+ setName(e.target.value)} /> + + {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
+

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

+
+ 管理员配置说明 +
    +
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. +
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. +
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. +
+
+
} + +
} +
; + return
+
+
+ {creationControls} + {errorTarget === 'creation' && error &&

{error}

} +
+
+
+ {errorTarget === 'management' && error &&

{error}

} {catalogError &&

知识库与模型目录加载失败:{catalogError}。请刷新重试。

}
-
+ {documentPending && selectedKnowledge &&

有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。

} +
+ 使用说明与费用 +

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

+
{!catalogLoading && !catalogError && !manageable.length &&

还没有可管理的知识库,可以新建一个保存自己的文件。MakeLore知识仅供检索。

} {selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) &&

{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。

} - {creating &&
- setName(e.target.value)} /> - - {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
-

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

-
- 管理员配置说明 -
    -
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. -
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. -
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. -
-
-
} - -
} - {documentPending && selectedKnowledge &&

有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。

} {kbId && !selectedKnowledge && !catalogLoading && !catalogError &&

这个知识库已无法管理。{documentPending ? '未确认的操作仍保留在当前页面。' : '请重新选择自己的知识库。'}

{documentPending &&

放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。

}
} + {!selectedKnowledge &&
+ +
} {kbId && } -
-
; +
+
; } diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 14e0ac64..0cd84b6d 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -3,7 +3,7 @@ import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { usePendingCloudInput } from './CloudPending'; -import type { CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents'; +import type { CloudKnowledgeFile } from '../../../shared/cloud-agents'; function documentState(file: CloudKnowledgeFile) { const task = file.processing_task; @@ -61,8 +61,6 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled const [readError, setReadError] = useState(''); const [uploading, setUploading] = useState(''); const [submitting, setSubmitting] = useState(null); - const [importing, setImporting] = useState(false); - const [importPending, setImportPending] = useState(false); const [deleting, setDeleting] = useState(null); const [processErrors, setProcessErrors] = useState>({}); const uploadIntents = useRef>({}); @@ -71,7 +69,7 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled const permitted = useRef(canRead); useEffect(() => { permitted.current = canRead; }, [canRead]); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); - const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0 || importPending; + const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0; usePendingCloudInput(unresolved); useEffect(() => { onPendingChange?.(unresolved); return () => onPendingChange?.(false); }, [onPendingChange, unresolved]); const refresh = () => setRevision(value => value + 1); @@ -154,14 +152,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled const active = states.some(state => state.active) || submitting !== null; return
- - +

上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。

{uploading &&

{uploading}

} - {importing && { setImporting(false); await acceptFile(file); }} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'} {active ? '处理中 · 状态自动更新' : '状态自动更新'} @@ -215,52 +210,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled {deleting &&

{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : '删除文档“' + deleting.name + '”及其索引?'}

}
; } - -function AttachmentImport({ slug, kbId, busy, act, onImported, onPendingChange }: { - slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; - onImported: (file: CloudKnowledgeFile) => Promise; - onPendingChange: (pending: boolean) => void; -}) { - const [threads, setThreads] = useState([]); - const [thread, setThread] = useState(''); - const [files, setFiles] = useState([]); - const [file, setFile] = useState(''); - const [error, setError] = useState(''); - const operation = useRef(null); - const unresolved = Boolean(operation.current); - useEffect(() => { onPendingChange(unresolved); return () => onPendingChange(false); }, [onPendingChange, unresolved]); - usePendingCloudInput(busy || Boolean(operation.current)); - useEffect(() => { - let live = true; - const load = async () => { - let page = await cloudAgentsApi.call('threads', {}); - while (page.next_offset !== null) { const next = await cloudAgentsApi.call('threads', { offset: page.next_offset }); page = { ...next, threads: [...page.threads, ...next.threads] }; } - if (live) setThreads(page.threads); - }; - void load().catch(e => { if (live) setError(e instanceof Error ? e.message : '对话读取失败'); }); - return () => { live = false; }; - }, []); - useEffect(() => { - let live = true; setFiles([]); setFile(''); - if (thread) cloudAgentsApi.call('attachments', { thread_id: thread }).then(page => { if (live) setFiles(page.attachments); }).catch(e => { if (live) setError(e instanceof Error ? e.message : '附件读取失败'); }); - return () => { live = false; }; - }, [thread]); - return
-

只显示你自己的对话附件。导入后自动处理,变为“可用于回答”后才能检索。

- - - - {error &&

{error}

} -
; -} diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 65ce7dd9..0c78f04a 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X } from 'lucide-react'; +import { Library, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CloudKnowledgePanel, type CloudOwnedKnowledgeCatalog } from './CloudKnowledge'; import type { CloudResource } from '../../../shared/cloud-agents'; @@ -16,7 +16,14 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load const unknown = ownedIds === null ? available : []; const selectionFull = selected.length >= 100; const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); }; - return
+ return
+ option.key)} + onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }} + onCreated={onRefresh} /> +
+
+

从 MakeLore知识和我的知识库中选择,可同时使用多个。