mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat: promote agent documents as primary workspace panel (#13924)
* ♻️ refactor: adopt Notebook list + EditorCanvas for agent documents The agent working sidebar previously used a FileTree directory view and a hand-rolled Markdown+TextArea editor with manual save. Agent documents already back onto the canonical `documents` table via an FK, so they can reuse the exact same rendering surface as Notebook. - AgentDocumentsGroup: replace FileTree with a flat card list styled after Portal/Notebook/DocumentItem (icon + title + description + delete). - AgentDocumentEditorPanel: drop the bespoke draft/save/segmented view logic; mount the shared <EditorCanvas documentId={doc.documentId} sourceType="notebook" /> inside an EditorProvider so auto-save and rich editing are handled by useDocumentStore. * ✨ feat: promote agent documents as the primary workspace panel - Replace the agent-document sidebar with a Notebook-style list: pill filter (All/Docs/Web), per-item createdAt, globe icon for sourceType=web. - Add a stable panel header "Resources" with a close button (small size, consistent with other chat header actions); no border divider. - Wire clicks to the shared Portal Document view via openDocument(), retiring the inline AgentDocumentEditorPanel. - Portal/Document/Header now resolves title directly from documentId via documentService.getDocumentById + a skeleton loading state. - Portal top-right close icon switched to `X`. - Layout: move AgentWorkingSidebar to the rightmost position; auto-collapse the left navigation sidebar while Portal is open (PortalAutoCollapse). - Header: remove dead NotebookButton, drop the Notebook menu item; add a WorkingPanelToggle visible only when the working panel is collapsed. - ProgressSection hides itself when the topic has no GTD todos. - Builtin tool list removes Notebook; migrate CreateDocument Render and Streaming renderers to builtin-tool-agent-documents (notebook package kept for legacy rendering of historical tool calls). - agent_documents list UI now reads from a separate SWR key (documentsList) so the agent-store context mapping doesn't strip documentId/sourceType/createdAt from the UI payload. - i18n: add workingPanel.resources.filter.{all,documents,web}, viewMode.{list,tree}, and the expanded empty-state copy; zh-CN translations seeded for preview. - New local-testing reference: agent-browser-login (inject better-auth cookie for authenticated agent-browser sessions). * update * 🐛 fix: satisfy tsc strict i18next keys, remove duplicate getDocumentById, coerce showLeftPanel * ♻️ refactor: graduate agent working panel out of labs
This commit is contained in:
parent
7981bab5bd
commit
75b55edca1
41 changed files with 1052 additions and 842 deletions
|
|
@ -173,6 +173,10 @@ agent-browser state save auth.json
|
|||
agent-browser state load auth.json
|
||||
```
|
||||
|
||||
### LobeHub dev server — inject better-auth cookie
|
||||
|
||||
`agent-browser --headed` on macOS can create an off-screen Chromium window, blocking manual login. For a local LobeHub dev server (e.g. `localhost:3011`), copy the `better-auth.session_token` cookie out of a **Network request** in the user's own Chrome DevTools and load it via `state load`. See [references/agent-browser-login.md](./references/agent-browser-login.md) for the full recipe.
|
||||
|
||||
## Semantic Locators (Alternative to Refs)
|
||||
|
||||
```bash
|
||||
|
|
|
|||
110
.agents/skills/local-testing/references/agent-browser-login.md
Normal file
110
.agents/skills/local-testing/references/agent-browser-login.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Log `agent-browser` into a local LobeHub dev server
|
||||
|
||||
`agent-browser --headed` on macOS often creates the Chromium window off-screen — the user can't see or interact with it, so manual login inside the agent-browser session fails. Instead of sharing the user's real Chrome profile, copy the **better-auth session cookie** out of a request in DevTools and inject it into the agent-browser session as a Playwright-style state file.
|
||||
|
||||
## When to use
|
||||
|
||||
- You need `agent-browser` to reach an authenticated page on `http://localhost:<port>` (e.g. `localhost:3011`).
|
||||
- The user already has a logged-in tab of the same dev server in their own Chrome.
|
||||
- Spawning a headed Chromium to let the user log in manually is unreliable (window off-screen, no interaction).
|
||||
|
||||
Do **not** use this on production URLs — only local dev. Treat the cookie as a secret: don't paste it into shared logs, PRs, or commit it anywhere.
|
||||
|
||||
## Step 1 — Ask the user to copy the cookie from a Network request, NOT `document.cookie`
|
||||
|
||||
`document.cookie` will not return HttpOnly cookies, which is exactly where better-auth puts its session. Instruct the user:
|
||||
|
||||
1. Open the logged-in tab (`http://localhost:<port>/…`) in their own Chrome.
|
||||
2. `Cmd+Option+I` → **Network** tab.
|
||||
3. Refresh, click any same-origin request (e.g. the top-level document request).
|
||||
4. In the right pane under **Request Headers**, right-click the `Cookie:` line → **Copy value** (or copy the entire header).
|
||||
5. Paste the string into chat.
|
||||
|
||||
You only need the better-auth pieces. Everything else (Clerk, `LOBE_LOCALE`, HMR hash, theme vars) is noise and can stay. The minimum viable set is:
|
||||
|
||||
```
|
||||
better-auth.session_token=<value>; better-auth.state=<value>
|
||||
```
|
||||
|
||||
## Step 2 — Build a Playwright-style state file
|
||||
|
||||
`agent-browser state load` expects Playwright's `storageState` format: a JSON with a `cookies` array and an `origins` array.
|
||||
|
||||
```bash
|
||||
cat > /tmp/mkstate.py << 'PY'
|
||||
import json, sys, time
|
||||
|
||||
# Read the Cookie header from stdin (allows optional "Cookie: " prefix).
|
||||
raw = sys.stdin.read().strip()
|
||||
if raw.lower().startswith("cookie:"):
|
||||
raw = raw.split(":", 1)[1].strip()
|
||||
|
||||
# Keep only better-auth cookies. Extend this set if the app genuinely needs more.
|
||||
WANTED = {"better-auth.session_token", "better-auth.state"}
|
||||
|
||||
cookies = []
|
||||
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
|
||||
for pair in raw.split("; "):
|
||||
if "=" not in pair:
|
||||
continue
|
||||
name, _, value = pair.partition("=")
|
||||
if name not in WANTED:
|
||||
continue
|
||||
cookies.append({
|
||||
"name": name,
|
||||
"value": value,
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": exp,
|
||||
"httpOnly": False,
|
||||
"secure": False,
|
||||
"sameSite": "Lax",
|
||||
})
|
||||
|
||||
if not cookies:
|
||||
sys.stderr.write("no better-auth cookies found in input\n")
|
||||
sys.exit(1)
|
||||
|
||||
print(json.dumps({"cookies": cookies, "origins": []}, indent=2))
|
||||
PY
|
||||
|
||||
# Feed the copied Cookie header in via env var or heredoc.
|
||||
printf '%s' "$COOKIE_HEADER" | python3 /tmp/mkstate.py > /tmp/state.json
|
||||
```
|
||||
|
||||
**Note on `httpOnly`**: the real cookie in the user's browser is HttpOnly, but `storageState` doesn't enforce the flag on load — it just attaches the value. Storing with `httpOnly: false` is fine for local dev and sidesteps a CDP-context quirk where HttpOnly cookies sometimes fail to attach.
|
||||
|
||||
## Step 3 — Load state and navigate
|
||||
|
||||
```bash
|
||||
SESSION="my-test" # any stable session name
|
||||
|
||||
agent-browser --session "$SESSION" state load /tmp/state.json
|
||||
agent-browser --session "$SESSION" open "http://localhost:3011/"
|
||||
agent-browser --session "$SESSION" get url
|
||||
# Expect NOT /signin?callbackUrl=… — if you still see signin, cookie didn't apply.
|
||||
```
|
||||
|
||||
## Step 4 — Verify
|
||||
|
||||
```bash
|
||||
agent-browser --session "$SESSION" snapshot -i | head -20
|
||||
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
|
||||
```
|
||||
|
||||
## Common failure modes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Still redirects to `/signin` after `state load` | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
|
||||
| `state load` reports 0 cookies | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is; split on `"; "` |
|
||||
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-load |
|
||||
| Domain mismatch | Use `domain: "localhost"` literally, no leading dot for local dev | — |
|
||||
|
||||
## Scope
|
||||
|
||||
Only covers authenticating an **agent-browser** session into a **local** LobeHub dev server. It does not:
|
||||
|
||||
- Work for production — production cookies are `Secure; HttpOnly; Domain=.lobehub.com` and must be delivered over HTTPS.
|
||||
- Replace real OAuth flows — tests that must exercise the login UI need a real Chromium with `--remote-debugging-port` or a bot account.
|
||||
- Flow cookies back to the user's Chrome — injection is one-way (into agent-browser only).
|
||||
|
|
@ -552,33 +552,38 @@
|
|||
"workflow.toolDisplayName.upsertDocumentByFilename": "更新了文档",
|
||||
"workflow.toolDisplayName.writeLocalFile": "写入了文件",
|
||||
"workflow.working": "处理中...",
|
||||
"workingPanel.agentDocuments": "助理文档",
|
||||
"workingPanel.agentDocuments": "代理文档",
|
||||
"workingPanel.documents.close": "关闭",
|
||||
"workingPanel.documents.discard": "放弃更改",
|
||||
"workingPanel.documents.discard": "放弃",
|
||||
"workingPanel.documents.edit": "编辑",
|
||||
"workingPanel.documents.error": "文档加载失败",
|
||||
"workingPanel.documents.loading": "文档加载中...",
|
||||
"workingPanel.documents.loading": "文档加载中…",
|
||||
"workingPanel.documents.preview": "预览",
|
||||
"workingPanel.documents.save": "保存",
|
||||
"workingPanel.documents.saved": "所有更改已保存",
|
||||
"workingPanel.documents.saved": "已保存全部变更",
|
||||
"workingPanel.documents.title": "文档",
|
||||
"workingPanel.documents.unsaved": "有未保存更改",
|
||||
"workingPanel.documents.unsaved": "有未保存的变更",
|
||||
"workingPanel.progress": "进度",
|
||||
"workingPanel.progress.allCompleted": "全部任务已完成",
|
||||
"workingPanel.progress.allCompleted": "所有任务已完成",
|
||||
"workingPanel.resources": "资源",
|
||||
"workingPanel.resources.deleteConfirm": "此操作无法撤销。",
|
||||
"workingPanel.resources.deleteError": "删除文档失败",
|
||||
"workingPanel.resources.deleteConfirm": "该操作无法撤销。",
|
||||
"workingPanel.resources.deleteError": "删除失败",
|
||||
"workingPanel.resources.deleteSuccess": "文档已删除",
|
||||
"workingPanel.resources.deleteTitle": "删除文档?",
|
||||
"workingPanel.resources.empty": "暂无助理文档",
|
||||
"workingPanel.resources.deleteTitle": "删除该文档?",
|
||||
"workingPanel.resources.empty": "暂无文档,当前 agent 关联的文档将会显示在这里",
|
||||
"workingPanel.resources.error": "资源加载失败",
|
||||
"workingPanel.resources.loading": "资源加载中...",
|
||||
"workingPanel.resources.filter.all": "全部",
|
||||
"workingPanel.resources.filter.documents": "文档",
|
||||
"workingPanel.resources.filter.web": "网页",
|
||||
"workingPanel.resources.loading": "资源加载中…",
|
||||
"workingPanel.resources.previewError": "预览加载失败",
|
||||
"workingPanel.resources.previewLoading": "预览加载中...",
|
||||
"workingPanel.resources.previewLoading": "预览加载中…",
|
||||
"workingPanel.resources.renameEmpty": "标题不能为空",
|
||||
"workingPanel.resources.renameError": "重命名文档失败",
|
||||
"workingPanel.resources.renameSuccess": "文档已重命名",
|
||||
"workingPanel.title": "Working Panel",
|
||||
"workingPanel.resources.renameError": "重命名失败",
|
||||
"workingPanel.resources.renameSuccess": "重命名成功",
|
||||
"workingPanel.resources.viewMode.list": "列表视图",
|
||||
"workingPanel.resources.viewMode.tree": "目录视图",
|
||||
"workingPanel.title": "工作面板",
|
||||
"you": "你",
|
||||
"zenMode": "专注模式"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,5 +14,13 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@lobehub/ui": "^5",
|
||||
"antd": "^6",
|
||||
"antd-style": "*",
|
||||
"lucide-react": "*",
|
||||
"react": "*",
|
||||
"react-i18next": "*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
'use client';
|
||||
|
||||
import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow, TooltipGroup } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { FileTextIcon, Maximize2, Minimize2, PencilLine } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/slices/portal/selectors';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 16px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
content: css`
|
||||
padding-inline: 16px;
|
||||
font-size: 14px;
|
||||
`,
|
||||
expandButton: css`
|
||||
position: absolute;
|
||||
inset-block-end: 16px;
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
box-shadow: ${cssVar.boxShadow};
|
||||
`,
|
||||
header: css`
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
icon: css`
|
||||
color: ${cssVar.colorPrimary};
|
||||
`,
|
||||
title: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface DocumentCardProps {
|
||||
content: string;
|
||||
documentId?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const DocumentCard = memo<DocumentCardProps>(({ content, documentId, title }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const [portalDocumentId, openDocument, closeDocument] = useChatStore((s) => [
|
||||
chatPortalSelectors.portalDocumentId(s),
|
||||
s.openDocument,
|
||||
s.closeDocument,
|
||||
]);
|
||||
|
||||
const isExpanded = !!documentId && portalDocumentId === documentId;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!documentId) return;
|
||||
if (isExpanded) {
|
||||
closeDocument();
|
||||
} else {
|
||||
openDocument(documentId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
<FileTextIcon className={styles.icon} size={16} />
|
||||
<Flexbox flex={1}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
</Flexbox>
|
||||
<TooltipGroup>
|
||||
<Flexbox horizontal gap={4}>
|
||||
<CopyButton
|
||||
content={content}
|
||||
size={'small'}
|
||||
title={t('builtins.lobe-notebook.actions.copy')}
|
||||
/>
|
||||
{documentId && (
|
||||
<ActionIcon
|
||||
icon={PencilLine}
|
||||
size={'small'}
|
||||
title={t('builtins.lobe-notebook.actions.edit')}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
</TooltipGroup>
|
||||
</Flexbox>
|
||||
<ScrollShadow className={styles.content} offset={12} size={12} style={{ maxHeight: 400 }}>
|
||||
<Markdown style={{ overflow: 'unset', paddingBottom: 40 }} variant={'chat'}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</ScrollShadow>
|
||||
|
||||
{documentId && (
|
||||
<Button
|
||||
className={styles.expandButton}
|
||||
color={'default'}
|
||||
icon={isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
shape={'round'}
|
||||
variant={'outlined'}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{isExpanded
|
||||
? t('builtins.lobe-notebook.actions.collapse')
|
||||
: t('builtins.lobe-notebook.actions.expand')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default DocumentCard;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { CreateDocumentArgs, CreateDocumentState } from '../../../types';
|
||||
import DocumentCard from './DocumentCard';
|
||||
|
||||
export type CreateDocumentRenderProps = Pick<
|
||||
BuiltinRenderProps<CreateDocumentArgs, CreateDocumentState>,
|
||||
'args' | 'pluginState'
|
||||
>;
|
||||
|
||||
const CreateDocument = memo<CreateDocumentRenderProps>(({ args, pluginState }) => {
|
||||
const title = args?.title;
|
||||
const content = args?.content;
|
||||
|
||||
if (!title || !content) return null;
|
||||
|
||||
return <DocumentCard content={content} documentId={pluginState?.documentId} title={title} />;
|
||||
});
|
||||
|
||||
export default CreateDocument;
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
// Intentionally empty: Agent Documents has no dedicated render components yet.
|
||||
// Keep this export as a stable extension point for future UI renderers.
|
||||
export const AgentDocumentsRenders = {};
|
||||
import CreateDocument from './CreateDocument';
|
||||
|
||||
export const AgentDocumentsRenders = {
|
||||
createDocument: CreateDocument,
|
||||
};
|
||||
|
||||
export { default as CreateDocument } from './CreateDocument';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
'use client';
|
||||
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import BubblesLoading from '@/components/BubblesLoading';
|
||||
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
|
||||
import StreamingMarkdown from '@/components/StreamingMarkdown';
|
||||
|
||||
import type { CreateDocumentArgs } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 16px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
header: css`
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
icon: css`
|
||||
color: ${cssVar.colorPrimary};
|
||||
`,
|
||||
title: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
export const CreateDocumentStreaming = memo<BuiltinStreamingProps<CreateDocumentArgs>>(
|
||||
({ args }) => {
|
||||
const { content, title } = args || {};
|
||||
|
||||
if (!content && !title) return null;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
<FileTextIcon className={styles.icon} size={16} />
|
||||
<Flexbox flex={1}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
</Flexbox>
|
||||
<NeuralNetworkLoading size={20} />
|
||||
</Flexbox>
|
||||
{!content ? (
|
||||
<Flexbox paddingBlock={16} paddingInline={12}>
|
||||
<BubblesLoading />
|
||||
</Flexbox>
|
||||
) : (
|
||||
<StreamingMarkdown>{content}</StreamingMarkdown>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CreateDocumentStreaming.displayName = 'AgentDocumentsCreateDocumentStreaming';
|
||||
|
||||
export default CreateDocumentStreaming;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { BuiltinStreaming } from '@lobechat/types';
|
||||
|
||||
import { AgentDocumentsApiName } from '../../types';
|
||||
import { CreateDocumentStreaming } from './CreateDocument';
|
||||
|
||||
export const AgentDocumentsStreamings: Record<string, BuiltinStreaming> = {
|
||||
[AgentDocumentsApiName.createDocument]: CreateDocumentStreaming as BuiltinStreaming,
|
||||
};
|
||||
|
|
@ -2,3 +2,4 @@ export { AgentDocumentsManifest } from '../manifest';
|
|||
export * from '../types';
|
||||
export { AgentDocumentsInspectors } from './Inspector';
|
||||
export { AgentDocumentsRenders } from './Render';
|
||||
export { AgentDocumentsStreamings } from './Streaming';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
|
|||
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
import { MemoryManifest } from '@lobechat/builtin-tool-memory';
|
||||
import { MessageManifest } from '@lobechat/builtin-tool-message';
|
||||
import { NotebookManifest } from '@lobechat/builtin-tool-notebook';
|
||||
import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent';
|
||||
import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device';
|
||||
import { SkillStoreManifest } from '@lobechat/builtin-tool-skill-store';
|
||||
|
|
@ -85,7 +84,6 @@ export const runtimeManagedToolIds = [
|
|||
LocalSystemManifest.identifier,
|
||||
MemoryManifest.identifier,
|
||||
RemoteDeviceManifest.identifier,
|
||||
AgentDocumentsManifest.identifier,
|
||||
WebBrowsingManifest.identifier,
|
||||
];
|
||||
|
||||
|
|
@ -135,6 +133,11 @@ export const builtinTools: LobeBuiltinTool[] = [
|
|||
manifest: CloudSandboxManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
identifier: AgentDocumentsManifest.identifier,
|
||||
manifest: AgentDocumentsManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
identifier: CredsManifest.identifier,
|
||||
manifest: CredsManifest,
|
||||
|
|
@ -165,12 +168,6 @@ export const builtinTools: LobeBuiltinTool[] = [
|
|||
manifest: AgentBuilderManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
hidden: true,
|
||||
identifier: AgentDocumentsManifest.identifier,
|
||||
manifest: AgentDocumentsManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
discoverable: false,
|
||||
hidden: true,
|
||||
|
|
@ -196,11 +193,6 @@ export const builtinTools: LobeBuiltinTool[] = [
|
|||
manifest: GTDManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
identifier: NotebookManifest.identifier,
|
||||
manifest: NotebookManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
identifier: CalculatorManifest.identifier,
|
||||
manifest: CalculatorManifest,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
} from '@lobechat/builtin-tool-activator/client';
|
||||
import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder';
|
||||
import { AgentBuilderRenders } from '@lobechat/builtin-tool-agent-builder/client';
|
||||
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
|
||||
import { AgentDocumentsRenders } from '@lobechat/builtin-tool-agent-documents/client';
|
||||
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
|
||||
import { AgentManagementRenders } from '@lobechat/builtin-tool-agent-management/client';
|
||||
import { ClaudeCodeIdentifier, ClaudeCodeRenders } from '@lobechat/builtin-tool-claude-code/client';
|
||||
|
|
@ -39,6 +41,7 @@ import { type BuiltinRender } from '@lobechat/types';
|
|||
*/
|
||||
const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
|
||||
[AgentBuilderManifest.identifier]: AgentBuilderRenders as Record<string, BuiltinRender>,
|
||||
[AgentDocumentsManifest.identifier]: AgentDocumentsRenders as Record<string, BuiltinRender>,
|
||||
[AgentManagementManifest.identifier]: AgentManagementRenders as Record<string, BuiltinRender>,
|
||||
[ClaudeCodeIdentifier]: ClaudeCodeRenders as Record<string, BuiltinRender>,
|
||||
[CloudSandboxManifest.identifier]: CloudSandboxRenders as Record<string, BuiltinRender>,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import {
|
|||
AgentBuilderManifest,
|
||||
AgentBuilderStreamings,
|
||||
} from '@lobechat/builtin-tool-agent-builder/client';
|
||||
import {
|
||||
AgentDocumentsManifest,
|
||||
AgentDocumentsStreamings,
|
||||
} from '@lobechat/builtin-tool-agent-documents/client';
|
||||
import {
|
||||
AgentManagementManifest,
|
||||
AgentManagementStreamings,
|
||||
|
|
@ -38,6 +42,7 @@ import { type BuiltinStreaming } from '@lobechat/types';
|
|||
*/
|
||||
const BuiltinToolStreamings: Record<string, Record<string, BuiltinStreaming>> = {
|
||||
[AgentBuilderManifest.identifier]: AgentBuilderStreamings as Record<string, BuiltinStreaming>,
|
||||
[AgentDocumentsManifest.identifier]: AgentDocumentsStreamings as Record<string, BuiltinStreaming>,
|
||||
[AgentManagementManifest.identifier]: AgentManagementStreamings as Record<
|
||||
string,
|
||||
BuiltinStreaming
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const RECOMMENDED_SKILLS: RecommendedSkillItem[] = [
|
|||
{ id: 'lobe-user-memory', type: RecommendedSkillType.Builtin },
|
||||
{ id: 'lobe-cloud-sandbox', type: RecommendedSkillType.Builtin },
|
||||
{ id: 'lobe-gtd', type: RecommendedSkillType.Builtin },
|
||||
{ id: 'lobe-notebook', type: RecommendedSkillType.Builtin },
|
||||
{ id: 'lobe-agent-documents', type: RecommendedSkillType.Builtin },
|
||||
{ id: 'lobe-message', type: RecommendedSkillType.Builtin },
|
||||
// Klavis skills
|
||||
{ id: 'gmail', type: RecommendedSkillType.Klavis },
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export const DEFAULT_PREFERENCE: UserPreference = {
|
|||
topic: true,
|
||||
},
|
||||
lab: {
|
||||
enableAgentWorkingPanel: false,
|
||||
enableHeterogeneousAgent: false,
|
||||
enableInputMarkdown: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export class AgentDocumentModel {
|
|||
policyLoadFormat,
|
||||
policyLoadPosition: settings.policyLoadPosition,
|
||||
policyLoadRule: settings.policyLoadRule,
|
||||
source: doc.source ?? null,
|
||||
sourceType: doc.sourceType,
|
||||
templateId: settings.templateId ?? null,
|
||||
title: doc.title ?? doc.filename ?? '',
|
||||
updatedAt: settings.updatedAt,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export {
|
|||
// Type-only exports (interfaces)
|
||||
export type { AgentDocumentPolicy, DocumentLoadRules } from '@lobechat/agent-templates';
|
||||
|
||||
export type AgentDocumentSourceType = 'file' | 'web' | 'api' | 'topic';
|
||||
|
||||
export interface AgentDocument {
|
||||
accessPublic: number;
|
||||
accessSelf: number;
|
||||
|
|
@ -43,6 +45,8 @@ export interface AgentDocument {
|
|||
policyLoadFormat: DocumentLoadFormat;
|
||||
policyLoadPosition: string;
|
||||
policyLoadRule: string;
|
||||
source: string | null;
|
||||
sourceType: AgentDocumentSourceType;
|
||||
templateId: string | null;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
|
|
|
|||
|
|
@ -38,10 +38,6 @@ export const UserGuideSchema = z.object({
|
|||
export type UserGuide = z.infer<typeof UserGuideSchema>;
|
||||
|
||||
export const UserLabSchema = z.object({
|
||||
/**
|
||||
* enable agent working panel entry in chat header menu
|
||||
*/
|
||||
enableAgentWorkingPanel: z.boolean().optional(),
|
||||
/**
|
||||
* enable server-side agent execution via Gateway WebSocket
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,59 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Flexbox, Skeleton, Text } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { documentService } from '@/services/document';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
import { useNotebookStore } from '@/store/notebook';
|
||||
import { notebookSelectors } from '@/store/notebook/selectors';
|
||||
import { oneLineEllipsis } from '@/styles';
|
||||
import { standardizeIdentifier } from '@/utils/identifier';
|
||||
|
||||
import AutoSaveHint from './AutoSaveHint';
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation('portal');
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const documentId = useChatStore(chatPortalSelectors.portalDocumentId);
|
||||
|
||||
const [topicId, documentId] = useChatStore((s) => [
|
||||
s.activeTopicId,
|
||||
chatPortalSelectors.portalDocumentId(s),
|
||||
]);
|
||||
const { data: document, isLoading } = useClientDataSWR(
|
||||
documentId ? ['portal-document-header', documentId] : null,
|
||||
() => documentService.getDocumentById(documentId!),
|
||||
);
|
||||
|
||||
const [useFetchDocuments, title, fileType] = useNotebookStore((s) => [
|
||||
s.useFetchDocuments,
|
||||
notebookSelectors.getDocumentById(topicId, documentId)(s)?.title,
|
||||
notebookSelectors.getDocumentById(topicId, documentId)(s)?.fileType,
|
||||
]);
|
||||
useFetchDocuments(topicId);
|
||||
const title = document?.filename || document?.title;
|
||||
|
||||
const handleOpenInPageEditor = async () => {
|
||||
if (!documentId) return;
|
||||
if (!documentId) return null;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Update fileType to custom/document so it appears in page list
|
||||
await documentService.updateDocument({
|
||||
fileType: 'custom/document',
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
// Navigate to the page editor
|
||||
// Note: /page route automatically adds 'docs_' prefix to the id
|
||||
navigate(`/page/${standardizeIdentifier(documentId)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!title) return null;
|
||||
if (isLoading || !title) {
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
flex={1}
|
||||
gap={12}
|
||||
justify={'space-between'}
|
||||
width={'100%'}
|
||||
>
|
||||
<Flexbox flex={1}>
|
||||
<Skeleton.Button active size={'small'} style={{ height: 16, width: 180 }} />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} flex={1} gap={12} justify={'space-between'} width={'100%'}>
|
||||
|
|
@ -64,17 +49,6 @@ const Header = () => {
|
|||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<AutoSaveHint />
|
||||
{fileType !== 'agent/plan' && (
|
||||
<Button
|
||||
icon={<ExternalLink size={14} />}
|
||||
loading={loading}
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={handleOpenInPageEditor}
|
||||
>
|
||||
{t('openInPageEditor')}
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
|
||||
import { ActionIcon, Flexbox } from '@lobehub/ui';
|
||||
import { ArrowLeft, PanelRightCloseIcon } from 'lucide-react';
|
||||
import { ArrowLeft, X } from 'lucide-react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ const Header = memo<{ title: ReactNode }>(({ title }) => {
|
|||
}
|
||||
right={
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
icon={X}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
onClick={() => {
|
||||
clearPortalStack();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import { HotkeyEnum } from '@lobechat/const/hotkeys';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useNavigateToAgent } from '@/hooks/useNavigateToAgent';
|
||||
import { usePinnedAgentState } from '@/hooks/usePinnedAgentState';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { labPreferSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { useHotkeyById } from './useHotkeyById';
|
||||
|
||||
|
|
@ -44,23 +41,11 @@ export const useToggleLeftPanelHotkey = () => {
|
|||
export const useToggleRightPanelHotkey = () => {
|
||||
const isZenMode = useGlobalStore((s) => s.status.zenMode);
|
||||
const toggleConfig = useGlobalStore((s) => s.toggleRightPanel);
|
||||
const { pathname } = useLocation();
|
||||
const enableAgentWorkingPanel = useUserStore(labPreferSelectors.enableAgentWorkingPanel);
|
||||
|
||||
return useHotkeyById(
|
||||
HotkeyEnum.ToggleRightPanel,
|
||||
() => {
|
||||
// TODO: Remove this labs-only guard once Working Panel is no longer an experimental feature.
|
||||
// Keep in sync with the related TODO in `src/routes/(main)/agent/features/Conversation/index.tsx`.
|
||||
const isChatRoute = pathname.startsWith('/agent') || pathname.startsWith('/chat');
|
||||
if (isChatRoute && !enableAgentWorkingPanel) return;
|
||||
toggleConfig();
|
||||
},
|
||||
{
|
||||
enableOnContentEditable: true,
|
||||
enabled: !isZenMode,
|
||||
},
|
||||
);
|
||||
return useHotkeyById(HotkeyEnum.ToggleRightPanel, () => toggleConfig(), {
|
||||
enableOnContentEditable: true,
|
||||
enabled: !isZenMode,
|
||||
});
|
||||
};
|
||||
|
||||
// CMDK
|
||||
|
|
|
|||
|
|
@ -609,14 +609,20 @@ export default {
|
|||
'workingPanel.resources.deleteError': 'Failed to delete document',
|
||||
'workingPanel.resources.deleteSuccess': 'Document deleted',
|
||||
'workingPanel.resources.deleteTitle': 'Delete document?',
|
||||
'workingPanel.resources.empty': 'No agent documents yet',
|
||||
'workingPanel.resources.empty':
|
||||
'No documents yet. Documents associated with this agent will show up here.',
|
||||
'workingPanel.resources.error': 'Failed to load resources',
|
||||
'workingPanel.resources.filter.all': 'All',
|
||||
'workingPanel.resources.filter.documents': 'Documents',
|
||||
'workingPanel.resources.filter.web': 'Web',
|
||||
'workingPanel.resources.loading': 'Loading resources...',
|
||||
'workingPanel.resources.previewError': 'Failed to load preview',
|
||||
'workingPanel.resources.previewLoading': 'Loading preview...',
|
||||
'workingPanel.resources.renameEmpty': 'Title cannot be empty',
|
||||
'workingPanel.resources.renameError': 'Failed to rename document',
|
||||
'workingPanel.resources.renameSuccess': 'Document renamed',
|
||||
'workingPanel.resources.viewMode.list': 'List view',
|
||||
'workingPanel.resources.viewMode.tree': 'Tree view',
|
||||
'workingPanel.documents.close': 'Close',
|
||||
'workingPanel.documents.error': 'Failed to load document',
|
||||
'workingPanel.documents.loading': 'Loading document...',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
export default {
|
||||
'features.agentWorkingPanel.desc':
|
||||
"A place to view an agent's progress and accessible resources.",
|
||||
'features.agentWorkingPanel.title': 'Working Panel',
|
||||
'features.assistantMessageGroup.desc':
|
||||
'Group agent messages and their tool call results together for display',
|
||||
'features.assistantMessageGroup.title': 'Agent Message Grouping',
|
||||
|
|
|
|||
37
src/routes/(main)/agent/_layout/PortalAutoCollapse.tsx
Normal file
37
src/routes/(main)/agent/_layout/PortalAutoCollapse.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client';
|
||||
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
/**
|
||||
* Auto-collapse the left nav panel while Portal is open on the agent page.
|
||||
* Restores the previous state when Portal closes.
|
||||
*/
|
||||
const PortalAutoCollapse = memo(() => {
|
||||
const showPortal = useChatStore(chatPortalSelectors.showPortal);
|
||||
const savedShowLeftPanelRef = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { status, toggleLeftPanel } = useGlobalStore.getState();
|
||||
|
||||
if (showPortal) {
|
||||
// Remember the left-panel state only on the transition false→true
|
||||
if (savedShowLeftPanelRef.current === null) {
|
||||
savedShowLeftPanelRef.current = !!status.showLeftPanel;
|
||||
if (status.showLeftPanel) toggleLeftPanel(false);
|
||||
}
|
||||
} else if (savedShowLeftPanelRef.current !== null) {
|
||||
if (savedShowLeftPanelRef.current) toggleLeftPanel(true);
|
||||
savedShowLeftPanelRef.current = null;
|
||||
}
|
||||
}, [showPortal]);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
PortalAutoCollapse.displayName = 'PortalAutoCollapse';
|
||||
|
||||
export default PortalAutoCollapse;
|
||||
|
|
@ -7,6 +7,7 @@ import ProtocolUrlHandler from '@/features/ProtocolUrlHandler';
|
|||
import { useInitAgentConfig } from '@/hooks/useInitAgentConfig';
|
||||
import AgentIdSync from '@/routes/(main)/agent/_layout/AgentIdSync';
|
||||
|
||||
import PortalAutoCollapse from './PortalAutoCollapse';
|
||||
import RegisterHotkeys from './RegisterHotkeys';
|
||||
import Sidebar from './Sidebar';
|
||||
import { styles } from './style';
|
||||
|
|
@ -23,6 +24,7 @@ const Layout: FC = () => {
|
|||
<RegisterHotkeys />
|
||||
{isDesktop && <ProtocolUrlHandler />}
|
||||
<AgentIdSync />
|
||||
<PortalAutoCollapse />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,67 +2,34 @@
|
|||
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { type DropdownItem } from '@lobehub/ui';
|
||||
import { FilePenIcon, Maximize2, PanelRightOpen } from 'lucide-react';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { labPreferSelectors } from '@/store/user/selectors';
|
||||
|
||||
export const useMenu = (): { menuItems: DropdownItem[] } => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { t: tPortal } = useTranslation('portal');
|
||||
|
||||
const [wideScreen, toggleRightPanel, toggleWideScreen] = useGlobalStore((s) => [
|
||||
const [wideScreen, toggleWideScreen] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.wideScreen(s),
|
||||
s.toggleRightPanel,
|
||||
s.toggleWideScreen,
|
||||
]);
|
||||
const enableAgentWorkingPanel = useUserStore(labPreferSelectors.enableAgentWorkingPanel);
|
||||
|
||||
const toggleNotebook = useChatStore((s) => s.toggleNotebook);
|
||||
|
||||
const menuItems = useMemo<DropdownItem[]>(() => {
|
||||
const items: DropdownItem[] = [
|
||||
const menuItems = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
icon: <Icon icon={FilePenIcon} />,
|
||||
key: 'notebook',
|
||||
label: tPortal('notebook.title'),
|
||||
onClick: () => toggleNotebook(),
|
||||
checked: wideScreen,
|
||||
icon: <Icon icon={Maximize2} />,
|
||||
key: 'full-width',
|
||||
label: t('viewMode.fullWidth'),
|
||||
onCheckedChange: toggleWideScreen,
|
||||
type: 'switch',
|
||||
},
|
||||
];
|
||||
|
||||
if (enableAgentWorkingPanel) {
|
||||
items.push({
|
||||
icon: <Icon icon={PanelRightOpen} />,
|
||||
key: 'agent-workspace',
|
||||
label: t('workingPanel.title'),
|
||||
onClick: () => toggleRightPanel(),
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
checked: wideScreen,
|
||||
icon: <Icon icon={Maximize2} />,
|
||||
key: 'full-width',
|
||||
label: t('viewMode.fullWidth'),
|
||||
onCheckedChange: toggleWideScreen,
|
||||
type: 'switch',
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [
|
||||
enableAgentWorkingPanel,
|
||||
t,
|
||||
tPortal,
|
||||
toggleNotebook,
|
||||
toggleRightPanel,
|
||||
toggleWideScreen,
|
||||
wideScreen,
|
||||
]);
|
||||
],
|
||||
[t, toggleWideScreen, wideScreen],
|
||||
);
|
||||
|
||||
return { menuItems };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { FilePenIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
const NotebookButton = memo(() => {
|
||||
const { t } = useTranslation('portal');
|
||||
const [showNotebook, toggleNotebook] = useChatStore((s) => [s.showNotebook, s.toggleNotebook]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
active={showNotebook}
|
||||
icon={FilePenIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
title={t('notebook.title')}
|
||||
onClick={() => toggleNotebook()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default NotebookButton;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@lobechat/const';
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { PanelRightOpenIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
|
||||
const WorkingPanelToggle = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [showRightPanel, toggleRightPanel] = useGlobalStore((s) => [
|
||||
systemStatusSelectors.showRightPanel(s),
|
||||
s.toggleRightPanel,
|
||||
]);
|
||||
|
||||
if (showRightPanel) return null;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
icon={PanelRightOpenIcon}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
title={t('workingPanel.title')}
|
||||
onClick={() => toggleRightPanel(true)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkingPanelToggle;
|
||||
|
|
@ -9,6 +9,7 @@ import NavHeader from '@/features/NavHeader';
|
|||
import HeaderActions from './HeaderActions';
|
||||
import ShareButton from './ShareButton';
|
||||
import Tags from './Tags';
|
||||
import WorkingPanelToggle from './WorkingPanelToggle';
|
||||
|
||||
const Header = memo(() => {
|
||||
return (
|
||||
|
|
@ -21,6 +22,7 @@ const Header = memo(() => {
|
|||
right={
|
||||
<Flexbox horizontal align={'center'} style={{ backgroundColor: cssVar.colorBgContainer }}>
|
||||
<ShareButton />
|
||||
<WorkingPanelToggle />
|
||||
<HeaderActions />
|
||||
</Flexbox>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
|
||||
import { ActionIcon, Button, Flexbox, Icon, Markdown, Skeleton, Text } from '@lobehub/ui';
|
||||
import { Segmented } from 'antd';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { Eye, PanelRightCloseIcon, SquarePen } from 'lucide-react';
|
||||
import { extname } from 'pathe';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EditorTextArea from '@/features/EditorModal/TextArea';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
height: 100%;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
editor: css`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
`,
|
||||
editorWrapper: css`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`,
|
||||
footer: css`
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.2s ${cssVar.motionEaseInOut},
|
||||
opacity 0.2s ${cssVar.motionEaseInOut},
|
||||
transform 0.2s ${cssVar.motionEaseInOut},
|
||||
border-color 0.2s ${cssVar.motionEaseInOut};
|
||||
`,
|
||||
footerOpen: css`
|
||||
transform: translateY(0);
|
||||
max-height: 72px;
|
||||
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
|
||||
opacity: 1;
|
||||
`,
|
||||
footerClosed: css`
|
||||
pointer-events: none;
|
||||
|
||||
transform: translateY(8px);
|
||||
|
||||
max-height: 0;
|
||||
border-block-start-color: transparent;
|
||||
|
||||
opacity: 0;
|
||||
`,
|
||||
footerInner: css`
|
||||
padding: 12px;
|
||||
`,
|
||||
header: css`
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
preview: css`
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface AgentDocumentEditorPanelProps {
|
||||
onClose: () => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
|
||||
type DocumentViewMode = 'edit' | 'preview';
|
||||
|
||||
const isMarkdownFile = (filename?: string) => {
|
||||
if (!filename) return false;
|
||||
|
||||
const extension = extname(filename).toLowerCase();
|
||||
return extension === '.md' || extension === '.markdown';
|
||||
};
|
||||
|
||||
const AgentDocumentEditorPanel = memo<AgentDocumentEditorPanelProps>(
|
||||
({ selectedDocumentId, onClose }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [savedContent, setSavedContent] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DocumentViewMode>('edit');
|
||||
const initializedDocumentIdRef = useRef<string | null>(null);
|
||||
|
||||
const { data, error, isLoading, mutate } = useClientDataSWR(
|
||||
agentId && selectedDocumentId
|
||||
? agentDocumentSWRKeys.readDocument(agentId, selectedDocumentId)
|
||||
: null,
|
||||
() => agentDocumentService.readDocument({ agentId: agentId!, id: selectedDocumentId! }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
if (data.id !== selectedDocumentId) return;
|
||||
if (initializedDocumentIdRef.current === data.id) return;
|
||||
|
||||
setDraft(data.content);
|
||||
setSavedContent(data.content);
|
||||
setViewMode(isMarkdownFile(data.filename || data.title) ? 'preview' : 'edit');
|
||||
initializedDocumentIdRef.current = data.id;
|
||||
}, [data, selectedDocumentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDocumentId) return;
|
||||
initializedDocumentIdRef.current = null;
|
||||
}, [selectedDocumentId]);
|
||||
|
||||
const isDirty = useMemo(() => draft !== savedContent, [draft, savedContent]);
|
||||
const isDocumentReady = data?.id === selectedDocumentId;
|
||||
const shouldShowLoading = Boolean(selectedDocumentId) && (isLoading || !isDocumentReady);
|
||||
const isOpen = Boolean(selectedDocumentId);
|
||||
const isMarkdownDocument = isMarkdownFile(data?.filename || data?.title);
|
||||
|
||||
if (!agentId) return null;
|
||||
|
||||
const saveDocument = async () => {
|
||||
if (!isDirty || isSaving || !selectedDocumentId) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await agentDocumentService.editDocument({
|
||||
agentId,
|
||||
content: draft,
|
||||
id: selectedDocumentId,
|
||||
});
|
||||
await mutate();
|
||||
setSavedContent(draft);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} data-testid="workspace-document-panel">
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
justify={'space-between'}
|
||||
padding={12}
|
||||
>
|
||||
<Text strong>{data?.filename || data?.title || t('workingPanel.documents.title')}</Text>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{isMarkdownDocument && (
|
||||
<Segmented<DocumentViewMode>
|
||||
value={viewMode}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Icon icon={Eye} size={14} />
|
||||
<span style={{ fontSize: 12 }}>{t('workingPanel.documents.preview')}</span>
|
||||
</Flexbox>
|
||||
),
|
||||
value: 'preview',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Icon icon={SquarePen} size={14} />
|
||||
<span style={{ fontSize: 12 }}>{t('workingPanel.documents.edit')}</span>
|
||||
</Flexbox>
|
||||
),
|
||||
value: 'edit',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setViewMode(value)}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{shouldShowLoading && (
|
||||
<Flexbox className={styles.editor} gap={8}>
|
||||
<Skeleton active paragraph={{ rows: 10 }} title={false} />
|
||||
</Flexbox>
|
||||
)}
|
||||
{error && (
|
||||
<Text style={{ padding: 12 }} type={'danger'}>
|
||||
{t('workingPanel.documents.error')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!shouldShowLoading && !error && data && (
|
||||
<>
|
||||
<Flexbox className={styles.editorWrapper}>
|
||||
{viewMode === 'preview' ? (
|
||||
<div className={styles.preview}>
|
||||
<Markdown variant={'chat'}>{draft}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<EditorTextArea
|
||||
style={{ height: '100%', resize: 'none' }}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
className={`${styles.footer} ${isDirty ? styles.footerOpen : styles.footerClosed}`}
|
||||
>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.footerInner}
|
||||
justify={'space-between'}
|
||||
>
|
||||
<Text type={'secondary'}>{t('workingPanel.documents.unsaved')}</Text>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Button
|
||||
disabled={isSaving || shouldShowLoading}
|
||||
size={'small'}
|
||||
onClick={() => setDraft(savedContent)}
|
||||
>
|
||||
{t('workingPanel.documents.discard')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={shouldShowLoading || Boolean(error)}
|
||||
loading={isSaving}
|
||||
size={'small'}
|
||||
type={'primary'}
|
||||
onClick={saveDocument}
|
||||
>
|
||||
{t('workingPanel.documents.save')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AgentDocumentEditorPanel.displayName = 'AgentDocumentEditorPanel';
|
||||
|
||||
export default AgentDocumentEditorPanel;
|
||||
|
|
@ -110,6 +110,8 @@ const ProgressSection = memo(() => {
|
|||
);
|
||||
const hasTasks = progress.items.length > 0;
|
||||
|
||||
if (!hasTasks) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.barWrap}>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,39 @@ import AgentDocumentsGroup from './AgentDocumentsGroup';
|
|||
const useClientDataSWR = vi.fn();
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
ActionIcon: ({ onClick, title }: { onClick?: (e: React.MouseEvent) => void; title?: string }) => (
|
||||
<button aria-label={title} onClick={onClick}>
|
||||
{title}
|
||||
</button>
|
||||
),
|
||||
Center: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Empty: ({ description }: { description?: ReactNode }) => <div>{description}</div>,
|
||||
Flexbox: ({
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div onClick={onClick} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Text: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { error: vi.fn(), success: vi.fn() },
|
||||
modal: { confirm: vi.fn() },
|
||||
}),
|
||||
},
|
||||
Spin: () => <div data-testid="spin" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/swr', () => ({
|
||||
useClientDataSWR: (...args: unknown[]) => useClientDataSWR(...args),
|
||||
}));
|
||||
|
|
@ -22,12 +49,11 @@ vi.mock('react-i18next', () => ({
|
|||
t: (key: string) =>
|
||||
(
|
||||
({
|
||||
'workingPanel.agentDocuments': 'Agent Documents',
|
||||
'workingPanel.resources.empty': 'No agent documents yet',
|
||||
'workingPanel.resources.error': 'Failed to load resources',
|
||||
'workingPanel.resources.loading': 'Loading resources...',
|
||||
'workingPanel.resources.previewError': 'Failed to load preview',
|
||||
'workingPanel.resources.previewLoading': 'Loading preview...',
|
||||
'workingPanel.resources.filter.all': 'All',
|
||||
'workingPanel.resources.filter.documents': 'Documents',
|
||||
'workingPanel.resources.filter.web': 'Web',
|
||||
}) as Record<string, string>
|
||||
)[key] || key,
|
||||
}),
|
||||
|
|
@ -36,9 +62,11 @@ vi.mock('react-i18next', () => ({
|
|||
vi.mock('@/services/agentDocument', () => ({
|
||||
agentDocumentSWRKeys: {
|
||||
documents: (agentId: string) => ['agent-documents', agentId],
|
||||
documentsList: (agentId: string) => ['agent-documents-list', agentId],
|
||||
},
|
||||
agentDocumentService: {
|
||||
getDocuments: vi.fn(),
|
||||
removeDocument: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -47,24 +75,18 @@ vi.mock('@/store/agent', () => ({
|
|||
selector({ activeAgentId: 'agent-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/FileTree', () => ({
|
||||
default: ({
|
||||
onSelectFile,
|
||||
resourceTree,
|
||||
}: {
|
||||
onSelectFile: (path: string) => void;
|
||||
resourceTree: Array<{ children?: Array<{ name: string; path: string }> }>;
|
||||
}) => (
|
||||
<div>
|
||||
{resourceTree.flatMap((node) =>
|
||||
(node.children || []).map((child) => (
|
||||
<button key={child.path} onClick={() => onSelectFile(child.path)}>
|
||||
{child.name}
|
||||
</button>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
const openDocument = vi.fn();
|
||||
const closeDocument = vi.fn();
|
||||
|
||||
vi.mock('@/store/chat', () => ({
|
||||
useChatStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ closeDocument, openDocument, portalStack: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/chat/selectors', () => ({
|
||||
chatPortalSelectors: {
|
||||
portalDocumentId: () => null,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AgentDocumentsGroup', () => {
|
||||
|
|
@ -72,26 +94,109 @@ describe('AgentDocumentsGroup', () => {
|
|||
useClientDataSWR.mockReset();
|
||||
});
|
||||
|
||||
it('renders documents and delegates selection to parent', async () => {
|
||||
const onSelectDocument = vi.fn();
|
||||
|
||||
it('renders documents and opens via openDocument', async () => {
|
||||
useClientDataSWR.mockImplementation((key: unknown) => {
|
||||
if (Array.isArray(key) && key[0] === 'agent-documents') {
|
||||
if (Array.isArray(key) && key[0] === 'agent-documents-list') {
|
||||
return {
|
||||
data: [{ filename: 'brief.md', id: 'doc-1', templateId: 'claw', title: 'Brief' }],
|
||||
data: [
|
||||
{
|
||||
createdAt: new Date('2026-04-16T00:00:00Z'),
|
||||
description: 'A short brief',
|
||||
documentId: 'doc-content-1',
|
||||
filename: 'brief.md',
|
||||
id: 'doc-1',
|
||||
sourceType: 'file',
|
||||
templateId: 'claw',
|
||||
title: 'Brief',
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
return { data: undefined, error: undefined, isLoading: false };
|
||||
return { data: undefined, error: undefined, isLoading: false, mutate: vi.fn() };
|
||||
});
|
||||
|
||||
render(<AgentDocumentsGroup selectedDocumentId={null} onSelectDocument={onSelectDocument} />);
|
||||
render(<AgentDocumentsGroup />);
|
||||
|
||||
expect(await screen.findByText('brief.md')).toBeInTheDocument();
|
||||
const item = await screen.findByText('brief.md');
|
||||
expect(item).toBeInTheDocument();
|
||||
expect(screen.getByText('A short brief')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('brief.md'));
|
||||
expect(onSelectDocument).toHaveBeenCalledWith('doc-1');
|
||||
fireEvent.click(item);
|
||||
expect(openDocument).toHaveBeenCalledWith('doc-content-1');
|
||||
});
|
||||
|
||||
it('filters documents by source type via segmented tabs', () => {
|
||||
useClientDataSWR.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
createdAt: new Date('2026-04-16T00:00:00Z'),
|
||||
description: 'File doc',
|
||||
documentId: 'doc-content-1',
|
||||
filename: 'brief.md',
|
||||
id: 'doc-1',
|
||||
sourceType: 'file',
|
||||
templateId: 'claw',
|
||||
title: 'Brief',
|
||||
},
|
||||
{
|
||||
createdAt: new Date('2026-04-16T00:00:00Z'),
|
||||
description: 'Crawled page',
|
||||
documentId: 'doc-content-2',
|
||||
filename: 'example.com',
|
||||
id: 'doc-2',
|
||||
sourceType: 'web',
|
||||
templateId: null,
|
||||
title: 'Example',
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AgentDocumentsGroup />);
|
||||
|
||||
expect(screen.getByText('brief.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Web'));
|
||||
|
||||
expect(screen.queryByText('brief.md')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Documents'));
|
||||
|
||||
expect(screen.getByText('brief.md')).toBeInTheDocument();
|
||||
expect(screen.queryByText('example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no documents', () => {
|
||||
useClientDataSWR.mockReturnValue({
|
||||
data: [],
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AgentDocumentsGroup />);
|
||||
|
||||
expect(screen.getByText('No agent documents yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state', () => {
|
||||
useClientDataSWR.mockReturnValue({
|
||||
data: [],
|
||||
error: new Error('oops'),
|
||||
isLoading: false,
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
render(<AgentDocumentsGroup />);
|
||||
|
||||
expect(screen.getByText('Failed to load resources')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,204 +1,312 @@
|
|||
import type { SkillResourceTreeNode } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { ActionIcon, Center, Empty, Flexbox, Text } from '@lobehub/ui';
|
||||
import { App, Spin } from 'antd';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { FileTextIcon, GlobeIcon, type LucideIcon, Trash2Icon } from 'lucide-react';
|
||||
import { memo, type MouseEvent, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FileTree, { FileTreeSkeleton } from '@/features/FileTree';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
interface AgentDocumentsGroupProps {
|
||||
onSelectDocument: (id: string | null) => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
type ResourceFilter = 'all' | 'documents' | 'web';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
containerActive: css`
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
description: css`
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
groupLabel: css`
|
||||
padding-inline: 4px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
`,
|
||||
meta: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
pillActive: css`
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
pillTab: css`
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: transparent;
|
||||
|
||||
transition:
|
||||
background ${cssVar.motionDurationFast} ${cssVar.motionEaseInOut},
|
||||
color ${cssVar.motionDurationFast} ${cssVar.motionEaseInOut};
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
title: css`
|
||||
font-weight: 500;
|
||||
`,
|
||||
}));
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ labelKey: 'workingPanel.resources.filter.all', value: 'all' },
|
||||
{ labelKey: 'workingPanel.resources.filter.documents', value: 'documents' },
|
||||
{ labelKey: 'workingPanel.resources.filter.web', value: 'web' },
|
||||
] as const satisfies readonly { labelKey: string; value: ResourceFilter }[];
|
||||
|
||||
type AgentDocumentListItem = Awaited<ReturnType<typeof agentDocumentService.getDocuments>>[number];
|
||||
|
||||
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(
|
||||
({ onSelectDocument, selectedDocumentId }) => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { message, modal } = App.useApp();
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(null);
|
||||
interface DocumentItemProps {
|
||||
agentId: string;
|
||||
document: AgentDocumentListItem;
|
||||
isActive: boolean;
|
||||
mutate: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documents(agentId) : null, () =>
|
||||
agentDocumentService.getDocuments({ agentId: agentId! }),
|
||||
);
|
||||
const DocumentItem = memo<DocumentItemProps>(({ agentId, document, isActive, mutate }) => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { message, modal } = App.useApp();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const openDocument = useChatStore((s) => s.openDocument);
|
||||
const closeDocument = useChatStore((s) => s.closeDocument);
|
||||
|
||||
const resourceTree = useMemo<SkillResourceTreeNode[]>(
|
||||
() => [
|
||||
{
|
||||
children: data.map((item) => ({
|
||||
name: item.filename || item.title,
|
||||
path: item.id,
|
||||
type: 'file' as const,
|
||||
})),
|
||||
name: t('workingPanel.agentDocuments'),
|
||||
path: 'agent-documents',
|
||||
type: 'directory' as const,
|
||||
},
|
||||
],
|
||||
[data, t],
|
||||
);
|
||||
const title = document.filename || document.title || '';
|
||||
const description = document.description ?? undefined;
|
||||
const isWeb = document.sourceType === 'web';
|
||||
const IconComponent: LucideIcon = isWeb ? GlobeIcon : FileTextIcon;
|
||||
const createdAtLabel = document.createdAt ? dayjs(document.createdAt).fromNow() : null;
|
||||
|
||||
const handleCommitRenameDocument = useCallback(
|
||||
async (file: { name: string; path: string }, nextName: string) => {
|
||||
if (!agentId) return;
|
||||
|
||||
const normalizedTitle = nextName.trim();
|
||||
setEditingDocumentId(null);
|
||||
|
||||
if (!normalizedTitle) {
|
||||
message.error(t('workingPanel.resources.renameEmpty', { ns: 'chat' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedTitle === file.name) {
|
||||
return;
|
||||
}
|
||||
const handleOpen = () => {
|
||||
if (!document.documentId) return;
|
||||
openDocument(document.documentId);
|
||||
};
|
||||
|
||||
const handleDelete = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
modal.confirm({
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await mutate(
|
||||
async (current: AgentDocumentListItem[] = []) => {
|
||||
const renamed = await agentDocumentService.renameDocument({
|
||||
agentId,
|
||||
id: file.path,
|
||||
newTitle: normalizedTitle,
|
||||
});
|
||||
|
||||
return current.map((item) =>
|
||||
item.id === file.path
|
||||
? {
|
||||
...item,
|
||||
filename: renamed?.filename ?? item.filename,
|
||||
title: renamed?.title ?? normalizedTitle,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: (current: AgentDocumentListItem[] = []) =>
|
||||
current.map((item) =>
|
||||
item.id === file.path
|
||||
? {
|
||||
...item,
|
||||
filename: normalizedTitle,
|
||||
title: normalizedTitle,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
revalidate: false,
|
||||
rollbackOnError: true,
|
||||
},
|
||||
);
|
||||
|
||||
message.success(t('workingPanel.resources.renameSuccess', { ns: 'chat' }));
|
||||
if (isActive) closeDocument();
|
||||
await agentDocumentService.removeDocument({ agentId, id: document.id });
|
||||
await mutate();
|
||||
message.success(t('workingPanel.resources.deleteSuccess', { ns: 'chat' }));
|
||||
} catch (error) {
|
||||
message.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('workingPanel.resources.renameError', { ns: 'chat' }),
|
||||
: t('workingPanel.resources.deleteError', { ns: 'chat' }),
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
[agentId, message, mutate, t],
|
||||
);
|
||||
title: t('workingPanel.resources.deleteTitle', { ns: 'chat' }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteDocument = useCallback(
|
||||
(id: string) => {
|
||||
if (!agentId) return;
|
||||
|
||||
modal.confirm({
|
||||
content: t('workingPanel.resources.deleteConfirm', { ns: 'chat' }),
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('delete', { ns: 'common' }),
|
||||
onOk: async () => {
|
||||
const wasSelected = selectedDocumentId === id;
|
||||
if (wasSelected) onSelectDocument(null);
|
||||
|
||||
try {
|
||||
await mutate(
|
||||
async (current = []) => {
|
||||
await agentDocumentService.removeDocument({ agentId, id });
|
||||
return current.filter((item) => item.id !== id);
|
||||
},
|
||||
{
|
||||
optimisticData: (current = []) => current.filter((item) => item.id !== id),
|
||||
revalidate: false,
|
||||
rollbackOnError: true,
|
||||
},
|
||||
);
|
||||
|
||||
message.success(t('workingPanel.resources.deleteSuccess', { ns: 'chat' }));
|
||||
} catch (error) {
|
||||
if (wasSelected) onSelectDocument(id);
|
||||
message.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('workingPanel.resources.deleteError', { ns: 'chat' }),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
title: t('workingPanel.resources.deleteTitle', { ns: 'chat' }),
|
||||
});
|
||||
},
|
||||
[agentId, message, modal, mutate, onSelectDocument, selectedDocumentId, t],
|
||||
);
|
||||
|
||||
const getFileContextMenuItems = useCallback(
|
||||
(file: { path: string }) => [
|
||||
{
|
||||
icon: <Icon icon={Pencil} />,
|
||||
key: 'rename',
|
||||
label: t('rename', { ns: 'common' }),
|
||||
onClick: () => setEditingDocumentId(file.path),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash2} />,
|
||||
key: 'delete',
|
||||
label: t('delete', { ns: 'common' }),
|
||||
onClick: () => handleDeleteDocument(file.path),
|
||||
},
|
||||
],
|
||||
[handleDeleteDocument, t],
|
||||
);
|
||||
|
||||
if (!agentId) return null;
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
{isLoading && <FileTreeSkeleton rows={6} showRootFile={false} />}
|
||||
{error && <Text type={'danger'}>{t('workingPanel.resources.error')}</Text>}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<Text type={'secondary'}>{t('workingPanel.resources.empty')}</Text>
|
||||
)}
|
||||
{!isLoading && !error && data.length > 0 && (
|
||||
<FileTree
|
||||
editableFilePath={editingDocumentId}
|
||||
getFileContextMenuItems={getFileContextMenuItems}
|
||||
resourceTree={resourceTree}
|
||||
rootFile={null}
|
||||
selectedFile={selectedDocumentId || ''}
|
||||
onCancelRenameFile={() => setEditingDocumentId(null)}
|
||||
onCommitRenameFile={handleCommitRenameDocument}
|
||||
onSelectFile={onSelectDocument}
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'flex-start'}
|
||||
className={`${styles.container} ${isActive ? styles.containerActive : ''}`}
|
||||
gap={8}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<IconComponent size={16} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Flexbox gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Flexbox horizontal align={'center'} distribution={'space-between'}>
|
||||
<Text ellipsis className={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
icon={Trash2Icon}
|
||||
loading={deleting}
|
||||
size={'small'}
|
||||
title={t('delete', { ns: 'common' })}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</Flexbox>
|
||||
{description && (
|
||||
<Text className={styles.description} ellipsis={{ rows: 2 }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{createdAtLabel && <Text className={styles.meta}>{createdAtLabel}</Text>}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
DocumentItem.displayName = 'AgentDocumentsGroupItem';
|
||||
|
||||
interface AgentDocumentsGroupProps {
|
||||
viewMode?: 'list' | 'tree';
|
||||
}
|
||||
|
||||
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ viewMode = 'list' }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const activeDocumentId = useChatStore(chatPortalSelectors.portalDocumentId);
|
||||
const [filter, setFilter] = useState<ResourceFilter>('all');
|
||||
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () =>
|
||||
agentDocumentService.getDocuments({ agentId: agentId! }),
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (filter === 'documents') return data.filter((doc) => doc.sourceType !== 'web');
|
||||
if (filter === 'web') return data.filter((doc) => doc.sourceType === 'web');
|
||||
return data;
|
||||
}, [data, filter]);
|
||||
|
||||
const treeGroups = useMemo(() => {
|
||||
const docs = data.filter((doc) => doc.sourceType !== 'web');
|
||||
const webs = data.filter((doc) => doc.sourceType === 'web');
|
||||
return (
|
||||
[
|
||||
{ items: docs, labelKey: 'workingPanel.resources.filter.documents' },
|
||||
{ items: webs, labelKey: 'workingPanel.resources.filter.web' },
|
||||
] as const
|
||||
).filter((group) => group.items.length > 0);
|
||||
}, [data]);
|
||||
|
||||
if (!agentId) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center flex={1} paddingBlock={24}>
|
||||
<Spin />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center flex={1} paddingBlock={24}>
|
||||
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.resources.empty')} icon={FileTextIcon} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'tree') {
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
{treeGroups.map((group) => (
|
||||
<Flexbox gap={8} key={group.labelKey}>
|
||||
<Text className={styles.groupLabel} type={'secondary'}>
|
||||
{t(group.labelKey)}
|
||||
</Text>
|
||||
<Flexbox gap={8}>
|
||||
{group.items.map((doc) => (
|
||||
<DocumentItem
|
||||
agentId={agentId}
|
||||
document={doc}
|
||||
isActive={activeDocumentId === doc.documentId}
|
||||
key={doc.id}
|
||||
mutate={mutate}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox gap={12}>
|
||||
<Flexbox horizontal gap={4} role={'tablist'}>
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const active = filter === option.value;
|
||||
return (
|
||||
<div
|
||||
aria-selected={active}
|
||||
className={cx(styles.pillTab, active && styles.pillActive)}
|
||||
key={option.value}
|
||||
role={'tab'}
|
||||
onClick={() => setFilter(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
{filteredData.length === 0 ? (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty
|
||||
description={t('workingPanel.resources.empty')}
|
||||
icon={filter === 'web' ? GlobeIcon : FileTextIcon}
|
||||
/>
|
||||
</Center>
|
||||
) : (
|
||||
<Flexbox gap={8}>
|
||||
{filteredData.map((doc) => (
|
||||
<DocumentItem
|
||||
agentId={agentId}
|
||||
document={doc}
|
||||
isActive={activeDocumentId === doc.documentId}
|
||||
key={doc.id}
|
||||
mutate={mutate}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
AgentDocumentsGroup.displayName = 'AgentDocumentsGroup';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,14 @@
|
|||
import { Accordion, AccordionItem, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AgentDocumentsGroup from './AgentDocumentsGroup';
|
||||
|
||||
interface ResourcesSectionProps {
|
||||
onSelectDocument: (id: string | null) => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
|
||||
const ResourcesSection = memo<ResourcesSectionProps>(({ onSelectDocument, selectedDocumentId }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
export type ResourceViewMode = 'list' | 'tree';
|
||||
|
||||
const ResourcesSection = memo(() => {
|
||||
return (
|
||||
<Flexbox data-testid="workspace-resources" padding={16}>
|
||||
<Accordion defaultExpandedKeys={['resources']} gap={0}>
|
||||
<AccordionItem
|
||||
itemKey={'resources'}
|
||||
paddingBlock={2}
|
||||
paddingInline={6}
|
||||
title={<Text strong>{t('workingPanel.resources')}</Text>}
|
||||
styles={{
|
||||
header: {
|
||||
width: 'fit-content',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flexbox paddingBlock={8}>
|
||||
<AgentDocumentsGroup
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onSelectDocument={onSelectDocument}
|
||||
/>
|
||||
</Flexbox>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Flexbox data-testid="workspace-resources" paddingBlock={8} paddingInline={16}>
|
||||
<AgentDocumentsGroup viewMode={'list'} />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ import { initialState } from '@/store/global/initialState';
|
|||
import { useUserStore } from '@/store/user';
|
||||
import { initialState as initialUserState } from '@/store/user/initialState';
|
||||
|
||||
import Conversation from '../index';
|
||||
import AgentWorkingSidebar from './index';
|
||||
|
||||
vi.mock('@/libs/swr', async (importOriginal) => {
|
||||
|
|
@ -38,6 +37,9 @@ vi.mock('@lobehub/ui', () => ({
|
|||
Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Center: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
Checkbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
|
|
@ -58,6 +60,7 @@ vi.mock('@lobehub/ui', () => ({
|
|||
{children}
|
||||
</div>
|
||||
),
|
||||
Empty: ({ description }: { description?: ReactNode }) => <div>{description}</div>,
|
||||
Avatar: ({ avatar }: { avatar?: ReactNode | string }) => <div>{avatar}</div>,
|
||||
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
|
||||
<div {...props}>{children}</div>
|
||||
|
|
@ -73,6 +76,17 @@ vi.mock('@lobehub/ui', () => ({
|
|||
TooltipGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { error: vi.fn(), success: vi.fn() },
|
||||
modal: { confirm: vi.fn() },
|
||||
}),
|
||||
},
|
||||
Progress: () => <div data-testid="workspace-progress-bar" />,
|
||||
Spin: () => <div data-testid="spin" />,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
|
|
@ -85,49 +99,30 @@ vi.mock('react-i18next', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/DragUploadZone', () => ({
|
||||
default: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
useUploadFiles: () => ({ handleUploadFiles: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
useAgentStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector?.({
|
||||
activeAgentId: 'agent-1',
|
||||
useFetchBotProviders: () => ({ data: [], isLoading: false }),
|
||||
useFetchPlatformDefinitions: () => ({ data: [], isLoading: false }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/agent/selectors', () => ({
|
||||
agentSelectors: {
|
||||
currentAgentModel: () => 'mock-model',
|
||||
currentAgentModelProvider: () => 'mock-provider',
|
||||
vi.mock('@/store/chat', () => ({
|
||||
useChatStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
activeTopicId: undefined,
|
||||
closeDocument: vi.fn(),
|
||||
dbMessagesMap: {},
|
||||
openDocument: vi.fn(),
|
||||
portalStack: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/chat/selectors', () => ({
|
||||
chatPortalSelectors: {
|
||||
portalDocumentId: () => null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/Conversation/store', () => ({
|
||||
dataSelectors: {
|
||||
dbMessages: (state: { dbMessages?: unknown[] }) => state.dbMessages,
|
||||
},
|
||||
useConversationStore: (selector: (state: { dbMessages: unknown[] }) => unknown) =>
|
||||
selector({ dbMessages: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationArea', () => ({
|
||||
default: () => <div>conversation-area</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../Header', () => ({
|
||||
default: () => <div>chat-header</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./AgentDocumentEditorPanel', () => ({
|
||||
default: ({ selectedDocumentId }: { selectedDocumentId: string | null }) => (
|
||||
<div data-testid="workspace-document-panel">{selectedDocumentId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(swr.useClientDataSWR).mockImplementation((() => ({
|
||||
data: [],
|
||||
|
|
@ -143,7 +138,7 @@ beforeEach(() => {
|
|||
...initialUserState,
|
||||
preference: {
|
||||
...initialUserState.preference,
|
||||
lab: { ...initialUserState.preference.lab, enableAgentWorkingPanel: false },
|
||||
lab: { ...initialUserState.preference.lab },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -152,56 +147,21 @@ afterEach(() => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Conversation right panel mount', () => {
|
||||
it('does not mount the conversation-side right panel path when working panel lab feature is disabled', () => {
|
||||
render(<Conversation />);
|
||||
describe('AgentWorkingSidebar', () => {
|
||||
it('renders panel header title and resources empty state', () => {
|
||||
render(<AgentWorkingSidebar />);
|
||||
|
||||
expect(screen.getByText('chat-header')).toBeInTheDocument();
|
||||
expect(screen.getByText('conversation-area')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('right-panel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('workspace-resources')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mounts the conversation-side right panel path and defaults the right panel to collapsed when working panel lab feature is enabled', async () => {
|
||||
useUserStore.setState({
|
||||
preference: {
|
||||
...useUserStore.getState().preference,
|
||||
lab: { ...useUserStore.getState().preference.lab, enableAgentWorkingPanel: true },
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = render(<Conversation />);
|
||||
|
||||
expect(screen.getByText('chat-header')).toBeInTheDocument();
|
||||
expect(screen.getByText('conversation-area')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-stable-layout', 'true');
|
||||
expect(screen.getByTestId('workspace-resources')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-expand', 'false');
|
||||
expect(useGlobalStore.getState().status.showRightPanel).toBe(false);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useGlobalStore.getState().status.showRightPanel).toBe(false);
|
||||
});
|
||||
|
||||
it('renders resources section and empty state', () => {
|
||||
render(<AgentWorkingSidebar selectedDocumentId={null} onSelectDocument={vi.fn()} />);
|
||||
// Panel-level title
|
||||
expect(screen.getAllByText('Resources').length).toBeGreaterThan(0);
|
||||
|
||||
const resources = screen.getByTestId('workspace-resources');
|
||||
|
||||
expect(resources).toHaveTextContent('Resources');
|
||||
expect(resources).toHaveTextContent('No agent documents yet');
|
||||
});
|
||||
|
||||
it('switches to document editor inside the right panel when a document is selected', () => {
|
||||
render(<AgentWorkingSidebar selectedDocumentId={'doc-1'} onSelectDocument={vi.fn()} />);
|
||||
it('mounts a right panel wrapper', () => {
|
||||
render(<AgentWorkingSidebar />);
|
||||
|
||||
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workspace-document-panel')).toHaveTextContent('doc-1');
|
||||
expect(screen.queryByTestId('workspace-resources')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-stable-layout', 'true');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,53 +1,56 @@
|
|||
import { ActionIcon, Flexbox } from '@lobehub/ui';
|
||||
import { ActionIcon, Flexbox, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { PanelRightCloseIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
|
||||
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@/const/layoutTokens';
|
||||
import RightPanel from '@/features/RightPanel';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
import AgentDocumentEditorPanel from './AgentDocumentEditorPanel';
|
||||
import ProgressSection from './ProgressSection';
|
||||
import ResourcesSection from './ResourcesSection';
|
||||
|
||||
interface AgentWorkingSidebarProps {
|
||||
onSelectDocument: (id: string | null) => void;
|
||||
selectedDocumentId: string | null;
|
||||
}
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
body: css`
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
`,
|
||||
header: css`
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
}));
|
||||
|
||||
const AgentWorkingSidebar = memo<AgentWorkingSidebarProps>(
|
||||
({ onSelectDocument, selectedDocumentId }) => {
|
||||
const toggleRightPanel = useGlobalStore((s) => s.toggleRightPanel);
|
||||
const isDocumentMode = Boolean(selectedDocumentId);
|
||||
const AgentWorkingSidebar = memo(() => {
|
||||
const { t } = useTranslation('chat');
|
||||
const toggleRightPanel = useGlobalStore((s) => s.toggleRightPanel);
|
||||
|
||||
return (
|
||||
<RightPanel stableLayout defaultWidth={360} maxWidth={720} minWidth={300}>
|
||||
{isDocumentMode ? (
|
||||
<AgentDocumentEditorPanel
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onClose={() => onSelectDocument(null)}
|
||||
return (
|
||||
<RightPanel stableLayout defaultWidth={360} maxWidth={720} minWidth={300}>
|
||||
<Flexbox height={'100%'} width={'100%'}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
height={44}
|
||||
justify={'space-between'}
|
||||
paddingInline={16}
|
||||
>
|
||||
<Text strong>{t('workingPanel.resources')}</Text>
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
|
||||
onClick={() => toggleRightPanel(false)}
|
||||
/>
|
||||
) : (
|
||||
<Flexbox height={'100%'} style={{ position: 'relative' }} width={'100%'}>
|
||||
<ActionIcon
|
||||
icon={PanelRightCloseIcon}
|
||||
size={DESKTOP_HEADER_ICON_SIZE}
|
||||
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
|
||||
onClick={() => toggleRightPanel(false)}
|
||||
/>
|
||||
<Flexbox gap={8} height={'100%'} style={{ overflowY: 'auto' }} width={'100%'}>
|
||||
{/* <AgentSummary /> */}
|
||||
<ProgressSection />
|
||||
<ResourcesSection
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onSelectDocument={onSelectDocument}
|
||||
/>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
</RightPanel>
|
||||
);
|
||||
},
|
||||
);
|
||||
</Flexbox>
|
||||
<Flexbox className={styles.body} gap={8} width={'100%'}>
|
||||
<ProgressSection />
|
||||
<ResourcesSection />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</RightPanel>
|
||||
);
|
||||
});
|
||||
|
||||
export default AgentWorkingSidebar;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Flexbox, TooltipGroup } from '@lobehub/ui';
|
||||
import React, { memo, Suspense, useEffect, useState } from 'react';
|
||||
import React, { memo, Suspense, useEffect } from 'react';
|
||||
|
||||
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
|
@ -7,12 +7,9 @@ import { useAgentStore } from '@/store/agent';
|
|||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { labPreferSelectors } from '@/store/user/selectors';
|
||||
|
||||
import ConversationArea from './ConversationArea';
|
||||
import ChatHeader from './Header';
|
||||
import AgentWorkingSidebar from './WorkingSidebar';
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
|
|
@ -23,19 +20,12 @@ const wrapperStyle: React.CSSProperties = {
|
|||
const ChatConversation = memo(() => {
|
||||
const showHeader = useGlobalStore(systemStatusSelectors.showChatHeader);
|
||||
const isStatusInit = useGlobalStore(systemStatusSelectors.isStatusInit);
|
||||
const enableAgentWorkingPanel = useUserStore(labPreferSelectors.enableAgentWorkingPanel);
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
|
||||
// Get current agent's model info for vision support check
|
||||
const model = useAgentStore(agentSelectors.currentAgentModel);
|
||||
const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
|
||||
const { handleUploadFiles } = useUploadFiles({ model, provider });
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDocumentId(null);
|
||||
}, [activeAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStatusInit) return;
|
||||
useGlobalStore.getState().toggleRightPanel(false);
|
||||
|
|
@ -44,26 +34,11 @@ const ChatConversation = memo(() => {
|
|||
return (
|
||||
<Suspense fallback={<Loading debugId="Agent > ChatConversation" />}>
|
||||
<DragUploadZone style={wrapperStyle} onUploadFiles={handleUploadFiles}>
|
||||
<Flexbox
|
||||
horizontal
|
||||
height={'100%'}
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
<Flexbox flex={1} height={'100%'} style={{ minWidth: 0 }}>
|
||||
{showHeader && <ChatHeader />}
|
||||
<TooltipGroup>
|
||||
<ConversationArea />
|
||||
</TooltipGroup>
|
||||
</Flexbox>
|
||||
{/* TODO: Remove this labs-only mount gate once Working Panel is no longer experimental.
|
||||
See the matching TODO in `src/hooks/useHotkeys/globalScope.ts`. */}
|
||||
{enableAgentWorkingPanel && (
|
||||
<AgentWorkingSidebar
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
onSelectDocument={setSelectedDocumentId}
|
||||
/>
|
||||
)}
|
||||
<Flexbox flex={1} height={'100%'} style={{ minWidth: 0 }}>
|
||||
{showHeader && <ChatHeader />}
|
||||
<TooltipGroup>
|
||||
<ConversationArea />
|
||||
</TooltipGroup>
|
||||
</Flexbox>
|
||||
</DragUploadZone>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { memo } from 'react';
|
|||
import MainInterfaceTracker from '@/components/Analytics/MainInterfaceTracker';
|
||||
|
||||
import Conversation from './features/Conversation';
|
||||
import AgentWorkingSidebar from './features/Conversation/WorkingSidebar';
|
||||
import PageTitle from './features/PageTitle';
|
||||
import Portal from './features/Portal';
|
||||
import TelemetryNotification from './features/TelemetryNotification';
|
||||
|
|
@ -22,6 +23,7 @@ const ChatPage = memo(() => {
|
|||
>
|
||||
<Conversation />
|
||||
<Portal />
|
||||
<AgentWorkingSidebar />
|
||||
</Flexbox>
|
||||
<MainInterfaceTracker />
|
||||
<TelemetryNotification mobile={false} />
|
||||
|
|
|
|||
|
|
@ -39,14 +39,12 @@ const Page = memo(() => {
|
|||
isPreferenceInit,
|
||||
enableInputMarkdown,
|
||||
enableGatewayMode,
|
||||
enableAgentWorkingPanel,
|
||||
enableHeterogeneousAgent,
|
||||
updateLab,
|
||||
] = useUserStore((s) => [
|
||||
preferenceSelectors.isPreferenceInit(s),
|
||||
labPreferSelectors.enableInputMarkdown(s),
|
||||
labPreferSelectors.enableGatewayMode(s),
|
||||
labPreferSelectors.enableAgentWorkingPanel(s),
|
||||
labPreferSelectors.enableHeterogeneousAgent(s),
|
||||
s.updateLab,
|
||||
]);
|
||||
|
|
@ -125,19 +123,6 @@ const Page = memo(() => {
|
|||
label: tLabs('features.inputMarkdown.title'),
|
||||
minWidth: undefined,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Switch
|
||||
checked={enableAgentWorkingPanel}
|
||||
loading={!isPreferenceInit}
|
||||
onChange={(checked: boolean) => updateLab({ enableAgentWorkingPanel: checked })}
|
||||
/>
|
||||
),
|
||||
className: styles.labItem,
|
||||
desc: tLabs('features.agentWorkingPanel.desc'),
|
||||
label: tLabs('features.agentWorkingPanel.title'),
|
||||
minWidth: undefined,
|
||||
},
|
||||
...(isDesktop
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ import { lambdaClient } from '@/libs/trpc/client';
|
|||
|
||||
export const agentDocumentSWRKeys = {
|
||||
documents: (agentId: string) => ['agent-documents', agentId] as const,
|
||||
/**
|
||||
* UI-side list: raw AgentDocumentWithRules (includes documentId, sourceType, createdAt).
|
||||
* Kept separate from `documents` because the agent store writes mapAgentDocumentsToContext(...)
|
||||
* under that key, which drops those fields.
|
||||
*/
|
||||
documentsList: (agentId: string) => ['agent-documents-list', agentId] as const,
|
||||
readDocument: (agentId: string, id: string) =>
|
||||
['workspace-agent-document-editor', agentId, id] as const,
|
||||
};
|
||||
|
|
@ -29,6 +35,7 @@ export const normalizeAgentDocumentPosition = (
|
|||
|
||||
const revalidateAgentDocuments = async (agentId: string) => {
|
||||
await mutate(agentDocumentSWRKeys.documents(agentId));
|
||||
await mutate(agentDocumentSWRKeys.documentsList(agentId));
|
||||
};
|
||||
|
||||
const revalidateReadDocument = async (agentId: string, id: string) => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { DEFAULT_PREFERENCE } from '@lobechat/const';
|
|||
import { type UserState } from '@/store/user/initialState';
|
||||
|
||||
export const labPreferSelectors = {
|
||||
enableAgentWorkingPanel: (s: UserState): boolean =>
|
||||
s.preference.lab?.enableAgentWorkingPanel ?? false,
|
||||
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
|
||||
enableHeterogeneousAgent: (s: UserState): boolean =>
|
||||
s.preference.lab?.enableHeterogeneousAgent ?? false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue