diff --git a/.agents/skills/local-testing/SKILL.md b/.agents/skills/local-testing/SKILL.md index bbbc42f8aa..9affb73d43 100644 --- a/.agents/skills/local-testing/SKILL.md +++ b/.agents/skills/local-testing/SKILL.md @@ -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 diff --git a/.agents/skills/local-testing/references/agent-browser-login.md b/.agents/skills/local-testing/references/agent-browser-login.md new file mode 100644 index 0000000000..cdd638fbf4 --- /dev/null +++ b/.agents/skills/local-testing/references/agent-browser-login.md @@ -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:` (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:/…`) 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=; better-auth.state= +``` + +## 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). diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index f4e2cb40d1..fd8588e8b9 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -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": "专注模式" } diff --git a/packages/builtin-tool-agent-documents/package.json b/packages/builtin-tool-agent-documents/package.json index 5e863b439e..92508f3582 100644 --- a/packages/builtin-tool-agent-documents/package.json +++ b/packages/builtin-tool-agent-documents/package.json @@ -14,5 +14,13 @@ }, "devDependencies": { "@lobechat/types": "workspace:*" + }, + "peerDependencies": { + "@lobehub/ui": "^5", + "antd": "^6", + "antd-style": "*", + "lucide-react": "*", + "react": "*", + "react-i18next": "*" } } diff --git a/packages/builtin-tool-agent-documents/src/client/Render/CreateDocument/DocumentCard.tsx b/packages/builtin-tool-agent-documents/src/client/Render/CreateDocument/DocumentCard.tsx new file mode 100644 index 0000000000..b9f3ed4ade --- /dev/null +++ b/packages/builtin-tool-agent-documents/src/client/Render/CreateDocument/DocumentCard.tsx @@ -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(({ 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 ( + + + + +
{title}
+
+ + + + {documentId && ( + + )} + + +
+ + + {content} + + + + {documentId && ( + + )} +
+ ); +}); + +export default DocumentCard; diff --git a/packages/builtin-tool-agent-documents/src/client/Render/CreateDocument/index.tsx b/packages/builtin-tool-agent-documents/src/client/Render/CreateDocument/index.tsx new file mode 100644 index 0000000000..26a67bc94f --- /dev/null +++ b/packages/builtin-tool-agent-documents/src/client/Render/CreateDocument/index.tsx @@ -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, + 'args' | 'pluginState' +>; + +const CreateDocument = memo(({ args, pluginState }) => { + const title = args?.title; + const content = args?.content; + + if (!title || !content) return null; + + return ; +}); + +export default CreateDocument; diff --git a/packages/builtin-tool-agent-documents/src/client/Render/index.ts b/packages/builtin-tool-agent-documents/src/client/Render/index.ts index b5254584cf..9dbc514a88 100644 --- a/packages/builtin-tool-agent-documents/src/client/Render/index.ts +++ b/packages/builtin-tool-agent-documents/src/client/Render/index.ts @@ -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'; diff --git a/packages/builtin-tool-agent-documents/src/client/Streaming/CreateDocument/index.tsx b/packages/builtin-tool-agent-documents/src/client/Streaming/CreateDocument/index.tsx new file mode 100644 index 0000000000..4090917670 --- /dev/null +++ b/packages/builtin-tool-agent-documents/src/client/Streaming/CreateDocument/index.tsx @@ -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>( + ({ args }) => { + const { content, title } = args || {}; + + if (!content && !title) return null; + + return ( + + + + +
{title}
+
+ +
+ {!content ? ( + + + + ) : ( + {content} + )} +
+ ); + }, +); + +CreateDocumentStreaming.displayName = 'AgentDocumentsCreateDocumentStreaming'; + +export default CreateDocumentStreaming; diff --git a/packages/builtin-tool-agent-documents/src/client/Streaming/index.ts b/packages/builtin-tool-agent-documents/src/client/Streaming/index.ts new file mode 100644 index 0000000000..5121a55e12 --- /dev/null +++ b/packages/builtin-tool-agent-documents/src/client/Streaming/index.ts @@ -0,0 +1,8 @@ +import type { BuiltinStreaming } from '@lobechat/types'; + +import { AgentDocumentsApiName } from '../../types'; +import { CreateDocumentStreaming } from './CreateDocument'; + +export const AgentDocumentsStreamings: Record = { + [AgentDocumentsApiName.createDocument]: CreateDocumentStreaming as BuiltinStreaming, +}; diff --git a/packages/builtin-tool-agent-documents/src/client/index.ts b/packages/builtin-tool-agent-documents/src/client/index.ts index 0134ca6f7e..63a1eb3bc1 100644 --- a/packages/builtin-tool-agent-documents/src/client/index.ts +++ b/packages/builtin-tool-agent-documents/src/client/index.ts @@ -2,3 +2,4 @@ export { AgentDocumentsManifest } from '../manifest'; export * from '../types'; export { AgentDocumentsInspectors } from './Inspector'; export { AgentDocumentsRenders } from './Render'; +export { AgentDocumentsStreamings } from './Streaming'; diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index 278a30624e..5e545a494e 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -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, diff --git a/packages/builtin-tools/src/renders.ts b/packages/builtin-tools/src/renders.ts index 6d404db849..7194001f0b 100644 --- a/packages/builtin-tools/src/renders.ts +++ b/packages/builtin-tools/src/renders.ts @@ -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> = { [AgentBuilderManifest.identifier]: AgentBuilderRenders as Record, + [AgentDocumentsManifest.identifier]: AgentDocumentsRenders as Record, [AgentManagementManifest.identifier]: AgentManagementRenders as Record, [ClaudeCodeIdentifier]: ClaudeCodeRenders as Record, [CloudSandboxManifest.identifier]: CloudSandboxRenders as Record, diff --git a/packages/builtin-tools/src/streamings.ts b/packages/builtin-tools/src/streamings.ts index e674781122..307017543a 100644 --- a/packages/builtin-tools/src/streamings.ts +++ b/packages/builtin-tools/src/streamings.ts @@ -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> = { [AgentBuilderManifest.identifier]: AgentBuilderStreamings as Record, + [AgentDocumentsManifest.identifier]: AgentDocumentsStreamings as Record, [AgentManagementManifest.identifier]: AgentManagementStreamings as Record< string, BuiltinStreaming diff --git a/packages/const/src/recommendedSkill.ts b/packages/const/src/recommendedSkill.ts index 89477084ac..2d8bf41311 100644 --- a/packages/const/src/recommendedSkill.ts +++ b/packages/const/src/recommendedSkill.ts @@ -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 }, diff --git a/packages/const/src/user.ts b/packages/const/src/user.ts index 3659382725..094a1ef33b 100644 --- a/packages/const/src/user.ts +++ b/packages/const/src/user.ts @@ -17,7 +17,6 @@ export const DEFAULT_PREFERENCE: UserPreference = { topic: true, }, lab: { - enableAgentWorkingPanel: false, enableHeterogeneousAgent: false, enableInputMarkdown: true, }, diff --git a/packages/database/src/models/agentDocuments/agentDocument.ts b/packages/database/src/models/agentDocuments/agentDocument.ts index 81604cc5ab..0d85221971 100644 --- a/packages/database/src/models/agentDocuments/agentDocument.ts +++ b/packages/database/src/models/agentDocuments/agentDocument.ts @@ -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, diff --git a/packages/database/src/models/agentDocuments/types.ts b/packages/database/src/models/agentDocuments/types.ts index 57fa270fee..4697e9cf8b 100644 --- a/packages/database/src/models/agentDocuments/types.ts +++ b/packages/database/src/models/agentDocuments/types.ts @@ -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; diff --git a/packages/types/src/user/preference.ts b/packages/types/src/user/preference.ts index 3825c8dccb..4d7a8af7eb 100644 --- a/packages/types/src/user/preference.ts +++ b/packages/types/src/user/preference.ts @@ -38,10 +38,6 @@ export const UserGuideSchema = z.object({ export type UserGuide = z.infer; 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 */ diff --git a/src/features/Portal/Document/Header.tsx b/src/features/Portal/Document/Header.tsx index fb57f3b927..fbe7c276da 100644 --- a/src/features/Portal/Document/Header.tsx +++ b/src/features/Portal/Document/Header.tsx @@ -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 ( + + + + + + ); + } return ( @@ -64,17 +49,6 @@ const Header = () => { - {fileType !== 'agent/plan' && ( - - )} ); diff --git a/src/features/Portal/components/Header.tsx b/src/features/Portal/components/Header.tsx index 714fdb75ef..500a292007 100644 --- a/src/features/Portal/components/Header.tsx +++ b/src/features/Portal/components/Header.tsx @@ -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={ { clearPortalStack(); diff --git a/src/hooks/useHotkeys/globalScope.ts b/src/hooks/useHotkeys/globalScope.ts index 1e9cc04fcd..a4fef8d190 100644 --- a/src/hooks/useHotkeys/globalScope.ts +++ b/src/hooks/useHotkeys/globalScope.ts @@ -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 diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 1d0f44f192..5489eef5a9 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -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...', diff --git a/src/locales/default/labs.ts b/src/locales/default/labs.ts index f4e9a5c526..d55a2540c3 100644 --- a/src/locales/default/labs.ts +++ b/src/locales/default/labs.ts @@ -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', diff --git a/src/routes/(main)/agent/_layout/PortalAutoCollapse.tsx b/src/routes/(main)/agent/_layout/PortalAutoCollapse.tsx new file mode 100644 index 0000000000..5290154061 --- /dev/null +++ b/src/routes/(main)/agent/_layout/PortalAutoCollapse.tsx @@ -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(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; diff --git a/src/routes/(main)/agent/_layout/index.tsx b/src/routes/(main)/agent/_layout/index.tsx index f4de5f5c51..63dfe043f0 100644 --- a/src/routes/(main)/agent/_layout/index.tsx +++ b/src/routes/(main)/agent/_layout/index.tsx @@ -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 = () => { {isDesktop && } + ); }; diff --git a/src/routes/(main)/agent/features/Conversation/Header/HeaderActions/useMenu.tsx b/src/routes/(main)/agent/features/Conversation/Header/HeaderActions/useMenu.tsx index 9c926fa3d1..58b6d93f29 100644 --- a/src/routes/(main)/agent/features/Conversation/Header/HeaderActions/useMenu.tsx +++ b/src/routes/(main)/agent/features/Conversation/Header/HeaderActions/useMenu.tsx @@ -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(() => { - const items: DropdownItem[] = [ + const menuItems = useMemo( + () => [ { - icon: , - key: 'notebook', - label: tPortal('notebook.title'), - onClick: () => toggleNotebook(), + checked: wideScreen, + icon: , + key: 'full-width', + label: t('viewMode.fullWidth'), + onCheckedChange: toggleWideScreen, + type: 'switch', }, - ]; - - if (enableAgentWorkingPanel) { - items.push({ - icon: , - key: 'agent-workspace', - label: t('workingPanel.title'), - onClick: () => toggleRightPanel(), - }); - } - - items.push({ - checked: wideScreen, - icon: , - 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 }; }; diff --git a/src/routes/(main)/agent/features/Conversation/Header/NotebookButton/index.tsx b/src/routes/(main)/agent/features/Conversation/Header/NotebookButton/index.tsx deleted file mode 100644 index 4a1b415bbe..0000000000 --- a/src/routes/(main)/agent/features/Conversation/Header/NotebookButton/index.tsx +++ /dev/null @@ -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 ( - toggleNotebook()} - /> - ); -}); - -export default NotebookButton; diff --git a/src/routes/(main)/agent/features/Conversation/Header/WorkingPanelToggle/index.tsx b/src/routes/(main)/agent/features/Conversation/Header/WorkingPanelToggle/index.tsx new file mode 100644 index 0000000000..984098fdb0 --- /dev/null +++ b/src/routes/(main)/agent/features/Conversation/Header/WorkingPanelToggle/index.tsx @@ -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 ( + toggleRightPanel(true)} + /> + ); +}); + +export default WorkingPanelToggle; diff --git a/src/routes/(main)/agent/features/Conversation/Header/index.tsx b/src/routes/(main)/agent/features/Conversation/Header/index.tsx index 1321c25f44..fa9a082c09 100644 --- a/src/routes/(main)/agent/features/Conversation/Header/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/Header/index.tsx @@ -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={ + } diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/AgentDocumentEditorPanel.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/AgentDocumentEditorPanel.tsx deleted file mode 100644 index 9699e1e455..0000000000 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/AgentDocumentEditorPanel.tsx +++ /dev/null @@ -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( - ({ 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('edit'); - const initializedDocumentIdRef = useRef(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 ( - - - {data?.filename || data?.title || t('workingPanel.documents.title')} - - {isMarkdownDocument && ( - - value={viewMode} - options={[ - { - label: ( - - - {t('workingPanel.documents.preview')} - - ), - value: 'preview', - }, - { - label: ( - - - {t('workingPanel.documents.edit')} - - ), - value: 'edit', - }, - ]} - onChange={(value) => setViewMode(value)} - /> - )} - - - - - {shouldShowLoading && ( - - - - )} - {error && ( - - {t('workingPanel.documents.error')} - - )} - - {!shouldShowLoading && !error && data && ( - <> - - {viewMode === 'preview' ? ( -
- {draft} -
- ) : ( - - )} -
- - - {t('workingPanel.documents.unsaved')} - - - - - - - - )} -
- ); - }, -); - -AgentDocumentEditorPanel.displayName = 'AgentDocumentEditorPanel'; - -export default AgentDocumentEditorPanel; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ProgressSection/index.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ProgressSection/index.tsx index 24df1b5899..b8a9540fd0 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ProgressSection/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ProgressSection/index.tsx @@ -110,6 +110,8 @@ const ProgressSection = memo(() => { ); const hasTasks = progress.items.length > 0; + if (!hasTasks) return null; + return ( <>
diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.test.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.test.tsx index 5683b83ae0..1c473c982a 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.test.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.test.tsx @@ -7,12 +7,39 @@ import AgentDocumentsGroup from './AgentDocumentsGroup'; const useClientDataSWR = vi.fn(); vi.mock('@lobehub/ui', () => ({ - Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( -
{children}
+ ActionIcon: ({ onClick, title }: { onClick?: (e: React.MouseEvent) => void; title?: string }) => ( + + ), + Center: ({ children }: { children?: ReactNode }) =>
{children}
, + Empty: ({ description }: { description?: ReactNode }) =>
{description}
, + Flexbox: ({ + children, + onClick, + ...props + }: { + children?: ReactNode; + onClick?: () => void; + [key: string]: unknown; + }) => ( +
+ {children} +
), Text: ({ children }: { children?: ReactNode }) =>
{children}
, })); +vi.mock('antd', () => ({ + App: { + useApp: () => ({ + message: { error: vi.fn(), success: vi.fn() }, + modal: { confirm: vi.fn() }, + }), + }, + 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 )[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 }> }>; - }) => ( -
- {resourceTree.flatMap((node) => - (node.children || []).map((child) => ( - - )), - )} -
- ), +const openDocument = vi.fn(); +const closeDocument = vi.fn(); + +vi.mock('@/store/chat', () => ({ + useChatStore: (selector: (state: Record) => 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(); + render(); - 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(); + + 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(); + + 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(); + + expect(screen.getByText('Failed to load resources')).toBeInTheDocument(); }); }); diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx index b6ddf9f15e..2ca04adafd 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx @@ -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>[number]; -const AgentDocumentsGroup = memo( - ({ onSelectDocument, selectedDocumentId }) => { - const { t } = useTranslation(['chat', 'common']); - const { message, modal } = App.useApp(); - const agentId = useAgentStore((s) => s.activeAgentId); - const [editingDocumentId, setEditingDocumentId] = useState(null); +interface DocumentItemProps { + agentId: string; + document: AgentDocumentListItem; + isActive: boolean; + mutate: () => Promise; +} - const { - data = [], - error, - isLoading, - mutate, - } = useClientDataSWR(agentId ? agentDocumentSWRKeys.documents(agentId) : null, () => - agentDocumentService.getDocuments({ agentId: agentId! }), - ); +const DocumentItem = memo(({ 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( - () => [ - { - 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: , - key: 'rename', - label: t('rename', { ns: 'common' }), - onClick: () => setEditingDocumentId(file.path), - }, - { type: 'divider' as const }, - { - danger: true, - icon: , - key: 'delete', - label: t('delete', { ns: 'common' }), - onClick: () => handleDeleteDocument(file.path), - }, - ], - [handleDeleteDocument, t], - ); - - if (!agentId) return null; - - return ( - - {isLoading && } - {error && {t('workingPanel.resources.error')}} - {!isLoading && !error && data.length === 0 && ( - {t('workingPanel.resources.empty')} - )} - {!isLoading && !error && data.length > 0 && ( - setEditingDocumentId(null)} - onCommitRenameFile={handleCommitRenameDocument} - onSelectFile={onSelectDocument} + return ( + + + + + + {title} + + + + {description && ( + + {description} + )} + {createdAtLabel && {createdAtLabel}} + + + ); +}); + +DocumentItem.displayName = 'AgentDocumentsGroupItem'; + +interface AgentDocumentsGroupProps { + viewMode?: 'list' | 'tree'; +} + +const AgentDocumentsGroup = memo(({ viewMode = 'list' }) => { + const { t } = useTranslation('chat'); + const agentId = useAgentStore((s) => s.activeAgentId); + const activeDocumentId = useChatStore(chatPortalSelectors.portalDocumentId); + const [filter, setFilter] = useState('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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {t('workingPanel.resources.error')} +
+ ); + } + + if (data.length === 0) { + return ( +
+ +
+ ); + } + + if (viewMode === 'tree') { + return ( + + {treeGroups.map((group) => ( + + + {t(group.labelKey)} + + + {group.items.map((doc) => ( + + ))} + + + ))} ); - }, -); + } + + return ( + + + {FILTER_OPTIONS.map((option) => { + const active = filter === option.value; + return ( +
setFilter(option.value)} + > + {t(option.labelKey)} +
+ ); + })} +
+ {filteredData.length === 0 ? ( +
+ +
+ ) : ( + + {filteredData.map((doc) => ( + + ))} + + )} +
+ ); +}); AgentDocumentsGroup.displayName = 'AgentDocumentsGroup'; diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx index ea5b124d83..85dbae3e80 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/index.tsx @@ -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(({ onSelectDocument, selectedDocumentId }) => { - const { t } = useTranslation('chat'); +export type ResourceViewMode = 'list' | 'tree'; +const ResourcesSection = memo(() => { return ( - - - {t('workingPanel.resources')}} - styles={{ - header: { - width: 'fit-content', - }, - }} - > - - - - - + + ); }); diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx index 10d90afc21..b03fa287fd 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.test.tsx @@ -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 }) => ( ), + Center: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), Checkbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
{children}
), @@ -58,6 +60,7 @@ vi.mock('@lobehub/ui', () => ({ {children}
), + Empty: ({ description }: { description?: ReactNode }) =>
{description}
, Avatar: ({ avatar }: { avatar?: ReactNode | string }) =>
{avatar}
, Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
{children}
@@ -73,6 +76,17 @@ vi.mock('@lobehub/ui', () => ({ TooltipGroup: ({ children }: { children?: ReactNode }) =>
{children}
, })); +vi.mock('antd', () => ({ + App: { + useApp: () => ({ + message: { error: vi.fn(), success: vi.fn() }, + modal: { confirm: vi.fn() }, + }), + }, + Progress: () =>
, + 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 }) =>
{children}
, - useUploadFiles: () => ({ handleUploadFiles: vi.fn() }), -})); - vi.mock('@/store/agent', () => ({ useAgentStore: (selector: (state: Record) => 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) => 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: () =>
conversation-area
, -})); - -vi.mock('../Header', () => ({ - default: () =>
chat-header
, -})); - -vi.mock('./AgentDocumentEditorPanel', () => ({ - default: ({ selectedDocumentId }: { selectedDocumentId: string | null }) => ( -
{selectedDocumentId}
- ), -})); - 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(); +describe('AgentWorkingSidebar', () => { + it('renders panel header title and resources empty state', () => { + render(); - 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(); - - 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(); + // 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(); + it('mounts a right panel wrapper', () => { + render(); 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'); }); }); diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx index 69a90d36e2..516749d87d 100644 --- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx @@ -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( - ({ 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 ( - - {isDocumentMode ? ( - onSelectDocument(null)} + return ( + + + + {t('workingPanel.resources')} + toggleRightPanel(false)} /> - ) : ( - - toggleRightPanel(false)} - /> - - {/* */} - - - - - )} - - ); - }, -); + + + + + + + + ); +}); export default AgentWorkingSidebar; diff --git a/src/routes/(main)/agent/features/Conversation/index.tsx b/src/routes/(main)/agent/features/Conversation/index.tsx index aadb865024..41f2c8603e 100644 --- a/src/routes/(main)/agent/features/Conversation/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/index.tsx @@ -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(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 ( }> - - - {showHeader && } - - - - - {/* 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 && ( - - )} + + {showHeader && } + + + diff --git a/src/routes/(main)/agent/index.tsx b/src/routes/(main)/agent/index.tsx index 2514557e77..f3f0da7a69 100644 --- a/src/routes/(main)/agent/index.tsx +++ b/src/routes/(main)/agent/index.tsx @@ -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(() => { > + diff --git a/src/routes/(main)/settings/advanced/index.tsx b/src/routes/(main)/settings/advanced/index.tsx index a84044469f..2f40f1d26f 100644 --- a/src/routes/(main)/settings/advanced/index.tsx +++ b/src/routes/(main)/settings/advanced/index.tsx @@ -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: ( - updateLab({ enableAgentWorkingPanel: checked })} - /> - ), - className: styles.labItem, - desc: tLabs('features.agentWorkingPanel.desc'), - label: tLabs('features.agentWorkingPanel.title'), - minWidth: undefined, - }, ...(isDesktop ? [ { diff --git a/src/services/agentDocument.ts b/src/services/agentDocument.ts index 201861f6f4..6ba7a4506b 100644 --- a/src/services/agentDocument.ts +++ b/src/services/agentDocument.ts @@ -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) => { diff --git a/src/store/user/slices/preference/selectors/labPrefer.ts b/src/store/user/slices/preference/selectors/labPrefer.ts index 9826e2edf3..f0aad389cd 100644 --- a/src/store/user/slices/preference/selectors/labPrefer.ts +++ b/src/store/user/slices/preference/selectors/labPrefer.ts @@ -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,