feat(agents): add grouped knowledge selection and personal uploads
This commit is contained in:
1 parent
a324a3ad0d
commit
3786a18b43
12 files changed
+763
-54
No files matched your search
@@ -12,34 +12,41 @@
|
||||
|
||||
## Scope
|
||||
|
||||
- Read-only review of personal Agent advanced knowledge configuration, native Yuxi sharing and the existing Makelore facade; propose dropdown and personal-document upload interactions.
|
||||
- No product or test modifications remain. User clarified that design discussion must precede implementation.
|
||||
- Implement the now approved knowledge interaction: grouped multi-selection labeled exactly MakeLore知识 and 我的知识库, plus personal document upload/processing through existing APIs.
|
||||
- Preserve explicit save/apply, shared read-only selection, own resource management and missing/unavailable selected IDs.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User requests a proposal for selecting Yuxi platform knowledge or uploading personal files as knowledge. Latest instruction explicitly forbids beginning code changes until the design is discussed. Screenshot is teammate-provided evidence of native global-read sharing, not an instruction to modify platform permissions.
|
||||
- User first requested a proposal and stopped initial implementation. After discussion, the latest instruction explicitly authorizes modification and specifies the exact labels MakeLore知识 and 我的知识库 with multiple selection. Screenshot is evidence of native global-read sharing, not authorization to change platform permissions.
|
||||
- Concurrent and Planning Gates Passed: matching task/worktree reservation verified at the completed current trial base bcb1b615; entry/memory, own record, positioning/current snapshot, decisions/system/domain/success, relevant knowledge evidence and commitments loaded; peer Scope/Intent/Promotion sections assessed.
|
||||
- Main owns credentials and file IO; Yuxi remains authority for readable/selectable resources and mutations. Shared read-only libraries must not expose upload/replace/process/delete controls. Selection only changes the draft until explicitly saved/applied.
|
||||
- Preserve completed card size, avatar, configuration, chat/composer and thinking changes. Active 20261012-agent-chat-files-review-e87b41c6 owns separate chat attachment UI; isolate files and retain its eventual integration. Historical undefined child-skill scope has no concrete dependency.
|
||||
- Do not steal desktop focus, open native file pickers in verification, mutate live cloud knowledge, send paid model requests, deploy, push or edit peer/canonical documents. Background temporary-app rollout must use a current source comparison and preserve profile/latest compatible peer changes.
|
||||
- Initial implementation interpretation was corrected by the user. All known task-owned product/test experiments were reverted, implementation agents stopped, and the temporary application/cloud remained untouched.
|
||||
- The earlier premature experiments were fully reverted before design discussion. This same-task implementation resume passed ownership/context gates with all peer records rechecked; current temporary Renderer now includes the peer chat-files implementation, which is read-only and must be retained on future integration.
|
||||
- Plan: implement grouped dropdown/selected list and personal upload affordance using catalog-to-owned-list ID joins; exercise meaningful draft, permission, retry and layout regressions; typecheck, build and independently review; record verified source handoff. Current request changes code; no automatic cloud or runtime update is necessary.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Existing facade catalog already lists authorized usable owned/shared Milvus knowledge libraries. The separate Agent-scoped knowledge management list contains only the current creator's libraries across Agents; its IDs provide an authoritative join for identifying personal versus shared libraries without a new backend DTO.
|
||||
- All document upload/list/process/delete operations enforce owned_knowledge independently. Shared libraries can be bound but cannot be maintained through this facade. Shared document metadata is not available through the owned-management endpoint; proposal shows only shared name/description.
|
||||
- Personal Makelore cloud execution already derives retrieval tools from selected and authorized knowledge, so selection writes only configuration.knowledges and does not force a knowledge-base Skill/tools/preload selection. Native Yuxi web configuration has a different Skill requirement.
|
||||
- Proposal: dropdown adds multiple readable libraries to an explicit selected list; a separate personal-upload action expands existing create/select-owned library and document processing workflow. Bindings remain draft-only until explicit save/apply. Upload readiness follows server parsing/indexing and chunk availability, with existing idempotent retries.
|
||||
- Implemented the approved knowledge interaction in advanced configuration: dropdown groups labeled exactly MakeLore知识 and 我的知识库, multiple selections with individual removal and the existing 100-library limit. Selection remains draft-only until explicit save/apply; no Skill/tool/preload selections are added.
|
||||
- Readable/usable facade catalog IDs are joined with the server's creator-owned management list. MakeLore知识 exposes only name/description and read-only binding. Shared libraries never appear in the personal manager or mount document mutators. No backend DTO, permission or cloud contract changed.
|
||||
- 上传自己的文件 expands the existing owned library create/select and document workflow. Uploads automatically parse/index and display server-derived readiness; retries preserve operation IDs and existing usable contents. Owned libraries that are absent from the usable catalog remain manageable without silently adding invalid bindings; existing unavailable bindings stay visible and removable.
|
||||
- Async create/delete completion uses the latest configuration callbacks, preserving interleaved model and knowledge changes. Confirmed creation can finish during an unrelated save. Unconfirmed upload/process/import requests remain scoped to their library through refresh/error and retry-picker cancellation, preserve leave protection, and prevent unrelated switch/delete/replace. A successful ownership-list omission disables/hides the scoped manager until explicit discard; temporary loading/read errors allow an already-authorized upload to finish automatic processing exactly once.
|
||||
- Existing personal Makelore execution derives retrieval tools from selected authorized knowledge. The Yuxi source-contract review confirmed this flow needs no knowledge-base Skill selection or new cloud patch. Native Yuxi web configuration has a separate Skill requirement. Review evidence is in task 20261012-knowledge-api-review-c82e607a (commit a0334f9); no backend product code was changed.
|
||||
- README now documents selection, personal upload, availability and retry semantics. Product changes are limited to four Renderer files; focused and adjacent tests cover the new flow. No primary-checkout merge, push, live cloud mutation or temporary-app rollout was performed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Source-contract inspection only; no claim of live cloud or runtime acceptance. Native ACL/runtime verification delegated to the isolated Yuxi contract review.
|
||||
- Confirmed product/test rollback to recorded base; only this task record remains changed. No deployment, app update, native picker, focus change or model call.
|
||||
- Used the repository-pinned pnpm 10.33.4. Final typecheck, scoped ESLint (zero errors/warnings), git diff --check and build:vite passed. Renderer, Main, preload and utility build outputs succeeded; existing mixed-import/chunk-size/Browserslist warnings remain outside this change.
|
||||
- Final unit verification: 62/62 across cloud-agents-page, cloud-knowledge-picker, cloud-knowledge-catalog and cloud-knowledge-status. Covers grouping, multiple selection/removal, draft persistence, shared non-mutation, owned catalog failure/recovery, missing IDs, selection limit, create/delete races, current model preservation, upload/process retry identity, retry cancellation, lost ownership, delayed upload through refresh/error, temporary parent disable and unusable-owned management.
|
||||
- Headless Chrome verified all 14 focused/adjacent browser scenarios. The final combined run passed all five knowledge scenarios and seven layout/avatar scenarios; two card-return cases initially used an outdated chat-button label. Updated that test-only locator to the current 返回智能体列表 label, then reran both cases successfully (2/2, 5.7 s). Knowledge tests verified 375px/1440px multi-selection and removal through explicit save/reopen, personal create/upload/automatic processing to indexed six-chunk readiness, and cancelled/failed upload retaining bindings, unsaved input and operation identity. Adjacent layout/card/avatar cases verify 375px/768px/1024px/1366px/1440px as appropriate.
|
||||
- Independently reviewed final Renderer code and 31 focused unit cases: PASS, with all scoped findings resolved. Visually inspected background screenshots of desktop selection and narrow-window ready documents; no horizontal overflow or control clipping. Tests use existing wire formats and IPC fixtures, not real cloud uploads or paid model calls.
|
||||
- All verification stayed headless/background. No Electron/native picker was launched, desktop app activated, running temporary process stopped, cloud deployed, or external message sent. Source-only feature is ready for integration; no claim of live cloud acceptance or temporary-app update.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Await user agreement on the interaction before any implementation. Preserve the live runtime's latest compatible peer changes when a later rollout is authorized.
|
||||
- A later authorized temporary-app rollout must apply this knowledge-only change over the current source and preserve the latest compatible peer chat-files/UI changes, user profile and focus behavior. The current trial uses the separate 20261012-agent-chat-files-review-e87b41c6 Renderer source; do not replace it wholesale with this earlier base.
|
||||
- MakeLore知识 shows only libraries already authorized/readable/usable for the signed-in account. Platform library sharing and enabled embedding setup remain existing administrator prerequisites, not changes made by this task.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
- Promote the approved grouped multi-selection, shared read-only versus owned-management semantics, unavailable-binding retention and personal upload/retry flow into canonical Agent configuration/knowledge documentation during integration. Feature mode intentionally leaves canonical and peer task records unchanged.
|
||||
@@ -14,6 +14,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
|
||||
- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部只展示返回、只读头像和名称,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。对话文件按需展开查看和选择,生成文件仍可下载;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考独立于正文展示,运行时可展开、手动折叠,完成后折叠;无思考内容时不显示。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要 Yuxi API 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
|
||||
高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。我的知识库以服务端本人管理目录为准,提供「上传自己的文件」入口,可新建库或追加到已有个人库,上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。
|
||||
|
||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||
|
||||
Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。
|
||||
|
||||
@@ -7,7 +7,14 @@ import type { CloudAgentOperations, CloudKnowledge } from '../../../shared/cloud
|
||||
|
||||
import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments';
|
||||
|
||||
export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void }) {
|
||||
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 }: {
|
||||
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;
|
||||
}) {
|
||||
type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number];
|
||||
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null);
|
||||
const [kbId, setKbId] = useState('');
|
||||
@@ -19,10 +26,17 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
usePendingCloudInput(busy || (creating && Boolean(name.trim())));
|
||||
const [documentPending, setDocumentPending] = useState(false);
|
||||
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
|
||||
usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim())));
|
||||
const operationInFlight = useRef(false);
|
||||
const callbacks = useRef({ onCreated, onDeleted });
|
||||
useEffect(() => { callbacks.current = { onCreated, onDeleted }; }, [onCreated, onDeleted]);
|
||||
const alive = useRef(true);
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
onCatalog?.({ databases: catalogLoading || catalogError ? null : catalog?.databases ?? null, loading: catalogLoading, error: catalogError });
|
||||
}, [catalog, catalogLoading, catalogError, onCatalog]);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setCatalogLoading(true); setCatalogError('');
|
||||
@@ -34,32 +48,52 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
||||
.catch(e => { if (current) setCatalogError(String(e.message || e)); })
|
||||
.finally(() => { if (current) setCatalogLoading(false); });
|
||||
return () => { current = false; };
|
||||
}, [slug, refresh]);
|
||||
}, [slug, refresh, refreshToken]);
|
||||
const act = async (operation: () => Promise<void>) => {
|
||||
if (disabled || operationInFlight.current) return;
|
||||
operationInFlight.current = true;
|
||||
setBusy(true); setError('');
|
||||
try { await operation(); }
|
||||
catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); }
|
||||
finally { if (alive.current) setBusy(false); }
|
||||
finally { operationInFlight.current = false; if (alive.current) setBusy(false); }
|
||||
};
|
||||
// This personal facade endpoint authoritatively lists libraries owned by the current account.
|
||||
const manageable = catalog?.databases ?? [];
|
||||
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 <details className="rounded-lg border p-4">
|
||||
<summary className="min-h-8 cursor-pointer text-sm font-medium">管理我的知识文档</summary>
|
||||
<summary className="min-h-8 cursor-pointer text-sm font-medium">上传自己的文件</summary>
|
||||
<div className="mt-4 space-y-4 text-sm">
|
||||
<p className="text-xs leading-5 text-muted-foreground">选择或新建自己的知识库,再上传文件。选用只会加入当前配置,保存并应用后生效。</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。</p>
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={busy}
|
||||
onChange={e => { setKbId(e.target.value); setError(''); }}>
|
||||
<option value="">选择我的知识库</option>
|
||||
{catalog?.databases.map(kb => <option key={kb.kb_id} value={kb.kb_id}>{kb.name}</option>)}
|
||||
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={managementDisabled || documentPending}
|
||||
onChange={e => {
|
||||
const selected = manageable.find(kb => kb.kb_id === e.target.value);
|
||||
if (managementDisabled || documentPending || (selected && bindable(selected.kb_id) && selectionFull && !selectedIds.includes(selected.kb_id))) return;
|
||||
setKbId(selected?.kb_id ?? ''); setError('');
|
||||
if (selected) (onSelected ?? onCreated)(selected);
|
||||
}}>
|
||||
<option value="">{catalogLoading ? '正在读取我的知识库…' : '选择我的知识库'}</option>
|
||||
{kbId && !selectedKnowledge && <option value={kbId} disabled>知识库管理权限待确认</option>}
|
||||
{manageable.map(kb => <option key={kb.kb_id} value={kb.kb_id} disabled={bindable(kb.kb_id) && selectionFull && !selectedIds.includes(kb.kb_id)}>
|
||||
{kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''}
|
||||
</option>)}
|
||||
</select>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setCreating(v => !v)}>新建知识库</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
|
||||
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || selectionFull || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
|
||||
<Button type="button" variant="ghost" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
|
||||
</div>
|
||||
{!catalogLoading && !catalogError && !manageable.length && <p className="text-xs leading-5 text-muted-foreground">还没有可管理的知识库,可以新建一个保存自己的文件。MakeLore知识仅供检索。</p>}
|
||||
{selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) && <p role="status" className="text-xs leading-5 text-amber-800">
|
||||
{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。
|
||||
</p>}
|
||||
{creating && <div className="space-y-3 rounded-md bg-muted/30 p-3">
|
||||
<Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={busy || !!createIntent.current} onChange={e => setName(e.target.value)} />
|
||||
<Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={disabled || busy || !!createIntent.current} onChange={e => setName(e.target.value)} />
|
||||
<select aria-label="向量模型" className="h-10 w-full rounded-md border bg-background px-3" value={model}
|
||||
disabled={busy || !!createIntent.current || catalogLoading || !!catalogError || !catalog?.models.length} onChange={e => setModel(e.target.value)}>
|
||||
disabled={disabled || busy || !!createIntent.current || catalogLoading || !!catalogError || !catalog?.models.length} onChange={e => setModel(e.target.value)}>
|
||||
<option value="">{catalogLoading ? '正在加载向量模型…' : catalogError ? '向量模型加载失败' : !catalog?.models.length ? '暂无可用向量模型' : '选择向量模型'}</option>
|
||||
{catalog?.models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
@@ -74,15 +108,27 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
||||
</ol>
|
||||
</details>
|
||||
</div>}
|
||||
<Button type="button" disabled={busy || !name.trim() || (!createIntent.current && (catalogLoading || !!catalogError || !catalog?.models.some(item => item.id === model)))} onClick={() => void act(async () => {
|
||||
<Button type="button" disabled={disabled || busy || documentPending || (selectionFull && !createIntent.current) || !name.trim() || (!createIntent.current && (catalogLoading || !!catalogError || !catalog?.models.some(item => item.id === model)))} onClick={() => void act(async () => {
|
||||
createIntent.current ??= { operation_id: crypto.randomUUID(), name: name.trim(), embedding_model: model };
|
||||
const kb = await cloudAgentsApi.call('createKnowledge', { slug, ...createIntent.current });
|
||||
createIntent.current = null;
|
||||
if (alive.current) { setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); onCreated(kb); }
|
||||
if (alive.current) {
|
||||
setCatalog(previous => previous ? { ...previous, databases: [kb, ...previous.databases.filter(value => value.kb_id !== kb.kb_id)] } : previous);
|
||||
setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); callbacks.current.onCreated(kb);
|
||||
}
|
||||
})}>{createIntent.current ? '重试创建' : '创建并选用'}</Button>
|
||||
</div>}
|
||||
{kbId && <CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={busy} act={act}
|
||||
onDeleteKnowledge={() => { onDeleted?.(kbId); setKbId(''); setRefresh(v => v + 1); }} />}
|
||||
{documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>}
|
||||
{kbId && !selectedKnowledge && !catalogLoading && !catalogError && <div role="alert" className="space-y-2 rounded-lg border border-amber-200 p-3">
|
||||
<p>这个知识库已无法管理。{documentPending ? '未确认的操作仍保留在当前页面。' : '请重新选择自己的知识库。'}</p>
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled || busy} onClick={() => { setKbId(''); setDocumentPending(false); }}>
|
||||
{documentPending ? '放弃未确认操作并重新选择' : '重新选择知识库'}
|
||||
</Button>
|
||||
{documentPending && <p className="text-xs leading-5 text-muted-foreground">放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。</p>}
|
||||
</div>}
|
||||
{kbId && <div hidden={!selectedKnowledge}><CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={managementDisabled || !selectedKnowledge}
|
||||
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending}
|
||||
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -47,10 +47,11 @@ function documentState(file: CloudKnowledgeFile) {
|
||||
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
|
||||
}
|
||||
|
||||
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge }: {
|
||||
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: {
|
||||
slug: string; kbId: string; busy: boolean;
|
||||
act: (operation: () => Promise<void>) => Promise<void>;
|
||||
onDeleteKnowledge: () => void;
|
||||
canRead?: boolean; onPendingChange?: (pending: boolean) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
@@ -61,14 +62,21 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
const [uploading, setUploading] = useState('');
|
||||
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importPending, setImportPending] = useState(false);
|
||||
const [deleting, setDeleting] = useState<CloudKnowledgeFile | 'database' | null>(null);
|
||||
const [processErrors, setProcessErrors] = useState<Record<string, string>>({});
|
||||
const uploadIntents = useRef<Record<string, string>>({});
|
||||
const processIntents = useRef<Record<string, { operationId: string; previousTaskId?: string }>>({});
|
||||
const alive = useRef(true);
|
||||
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;
|
||||
usePendingCloudInput(unresolved);
|
||||
useEffect(() => { onPendingChange?.(unresolved); return () => onPendingChange?.(false); }, [onPendingChange, unresolved]);
|
||||
const refresh = () => setRevision(value => value + 1);
|
||||
useEffect(() => {
|
||||
if (!canRead) return;
|
||||
let current = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const load = async () => {
|
||||
@@ -101,9 +109,10 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
};
|
||||
void load();
|
||||
return () => { current = false; clearTimeout(timer); };
|
||||
}, [slug, kbId, revision, pageCount]);
|
||||
}, [slug, kbId, revision, pageCount, canRead]);
|
||||
|
||||
const process = async (file: CloudKnowledgeFile) => {
|
||||
if (!alive.current || !permitted.current) return;
|
||||
setSubmitting(file.file_id);
|
||||
setProcessErrors(previous => { const value = { ...previous }; delete value[file.file_id]; return value; });
|
||||
const intent = processIntents.current[file.file_id] ??= {
|
||||
@@ -131,11 +140,12 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
};
|
||||
const upload = (replaces?: CloudKnowledgeFile) => act(async () => {
|
||||
const key = replaces?.file_id ?? 'new';
|
||||
const retrying = Boolean(uploadIntents.current[key]);
|
||||
uploadIntents.current[key] ??= crypto.randomUUID();
|
||||
setUploading(replaces ? '正在选择并上传替换文档…' : '正在选择并上传文档…');
|
||||
try {
|
||||
const file = await cloudAgentsApi.uploadKnowledge(slug, kbId, uploadIntents.current[key], replaces?.file_id);
|
||||
delete uploadIntents.current[key];
|
||||
if (file || !retrying) delete uploadIntents.current[key];
|
||||
if (alive.current) setUploading('');
|
||||
if (file) await acceptFile(file);
|
||||
} finally { if (alive.current) setUploading(''); }
|
||||
@@ -144,13 +154,14 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
const active = states.some(state => state.active) || submitting !== null;
|
||||
return <div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" disabled={busy} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '上传文档'}</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setImporting(value => !value)}>从对话附件导入</Button>
|
||||
<Button type="button" variant="ghost" disabled={loading} onClick={refresh}>刷新状态</Button>
|
||||
<Button type="button" disabled={busy || !canRead || (unresolved && !uploadIntents.current.new)} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '上传文档'}</Button>
|
||||
<Button type="button" variant="outline" disabled={busy || !canRead || unresolved} onClick={() => setImporting(value => !value)}>从对话附件导入</Button>
|
||||
<Button type="button" variant="ghost" disabled={loading || !canRead} onClick={refresh}>刷新状态</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
|
||||
{uploading && <p role="status" className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin" />{uploading}</p>}
|
||||
{importing && <AttachmentImport slug={slug} kbId={kbId} busy={busy} act={act} onImported={async file => { setImporting(false); await acceptFile(file); }} />}
|
||||
{importing && <AttachmentImport slug={slug} kbId={kbId} busy={busy || !canRead} act={act} onPendingChange={setImportPending}
|
||||
onImported={async file => { setImporting(false); await acceptFile(file); }} />}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b pb-2 text-xs text-muted-foreground" aria-live="polite">
|
||||
<span>{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'}</span>
|
||||
<span>{active ? '处理中 · 状态自动更新' : '状态自动更新'}</span>
|
||||
@@ -188,22 +199,22 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
{file.replaces_file_id && <p className="text-xs leading-5 text-muted-foreground">{state.ready ? '这是替换文档,新内容已可用。处理未完成时,重试可继续清理旧文档。' : '这是替换文档,新内容可用后才移除旧文档。'}</p>}
|
||||
{error && !state.active && <p role="alert" className="text-xs leading-5 text-destructive">{error}</p>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(state.pending || state.failed || error) && !state.active && <Button type="button" size="sm" variant="outline" disabled={busy || !!readError} onClick={() => void act(() => process(file))}>
|
||||
{(state.pending || state.failed || error) && !state.active && <Button type="button" size="sm" variant="outline" disabled={busy || !!readError || (unresolved && !processIntents.current[file.file_id])} onClick={() => void act(() => process(file))}>
|
||||
{state.failed || error ? '重试处理' : '开始处理'}
|
||||
</Button>}
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError} onClick={() => void upload(file)}>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError || (unresolved && !uploadIntents.current[file.file_id])} onClick={() => void upload(file)}>
|
||||
{uploadIntents.current[file.file_id] ? '重试替换' : '替换文档'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError} onClick={() => setDeleting(file)}>删除</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError || unresolved} onClick={() => setDeleting(file)}>删除</Button>
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
{next !== null && <Button type="button" variant="ghost" disabled={loading || busy} onClick={() => setPageCount(value => value + 1)}>加载更多文档</Button>}
|
||||
<div className="border-t pt-2"><Button type="button" variant="ghost" size="sm" disabled={busy || active || !!readError} onClick={() => setDeleting('database')}>删除知识库</Button></div>
|
||||
<div className="border-t pt-2"><Button type="button" variant="ghost" size="sm" disabled={busy || active || !!readError || unresolved} onClick={() => setDeleting('database')}>删除知识库</Button></div>
|
||||
{deleting && <div role="dialog" aria-label="删除知识内容" className="space-y-3 rounded-lg border border-amber-200 p-3">
|
||||
<p>{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : '删除文档“' + deleting.name + '”及其索引?'}</p>
|
||||
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => void act(async () => {
|
||||
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy || unresolved} onClick={() => void act(async () => {
|
||||
if (deleting === 'database') { await cloudAgentsApi.call('deleteKnowledge', { slug, kb_id: kbId }); onDeleteKnowledge(); }
|
||||
else await cloudAgentsApi.call('deleteKnowledgeFile', { slug, kb_id: kbId, file_id: deleting.file_id });
|
||||
if (alive.current) { setDeleting(null); refresh(); }
|
||||
@@ -212,9 +223,10 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AttachmentImport({ slug, kbId, busy, act, onImported }: {
|
||||
function AttachmentImport({ slug, kbId, busy, act, onImported, onPendingChange }: {
|
||||
slug: string; kbId: string; busy: boolean; act: (operation: () => Promise<void>) => Promise<void>;
|
||||
onImported: (file: CloudKnowledgeFile) => Promise<void>;
|
||||
onPendingChange: (pending: boolean) => void;
|
||||
}) {
|
||||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||||
const [thread, setThread] = useState('');
|
||||
@@ -222,6 +234,8 @@ function AttachmentImport({ slug, kbId, busy, act, onImported }: {
|
||||
const [file, setFile] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const operation = useRef<string | null>(null);
|
||||
const unresolved = Boolean(operation.current);
|
||||
useEffect(() => { onPendingChange(unresolved); return () => onPendingChange(false); }, [onPendingChange, unresolved]);
|
||||
usePendingCloudInput(busy || Boolean(operation.current));
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CloudKnowledgePanel, type CloudOwnedKnowledgeCatalog } from './CloudKnowledge';
|
||||
import type { CloudResource } from '../../../shared/cloud-agents';
|
||||
|
||||
export function CloudKnowledgeSelector({ slug, options, selected, disabled, loading, error, refreshToken, onChange, onRefresh }: {
|
||||
slug: string; options: CloudResource[]; selected: string[]; disabled: boolean; loading: boolean; error: string; refreshToken: number;
|
||||
onChange: (selected: string[]) => void; onRefresh: () => void;
|
||||
}) {
|
||||
const [owned, setOwned] = useState<CloudOwnedKnowledgeCatalog>({ databases: null, loading: true, error: '' });
|
||||
const ownedIds = owned.databases?.map(kb => kb.kb_id) ?? null;
|
||||
const available = options.filter(option => !selected.includes(option.key));
|
||||
const shared = ownedIds === null ? [] : available.filter(option => !ownedIds.includes(option.key));
|
||||
const personal = ownedIds === null ? [] : available.filter(option => ownedIds.includes(option.key));
|
||||
const unknown = ownedIds === null ? available : [];
|
||||
const selectionFull = selected.length >= 100;
|
||||
const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); };
|
||||
return <div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select aria-label="选择知识库" className="min-w-0 flex-1 basis-48" disabled={disabled || loading || Boolean(error) || selectionFull || available.length === 0} value=""
|
||||
onChange={event => { if (!disabled && !loading && !error && event.target.value && available.some(option => option.key === event.target.value)) add(event.target.value); }}>
|
||||
<option value="">{loading ? '正在读取知识库…' : error ? '知识库目录暂不可用' : available.length ? '选择知识库,可选多个' : options.length ? '已选用全部可用知识库' : '暂无可选知识库'}</option>
|
||||
{shared.length > 0 && <optgroup label="MakeLore知识">{shared.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
|
||||
{personal.length > 0 && <optgroup label="我的知识库">{personal.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
|
||||
{unknown.length > 0 && <optgroup label="可选知识库">{unknown.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
|
||||
</select>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled || loading || owned.loading} onClick={onRefresh}>刷新知识库</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{selectionFull ? '已选满100个知识库,请先移除一个再选择。' : '可多选,每次选择一个知识库加入配置。'}</p>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">知识库目录读取失败,请刷新重试。</p>
|
||||
: !loading && ownedIds !== null && options.every(option => ownedIds.includes(option.key)) && <p className="text-xs leading-5 text-muted-foreground">暂无可选的MakeLore知识。平台开放给你读取的知识库会出现在这里。</p>}
|
||||
{owned.error && <p className="text-xs leading-5 text-muted-foreground">我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。</p>}
|
||||
{selected.length > 0 ? <ul aria-label="已选知识库" className="space-y-2">
|
||||
{selected.map(id => {
|
||||
const option = options.find(item => item.key === id);
|
||||
const ownKnowledge = owned.databases?.find(item => item.kb_id === id);
|
||||
const name = option?.name ?? ownKnowledge?.name ?? id;
|
||||
return <li key={id} className="flex min-w-0 items-start gap-2 rounded-lg border bg-background/60 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-sm font-medium [overflow-wrap:anywhere]">{name}
|
||||
{option && ownedIds !== null && !ownedIds.includes(id) && <span className="ml-2 text-xs font-normal text-muted-foreground">MakeLore知识 · 只读</span>}
|
||||
{!option && <span className="ml-2 text-xs font-normal text-muted-foreground">{loading || error ? '目录待确认' : '已不可用'}</span>}
|
||||
</p>
|
||||
{option?.description && <p className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</p>}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} aria-label={'移除知识库 ' + name}
|
||||
onClick={() => onChange(selected.filter(value => value !== id))}><X aria-hidden="true" className="size-4" /></Button>
|
||||
</li>;
|
||||
})}
|
||||
</ul> : <p className="agent-resource-empty">还没有选择知识库</p>}
|
||||
<p className="text-xs leading-5 text-muted-foreground">选择后保存并应用配置,智能体即可使用这些知识。MakeLore知识只可检索,无法在这里修改。</p>
|
||||
<div className="agent-config-resource-manager"><CloudKnowledgePanel key={slug} slug={slug} disabled={disabled} onCatalog={setOwned} refreshToken={refreshToken}
|
||||
selectionFull={selectionFull} selectedIds={selected} selectableIds={loading || error ? null : options.map(option => option.key)}
|
||||
onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }}
|
||||
onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }}
|
||||
onCreated={kb => { add(kb.kb_id); onRefresh(); }} /></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { BookOpen, Brain, Library, Plug, RefreshCw, Users, Wrench } from 'lucide
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CloudKnowledgePanel } from './CloudKnowledge';
|
||||
import { CloudKnowledgeSelector } from './CloudKnowledgeSelector';
|
||||
import { AgentConfigHeading } from './AgentConfigHeading';
|
||||
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
|
||||
|
||||
@@ -22,12 +22,15 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
const [catalog, setCatalog] = useState<CloudCatalog | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [loadedCatalogRevision, setLoadedCatalogRevision] = useState(-1);
|
||||
const catalogLoading = loadedCatalogRevision !== refresh;
|
||||
const [search, setSearch] = useState('');
|
||||
const [choosing, setChoosing] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } })
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); });
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); })
|
||||
.finally(() => { if (live) setLoadedCatalogRevision(refresh); });
|
||||
return () => { live = false; };
|
||||
}, [refresh]);
|
||||
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
|
||||
@@ -56,8 +59,10 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
<legend className="sr-only">{title}</legend>
|
||||
<div className="agent-config-section-top">
|
||||
<AgentConfigHeading title={title} icon={icon} note={'已选 ' + value[key].length} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + label}</Button>
|
||||
{key !== 'knowledges' && <Button type="button" variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + label}</Button>}
|
||||
</div>
|
||||
{key === 'knowledges' ? <CloudKnowledgeSelector key={slug} slug={slug} options={options} selected={value.knowledges} disabled={disabled}
|
||||
loading={catalogLoading} error={error} refreshToken={refresh} onChange={selected => change('knowledges', selected)} onRefresh={() => setRefresh(v => v + 1)} /> : <>
|
||||
{choosing === key && <Input className="mb-3" aria-label={'搜索' + label} placeholder={'搜索' + label} value={search} onChange={e => setSearch(e.target.value)} />}
|
||||
{visibleOptions.length > 0 ? <div className="agent-resource-options">
|
||||
{visibleOptions.map(option => <label key={option.key} className="agent-resource-option">
|
||||
@@ -68,12 +73,10 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
<span><span>{option.name}</span>{option.description && <span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>}</span>
|
||||
</label>)}
|
||||
</div> : <p className="agent-resource-empty">{!catalog ? '正在读取资源…' : choosing === key && search ? '没有找到匹配的内容' : !options.length && !missing.length ? '当前没有可用资源' : '还没有选择'}</p>}
|
||||
</>}
|
||||
{key === 'skills' && value.skills.length > 0 && <label className="agent-resource-preload">
|
||||
<input type="checkbox" checked={value.preload_skills.length === value.skills.length}
|
||||
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选做事手册</label>}
|
||||
{key === 'knowledges' && <div className="agent-config-resource-manager"><CloudKnowledgePanel slug={slug}
|
||||
onDeleted={id => { change('knowledges', value.knowledges.filter(selected => selected !== id)); setRefresh(v => v + 1); }}
|
||||
onCreated={kb => { change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]); setRefresh(v => v + 1); }} /></div>}
|
||||
</fieldset>;
|
||||
})}
|
||||
</div>;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents';
|
||||
|
||||
let server: ViteDevServer;
|
||||
let baseUrl: string;
|
||||
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
|
||||
|
||||
// Render the real editor while replacing only the Main IPC boundary. No Electron,
|
||||
// native picker, real upload or cloud request is started by this suite.
|
||||
test.beforeAll(async () => {
|
||||
const entry = resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx');
|
||||
server = await createServer({
|
||||
configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-knowledge',
|
||||
resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' },
|
||||
server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'isolated-cloud-agent-knowledge', enforce: 'pre',
|
||||
resolveId(id) { if (id === '/__cloud-agent-knowledge-harness.tsx') return entry; },
|
||||
load(id) {
|
||||
if (id === entry) return `
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import CloudAgents from '/src/pages/CloudAgents/index.tsx';
|
||||
import '/src/styles/globals.css';
|
||||
const router = createMemoryRouter([{ path: '/cloud-agents', element: <CloudAgents /> }], { initialEntries: ['/cloud-agents?view=mine'] });
|
||||
createRoot(document.getElementById('root')).render(<div className="h-screen min-w-0"><RouterProvider router={router} /></div>);
|
||||
`;
|
||||
if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "knowledge-fixture" } }); }';
|
||||
},
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use((req, res, next) => {
|
||||
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-knowledge-harness.tsx"></script></body></html>');
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen(); baseUrl = server.resolvedUrls!.local[0];
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
const agent: CloudAgentDraft = {
|
||||
slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '用我的资料回答问题。',
|
||||
system_prompt: '请先查资料,再用简单的中文回答。', draft_revision: 1,
|
||||
configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' },
|
||||
published_version: null, enabled: true, updated_at: '2026-10-12T00:00:00Z',
|
||||
};
|
||||
// Existing facade wire format deliberately has no source/can_manage metadata.
|
||||
const libraries: CloudKnowledge[] = [
|
||||
{ kb_id: 'kb_fixturescience', name: '科学知识', description: '平台开放的科学资料', embedding_model: 'fixture-embedding' },
|
||||
{ kb_id: 'kb_fixturereading', name: '阅读知识', description: '平台开放的阅读资料', embedding_model: 'fixture-embedding' },
|
||||
{ kb_id: 'kb_fixturepersonal', name: '我的观察记录', description: '自己的资料', embedding_model: 'fixture-embedding' },
|
||||
];
|
||||
type FixtureCall = { path: string; method: string; input: Record<string, unknown> | null };
|
||||
type KnowledgeFixture = {
|
||||
agent: CloudAgentDraft; libraries: CloudKnowledge[]; files: Record<string, CloudKnowledgeFile[]>;
|
||||
calls: FixtureCall[]; ownedIds: string[]; upload: 'success' | 'cancel' | 'fail'; processFailure: boolean;
|
||||
};
|
||||
type FixtureWindow = Window & { __knowledgeFixture: KnowledgeFixture };
|
||||
|
||||
async function installFixture(page: Page) {
|
||||
page.on('pageerror', error => { throw error; });
|
||||
await page.addInitScript(({ agent, libraries }) => {
|
||||
const scope = window as unknown as FixtureWindow;
|
||||
const stored = sessionStorage.getItem('knowledge-fixture');
|
||||
const state: KnowledgeFixture = stored ? JSON.parse(stored) : {
|
||||
agent, libraries, files: {}, calls: [], ownedIds: ['kb_fixturepersonal'], upload: 'success', processFailure: false,
|
||||
};
|
||||
scope.__knowledgeFixture = state;
|
||||
const persist = () => sessionStorage.setItem('knowledge-fixture', JSON.stringify(state));
|
||||
const result = (json: unknown) => { persist(); return { ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }; };
|
||||
const failure = (message: string) => { persist(); return { ok: true, data: { status: 503, ok: false, json: { error: message } } }; };
|
||||
const managed = (id: unknown) => state.ownedIds.includes(String(id));
|
||||
Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
|
||||
invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => {
|
||||
if (channel === 'hostapi:token') return 'fixture';
|
||||
if (channel === 'hostapi:base-url') return 'http://fixture.invalid';
|
||||
if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC ' + channel);
|
||||
const path = request?.path ?? ''; const method = request?.method ?? 'GET';
|
||||
const input = request?.body ? JSON.parse(request.body) as Record<string, unknown> : null;
|
||||
state.calls.push({ path, method, input });
|
||||
if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: [] });
|
||||
if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true });
|
||||
if (path.startsWith('/api/cloud-agents/agents')) {
|
||||
if (path === '/api/cloud-agents/agents') return result({ agents: [state.agent], next_cursor: null });
|
||||
if (method === 'PATCH') Object.assign(state.agent, input, { draft_revision: state.agent.draft_revision + 1 });
|
||||
return result(state.agent);
|
||||
}
|
||||
if (path === '/api/cloud-agents/knowledge/pick') {
|
||||
if (!managed(input?.kb_id)) throw new Error('Document upload reached a read-only library');
|
||||
if (state.upload === 'cancel') return result(null);
|
||||
if (state.upload === 'fail') return failure('上传服务暂不可用');
|
||||
const file: CloudKnowledgeFile = { file_id: 'observations-file', name: '春天观察记录.md', size: 1200,
|
||||
status: 'uploaded', error: null, chunk_count: 0, available: false };
|
||||
state.files[String(input?.kb_id)] = [file]; return result(file);
|
||||
}
|
||||
if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture request ' + path);
|
||||
const args = input?.input as Record<string, unknown> | undefined;
|
||||
switch (input?.operation) {
|
||||
case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }],
|
||||
resources: { knowledges: state.libraries.map(kb => ({ key: kb.kb_id, name: kb.name, description: kb.description })) }, pricing: null });
|
||||
case 'knowledge': return result({ databases: state.libraries.filter(kb => managed(kb.kb_id)),
|
||||
models: [{ id: 'fixture-embedding', name: '平台向量模型', dimension: 768 }] });
|
||||
case 'access': return result({ published_version: null, enabled: true, share_url: 'niancode://agents/' + agent.slug,
|
||||
api_url: 'http://fixture.invalid', grants: [], applications: [], versions: [] });
|
||||
case 'createKnowledge': {
|
||||
const created: CloudKnowledge = { kb_id: 'kb_fixturecreated', name: String(args?.name), description: '', embedding_model: String(args?.embedding_model) };
|
||||
state.libraries.push(created); state.ownedIds.push(created.kb_id); return result(created);
|
||||
}
|
||||
case 'knowledgeFiles':
|
||||
if (!managed(args?.kb_id)) throw new Error('Document management reached a read-only library');
|
||||
return result({ files: state.files[String(args?.kb_id)] ?? [], next_offset: null });
|
||||
case 'processKnowledge': {
|
||||
if (!managed(args?.kb_id)) throw new Error('Processing reached a read-only library');
|
||||
if (state.processFailure) return failure('处理请求暂不可用');
|
||||
const file = state.files[String(args?.kb_id)]?.find(file => file.file_id === args?.file_id);
|
||||
if (!file) throw new Error('Unknown fixture document');
|
||||
file.processing_task = { task_id: 'processing-task', status: 'pending', error: null };
|
||||
return result({ task_id: 'processing-task' });
|
||||
}
|
||||
default: throw new Error('Unexpected fixture operation ' + String(input?.operation));
|
||||
}
|
||||
},
|
||||
} } });
|
||||
}, { agent, libraries });
|
||||
}
|
||||
|
||||
async function openKnowledge(page: Page) {
|
||||
await page.getByRole('button', { name: '继续制作', exact: true }).click();
|
||||
const editor = page.getByTestId('cloud-agent-editor');
|
||||
await editor.getByRole('tab', { name: '高级设置', exact: true }).click();
|
||||
const knowledge = editor.getByRole('group', { name: '知识库', exact: true });
|
||||
await knowledge.scrollIntoViewIfNeeded();
|
||||
await expect(knowledge.getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible();
|
||||
await expect(editor.getByRole('combobox', { name: '模型', exact: true })).toBeEnabled();
|
||||
return { editor, knowledge };
|
||||
}
|
||||
async function calls(page: Page) { return page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.calls); }
|
||||
async function expectNoClipping(page: Page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
const clipped = await page.getByTestId('cloud-agent-editor').evaluate(editor => {
|
||||
const scroll = editor.querySelector('.agent-config-scroll')!; const edge = scroll.getBoundingClientRect();
|
||||
return [...scroll.querySelectorAll('button, input, textarea, select, h2, summary, fieldset')]
|
||||
.filter(element => element.getClientRects().length > 0 && !element.classList.contains('sr-only'))
|
||||
.filter(element => { const bounds = element.getBoundingClientRect(); return bounds.left < edge.left - 1 || bounds.right > edge.right + 1; })
|
||||
.map(element => element.getAttribute('aria-label') ?? element.textContent?.trim());
|
||||
});
|
||||
expect(clipped).toEqual([]);
|
||||
}
|
||||
|
||||
for (const width of [375, 1440]) {
|
||||
test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl);
|
||||
const { editor, knowledge } = await openKnowledge(page);
|
||||
const choose = knowledge.getByRole('combobox', { name: '选择知识库', exact: true });
|
||||
await expect(choose.locator('optgroup[label="MakeLore知识"] option')).toHaveCount(2);
|
||||
await expect(choose.locator('optgroup[label="我的知识库"] option')).toHaveCount(1);
|
||||
await choose.selectOption('kb_fixturescience'); await choose.selectOption('kb_fixturereading'); await choose.selectOption('kb_fixturepersonal');
|
||||
const selected = knowledge.getByRole('list', { name: '已选知识库', exact: true });
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(3);
|
||||
await selected.getByRole('button', { name: '移除知识库 我的观察记录', exact: true }).click();
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(2);
|
||||
await choose.selectOption('kb_fixturepersonal');
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(3);
|
||||
await expect(selected.getByRole('button', { name: '移除知识库 科学知识', exact: true })).toBeVisible();
|
||||
await expect(selected.getByRole('button', { name: /上传|删除|处理|替换/ })).toHaveCount(0);
|
||||
expect((await calls(page)).filter(call => call.method === 'PATCH' || call.path.endsWith('/knowledge/pick'))).toEqual([]);
|
||||
await knowledge.getByText('上传自己的文件', { exact: true }).click();
|
||||
const management = knowledge.getByRole('combobox', { name: '管理的知识库', exact: true });
|
||||
await expect(management.locator('option[value="kb_fixturescience"], option[value="kb_fixturereading"]')).toHaveCount(0);
|
||||
await expect(knowledge.getByRole('button', { name: '上传文档', exact: true })).toHaveCount(0);
|
||||
await expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' });
|
||||
await editor.getByRole('button', { name: '保存草稿', exact: true }).click();
|
||||
await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible();
|
||||
expect((await calls(page)).filter(call => call.method === 'PATCH').map(call => (call.input?.configuration as { knowledges: string[] }).knowledges))
|
||||
.toEqual([['kb_fixturescience', 'kb_fixturereading', 'kb_fixturepersonal']]);
|
||||
await page.reload(); const reopened = await openKnowledge(page);
|
||||
await expect(reopened.knowledge.getByRole('list', { name: '已选知识库', exact: true }).getByRole('listitem')).toHaveCount(3);
|
||||
await expectNoClipping(page);
|
||||
});
|
||||
|
||||
test(`own library creation and explicit upload process a document to ready at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl);
|
||||
const { editor, knowledge } = await openKnowledge(page);
|
||||
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
|
||||
await knowledge.getByText('上传自己的文件', { exact: true }).click();
|
||||
expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]);
|
||||
await knowledge.getByRole('button', { name: '新建知识库', exact: true }).click();
|
||||
await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料');
|
||||
await knowledge.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding');
|
||||
await knowledge.getByRole('button', { name: '创建并选用', exact: true }).click();
|
||||
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated');
|
||||
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料');
|
||||
expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]);
|
||||
await knowledge.getByRole('button', { name: '上传文档', exact: true }).click();
|
||||
const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true });
|
||||
await expect(document.getByText('等待处理', { exact: true })).toBeVisible();
|
||||
const mutations = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge');
|
||||
expect(mutations).toHaveLength(2);
|
||||
expect(mutations[0].input).toMatchObject({ kb_id: 'kb_fixturecreated' });
|
||||
expect(mutations[1].input?.input).toMatchObject({ kb_id: 'kb_fixturecreated', file_id: 'observations-file' });
|
||||
await page.evaluate(() => {
|
||||
const file = (window as unknown as FixtureWindow).__knowledgeFixture.files['kb_fixturecreated'][0];
|
||||
Object.assign(file, { status: 'indexed', chunk_count: 6, available: true,
|
||||
processing_task: { task_id: 'processing-task', status: 'completed', error: null } });
|
||||
});
|
||||
await expect(document.getByText('可用于回答', { exact: true })).toBeVisible({ timeout: 12_000 });
|
||||
await expect(document.getByText('2 KB · 6 段可检索内容', { exact: true })).toBeVisible();
|
||||
await document.scrollIntoViewIfNeeded();
|
||||
await expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' });
|
||||
// Document mutations never implicitly save the Agent draft.
|
||||
expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]);
|
||||
await editor.getByRole('button', { name: '保存草稿', exact: true }).click();
|
||||
await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.agent.configuration.knowledges))
|
||||
.toEqual(['kb_fixturescience', 'kb_fixturecreated']);
|
||||
});
|
||||
}
|
||||
|
||||
test('cancelled and failed uploads preserve shared choices and the personal draft', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl);
|
||||
const { editor, knowledge } = await openKnowledge(page);
|
||||
await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click();
|
||||
await editor.getByRole('textbox', { name: '提示词', exact: true }).fill('这是我还没有保存的提示词。');
|
||||
await editor.getByRole('tab', { name: '高级设置', exact: true }).click();
|
||||
await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience');
|
||||
await knowledge.getByText('上传自己的文件', { exact: true }).click();
|
||||
await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal');
|
||||
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; });
|
||||
await knowledge.getByRole('button', { name: '上传文档', exact: true }).click();
|
||||
await expect(knowledge.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled();
|
||||
await expect(knowledge.getByRole('article')).toHaveCount(0);
|
||||
expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toEqual([]);
|
||||
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'fail'; });
|
||||
await knowledge.getByRole('button', { name: '上传文档', exact: true }).click();
|
||||
await expect(knowledge.getByRole('alert')).toContainText('上传服务暂不可用');
|
||||
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturepersonal');
|
||||
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识');
|
||||
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的观察记录');
|
||||
// An uncertain upload response keeps its retry attached to the same library.
|
||||
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeDisabled();
|
||||
await expect(knowledge.getByRole('button', { name: '新建知识库', exact: true })).toBeDisabled();
|
||||
const failedUpload = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick')).at(-1)!;
|
||||
await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; });
|
||||
await knowledge.getByRole('button', { name: '重新选择同一文件并重试', exact: true }).click();
|
||||
await expect(knowledge.getByRole('button', { name: '重新选择同一文件并重试', exact: true })).toBeEnabled();
|
||||
await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeDisabled();
|
||||
const cancelledRetry = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick')).at(-1)!;
|
||||
expect(cancelledRetry.input?.operation_id).toBe(failedUpload.input?.operation_id);
|
||||
expect((await calls(page)).filter(call => call.method === 'PATCH' || call.input?.operation === 'processKnowledge')).toEqual([]);
|
||||
await expectNoClipping(page);
|
||||
await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click();
|
||||
await expect(editor.getByRole('textbox', { name: '提示词', exact: true })).toHaveValue('这是我还没有保存的提示词。');
|
||||
});
|
||||
@@ -200,8 +200,8 @@ for (const width of [375, 768, 1024, 1440]) {
|
||||
if (width === 375 || width === 1440) {
|
||||
await page.screenshot({ path: test.info().outputPath(`agent-advanced-upper-${width}.png`), animations: 'disabled' });
|
||||
}
|
||||
await advanced.getByText('管理我的知识文档', { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(advanced.getByText('管理我的知识文档', { exact: true })).toBeInViewport();
|
||||
await advanced.getByText('上传自己的文件', { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(advanced.getByText('上传自己的文件', { exact: true })).toBeInViewport();
|
||||
await expectConfigurationBounds(page);
|
||||
if (width === 375 || width === 1440) {
|
||||
await page.screenshot({ path: test.info().outputPath(`agent-advanced-lower-${width}.png`), animations: 'disabled' });
|
||||
@@ -238,7 +238,7 @@ for (const width of [375, 1366]) {
|
||||
await ready.getByRole('button', { name: '和它聊聊', exact: true }).click();
|
||||
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '我的智能体', exact: true }).click();
|
||||
await page.getByRole('button', { name: '返回智能体列表', exact: true }).click();
|
||||
await ready.getByRole('button', { name: '调整', exact: true }).click();
|
||||
await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.getByLabel('提示词', { exact: true })).toHaveValue(readyAgent.system_prompt);
|
||||
|
||||
@@ -748,7 +748,7 @@ it('switches editor pages while retaining unfinished knowledge form inputs', asy
|
||||
expect(instructions).not.toBeVisible();
|
||||
expect(screen.getByLabelText('模型')).toBeVisible();
|
||||
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('管理我的知识文档'));
|
||||
fireEvent.click(screen.getByText('上传自己的文件'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
|
||||
const knowledgeName = screen.getByLabelText('知识库名称');
|
||||
fireEvent.change(knowledgeName, { target: { value: '还没创建的资料' } });
|
||||
|
||||
@@ -13,7 +13,7 @@ afterEach(cleanup);
|
||||
|
||||
function openCreation() {
|
||||
render(<CloudKnowledgePanel slug={slug} onCreated={() => undefined} />);
|
||||
fireEvent.click(screen.getByText('管理我的知识文档'));
|
||||
fireEvent.click(screen.getByText('上传自己的文件'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useState } from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { ConfigurationFields } from '@/pages/CloudAgents/ConfigurationFields';
|
||||
import { CloudPendingContext } from '@/pages/CloudAgents/CloudPending';
|
||||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentConfiguration, type CloudCatalog, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents';
|
||||
|
||||
const api = vi.hoisted(() => ({ call: vi.fn(), uploadKnowledge: vi.fn() }));
|
||||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||||
const slug = 'ml-' + 'a'.repeat(32);
|
||||
const own = { kb_id: 'own', name: '我的创作资料' };
|
||||
const models = [{ id: 'embedding', name: '资料向量模型', dimension: 1024 }];
|
||||
let databases: CloudKnowledge[];
|
||||
let catalog: CloudCatalog;
|
||||
const changes = vi.fn();
|
||||
|
||||
function Harness({ initial = [], disabled = false, agentSlug = slug }: { initial?: string[]; disabled?: boolean; agentSlug?: string }) {
|
||||
const [value, setValue] = useState<CloudAgentConfiguration>({ ...EMPTY_CLOUD_CONFIGURATION, model: 'model', knowledges: initial });
|
||||
return <ConfigurationFields slug={agentSlug} value={value} disabled={disabled} onChange={next => { changes(next); setValue(next); }} />;
|
||||
}
|
||||
function choose(id: string) { fireEvent.change(screen.getByRole('combobox', { name: '选择知识库' }), { target: { value: id } }); }
|
||||
function openUpload() { fireEvent.click(screen.getByText('上传自己的文件')); }
|
||||
async function openOwn(readyText = '上传第一份文档,让智能体用你的资料回答。') {
|
||||
openUpload();
|
||||
await within(screen.getByRole('combobox', { name: '管理的知识库' })).findByRole('option', { name: own.name });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: own.kb_id } });
|
||||
await screen.findByText(readyText);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); databases = [own];
|
||||
catalog = { models: [{ id: 'model', name: '对话模型' }], resources: { knowledges: [
|
||||
{ key: 'shared', name: '创作指南', description: '平台开放资料' },
|
||||
{ key: 'other-shared', name: '自然百科' }, { key: own.kb_id, name: own.name },
|
||||
] }, pricing: { unit: 'points', usage_unit: 'tokens', unit_size: 1000, points_per_unit: '1', version: 1 } };
|
||||
api.call.mockImplementation(async operation => {
|
||||
if (operation === 'catalog') return catalog;
|
||||
if (operation === 'knowledge') return { databases, models };
|
||||
if (operation === 'knowledgeFiles') return { files: [], next_offset: null };
|
||||
throw new Error('Unexpected operation ' + operation);
|
||||
});
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
it('joins readable IDs to the owned-only catalog and selects multiple libraries in the draft', async () => {
|
||||
render(<Harness />);
|
||||
const sharedGroup = await screen.findByRole('group', { name: 'MakeLore知识' });
|
||||
expect(within(sharedGroup).getByRole('option', { name: '创作指南' })).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('group', { name: '我的知识库' })).getByRole('option', { name: own.name })).toBeInTheDocument();
|
||||
expect(screen.getByText('可多选,每次选择一个知识库加入配置。')).toBeVisible();
|
||||
choose('shared'); choose(own.kb_id); choose('other-shared');
|
||||
expect(within(screen.getByRole('list', { name: '已选知识库' })).getAllByRole('listitem')).toHaveLength(3);
|
||||
expect(screen.getAllByText('MakeLore知识 · 只读')).toHaveLength(2);
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', 'own', 'other-shared'], skills: [] }));
|
||||
expect(api.call.mock.calls.every(([operation]) => ['catalog', 'knowledge'].includes(operation))).toBe(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除知识库 创作指南' }));
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['own', 'other-shared'] }));
|
||||
});
|
||||
|
||||
it('never offers shared libraries to document management or mounts their mutating controls', async () => {
|
||||
databases = [];
|
||||
render(<Harness initial={['shared']} />);
|
||||
await screen.findByText('MakeLore知识 · 只读'); openUpload();
|
||||
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
|
||||
expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '上传文档', exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '删除知识库', exact: true })).not.toBeInTheDocument();
|
||||
expect(api.call.mock.calls.every(([operation]) => operation !== 'knowledgeFiles')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows readable binding after an owned-list failure while withholding document management and retries both catalogs', async () => {
|
||||
const original = api.call.getMockImplementation()!;
|
||||
let failed = true;
|
||||
api.call.mockImplementation((operation, input) => operation === 'knowledge' && failed
|
||||
? Promise.reject(new Error('我的目录离线')) : original(operation, input));
|
||||
render(<Harness />);
|
||||
await screen.findByText('我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。');
|
||||
expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument();
|
||||
choose('shared'); openUpload();
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: '上传文档', exact: true })).not.toBeInTheDocument();
|
||||
failed = false;
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
|
||||
await screen.findByText('MakeLore知识 · 只读');
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeEnabled();
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'catalog')).toHaveLength(2);
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'knowledge')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('retains missing IDs for removal during catalog failure instead of treating them as selectable', async () => {
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'catalog'
|
||||
? Promise.reject(new Error('读取失败')) : original(operation, input));
|
||||
render(<Harness initial={['removed-id']} />);
|
||||
await screen.findByText('知识库目录读取失败,请刷新重试。');
|
||||
expect(screen.getByText('目录待确认')).toBeVisible();
|
||||
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除知识库 removed-id' }));
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: [] }));
|
||||
});
|
||||
|
||||
it('enforces the 100-library limit and restores selection after removal', async () => {
|
||||
const selected = Array.from({ length: 100 }, (_, index) => 'old-' + index);
|
||||
render(<Harness initial={selected} />);
|
||||
await screen.findByRole('group', { name: 'MakeLore知识' });
|
||||
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
|
||||
openUpload(); expect(screen.getByRole('button', { name: '新建知识库' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除知识库 old-0' }));
|
||||
choose('shared');
|
||||
expect(changes.mock.calls.at(-1)?.[0].knowledges).toHaveLength(100);
|
||||
expect(changes.mock.calls.at(-1)?.[0].knowledges).toContain('shared');
|
||||
});
|
||||
|
||||
it('selects an existing owned library for uploads and cancelling a file picker does not process a file', async () => {
|
||||
render(<Harness initial={['shared']} />);
|
||||
await openOwn();
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] }));
|
||||
api.uploadKnowledge.mockResolvedValue(null);
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
|
||||
await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled());
|
||||
expect(api.call.mock.calls.every(([operation]) => operation !== 'processKnowledge')).toBe(true);
|
||||
expect(screen.getByRole('button', { name: '移除知识库 创作指南' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('appends a late creation result to the latest bindings after interleaved selection and removal', async () => {
|
||||
catalog.models.push({ id: 'model2', name: '另一个模型' });
|
||||
let resolve!: (value: CloudKnowledge) => void;
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'createKnowledge'
|
||||
? new Promise(value => { resolve = value; }) : original(operation, input));
|
||||
render(<Harness initial={['shared']} />);
|
||||
await screen.findByRole('group', { name: '我的知识库' }); openUpload();
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '新资料' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建并选用' }));
|
||||
choose('other-shared'); fireEvent.change(screen.getByRole('combobox', { name: '模型' }), { target: { value: 'model2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除知识库 创作指南' }));
|
||||
const created = { kb_id: 'new-own', name: '新资料' }; databases.push(created);
|
||||
catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name });
|
||||
await act(async () => resolve(created));
|
||||
await screen.findByRole('button', { name: '移除知识库 新资料' });
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ model: 'model2', knowledges: ['other-shared', 'new-own'] }));
|
||||
});
|
||||
|
||||
it('removes a deleted owned library from the latest bindings without losing changes made during deletion', async () => {
|
||||
let resolve!: () => void;
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'deleteKnowledge'
|
||||
? new Promise<void>(value => { resolve = value; }) : original(operation, input));
|
||||
render(<Harness initial={['shared', own.kb_id]} />); await openOwn();
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除知识库', exact: true }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认删除知识内容' }));
|
||||
choose('other-shared'); fireEvent.click(screen.getByRole('button', { name: '移除知识库 创作指南' }));
|
||||
databases = []; await act(async () => resolve());
|
||||
await waitFor(() => expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['other-shared'] })));
|
||||
});
|
||||
|
||||
it('ignores an owned-catalog reply from a previous agent after its manager has unmounted', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'knowledge' && input.slug === slug
|
||||
? new Promise(value => { resolve = value; }) : original(operation, input));
|
||||
const view = render(<Harness />);
|
||||
await screen.findByRole('group', { name: '可选知识库' });
|
||||
view.rerender(<Harness agentSlug={'ml-' + 'b'.repeat(32)} />);
|
||||
await screen.findByRole('group', { name: '我的知识库' });
|
||||
await act(async () => resolve({ databases: [], models: [] }));
|
||||
expect(within(screen.getByRole('group', { name: '我的知识库' })).getByRole('option', { name: own.name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps an uncertain upload scoped to its library through retry cancellation and releases the guard after acknowledgement', async () => {
|
||||
databases.push({ kb_id: 'other-own', name: '其他资料' });
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'processKnowledge'
|
||||
? Promise.resolve({ task_id: 'known-task' }) : original(operation, input));
|
||||
api.uploadKnowledge.mockRejectedValueOnce(new Error('上传结果未确认')).mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ file_id: 'uploaded', name: '资料.txt', size: 10, status: 'uploaded', chunk_count: 0, available: false, error: null });
|
||||
const pending: Record<string, boolean> = {};
|
||||
render(<CloudPendingContext.Provider value={(id, value) => { pending[id] = value; }}><Harness initial={['shared']} /></CloudPendingContext.Provider>);
|
||||
await openOwn();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
|
||||
await screen.findByText('上传结果未确认');
|
||||
expect(Object.values(pending).some(Boolean)).toBe(true);
|
||||
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
|
||||
expect(manager).toBeDisabled(); expect(screen.getByRole('button', { name: '新建知识库' })).toBeDisabled();
|
||||
fireEvent.change(manager, { target: { value: 'other-own' } }); expect(manager).toHaveValue(own.kb_id);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新选择同一文件并重试' }));
|
||||
await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '重新选择同一文件并重试' })).toBeEnabled());
|
||||
expect(manager).toBeDisabled(); expect(Object.values(pending).some(Boolean)).toBe(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新选择同一文件并重试' }));
|
||||
await waitFor(() => expect(manager).toBeEnabled());
|
||||
expect(api.uploadKnowledge.mock.calls.map(call => call[2])).toEqual([expect.any(String), api.uploadKnowledge.mock.calls[0][2], api.uploadKnowledge.mock.calls[0][2]]);
|
||||
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: own.kb_id, file_id: 'uploaded' }));
|
||||
expect(Object.values(pending).some(Boolean)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves uncertain processing while refreshing and releases it when the server reports a newly acknowledged task', async () => {
|
||||
const uploaded: CloudKnowledgeFile = { file_id: 'uploaded', name: '资料.txt', size: 10, status: 'uploaded', chunk_count: 0, available: false, error: null };
|
||||
let files = [uploaded];
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => {
|
||||
if (operation === 'knowledgeFiles') return Promise.resolve({ files, next_offset: null });
|
||||
if (operation === 'processKnowledge') return Promise.reject(new Error('处理请求未确认'));
|
||||
return original(operation, input);
|
||||
});
|
||||
render(<Harness />); await openOwn('资料.txt');
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始处理' }));
|
||||
await screen.findByText(/文件已保存,处理请求尚未确认/);
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '删除', exact: true })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '删除知识库', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '重试处理' })).toBeEnabled());
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
|
||||
files = [{ ...uploaded, processing_task: { task_id: 'observed-new-task', status: 'pending', error: null } }];
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }));
|
||||
await screen.findByText('等待处理');
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('retains unconfirmed operations in a disabled manager if its owned library disappears and requires explicit discard', async () => {
|
||||
api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认'));
|
||||
render(<Harness initial={['shared']} />); await openOwn();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
|
||||
await screen.findByText('上传结果未确认');
|
||||
const before = api.call.mock.calls.filter(([operation]) => operation === 'knowledgeFiles').length;
|
||||
databases = []; fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
|
||||
await screen.findByText('这个知识库已无法管理。未确认的操作仍保留在当前页面。');
|
||||
expect(screen.queryByRole('button', { name: '重新选择同一文件并重试' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'knowledgeFiles')).toHaveLength(before);
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃未确认操作并重新选择' }));
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeEnabled());
|
||||
expect(screen.getByRole('button', { name: '移除知识库 创作指南' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('automatically processes a confirmed upload that finishes while both knowledge catalogs are refreshing', async () => {
|
||||
let finishUpload!: (value: CloudKnowledgeFile) => void;
|
||||
let finishRefresh!: (value: unknown) => void;
|
||||
let holdRefresh = false;
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => {
|
||||
if (operation === 'knowledge' && holdRefresh) return new Promise(value => { finishRefresh = value; });
|
||||
if (operation === 'processKnowledge') return Promise.resolve({ task_id: 'confirmed-task' });
|
||||
return original(operation, input);
|
||||
});
|
||||
api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; }));
|
||||
render(<Harness />); await openOwn();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
|
||||
holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新目录' }));
|
||||
await screen.findByRole('option', { name: '正在读取我的知识库…' });
|
||||
await act(async () => finishUpload({ file_id: 'confirmed', name: '资料.txt', size: 10, status: 'uploaded', chunk_count: 0, available: false, error: null }));
|
||||
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: own.kb_id, file_id: 'confirmed' }));
|
||||
await act(async () => finishRefresh({ databases, models }));
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('finishes an authorized upload through a failed ownership refresh and does not process it again after recovery', async () => {
|
||||
let finishUpload!: (value: CloudKnowledgeFile) => void;
|
||||
let rejectRefresh!: (reason: Error) => void;
|
||||
let holdRefresh = false;
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => {
|
||||
if (operation === 'knowledge' && holdRefresh) return new Promise((_resolve, reject) => { rejectRefresh = reject; });
|
||||
if (operation === 'processKnowledge') return Promise.resolve({ task_id: 'confirmed-task' });
|
||||
return original(operation, input);
|
||||
});
|
||||
api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; }));
|
||||
render(<Harness />); await openOwn();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true }));
|
||||
holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
|
||||
await screen.findByRole('option', { name: '正在读取我的知识库…' });
|
||||
await act(async () => rejectRefresh(new Error('目录连接失败')));
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
|
||||
await act(async () => finishUpload({ file_id: 'confirmed', name: '资料.txt', size: 10, status: 'uploaded', chunk_count: 0, available: false, error: null }));
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'processKnowledge')).toHaveLength(1);
|
||||
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: own.kb_id, file_id: 'confirmed' }));
|
||||
holdRefresh = false; fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeEnabled());
|
||||
expect(api.uploadKnowledge).toHaveBeenCalledTimes(1);
|
||||
expect(api.call.mock.calls.filter(([operation]) => operation === 'processKnowledge')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds an already authorized creation result while the editor is temporarily disabled by saving', async () => {
|
||||
let resolve!: (value: CloudKnowledge) => void;
|
||||
const original = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation((operation, input) => operation === 'createKnowledge'
|
||||
? new Promise(value => { resolve = value; }) : original(operation, input));
|
||||
const view = render(<Harness initial={['shared']} />);
|
||||
await screen.findByRole('group', { name: '我的知识库' }); openUpload();
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '新资料' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建并选用' }));
|
||||
view.rerender(<Harness initial={['shared']} disabled />);
|
||||
const created = { kb_id: 'new-own', name: '新资料' }; databases.push(created);
|
||||
catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name });
|
||||
await act(async () => resolve(created));
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', 'new-own'] }));
|
||||
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps an owned but unusable library manageable without adding an invalid binding and marks a saved unavailable ID', async () => {
|
||||
const unusable = { kb_id: 'disabled-embedding', name: '旧资料' }; databases.push(unusable);
|
||||
render(<Harness initial={['shared', unusable.kb_id]} />);
|
||||
await screen.findByRole('button', { name: '移除知识库 旧资料' });
|
||||
expect(screen.getByText('已不可用')).toBeVisible(); openUpload();
|
||||
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
|
||||
expect(within(manager).getByRole('option', { name: '旧资料(当前暂不可选用)' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除知识库 旧资料' }));
|
||||
const before = changes.mock.calls.length;
|
||||
fireEvent.change(manager, { target: { value: unusable.kb_id } });
|
||||
await screen.findByText('上传第一份文档,让智能体用你的资料回答。');
|
||||
expect(screen.getByText('这个知识库当前暂不可选用。你仍可管理自己的文件,刷新目录后再试。')).toBeVisible();
|
||||
expect(changes).toHaveBeenCalledTimes(before);
|
||||
expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled();
|
||||
expect(changes.mock.calls.at(-1)?.[0].knowledges).toEqual(['shared']);
|
||||
});
|
||||
@@ -22,7 +22,7 @@ beforeEach(() => {
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
async function open() {
|
||||
render(<CloudKnowledgePanel slug="ml-agent" onCreated={() => undefined} />);
|
||||
fireEvent.click(screen.getByText('管理我的知识文档'));
|
||||
fireEvent.click(screen.getByText('上传自己的文件'));
|
||||
await screen.findByRole('option', { name: '资料' });
|
||||
fireEvent.change(screen.getByLabelText('管理的知识库'), { target: { value: 'kb' } });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user