fix(knowledge): separate creation management and selection
This commit is contained in:
1 parent
a589a864d7
commit
8e0e52f30e
11 files changed
+223
-112
No files matched your search
@@ -13,28 +13,31 @@
|
||||
## Scope
|
||||
|
||||
- Explain the screenshot's "从聊天选择文件" entry, then remove the chat-file import entry/workflow at the user's explicit instruction. Keep computer upload, current document management and knowledge-library selection/activation.
|
||||
- Latest follow-up: separate creation, owned-library management and Agent binding. Top left is 「上传自己的文件」 with the new-library entry/form, top right is 「查看我的知识库」 with the managed-library dropdown/upload/files, and 「选择知识库」 spans below both.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User initially requested explanation; the latest explicit instruction authorizes removing chat-file import and retaining computer upload. This supersedes the previous feature's approved chat-import entry.
|
||||
- Additional user steering: place the knowledge creation button below the computer upload button. Move the new-library entry/creation form under the upload action row; keep first-library creation available when upload has no selected target.
|
||||
- Earlier steering placed creation beneath computer upload; the latest explicit layout supersedes that button order: the left creation card contains only the new-library entry/form; owned-library browsing and creation do not bind a library to the Agent. Binding remains an explicit bottom selection, and its 100-library limit does not block management or creation. Delete the old instruction paragraph and 「添加到我的知识库」 label.
|
||||
- Keep real files/chats/libraries, runtime, Main/backend APIs and old recovery records. No actual imports, deletion of user data, app update, main integration or push. Own only removal-related UI/copy/style/tests and this record.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Removed the "从聊天选择文件" entry, its picker component and exclusive styles/state/props/tests. Knowledge adding has only the computer-upload entry. Upload copy and README match.
|
||||
- Moved "新建知识库" and its expanded creation form below the computer-upload action row. Before a target is selected, the upload button is disabled and first-library creation remains available directly below it.
|
||||
- Top left 「上传自己的文件」 contains the new-library button and expanded creation form. Top right 「查看我的知识库」 contains the owned-library dropdown, directory refresh, computer upload and file management. Bottom 「选择知识库」 spans both columns; narrow cards stack creation, management and binding in that order. The old instruction paragraph and 「添加到我的知识库」 label are removed.
|
||||
- New-library creation now says 「创建知识库」 and opens the created library for management without changing Agent draft bindings. Browsing also leaves bindings untouched; bottom selection explicitly binds. Its 100-library limit no longer blocks browsing, uploading or creating.
|
||||
- Independent review found that using creation-form visibility to place a shared error could show an upload error in the creation card. Errors now follow the operation source, with a regression covering right-side upload failure while the left creation form is open.
|
||||
- Preserved automatic document processing, status polling, pagination, original-operation retries, replacement/deletion, permission-loss pending state, own/shared library distinctions and explicit save/apply of selection.
|
||||
- Main/shared/backend import contracts and recovery handling remain for existing pending operations. No real files/chats/libraries were imported, moved or deleted; current app/runtime was not refreshed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Initial explanation inspected committed client/Yuxi import behavior. Subsequent removal is explicitly authorized and does not change backend contracts.
|
||||
- Pinned corepack pnpm 10.33.4: 6 focused knowledge/catalog/configuration/page suites, 91 tests passed. Existing upload-to-ready test now also verifies no chat-import entry or chat API calls. Retired picker tests were deleted with the component.
|
||||
- Pinned corepack pnpm 10.33.4: 6 focused knowledge/catalog/configuration/page suites, 92 tests passed after the layout follow-up. Coverage includes independent creation/browsing/binding, 100-binding-limit management, interleaved draft changes, create result during save, upload-error placement, stable retry identities and permission-loss state. Upload-to-ready also verifies no chat-import entry or chat API calls. Retired picker tests were deleted with the component.
|
||||
- Typecheck, scoped ESLint, full Renderer/Main/preload/utility build and git diff --check passed.
|
||||
- Headless Vite/Playwright knowledge fixture: 5/5 passed in 18.0s, covering 375px/1440px library selection/save/reopen, first-library creation, upload/auto processing and failed/cancelled upload protection. Both viewport cases assert the new-library button is below upload before and after selecting/creating a target. No real cloud request, native picker or current app operation occurs in the fixture.
|
||||
- Two prior tests expected no upload button when a target was unavailable; updated explicitly to a disabled placeholder under the new requested layout, preserving the no-document-request assertions.
|
||||
- Independent review passed for removal and creation-control reorder, including uncertain upload/create and hidden permission-loss state. Inspected generated desktop screenshot confirming the physical button order and removal.
|
||||
- Latest headless Vite/Playwright fixture: 5/5 passed in 18.2s for 375px/1440px selection/save/reopen, browsing/creation without binding, explicit binding, upload-to-ready and failed/cancelled upload. Intermediate 768px/1024px layout assertions exercise the actual >620px container breakpoint. Ran in this worktree with explicit output directory `/tmp/makelore-knowledge-layout-e2e-20261013`; inspected desktop and mobile complete-card screenshots plus the desktop ready-document state. No real cloud request, native picker or current app operation occurs in the fixture.
|
||||
- The disabled computer-upload placeholder is now confined to right-side management before a target is selected, preserving no-document-request protection.
|
||||
- Independent review confirms automatic-binding removal, deletion cleanup, directory refresh, stable uncertain upload/create identities, hidden permission-loss state and no backend change; its error-source finding was fixed, tested and re-reviewed with no remaining actionable findings.
|
||||
- Task-aware documentation drift and whitespace checks run at completion; own record is the only .project-docs edit.
|
||||
|
||||
## Follow-ups
|
||||
@@ -43,7 +46,7 @@
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: personal Agents desktop knowledge UI documentation in a future integration. Proposal: adding knowledge documents uses computer upload only, and new-library creation appears below that upload entry. Evidence: explicit user instructions, source changes, focused regression/viewport checks. No API, storage, privacy or existing-file migration change; human direction already supplied.
|
||||
- Target: personal Agents desktop knowledge UI documentation in a future integration. Proposal: computer upload is the only document-add entry; top creation and owned-library management are separate from bottom Agent binding. Creation/browsing do not modify bindings, and the binding limit does not restrict management. Evidence: explicit user instructions, source changes, focused regression/viewport checks. No API, storage, privacy or existing-file migration change; human direction already supplied.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
@@ -52,10 +55,10 @@
|
||||
- Peer Scope/Intent/Promotion sections read-only; knowledge-workspace ready source is the explicit verifiable base. Unknown legacy scopes have no concrete dependency for this explanation.
|
||||
- Goal: personal Agent knowledge reuse with Main-owned transport and Yuxi-owned permissions/storage. Relevant files: picker, documents, knowledge selection and Yuxi knowledge/conversation service.
|
||||
- Constraints: no user-file import, live UI operation or app refresh; removal-related source edits are explicitly authorized. Runtime deployment of the peer's newer picker is not inferred from a screenshot.
|
||||
- Gate result: Passed on resume for the explicitly authorized removal. Ownership and peer scopes rechecked; source base and unchanged canonical context retained.
|
||||
- Gate result: Passed on resume for the explicitly authorized layout follow-up. Successful start/status confirms the same task/mode/branch/worktree/base. All peer Scope/Intent/Promotion sections read-only; the completed knowledge source is the verifiable base. The combined-trial task may later consume this source but this task owns no live update. Undefined legacy scope has no concrete dependency; canonical context is unchanged.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- Remove the chat-import button, component and its unused local props/state/styles; preserve upload, processing, retries and document management.
|
||||
- Update explanatory copy and README to computer upload only; remove retired picker tests and adapt existing upload/E2E coverage to confirm the remaining path.
|
||||
- Run focused knowledge tests, typecheck, scoped lint, build and the headless knowledge fixture; verify task document boundaries and prepare a committed source handoff.
|
||||
- Restructure owned-library UI into a top two-column creation/management row and a bottom binding card. Remove creation injection from the document list and automatic bindings on create/browse; preserve pending operations and permission-loss state.
|
||||
- Update README and focused tests. Independently verify creation/browsing with full binding selection and verify 375px/1440px geometry, upload/status, explicit selection/save/reopen.
|
||||
- Run focused knowledge suites, typecheck, scoped lint, build and headless fixture; inspect screenshots and independent source review, verify task document boundaries and commit the source handoff.
|
||||
@@ -14,7 +14,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
|
||||
- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考独立于正文展示,运行时可展开、手动折叠,完成后折叠;无思考内容时不显示。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要 Yuxi API 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
|
||||
高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,仅提供「从电脑上传」作为添加文件入口;「新建知识库」按钮和创建表单位于上传按钮下方,未选择知识库时上传按钮置灰,仍可先新建知识库;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。
|
||||
高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域上方左右分区:左侧「上传自己的文件」仅放「新建知识库」入口及展开的创建表单,右侧「查看我的知识库」通过下拉框查看本人知识库、从电脑追加资料和管理文件;下方独立「选择知识库」,选择智能体使用的「MakeLore知识」和「我的知识库」。小窗口按新建、查看、选用顺序纵向排列。创建成功后在右侧打开新库;新建和查看均不会自动加入智能体配置,选用上限不影响管理与新建。仅提供「从电脑上传」作为添加文件入口,未选择管理目标时上传按钮置灰;聊天文件导入入口已移除。上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。
|
||||
|
||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
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';
|
||||
@@ -10,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);
|
||||
@@ -22,13 +21,13 @@ 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);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [documentPending, setDocumentPending] = useState(false);
|
||||
const targetId = useId();
|
||||
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);
|
||||
@@ -51,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<void>) => {
|
||||
const act = async (operation: () => Promise<void>, 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); }
|
||||
@@ -65,7 +64,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
|
||||
const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError);
|
||||
const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id));
|
||||
const creationControls = <div className="space-y-3">
|
||||
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || selectionFull || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
|
||||
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
|
||||
{creating && <div className="space-y-3 rounded-md bg-muted/30 p-3">
|
||||
<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}
|
||||
@@ -84,7 +83,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
|
||||
</ol>
|
||||
</details>
|
||||
</div>}
|
||||
<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 () => {
|
||||
<Button type="button" disabled={disabled || busy || documentPending || !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;
|
||||
@@ -92,33 +91,35 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
|
||||
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>
|
||||
}, 'creation')}>{createIntent.current ? '重试创建' : '创建知识库'}</Button>
|
||||
</div>}
|
||||
{documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>}
|
||||
</div>;
|
||||
return <section aria-label="上传自己的文件" className="agent-knowledge-upload space-y-4">
|
||||
<div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-xs leading-5 text-muted-foreground">选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。</p>
|
||||
<label htmlFor={targetId} className="agent-knowledge-field-label">添加到我的知识库</label>
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
return <div className="agent-knowledge-owned-workspace">
|
||||
<section aria-label="上传自己的文件" className="agent-knowledge-upload space-y-4">
|
||||
<div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div>
|
||||
{creationControls}
|
||||
{errorTarget === 'creation' && error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</section>
|
||||
<section aria-label="查看我的知识库" className="agent-knowledge-management space-y-4 text-sm">
|
||||
<div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>查看我的知识库</h3></div>
|
||||
{errorTarget === 'management' && 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 id={targetId} aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={managementDisabled || documentPending}
|
||||
<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;
|
||||
if (managementDisabled || documentPending) 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)}>
|
||||
{manageable.map(kb => <option key={kb.kb_id} value={kb.kb_id}>
|
||||
{kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''}
|
||||
</option>)}
|
||||
</select>
|
||||
<Button type="button" variant="ghost" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
|
||||
</div>
|
||||
{documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>}
|
||||
<details className="agent-knowledge-help">
|
||||
<summary>使用说明与费用</summary>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。</p>
|
||||
@@ -134,13 +135,12 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
|
||||
</Button>
|
||||
{documentPending && <p className="text-xs leading-5 text-muted-foreground">放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。</p>}
|
||||
</div>}
|
||||
{!selectedKnowledge && <div className="space-y-4">
|
||||
{!selectedKnowledge && <div>
|
||||
<Button type="button" disabled title="请先选择或新建自己的知识库">从电脑上传</Button>
|
||||
{creationControls}
|
||||
</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); }}>{selectedKnowledge && creationControls}</CloudKnowledgeDocuments></div>}
|
||||
</div>
|
||||
</section>;
|
||||
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -47,11 +47,11 @@ function documentState(file: CloudKnowledgeFile) {
|
||||
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
|
||||
}
|
||||
|
||||
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange, children }: {
|
||||
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; children?: ReactNode;
|
||||
canRead?: boolean; onPendingChange?: (pending: boolean) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
@@ -155,7 +155,6 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
<Button type="button" disabled={busy || !canRead || (unresolved && !uploadIntents.current.new)} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '从电脑上传'}</Button>
|
||||
<Button type="button" variant="ghost" disabled={loading || !canRead} onClick={refresh}>刷新状态</Button>
|
||||
</div>
|
||||
{children}
|
||||
<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>}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b pb-2 text-xs text-muted-foreground" aria-live="polite">
|
||||
|
||||
@@ -18,10 +18,9 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load
|
||||
const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); };
|
||||
return <div className="agent-knowledge-workspace">
|
||||
<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(); } }}
|
||||
selectableIds={loading || error ? null : options.map(option => option.key)}
|
||||
onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }}
|
||||
onCreated={kb => { add(kb.kb_id); onRefresh(); }} />
|
||||
onCreated={onRefresh} />
|
||||
<section aria-label="选择知识库" className="agent-knowledge-selection space-y-4">
|
||||
<div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>选择知识库</h3></div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">从 MakeLore知识和我的知识库中选择,可同时使用多个。</p>
|
||||
|
||||
@@ -42,20 +42,20 @@
|
||||
.agent-config-resource-manager { margin-top: 18px; }
|
||||
.agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; }
|
||||
.agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; }
|
||||
.agent-knowledge-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); align-items: start; gap: 18px; min-width: 0; }
|
||||
.agent-knowledge-upload, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; }
|
||||
.agent-knowledge-workspace { display: grid; gap: 18px; min-width: 0; }
|
||||
.agent-knowledge-owned-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); align-items: start; gap: 18px; min-width: 0; }
|
||||
.agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; }
|
||||
.agent-knowledge-upload { border-color: #dce5f3; }
|
||||
.agent-knowledge-panel-heading { display: flex; align-items: center; gap: 9px; color: #33485c; }
|
||||
.agent-knowledge-panel-heading h3 { font-size: 16px; font-weight: 650; line-height: 1.5; }
|
||||
.agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; }
|
||||
.agent-knowledge-upload .agent-knowledge-panel-heading svg { color: #4a6e9c; }
|
||||
.agent-knowledge-field-label { display: block; font-size: 13px; font-weight: 600; color: #4c586b; }
|
||||
.agent-knowledge-help { border-top: 1px solid #e8e6e1; padding-top: 12px; }
|
||||
.agent-knowledge-help summary { min-height: 28px; width: fit-content; cursor: pointer; color: #606c7e; font-size: 12px; line-height: 1.7; }
|
||||
.agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; }
|
||||
.agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; }
|
||||
.agent-knowledge-selection button { flex-shrink: 0; }
|
||||
@container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } }
|
||||
@container (max-width: 620px) { .agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 640px) {
|
||||
.agent-editor-tabs { padding: 10px 12px; }
|
||||
.agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; }
|
||||
@@ -67,7 +67,8 @@
|
||||
.agent-config-heading-title { gap: 6px; }
|
||||
.agent-config-icon { width: 38px; height: 38px; border-radius: 13px; }
|
||||
.agent-config-section-top > button { margin-left: 48px; }
|
||||
.agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; }
|
||||
.agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; }
|
||||
.agent-knowledge-workspace, .agent-knowledge-owned-workspace { gap: 14px; }
|
||||
.agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
.agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { padding: 14px; border-radius: 14px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } }
|
||||
@@ -179,20 +179,50 @@ async function expectNoClipping(page: Page) {
|
||||
});
|
||||
expect(clipped).toEqual([]);
|
||||
}
|
||||
async function expectKnowledgeLayout(page: Page, width: number) {
|
||||
const layout = page.locator('.agent-knowledge-workspace');
|
||||
const upload = await layout.locator('.agent-knowledge-upload').boundingBox();
|
||||
const selection = await layout.locator('.agent-knowledge-selection').boundingBox();
|
||||
expect(upload).not.toBeNull(); expect(selection).not.toBeNull();
|
||||
if (width >= 1024) {
|
||||
expect(upload!.x + upload!.width).toBeLessThanOrEqual(selection!.x + 1);
|
||||
expect(Math.abs(upload!.y - selection!.y)).toBeLessThanOrEqual(1);
|
||||
async function expectKnowledgeLayout(page: Page) {
|
||||
const knowledge = page.getByTestId('cloud-agent-editor').getByRole('group', { name: '知识库', exact: true });
|
||||
const creation = knowledge.getByRole('region', { name: '上传自己的文件', exact: true });
|
||||
const management = knowledge.getByRole('region', { name: '查看我的知识库', exact: true });
|
||||
const selection = knowledge.getByRole('region', { name: '选择知识库', exact: true });
|
||||
await expect(creation.getByRole('button', { name: '新建知识库', exact: true })).toBeVisible();
|
||||
await expect(creation.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveCount(0);
|
||||
await expect(creation.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0);
|
||||
await expect(management.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeVisible();
|
||||
await expect(management.getByRole('button', { name: '从电脑上传', exact: true })).toBeVisible();
|
||||
await expect(management.getByRole('button', { name: '新建知识库', exact: true })).toHaveCount(0);
|
||||
await expect(selection.getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible();
|
||||
await expect(knowledge.getByText('添加到我的知识库', { exact: true })).toHaveCount(0);
|
||||
await expect(knowledge.getByText('选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。', { exact: true })).toHaveCount(0);
|
||||
const creationBounds = await creation.boundingBox();
|
||||
const managementBounds = await management.boundingBox();
|
||||
const selectionBounds = await selection.boundingBox();
|
||||
const ownedBounds = await knowledge.locator('.agent-knowledge-owned-workspace').boundingBox();
|
||||
expect(creationBounds).not.toBeNull(); expect(managementBounds).not.toBeNull(); expect(selectionBounds).not.toBeNull();
|
||||
expect(ownedBounds).not.toBeNull();
|
||||
if (ownedBounds!.width > 620) {
|
||||
expect(creationBounds!.x + creationBounds!.width).toBeLessThanOrEqual(managementBounds!.x + 1);
|
||||
expect(Math.abs(creationBounds!.y - managementBounds!.y)).toBeLessThanOrEqual(1);
|
||||
expect(selectionBounds!.y).toBeGreaterThanOrEqual(Math.max(creationBounds!.y + creationBounds!.height, managementBounds!.y + managementBounds!.height));
|
||||
expect(Math.abs(selectionBounds!.x + selectionBounds!.width - managementBounds!.x - managementBounds!.width)).toBeLessThanOrEqual(1);
|
||||
} else {
|
||||
expect(upload!.y + upload!.height).toBeLessThanOrEqual(selection!.y + 1);
|
||||
expect(Math.abs(upload!.x - selection!.x)).toBeLessThanOrEqual(1);
|
||||
expect(creationBounds!.y + creationBounds!.height).toBeLessThanOrEqual(managementBounds!.y + 1);
|
||||
expect(managementBounds!.y + managementBounds!.height).toBeLessThanOrEqual(selectionBounds!.y + 1);
|
||||
expect(Math.abs(creationBounds!.x - managementBounds!.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(creationBounds!.width - managementBounds!.width)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(creationBounds!.width - selectionBounds!.width)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect(Math.abs(creationBounds!.x - selectionBounds!.x)).toBeLessThanOrEqual(1);
|
||||
await expectNoClipping(page);
|
||||
}
|
||||
async function screenshotKnowledge(page: Page, name: string) {
|
||||
const viewport = page.viewportSize()!;
|
||||
const knowledge = page.getByTestId('cloud-agent-editor').getByRole('group', { name: '知识库', exact: true });
|
||||
const bounds = await knowledge.boundingBox();
|
||||
// Fit this scrollable card in the viewport so screenshots show every panel.
|
||||
await page.setViewportSize({ width: viewport.width, height: Math.ceil(bounds!.height + 260) });
|
||||
await knowledge.screenshot({ path: test.info().outputPath(name), animations: 'disabled' });
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
for (const width of [375, 1440]) {
|
||||
test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => {
|
||||
@@ -215,7 +245,7 @@ for (const width of [375, 1440]) {
|
||||
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 })).toBeDisabled();
|
||||
await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' });
|
||||
await expectKnowledgeLayout(page); await screenshotKnowledge(page, `knowledge-shared-${width}.png`);
|
||||
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))
|
||||
@@ -223,36 +253,53 @@ for (const width of [375, 1440]) {
|
||||
await page.reload(); const reopened = await openKnowledge(page);
|
||||
await expect(reopened.knowledge.getByRole('list', { name: '已选知识库', exact: true }).getByRole('listitem')).toHaveCount(3);
|
||||
await expectNoClipping(page);
|
||||
if (width === 1440) {
|
||||
for (const intermediateWidth of [768, 1024]) {
|
||||
await page.setViewportSize({ width: intermediateWidth, height: 850 });
|
||||
await expectKnowledgeLayout(page);
|
||||
await screenshotKnowledge(page, `knowledge-shared-${intermediateWidth}.png`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`own library creation and explicit upload process a document to ready at ${width}px`, async ({ page }) => {
|
||||
test(`own library browsing and creation stay unbound while computer upload processes a document 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');
|
||||
const selection = knowledge.getByRole('region', { name: '选择知识库', exact: true });
|
||||
const choose = selection.getByRole('combobox', { name: '选择知识库', exact: true });
|
||||
const selected = selection.getByRole('list', { name: '已选知识库', exact: true });
|
||||
await choose.selectOption('kb_fixturescience');
|
||||
await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible();
|
||||
expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]);
|
||||
const upload = knowledge.getByRole('button', { name: '从电脑上传', exact: true });
|
||||
const create = knowledge.getByRole('button', { name: '新建知识库', exact: true });
|
||||
const creation = knowledge.getByRole('region', { name: '上传自己的文件', exact: true });
|
||||
const management = knowledge.getByRole('region', { name: '查看我的知识库', exact: true });
|
||||
const manager = management.getByRole('combobox', { name: '管理的知识库', exact: true });
|
||||
const upload = management.getByRole('button', { name: '从电脑上传', exact: true });
|
||||
const create = creation.getByRole('button', { name: '新建知识库', exact: true });
|
||||
await expect(upload).toBeDisabled();
|
||||
const uploadBounds = await upload.boundingBox();
|
||||
const createBounds = await create.boundingBox();
|
||||
expect(uploadBounds).not.toBeNull(); expect(createBounds).not.toBeNull();
|
||||
expect(createBounds!.y).toBeGreaterThan(uploadBounds!.y + uploadBounds!.height);
|
||||
await create.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('我的春天资料');
|
||||
await expectKnowledgeLayout(page);
|
||||
await manager.selectOption('kb_fixturepersonal');
|
||||
await expect(upload).toBeEnabled();
|
||||
const selectedUploadBounds = await upload.boundingBox();
|
||||
const selectedCreateBounds = await create.boundingBox();
|
||||
expect(selectedUploadBounds).not.toBeNull(); expect(selectedCreateBounds).not.toBeNull();
|
||||
expect(selectedCreateBounds!.y).toBeGreaterThan(selectedUploadBounds!.y + selectedUploadBounds!.height);
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(1);
|
||||
await expect(selected).not.toContainText('我的观察记录');
|
||||
await manager.selectOption('');
|
||||
await expect(upload).toBeDisabled();
|
||||
await create.click();
|
||||
await creation.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料');
|
||||
await creation.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding');
|
||||
await expectKnowledgeLayout(page);
|
||||
await creation.getByRole('button', { name: '创建知识库', exact: true }).click();
|
||||
await expect(manager).toHaveValue('kb_fixturecreated');
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(1);
|
||||
await expect(selected).not.toContainText('我的春天资料');
|
||||
await expect(upload).toBeEnabled();
|
||||
await manager.selectOption('kb_fixturepersonal');
|
||||
await manager.selectOption('kb_fixturecreated');
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(1);
|
||||
await expect(knowledge.getByRole('button', { name: '从聊天选择文件', exact: true })).toHaveCount(0);
|
||||
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 upload.click();
|
||||
const document = management.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);
|
||||
@@ -265,8 +312,12 @@ for (const width of [375, 1440]) {
|
||||
});
|
||||
await expect(document.getByText('可用于回答', { exact: true })).toBeVisible({ timeout: 12_000 });
|
||||
await expect(document.getByText('2 KB · 6 段可检索内容', { exact: true })).toBeVisible();
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(1);
|
||||
await choose.selectOption('kb_fixturecreated');
|
||||
await expect(selected.getByRole('listitem')).toHaveCount(2);
|
||||
await expect(selected).toContainText('我的春天资料');
|
||||
await document.scrollIntoViewIfNeeded();
|
||||
await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' });
|
||||
await expectKnowledgeLayout(page); await screenshotKnowledge(page, `knowledge-upload-ready-${width}.png`);
|
||||
// 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();
|
||||
@@ -295,7 +346,8 @@ test('cancelled and failed uploads preserve shared choices and the personal draf
|
||||
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('我的观察记录');
|
||||
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).not.toContainText('我的观察记录');
|
||||
await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true }).getByRole('listitem')).toHaveCount(1);
|
||||
// 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();
|
||||
|
||||
@@ -748,7 +748,6 @@ 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.getByRole('button', { name: '新建知识库' }));
|
||||
const knowledgeName = screen.getByLabelText('知识库名称');
|
||||
fireEvent.change(knowledgeName, { target: { value: '还没创建的资料' } });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge';
|
||||
|
||||
@@ -13,14 +13,13 @@ afterEach(cleanup);
|
||||
|
||||
function openCreation() {
|
||||
render(<CloudKnowledgePanel slug={slug} onCreated={() => undefined} />);
|
||||
fireEvent.click(screen.getByText('上传自己的文件'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
|
||||
}
|
||||
|
||||
it('explains who configures an empty embedding catalog and refreshes without losing the name', async () => {
|
||||
api.call.mockResolvedValueOnce(empty).mockResolvedValueOnce(available);
|
||||
openCreation();
|
||||
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
|
||||
expect(within(screen.getByRole('region', { name: '查看我的知识库' })).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
|
||||
expect(await screen.findByText('暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。')).toBeVisible();
|
||||
expect(screen.getByRole('combobox', { name: '向量模型' })).toBeDisabled();
|
||||
expect(screen.getByText(/上传到知识库的文档可供/)).toHaveTextContent('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限');
|
||||
@@ -35,7 +34,7 @@ it('explains who configures an empty embedding catalog and refreshes without los
|
||||
expect(screen.getByRole('textbox', { name: '知识库名称' })).toHaveValue('创作资料');
|
||||
expect(screen.queryByText('管理员配置说明')).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } });
|
||||
expect(screen.getByRole('button', { name: '创建并选用' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: '创建知识库' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows loading instead of suggesting configuration is missing before the catalog arrives', () => {
|
||||
@@ -64,7 +63,7 @@ it('clears a removed selection after refresh so a new create cannot use an unava
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: '向量模型' })).toHaveValue(''));
|
||||
expect(screen.getByRole('button', { name: '创建并选用' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '创建知识库' })).toBeDisabled();
|
||||
expect(api.call.mock.calls.every(([operation]) => operation === 'knowledge')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -78,7 +77,7 @@ it('preserves an uncertain creation intent when its model disappears on refresh'
|
||||
await screen.findByRole('option', { name: '资料向量模型' });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '创作资料' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建并选用' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建知识库' }));
|
||||
await screen.findByRole('alert');
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
|
||||
await screen.findByText('管理员配置说明');
|
||||
|
||||
@@ -19,9 +19,7 @@ function Harness({ initial = [], disabled = false, agentSlug = slug }: { 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);
|
||||
@@ -47,6 +45,20 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries
|
||||
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();
|
||||
const creation = screen.getByRole('region', { name: '上传自己的文件', exact: true });
|
||||
const management = screen.getByRole('region', { name: '查看我的知识库', exact: true });
|
||||
const selection = screen.getByRole('region', { name: '选择知识库', exact: true });
|
||||
expect(within(creation).getByRole('button', { name: '新建知识库', exact: true })).toBeVisible();
|
||||
expect(within(creation).queryByRole('combobox', { name: '管理的知识库' })).not.toBeInTheDocument();
|
||||
expect(within(creation).queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
|
||||
expect(within(management).getByRole('combobox', { name: '管理的知识库' })).toBeVisible();
|
||||
expect(within(management).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
|
||||
expect(within(management).queryByRole('button', { name: '新建知识库', exact: true })).not.toBeInTheDocument();
|
||||
expect(within(selection).getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible();
|
||||
expect(creation.compareDocumentPosition(management) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(management.compareDocumentPosition(selection) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.queryByText('添加到我的知识库', { exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。', { exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('可多选,每次选择一个知识库加入配置。')).toBeVisible();
|
||||
choose('shared'); choose(own.kb_id); choose('other-shared');
|
||||
expect(within(screen.getByRole('list', { name: '已选知识库' })).getAllByRole('listitem')).toHaveLength(3);
|
||||
@@ -60,7 +72,7 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries
|
||||
it('never offers shared libraries to document management or enables their upload controls', async () => {
|
||||
databases = [];
|
||||
render(<Harness initial={['shared']} />);
|
||||
await screen.findByText('MakeLore知识 · 只读'); openUpload();
|
||||
await screen.findByText('MakeLore知识 · 只读');
|
||||
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
|
||||
expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
|
||||
@@ -76,7 +88,7 @@ it('allows readable binding after an owned-list failure while withholding docume
|
||||
render(<Harness />);
|
||||
await screen.findByText('我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。');
|
||||
expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument();
|
||||
choose('shared'); openUpload();
|
||||
choose('shared');
|
||||
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
|
||||
failed = false;
|
||||
@@ -99,48 +111,92 @@ it('retains missing IDs for removal during catalog failure instead of treating t
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: [] }));
|
||||
});
|
||||
|
||||
it('enforces the 100-library limit and restores selection after removal', async () => {
|
||||
it('limits bindings to 100 libraries without preventing browsing or creation', async () => {
|
||||
const selected = Array.from({ length: 100 }, (_, index) => 'old-' + index);
|
||||
const original = api.call.getMockImplementation()!;
|
||||
const created = { kb_id: 'new-own', name: '新资料' };
|
||||
api.call.mockImplementation((operation, input) => {
|
||||
if (operation === 'createKnowledge') {
|
||||
databases.push(created);
|
||||
catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name });
|
||||
return Promise.resolve(created);
|
||||
}
|
||||
return original(operation, input);
|
||||
});
|
||||
render(<Harness initial={selected} />);
|
||||
await screen.findByRole('group', { name: 'MakeLore知识' });
|
||||
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
|
||||
openUpload(); expect(screen.getByRole('button', { name: '新建知识库' })).toBeDisabled();
|
||||
await openOwn();
|
||||
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled();
|
||||
expect(changes).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: created.name } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } });
|
||||
expect(screen.getByRole('button', { name: '创建知识库', exact: true })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建知识库', exact: true }));
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id));
|
||||
expect(changes).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除知识库 old-0' }));
|
||||
choose('shared');
|
||||
await within(screen.getByRole('combobox', { name: '选择知识库' })).findByRole('option', { name: created.name });
|
||||
choose(created.kb_id);
|
||||
expect(changes.mock.calls.at(-1)?.[0].knowledges).toHaveLength(100);
|
||||
expect(changes.mock.calls.at(-1)?.[0].knowledges).toContain('shared');
|
||||
expect(changes.mock.calls.at(-1)?.[0].knowledges).toContain(created.kb_id);
|
||||
});
|
||||
|
||||
it('selects an existing owned library for uploads and cancelling a file picker does not process a file', async () => {
|
||||
it('browses an owned library without binding it and only explicit selection changes the draft', async () => {
|
||||
render(<Harness initial={['shared']} />);
|
||||
await openOwn();
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] }));
|
||||
expect(changes).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('button', { name: '移除知识库 ' + own.name })).not.toBeInTheDocument();
|
||||
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();
|
||||
expect(changes).not.toHaveBeenCalled();
|
||||
choose(own.kb_id);
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] }));
|
||||
});
|
||||
|
||||
it('appends a late creation result to the latest bindings after interleaved selection and removal', async () => {
|
||||
it('keeps an upload failure in the management column while the creation form is open', async () => {
|
||||
api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认'));
|
||||
render(<Harness initial={['shared']} />);
|
||||
await openOwn();
|
||||
const creation = screen.getByRole('region', { name: '上传自己的文件', exact: true });
|
||||
const management = screen.getByRole('region', { name: '查看我的知识库', exact: true });
|
||||
fireEvent.click(within(creation).getByRole('button', { name: '新建知识库', exact: true }));
|
||||
fireEvent.change(within(creation).getByRole('textbox', { name: '知识库名称' }), { target: { value: '还没创建的资料' } });
|
||||
fireEvent.click(within(management).getByRole('button', { name: '从电脑上传', exact: true }));
|
||||
expect(await within(management).findByRole('alert')).toHaveTextContent('上传结果未确认');
|
||||
expect(within(creation).queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(within(creation).getByRole('textbox', { name: '知识库名称' })).toHaveValue('还没创建的资料');
|
||||
expect(changes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a late creation separate from interleaved draft changes until explicitly selected', 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();
|
||||
await screen.findByRole('group', { name: '我的知识库' });
|
||||
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: '创建并选用' }));
|
||||
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: '移除知识库 新资料' });
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id));
|
||||
expect(screen.queryByRole('button', { name: '移除知识库 新资料' })).not.toBeInTheDocument();
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ model: 'model2', knowledges: ['other-shared'] }));
|
||||
await within(screen.getByRole('combobox', { name: '选择知识库' })).findByRole('option', { name: created.name });
|
||||
choose(created.kb_id);
|
||||
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ model: 'model2', knowledges: ['other-shared', 'new-own'] }));
|
||||
});
|
||||
|
||||
@@ -284,22 +340,24 @@ it('finishes an authorized upload through a failed ownership refresh and does no
|
||||
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 () => {
|
||||
it('retains an authorized creation for management while saving without altering draft bindings', 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();
|
||||
await screen.findByRole('group', { name: '我的知识库' });
|
||||
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: '创建并选用' }));
|
||||
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'] }));
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id));
|
||||
expect(changes).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('button', { name: '移除知识库 新资料' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -307,7 +365,7 @@ it('keeps an owned but unusable library manageable without adding an invalid bin
|
||||
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();
|
||||
expect(screen.getByText('已不可用')).toBeVisible();
|
||||
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
|
||||
expect(within(manager).getByRole('option', { name: '旧资料(当前暂不可选用)' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除知识库 旧资料' }));
|
||||
|
||||
@@ -24,7 +24,6 @@ beforeEach(() => {
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
async function open() {
|
||||
render(<CloudKnowledgePanel slug="ml-agent" onCreated={() => undefined} />);
|
||||
fireEvent.click(screen.getByText('上传自己的文件'));
|
||||
await screen.findByRole('option', { name: '资料' });
|
||||
fireEvent.change(screen.getByLabelText('管理的知识库'), { target: { value: 'kb' } });
|
||||
}
|
||||
@@ -38,6 +37,8 @@ it('uploads then processes automatically and refreshes through queue, parse, ind
|
||||
api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; });
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); });
|
||||
expect(screen.getByText('等待处理')).toBeVisible();
|
||||
expect(within(screen.getByRole('region', { name: '查看我的知识库' })).getByRole('article', { name: file.name })).toBeVisible();
|
||||
expect(within(screen.getByRole('region', { name: '上传自己的文件' })).queryByRole('article')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/0 个分块/)).not.toBeInTheDocument();
|
||||
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: 'kb', file_id: 'file' }));
|
||||
items = [{ ...file, status: 'parsing', processing_task: { task_id: 'task', status: 'running', error: null } }];
|
||||
|
||||
Reference in new issue
Block a user