From bfe54f47565f4839ab59210a2f349700ac7a30fa Mon Sep 17 00:00:00 2001 From: shsfcx Date: Thu, 13 Aug 2026 16:29:31 +0800 Subject: [PATCH 1/4] feat: add literature review and knowledge workflows Introduce the end-to-end literature review experience and local-first knowledge workspace so task files can be organized, referenced, and managed consistently. Co-authored-by: Cursor --- .../desktop/src/analytics/page-view.ts | 2 + .../desktop/src/api/tests/http.test.ts | 1 + App/frontend/desktop/src/app/router.tsx | 6 + App/frontend/desktop/src/app/routes.ts | 6 +- .../desktop/src/app/tests/routes.test.ts | 2 + .../desktop/src/components/file-type-icon.tsx | 306 ++ App/frontend/desktop/src/global.d.ts | 6 +- App/frontend/desktop/src/i18n/messages.ts | 476 +- .../i18n/tests/english-ui-coverage.test.ts | 2 + .../desktop/src/lib/agent-managed-files.ts | 48 + .../src/lib/composer-file-reference.ts | 49 + App/frontend/desktop/src/lib/file-type.ts | 181 + .../src/lib/tests/agent-managed-files.test.ts | 56 + .../lib/tests/composer-file-reference.test.ts | 43 + App/frontend/desktop/src/main.tsx | 30 + .../src/pages/agent-command-palette.tsx | 7 +- .../src/pages/agent-file-attachment-chip.tsx | 144 +- .../src/pages/agent-message-content.tsx | 5 +- .../src/pages/agent-thread-messages.tsx | 4 + App/frontend/desktop/src/pages/app-frame.tsx | 3 +- .../src/pages/home-composer-quick-actions.tsx | 421 ++ App/frontend/desktop/src/pages/home-page.tsx | 700 ++- .../desktop/src/pages/knowledge-demo-data.ts | 163 + .../desktop/src/pages/knowledge-page.tsx | 1712 ++++++++ .../src/pages/literature-review-demo-data.ts | 320 ++ .../src/pages/literature-review-page.tsx | 1764 ++++++++ .../desktop/src/pages/sidebar-resize.tsx | 12 +- .../tests/agent-command-palette.test.tsx | 5 +- .../tests/agent-file-attachment-chip.test.tsx | 39 +- .../tests/agent-thread-messages.test.tsx | 8 +- .../src/pages/tests/file-icon-system.test.tsx | 131 + .../src/pages/tests/home-page.test.tsx | 328 +- .../pages/tests/knowledge-demo-data.test.ts | 51 + .../tests/literature-review-outline.test.ts | 38 + App/frontend/desktop/src/pages/tools-page.tsx | 6 +- .../desktop/src/state/agent-chat-slice.ts | 44 +- .../desktop/src/state/agent-composer-state.ts | 7 + App/frontend/desktop/src/state/app-actions.ts | 8 +- .../src/state/tests/agent-chat-slice.test.ts | 6 +- App/frontend/desktop/src/styles.css | 3815 ++++++++++++++++- .../tests/prototype-page-alignment.test.ts | 6 +- App/frontend/desktop/src/theme/tokens.css | 31 + App/shell/desktop/interface/src/index.ts | 4 + App/shell/desktop/src/main/main.ts | 71 +- App/shell/desktop/src/preload/preload.cts | 24 +- .../tests/main-window-action-buffer.test.ts | 11 + .../tests/packaged-runtime-boundary.test.ts | 24 + package-lock.json | 124 +- 48 files changed, 10807 insertions(+), 443 deletions(-) create mode 100644 App/frontend/desktop/src/components/file-type-icon.tsx create mode 100644 App/frontend/desktop/src/lib/agent-managed-files.ts create mode 100644 App/frontend/desktop/src/lib/composer-file-reference.ts create mode 100644 App/frontend/desktop/src/lib/file-type.ts create mode 100644 App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts create mode 100644 App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts create mode 100644 App/frontend/desktop/src/pages/home-composer-quick-actions.tsx create mode 100644 App/frontend/desktop/src/pages/knowledge-demo-data.ts create mode 100644 App/frontend/desktop/src/pages/knowledge-page.tsx create mode 100644 App/frontend/desktop/src/pages/literature-review-demo-data.ts create mode 100644 App/frontend/desktop/src/pages/literature-review-page.tsx create mode 100644 App/frontend/desktop/src/pages/tests/file-icon-system.test.tsx create mode 100644 App/frontend/desktop/src/pages/tests/knowledge-demo-data.test.ts create mode 100644 App/frontend/desktop/src/pages/tests/literature-review-outline.test.ts diff --git a/App/frontend/desktop/src/analytics/page-view.ts b/App/frontend/desktop/src/analytics/page-view.ts index 9bebe1750..ef1129488 100644 --- a/App/frontend/desktop/src/analytics/page-view.ts +++ b/App/frontend/desktop/src/analytics/page-view.ts @@ -16,6 +16,8 @@ const ROUTE_PAGE_TITLES: Record = { "/tools": "Tools", "/memory": "Memory", "/memory-sources": "Memory Sources", + "/knowledge": "Knowledge Base", + "/literature-review": "Literature Review", "/settings": "Settings" }; diff --git a/App/frontend/desktop/src/api/tests/http.test.ts b/App/frontend/desktop/src/api/tests/http.test.ts index ade8a1810..392051d99 100644 --- a/App/frontend/desktop/src/api/tests/http.test.ts +++ b/App/frontend/desktop/src/api/tests/http.test.ts @@ -121,4 +121,5 @@ describe("requestJson", () => { }) ); }); + }); diff --git a/App/frontend/desktop/src/app/router.tsx b/App/frontend/desktop/src/app/router.tsx index 3f4bf7e66..849ea36d0 100644 --- a/App/frontend/desktop/src/app/router.tsx +++ b/App/frontend/desktop/src/app/router.tsx @@ -56,6 +56,8 @@ import { ApiKeyPage } from "../pages/api-key-page.js"; import { ApiKeyOptionalPage } from "../pages/api-key-optional-page.js"; import { ModelPage } from "../pages/model-page.js"; import { HomePage } from "../pages/home-page.js"; +import { KnowledgePage } from "../pages/knowledge-page.js"; +import { LiteratureReviewPage } from "../pages/literature-review-page.js"; import { LoginPage } from "../pages/login-page.js"; import { MemoryPage, writeMemorySubPage } from "../pages/memory-page.js"; import { OnboardingPage } from "../pages/onboarding-page.js"; @@ -374,6 +376,10 @@ function renderRoute(path: AppRoutePath) { return ; case "/memory-sources": return ; + case "/knowledge": + return ; + case "/literature-review": + return ; case "/settings": return ; case "/pet": diff --git a/App/frontend/desktop/src/app/routes.ts b/App/frontend/desktop/src/app/routes.ts index fc98a487f..ac18f115f 100644 --- a/App/frontend/desktop/src/app/routes.ts +++ b/App/frontend/desktop/src/app/routes.ts @@ -18,6 +18,8 @@ export type AppRoutePath = | "/tools" | "/memory" | "/memory-sources" + | "/knowledge" + | "/literature-review" | "/settings"; const CURRENT_ROUTE_STORAGE_KEY = "memmy.currentRoute"; @@ -54,6 +56,8 @@ export const routeTable: Record = { "/tools": { path: "/tools", navKey: "nav.tools", requiresBootstrap: true }, "/memory": { path: "/memory", navKey: "nav.memory", requiresBootstrap: true }, "/memory-sources": { path: "/memory-sources", navKey: "nav.memory", requiresBootstrap: true }, + "/knowledge": { path: "/knowledge", navKey: "nav.knowledge", requiresBootstrap: true }, + "/literature-review": { path: "/literature-review", navKey: "nav.literatureReview", requiresBootstrap: true }, "/settings": { path: "/settings", navKey: "nav.settings", requiresBootstrap: true } }; @@ -628,7 +632,7 @@ function isAppRoutePath(value: string | null): value is AppRoutePath { } function isRestorableRoute(path: AppRoutePath): boolean { - return path === "/main" || path === "/pet" || path === "/tools" || path === "/memory" || path === "/memory-sources" || path === "/settings"; + return path === "/main" || path === "/pet" || path === "/tools" || path === "/memory" || path === "/memory-sources" || path === "/knowledge" || path === "/settings"; } function isPostOnboardingRoute(path: AppRoutePath): boolean { diff --git a/App/frontend/desktop/src/app/tests/routes.test.ts b/App/frontend/desktop/src/app/tests/routes.test.ts index 3000e3b14..7670e65e0 100644 --- a/App/frontend/desktop/src/app/tests/routes.test.ts +++ b/App/frontend/desktop/src/app/tests/routes.test.ts @@ -98,6 +98,8 @@ describe("desktop route table", () => { "/tools", "/memory", "/memory-sources", + "/knowledge", + "/literature-review", "/settings" ]); }); diff --git a/App/frontend/desktop/src/components/file-type-icon.tsx b/App/frontend/desktop/src/components/file-type-icon.tsx new file mode 100644 index 000000000..283a89e80 --- /dev/null +++ b/App/frontend/desktop/src/components/file-type-icon.tsx @@ -0,0 +1,306 @@ +import type { DesktopSystemFolderIconKind } from "@memmy/desktop-interface"; +import { useEffect, useState } from "react"; +import { resolveFileType, type FileDisplayKind } from "../lib/file-type.js"; + +export type FileTypeIconSurface = "inline" | "row" | "card"; + +const systemFileIconCache = new Map(); +const pendingSystemFileIcons = new Map>(); +const systemFolderIconCache = new Map(); +const pendingSystemFolderIcons = new Map>(); + +function requestSystemFileIcon(filePath: string): Promise { + if (systemFileIconCache.has(filePath)) { + return Promise.resolve(systemFileIconCache.get(filePath) ?? null); + } + const pending = pendingSystemFileIcons.get(filePath); + if (pending) return pending; + const request = window.memmy?.getSystemFileIcon(filePath) + .catch(() => null) + .then((icon) => { + systemFileIconCache.set(filePath, icon); + pendingSystemFileIcons.delete(filePath); + return icon; + }) ?? Promise.resolve(null); + pendingSystemFileIcons.set(filePath, request); + return request; +} + +function useSystemFileIcon(filePath?: string): string | null { + const [icon, setIcon] = useState(() => ( + filePath && systemFileIconCache.has(filePath) + ? systemFileIconCache.get(filePath) ?? null + : null + )); + + useEffect(() => { + if (!filePath || typeof window === "undefined" || !window.memmy) { + setIcon(null); + return; + } + let active = true; + setIcon(systemFileIconCache.get(filePath) ?? null); + void requestSystemFileIcon(filePath).then((nextIcon) => { + if (active) setIcon(nextIcon); + }); + return () => { + active = false; + }; + }, [filePath]); + + return icon; +} + +function requestSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise { + if (systemFolderIconCache.has(kind)) { + return Promise.resolve(systemFolderIconCache.get(kind) ?? null); + } + const pending = pendingSystemFolderIcons.get(kind); + if (pending) return pending; + const request = window.memmy?.getSystemFolderIcon(kind) + .catch(() => null) + .then((icon) => { + systemFolderIconCache.set(kind, icon); + pendingSystemFolderIcons.delete(kind); + return icon; + }) ?? Promise.resolve(null); + pendingSystemFolderIcons.set(kind, request); + return request; +} + +function useSystemFolderIcon(kind: DesktopSystemFolderIconKind | null): string | null { + const [icon, setIcon] = useState(() => ( + kind && systemFolderIconCache.has(kind) ? systemFolderIconCache.get(kind) ?? null : null + )); + + useEffect(() => { + if (!kind || typeof window === "undefined" || !window.memmy) { + setIcon(null); + return; + } + let active = true; + setIcon(systemFolderIconCache.get(kind) ?? null); + void requestSystemFolderIcon(kind).then((nextIcon) => { + if (active) setIcon(nextIcon); + }); + return () => { + active = false; + }; + }, [kind]); + + return icon; +} + +function FileKindGlyph({ kind, compact }: { kind: FileDisplayKind; compact: boolean }) { + const commonProps = { + className: "file-type-icon__glyph", + fill: "none", + stroke: "currentColor", + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + strokeWidth: compact ? 1.65 : 1.35 + }; + switch (kind) { + case "pdf": + return ( + + ); + case "word": + return ( + + + {!compact ? : null} + + ); + case "spreadsheet": + return ( + + + + + ); + case "presentation": + return ( + + + + + ); + case "markdown": + return ( + + + + ); + case "code": + return ( + + + {!compact ? : null} + + ); + case "image": + return ( + + + + + + ); + case "video": + return ( + + + + + ); + case "audio": + return ( + + + + + + ); + case "archive": + return ( + + + + + ); + case "text": + case "generic": + return ( + + + + ); + } +} + +function DocumentSheet({ + kind, + surface, + formatLabel +}: { + kind: FileDisplayKind; + surface: FileTypeIconSurface; + formatLabel: string; +}) { + const compact = surface === "inline"; + return ( + + ); +} + +function FolderSheet({ open, surface }: { open?: boolean; surface: FileTypeIconSurface }) { + return ( + + ); +} + +export function FileTypeIcon(props: { + name: string; + mime?: string; + filePath?: string; + surface?: FileTypeIconSurface; + className?: string; +}) { + const resolved = resolveFileType(props.name, props.mime); + const surface = props.surface ?? "row"; + const systemIcon = useSystemFileIcon(props.filePath); + const formatLabel = resolved.shortLabel; + return ( + + {systemIcon ? ( + + ) : ( + + )} + + ); +} + +export function FolderTypeIcon(props: { + open?: boolean; + /** Opt-in only; knowledge lists stay on the custom SVG to avoid native icon IPC storms. */ + preferSystemIcon?: boolean; + systemKind?: DesktopSystemFolderIconKind; + surface?: FileTypeIconSurface; + className?: string; +}) { + const surface = props.surface ?? "row"; + const systemIcon = useSystemFolderIcon( + props.preferSystemIcon ? (props.systemKind ?? "folder") : null + ); + return ( + + {systemIcon ? ( + + ) : ( + + )} + + ); +} diff --git a/App/frontend/desktop/src/global.d.ts b/App/frontend/desktop/src/global.d.ts index 01a013b51..0df82a5d0 100644 --- a/App/frontend/desktop/src/global.d.ts +++ b/App/frontend/desktop/src/global.d.ts @@ -1,5 +1,5 @@ /** Global.d module. */ -import type { DesktopAppInfo, DesktopImageActionRequest, DesktopImageSaveResult, DesktopMemoryServiceRestartResult, DesktopProjectDirectorySelection, DesktopUpdateCheckResult, DesktopUpdateDownloadProgress, DesktopUpdateInstallResult } from "@memmy/desktop-interface"; +import type { DesktopAppInfo, DesktopImageActionRequest, DesktopImageSaveResult, DesktopMemoryServiceRestartResult, DesktopProjectDirectorySelection, DesktopSystemFileIconResult, DesktopSystemFolderIconKind, DesktopUpdateCheckResult, DesktopUpdateDownloadProgress, DesktopUpdateInstallResult } from "@memmy/desktop-interface"; declare global { type MemmyMicrophoneAccessStatus = "not-determined" | "granted" | "denied" | "restricted" | "unsupported"; @@ -38,6 +38,10 @@ declare global { openMailto(mailtoUrl: string): Promise; copyImageToClipboard(request: DesktopImageActionRequest): Promise; saveImage(request: DesktopImageActionRequest): Promise; + getPathForFile(file: File): string; + getSystemFileIcon(filePath: string): Promise; + getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise; + showItemInFolder(filePath: string): Promise; exportMemoryDatabase(): Promise<{ canceled: true } | { canceled: false; exportPath: string; bytes: number }>; installCliTools(): Promise; restartMemoryService(): Promise; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 6e543e742..51e074ad8 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -66,6 +66,8 @@ export const zhCNMessages = { "nav.pet": "桌宠", "nav.tools": "连接与工具", "nav.memory": "记忆管理", + "nav.knowledge": "知识库", + "nav.literatureReview": "文献综述", "nav.settings": "设置", "legal.back": "返回", "legal.terms": "服务协议", @@ -418,7 +420,7 @@ export const zhCNMessages = { "home.send": "发送", "home.stop": "停止", "home.scrollToLatest": "回到最新", - "home.commandPalette.commands": "指令", + "home.commandPalette.commands": "能力", "home.command.newTitle": "新对话", "home.command.newDescription": "停止当前任务,并开始一段全新的对话。", "home.command.stopTitle": "停止当前任务", @@ -466,9 +468,12 @@ export const zhCNMessages = { "home.asrEmptyAudio": "没有录到有效音频,请稍微说久一点再发送", "home.asrFailedWithMessage": "语音识别失败:{message}", "home.asrFailed": "语音识别失败,请重试", - "home.suggestion.one": "整理最近的 Agent 任务", - "home.suggestion.two": "总结我的代码偏好", - "home.suggestion.three": "检查今天的待办", + "home.suggestion.one": "帮我写一篇关于 AI Memory 研究的文献综述", + "home.suggestion.two": "帮我总结一下本周的工作", + "home.suggestion.three": "梳理我最近的一个任务,并列出可行的待办", + "home.suggestionPrompt.one": "请围绕 AI Memory 的核心技术、代表性研究与发展趋势,撰写一篇结构完整、包含引用和参考文献的中文文献综述", + "home.suggestionPrompt.two": "请结合我本周的任务、讨论和完成记录,总结关键进展、重要结论与遗留问题,并给出下周优先事项", + "home.suggestionPrompt.three": "请从我最近的任务中选择一个最值得继续推进的,梳理目标、当前进展和阻塞,并列出按优先级排序的可执行待办", "home.memoryLoading": "正在召回真实记忆", "home.memoryUnavailable": "记忆服务未连接", "home.memoryEmpty": "已连接真实记忆服务,未召回到相关记忆", @@ -1372,7 +1377,230 @@ export const zhCNMessages = { "pet.agentEmptyResponse": "Agent 已完成但没有返回文本", "pet.asrEmptyAudio": "没有录到有效音频,请稍微说久一点再发送", "pet.asrFailedWithMessage": "语音识别失败:{message}", - "pet.asrFailed": "语音识别失败,请重试" + "pet.asrFailed": "语音识别失败,请重试", + "home.quick.attach": "添加文件", + "home.quick.attachHint": "上传文件", + "composer.addToChat": "添加到聊天", + "home.quick.reference": "引用", + "home.quick.referenceHint": "引用知识库或文件", + "home.quick.referenceSearch": "搜索知识库或文件", + "home.quick.capability": "能力", + "home.quick.capabilityHint": "选择 Agent 能力", + "home.quick.kind.file": "文件", + "home.quick.kind.folder": "文件夹", + "home.quick.kind.kb": "知识库", + "home.quick.kbSection": "知识库", + "home.quick.fileSection": "文件", + "home.quick.kbSearchScope": "{count} 个文件 · 作为检索范围", + "home.quick.noMatches": "没有匹配的引用项", + "home.capability.literatureReview": "文献综述", + "home.capability.literatureReviewHint": "基于知识库生成结构化文献综述", + "home.capability.literatureReviewArgHint": "[综述主题]", + "home.capability.more": "更多能力", + "home.capability.moreHint": "输入 / 查看全部能力", + "kb.title": "知识库", + "kb.subtitle": "整理知识,让 Memmy 随时检索知识", + "kb.create": "新建知识库", + "kb.itemCount": "{count} 项", + "kb.updatedJustNow": "刚刚更新", + "kb.onboarding.flowLabel": "创建知识库流程", + "kb.onboarding.stepSync": "同步文件夹", + "kb.onboarding.stepCreate": "创建知识库", + "kb.onboarding.title": "同步本地文件", + "kb.onboarding.desc": "同步后,与 Memmy 对话时,可随时引用本地文件,并支持从文档中提取洞察,所有数据本地存储。", + "kb.onboarding.chooseFolders": "推荐文件夹", + "kb.onboarding.addFolder": "选择其他文件夹", + "kb.onboarding.addFolderPlaceholder": "例如 ~/Papers 或 ~/Documents/文献", + "kb.onboarding.start": "开始同步", + "kb.onboarding.skip": "暂不同步", + "kb.onboarding.createTitle": "创建第一个知识库", + "kb.onboarding.createDesc": "为一组相关文件创建独立的检索范围,之后可在任务中随时引用。", + "kb.onboarding.nameLabel": "知识库名称", + "kb.onboarding.namePlaceholder": "例如:大模型长期记忆", + "kb.onboarding.filesLabel": "选择文件(可选)", + "kb.onboarding.filesHint": "这里只展示部分已同步文件,创建后可继续添加。", + "kb.onboarding.noFiles": "暂时没有已同步文件,可先创建空知识库。", + "kb.onboarding.back": "上一步", + "kb.onboarding.skipCreate": "暂不创建", + "kb.onboarding.createAction": "创建知识库", + "kb.recent.title": "我的知识库", + "kb.base.actions": "知识库操作", + "kb.recent.desc": "常用的知识库会显示在这里", + "kb.recent.empty": "还没有知识库", + "kb.recent.viewAll": "查看全部 {count} 个", + "kb.recent.collapse": "收起", + "kb.files.title": "全部文件", + "kb.files.desc": "所有已导入的文件,可上传、删除或加入知识库", + "kb.files.add": "添加文件", + "kb.files.empty": "还没有文件,先同步一个文件夹", + "kb.files.emptyFolder": "这个文件夹是空的", + "kb.files.agentRoot": "Memmy 文件", + "kb.files.generatedFolder": "生成", + "kb.files.downloadedFolder": "下载", + "kb.files.showInFolder": "在文件夹中显示", + "kb.files.breadcrumbRoot": "根目录", + "kb.files.col.name": "名称", + "kb.files.col.path": "路径", + "kb.files.col.size": "大小", + "kb.files.col.updated": "更新时间", + "kb.files.col.status": "状态", + "kb.files.col.operate": "操作", + "kb.toolbar.upload": "上传文件", + "kb.toolbar.delete": "删除文件", + "kb.toolbar.filter": "筛选", + "kb.toolbar.filterAll": "全部状态", + "kb.toolbar.searchPlaceholder": "搜索文件", + "kb.selection.count": "已选 {count} 项", + "kb.selection.join": "添加到知识库", + "kb.selection.clear": "取消选择", + "kb.selection.createNew": "新建知识库", + "kb.selection.createNewHint": "用所选文件创建", + "kb.source.customName": "自定义文件夹", + "kb.source.customHint": "选择本地任意文件夹", + "kb.status.processed": "已解析", + "kb.status.processing": "解析中", + "kb.status.uploading": "上传中", + "kb.status.unsupported": "不支持的文件类型", + "kb.sync.title": "同步设置", + "kb.sync.desc": "这些文件夹中的内容会自动更新到全部文件", + "kb.sync.button": "同步设置", + "kb.sync.add": "添加文件夹", + "kb.sync.active": "同步中", + "kb.sync.paused": "已暂停", + "kb.sync.pause": "停止同步", + "kb.sync.recommended": "推荐文件夹", + "kb.sync.recommendedHint": "常见本地目录,一键加入同步", + "kb.sync.recommendedEmpty": "推荐目录都已同步", + "kb.sync.recommendAction": "同步", + "kb.sync.addSection": "添加文件夹", + "kb.sync.addSectionHint": "从本机直接选择要同步的文件夹", + "kb.sync.syncedSection": "同步文件夹", + "kb.sync.syncedSectionHint": "{count} 个", + "kb.sync.syncedEmpty": "还没有同步中的文件夹", + "kb.detail.back": "返回", + "kb.detail.empty": "这个知识库还没有文件", + "kb.detail.emptySearch": "没有匹配的文件", + "kb.detail.filesStat": "个文件", + "kb.detail.pathColumn": "路径", + "kb.detail.statusColumn": "状态", + "kb.detail.remove": "移出知识库", + "kb.detail.removeShort": "移出", + "kb.detail.searchPlaceholder": "搜索文件名", + "kb.folder.create": "新建文件夹", + "kb.folder.createTitle": "新建文件夹", + "kb.folder.defaultName": "新建文件夹", + "kb.folder.nameLabel": "文件夹名称", + "kb.folder.namePlaceholder": "例如:核心论文", + "kb.folder.delete": "删除文件夹", + "kb.folder.deleteTitle": "删除文件夹“{name}”?", + "kb.folder.deleteDesc": "文件夹中有 {count} 个文件。你可以仅删除文件夹并将文件移到知识库根目录,或同时从本机删除这些文件。", + "kb.folder.deleteEmptyDesc": "这是一个空文件夹,删除后无法恢复。", + "kb.folder.deleteKeepFiles": "仅删除文件夹", + "kb.folder.deleteWithFiles": "删除文件夹和本机文件", + "kb.pagination.label": "文件分页", + "kb.pagination.previous": "上一页", + "kb.pagination.next": "下一页", + "kb.removeConfirm.title": "移出知识库", + "kb.removeConfirm.desc": "确定将这 {count} 个文件移出“{name}”吗?仅从当前知识库移出,不会删除原文件。", + "kb.removeConfirm.confirm": "确认移出", + "kb.deleteFilesConfirm.title": "从本机删除文件?", + "kb.deleteFilesConfirm.desc": "确定删除这 {count} 个文件吗?文件将从本机删除,并从所有知识库中移除。此操作无法撤销。", + "kb.deleteFilesConfirm.confirm": "从本机删除", + "kb.deleteConfirm.title": "删除知识库", + "kb.deleteConfirm.desc": "确定删除“{name}”吗?文件本身不会被删除。", + "kb.modal.createTitle": "新建知识库", + "kb.modal.renameTitle": "重命名知识库", + "kb.modal.nameLabel": "名称", + "kb.modal.namePlaceholder": "例如:具身智能文献库", + "litrev.title.setup": "需求确认", + "litrev.title.execution": "执行中", + "litrev.scene": "文献综述", + "litrev.scene.lockedHint": "当前会话已锁定为文献综述工作流", + "litrev.thinking": "思考中", + "litrev.stage.done": "已完成", + "litrev.stage.current": "进行中", + "litrev.toolCall": "Agent 工具调用", + "litrev.question.recommended": "推荐", + "litrev.question.status.preparing": "正在准备提问", + "litrev.question.status.cancelled": "问题已取消", + "litrev.question.cardTitle": "确认研究需求", + "litrev.question.count": "{count} 个问题", + "litrev.question.confirm": "确认并继续", + "litrev.question.supplementPlaceholder": "其他补充…", + "litrev.question.close": "取消问题并结束会话", + "litrev.stageActivity.questions.generating": "生成问题中", + "litrev.stageActivity.questions.waiting": "等待用户回答", + "litrev.stageActivity.questions.done": "向用户提问", + "litrev.stageActivity.keywords.generating": "生成关键词中", + "litrev.stageActivity.keywords.waiting": "等待用户调整", + "litrev.stageActivity.keywords.done": "关键词列表", + "litrev.stageActivity.outline.generating": "生成大纲中", + "litrev.stageActivity.outline.waiting": "等待用户调整", + "litrev.stageActivity.outline.done": "大纲", + "litrev.stageActivity.references.generating": "检索文献中", + "litrev.stageActivity.references.waiting": "等待用户调整", + "litrev.stageActivity.references.done": "文献列表", + "litrev.stageActivity.tasks.generating": "生成任务列表中", + "litrev.stageActivity.tasks.done": "任务列表", + "litrev.stageActivity.preparation.done": "已完成研究准备", + "litrev.activity.durationSeconds": "{seconds} 秒", + "litrev.stageOutput.questions": "收到,研究要求已经明确。我先整理检索关键词,再据此生成大纲。", + "litrev.stageOutput.keywords": "关键词已经确认。我会围绕核心概念、方法机制和评测维度组织综述结构。", + "litrev.stageOutput.outline": "大纲已经确定。接下来检索并筛选与各章节直接相关的文献。", + "litrev.stageOutput.references": "参考文献范围已经确认。我先把任务列出来,然后开始整理资料和撰写正文。", + "litrev.workflow.close": "关闭并结束当前流程", + "litrev.workflow.cancelled": "流程已取消", + "litrev.workflow.cancelledDetail": "已停止当前流程,不会继续执行后续步骤。", + "litrev.wizard.step.keywords": "检索关键词", + "litrev.wizard.step.outline": "综述大纲", + "litrev.wizard.step.references": "参考文献来源", + "litrev.keywords.hint": "确认或调整检索关键词,可增删", + "litrev.keywords.itemLabel": "关键词", + "litrev.keywords.weight": "权重", + "litrev.keywords.add": "添加关键词", + "litrev.keywords.addPlaceholder": "输入新关键词后回车", + "litrev.keywords.confirm": "确认关键词", + "litrev.outline.hint": "上下拖动调整顺序;向右拖为二级,向左拖回一级", + "litrev.outline.itemLabel": "章节", + "litrev.outline.dragHint": "拖动调整顺序和层级", + "litrev.outline.addSection": "添加章节", + "litrev.outline.newSection": "新章节", + "litrev.outline.confirm": "确认大纲", + "litrev.refs.local": "本机已有", + "litrev.refs.fromWeb": "需联网获取", + "litrev.refs.note": "Agent 找到了一些相关文献。本机已有的可直接使用;联网文献会在你勾选后由 Agent 自动获取。", + "litrev.refs.policyTitle": "可选:整理到知识库", + "litrev.refs.targetKb": "选择目标知识库", + "litrev.refs.useDefault": "采用 Agent 默认选择", + "litrev.refs.addToKb": "收录到知识库", + "litrev.refs.addToKbHint": "选择一个或多个目标知识库", + "litrev.refs.selectedKbCount": "已选 {count} 个知识库", + "litrev.refs.confirmStart": "确认并开始", + "litrev.composer.setup": "补充要求或直接回复上方问题…", + "litrev.composer.task": "随时补充要求,Agent 会实时调整…", + "litrev.todo.progress": "正在执行计划 · {done}/{total}", + "litrev.todo.running": "执行中", + "litrev.todo.output.downloaded": "文献已经下载并校验完成。我先阅读摘要和正文,整理能够支撑各章节的证据。", + "litrev.todo.output.read": "资料阅读完成,核心方法、系统差异和评测结果已经归类。下面开始撰写正文。", + "litrev.todo.output.drafted": "正文初稿已经生成。我现在补齐参考文献,并统一引用格式。", + "litrev.todo.output.references": "参考文献已经整理完成。最后检查每条正文引用是否都有对应来源。", + "litrev.todo.output.checked": "引用检查完成,正文与参考文献可以一一对应。文档已经可以交付。", + "litrev.preview.scope": "浏览范围", + "litrev.preview.taskFiles": "任务文件", + "litrev.preview.knowledge": "知识库", + "litrev.preview.projectSpace": "项目空间", + "litrev.preview.knowledgeScope": "选择知识库", + "litrev.preview.allFiles": "全部文件", + "litrev.preview.toggleFiles": "展开或收起文件树", + "litrev.preview.resizeFiles": "调整文件树宽度", + "litrev.preview.outputs": "成果", + "litrev.preview.files": "文件", + "litrev.preview.openFiles": "打开的文件", + "litrev.workspace.title": "工作区", + "litrev.workspace.toggle": "打开或收起工作区", + "litrev.workspace.resize": "调整工作区宽度", + "litrev.workspace.emptyTitle": "还没有可预览的文件", + "litrev.workspace.emptyDesc": "任务执行后,产出的文件会显示在这里" } as const; export const enUSMessages: Record = { @@ -1430,6 +1658,8 @@ export const enUSMessages: Record = { "nav.pet": "Pet", "nav.tools": "Tool connections", "nav.memory": "Memory", + "nav.knowledge": "Knowledge", + "nav.literatureReview": "Literature review", "nav.settings": "Settings", "legal.back": "Back", "legal.terms": "Terms of Service", @@ -1782,7 +2012,7 @@ export const enUSMessages: Record = { "home.send": "Send", "home.stop": "Stop", "home.scrollToLatest": "Jump to latest", - "home.commandPalette.commands": "Commands", + "home.commandPalette.commands": "Capabilities", "home.command.newTitle": "New chat", "home.command.newDescription": "Stop the current task and start a fresh conversation.", "home.command.stopTitle": "Stop current task", @@ -1830,9 +2060,12 @@ export const enUSMessages: Record = { "home.asrEmptyAudio": "No valid audio was recorded. Please speak a little longer and try again.", "home.asrFailedWithMessage": "Speech recognition failed: {message}", "home.asrFailed": "Speech recognition failed. Please try again.", - "home.suggestion.one": "Organize recent Agent tasks", - "home.suggestion.two": "Summarize my coding preferences", - "home.suggestion.three": "Review today's todos", + "home.suggestion.one": "Write a literature review of AI Memory research", + "home.suggestion.two": "Summarize my work this week", + "home.suggestion.three": "Review a recent task and list actionable todos", + "home.suggestionPrompt.one": "Write a structured Chinese literature review of AI Memory's core techniques, representative research, and trends, with citations and references", + "home.suggestionPrompt.two": "Review my tasks, discussions, and completed work this week; summarize key progress, conclusions, open issues, and next week's priorities", + "home.suggestionPrompt.three": "Choose the most valuable recent task to continue, summarize its goal, progress, and blockers, then list prioritized actionable todos", "home.memoryLoading": "Recalling real memory", "home.memoryUnavailable": "Memory service is not connected", "home.memoryEmpty": "Real memory service connected; no related memories were recalled", @@ -2736,7 +2969,230 @@ export const enUSMessages: Record = { "pet.agentEmptyResponse": "Agent completed without returning text", "pet.asrEmptyAudio": "No valid audio was recorded. Please speak a little longer and try again.", "pet.asrFailedWithMessage": "Speech recognition failed: {message}", - "pet.asrFailed": "Speech recognition failed. Please try again." + "pet.asrFailed": "Speech recognition failed. Please try again.", + "home.quick.attach": "Add files", + "home.quick.attachHint": "Upload files", + "composer.addToChat": "Add to chat", + "home.quick.reference": "Reference", + "home.quick.referenceHint": "Reference a knowledge base or file", + "home.quick.referenceSearch": "Search knowledge bases or files", + "home.quick.capability": "Capabilities", + "home.quick.capabilityHint": "Choose an Agent capability", + "home.quick.kind.file": "File", + "home.quick.kind.folder": "Folder", + "home.quick.kind.kb": "KB", + "home.quick.kbSection": "Knowledge bases", + "home.quick.fileSection": "Files", + "home.quick.kbSearchScope": "{count} files · used as search scope", + "home.quick.noMatches": "No matching references", + "home.capability.literatureReview": "Literature review", + "home.capability.literatureReviewHint": "Generate a structured literature review from your knowledge base", + "home.capability.literatureReviewArgHint": "[topic]", + "home.capability.more": "More capabilities", + "home.capability.moreHint": "Type / to see all capabilities", + "kb.title": "Knowledge bases", + "kb.subtitle": "Organize materials Agents can search and cite", + "kb.create": "New knowledge base", + "kb.itemCount": "{count} items", + "kb.updatedJustNow": "Updated just now", + "kb.onboarding.flowLabel": "Knowledge base setup", + "kb.onboarding.stepSync": "Sync folders", + "kb.onboarding.stepCreate": "Create a knowledge base", + "kb.onboarding.title": "Sync local files", + "kb.onboarding.desc": "Once synced, you can reference local files anytime while chatting with Memmy and extract insights from documents. All data stays on your device.", + "kb.onboarding.chooseFolders": "Recommended folders", + "kb.onboarding.addFolder": "Choose another folder", + "kb.onboarding.addFolderPlaceholder": "e.g. ~/Papers or ~/Documents/references", + "kb.onboarding.start": "Start syncing", + "kb.onboarding.skip": "Not now", + "kb.onboarding.createTitle": "Create your first knowledge base", + "kb.onboarding.createDesc": "Create a focused search scope for related files, then reference it anytime in a task.", + "kb.onboarding.nameLabel": "Knowledge base name", + "kb.onboarding.namePlaceholder": "e.g. Long-term memory research", + "kb.onboarding.filesLabel": "Choose files (optional)", + "kb.onboarding.filesHint": "Only some synced files are shown here. You can add more after creating it.", + "kb.onboarding.noFiles": "No files are synced yet. You can create an empty knowledge base first.", + "kb.onboarding.back": "Back", + "kb.onboarding.skipCreate": "Not now", + "kb.onboarding.createAction": "Create knowledge base", + "kb.recent.title": "My knowledge bases", + "kb.base.actions": "Knowledge base actions", + "kb.recent.desc": "Your frequently used knowledge bases appear here", + "kb.recent.empty": "No knowledge bases yet", + "kb.recent.viewAll": "View all {count}", + "kb.recent.collapse": "Collapse", + "kb.files.title": "All files", + "kb.files.desc": "Every imported file — upload, delete, or add to a knowledge base", + "kb.files.add": "Add files", + "kb.files.empty": "No files yet — sync a folder first", + "kb.files.emptyFolder": "This folder is empty", + "kb.files.agentRoot": "Memmy Files", + "kb.files.generatedFolder": "Generated", + "kb.files.downloadedFolder": "Downloads", + "kb.files.showInFolder": "Show in folder", + "kb.files.breadcrumbRoot": "Root", + "kb.files.col.name": "Name", + "kb.files.col.path": "Path", + "kb.files.col.size": "Size", + "kb.files.col.updated": "Updated", + "kb.files.col.status": "Status", + "kb.files.col.operate": "Operate", + "kb.toolbar.upload": "Upload file", + "kb.toolbar.delete": "Delete files", + "kb.toolbar.filter": "Filter", + "kb.toolbar.filterAll": "All statuses", + "kb.toolbar.searchPlaceholder": "Search files", + "kb.selection.count": "{count} selected", + "kb.selection.join": "Add to knowledge base", + "kb.selection.clear": "Clear selection", + "kb.selection.createNew": "New knowledge base", + "kb.selection.createNewHint": "Create from selected files", + "kb.source.customName": "Custom folder", + "kb.source.customHint": "Choose any local folder", + "kb.status.processed": "Parsed", + "kb.status.processing": "Parsing", + "kb.status.uploading": "Uploading", + "kb.status.unsupported": "Unsupported file type", + "kb.sync.title": "Sync settings", + "kb.sync.desc": "Contents in these folders update automatically in All files", + "kb.sync.button": "Sync settings", + "kb.sync.add": "Add folder", + "kb.sync.active": "Syncing", + "kb.sync.paused": "Paused", + "kb.sync.pause": "Stop syncing", + "kb.sync.recommended": "Recommended folders", + "kb.sync.recommendedHint": "Common local folders — sync in one click", + "kb.sync.recommendedEmpty": "All recommended folders are already synced", + "kb.sync.recommendAction": "Sync", + "kb.sync.addSection": "Add folder", + "kb.sync.addSectionHint": "Pick a local folder to sync", + "kb.sync.syncedSection": "Synced folders", + "kb.sync.syncedSectionHint": "{count}", + "kb.sync.syncedEmpty": "No folders syncing yet", + "kb.detail.back": "Back", + "kb.detail.empty": "This knowledge base has no files yet", + "kb.detail.emptySearch": "No matching files", + "kb.detail.filesStat": " files", + "kb.detail.pathColumn": "Path", + "kb.detail.statusColumn": "Status", + "kb.detail.remove": "Remove from knowledge base", + "kb.detail.removeShort": "Remove", + "kb.detail.searchPlaceholder": "Search file names", + "kb.folder.create": "New folder", + "kb.folder.createTitle": "New folder", + "kb.folder.defaultName": "New folder", + "kb.folder.nameLabel": "Folder name", + "kb.folder.namePlaceholder": "e.g. Core papers", + "kb.folder.delete": "Delete folder", + "kb.folder.deleteTitle": "Delete “{name}”?", + "kb.folder.deleteDesc": "This folder contains {count} files. You can delete only the folder and move its files to the knowledge-base root, or also delete those files from this device.", + "kb.folder.deleteEmptyDesc": "This folder is empty. Deleting it cannot be undone.", + "kb.folder.deleteKeepFiles": "Delete folder only", + "kb.folder.deleteWithFiles": "Delete folder and device files", + "kb.pagination.label": "File pagination", + "kb.pagination.previous": "Previous page", + "kb.pagination.next": "Next page", + "kb.removeConfirm.title": "Remove from knowledge base", + "kb.removeConfirm.desc": "Remove these {count} files from “{name}”? The original files will not be deleted.", + "kb.removeConfirm.confirm": "Remove", + "kb.deleteFilesConfirm.title": "Delete files from this device?", + "kb.deleteFilesConfirm.desc": "Delete these {count} files? They will be deleted from this device and removed from every knowledge base. This cannot be undone.", + "kb.deleteFilesConfirm.confirm": "Delete from device", + "kb.deleteConfirm.title": "Delete knowledge base", + "kb.deleteConfirm.desc": "Delete \"{name}\"? The files themselves are kept.", + "kb.modal.createTitle": "New knowledge base", + "kb.modal.renameTitle": "Rename knowledge base", + "kb.modal.nameLabel": "Name", + "kb.modal.namePlaceholder": "e.g. Embodied AI papers", + "litrev.title.setup": "Scoping", + "litrev.title.execution": "Running", + "litrev.scene": "Literature review", + "litrev.scene.lockedHint": "This session is locked to the literature review workflow", + "litrev.thinking": "Thinking", + "litrev.stage.done": "Done", + "litrev.stage.current": "In progress", + "litrev.toolCall": "Agent tool call", + "litrev.question.recommended": "Recommended", + "litrev.question.status.preparing": "Preparing a question", + "litrev.question.status.cancelled": "Question cancelled", + "litrev.question.cardTitle": "Confirm research requirements", + "litrev.question.count": "{count} questions", + "litrev.question.confirm": "Confirm and continue", + "litrev.question.supplementPlaceholder": "Other details…", + "litrev.question.close": "Cancel the question and end the session", + "litrev.stageActivity.questions.generating": "Generating questions", + "litrev.stageActivity.questions.waiting": "Waiting for your answers", + "litrev.stageActivity.questions.done": "Asked the user", + "litrev.stageActivity.keywords.generating": "Generating keywords", + "litrev.stageActivity.keywords.waiting": "Waiting for adjustments", + "litrev.stageActivity.keywords.done": "Keyword list", + "litrev.stageActivity.outline.generating": "Generating outline", + "litrev.stageActivity.outline.waiting": "Waiting for adjustments", + "litrev.stageActivity.outline.done": "Outline", + "litrev.stageActivity.references.generating": "Searching references", + "litrev.stageActivity.references.waiting": "Waiting for adjustments", + "litrev.stageActivity.references.done": "Reference list", + "litrev.stageActivity.tasks.generating": "Generating task list", + "litrev.stageActivity.tasks.done": "Task list", + "litrev.stageActivity.preparation.done": "Research preparation completed", + "litrev.activity.durationSeconds": "{seconds}s", + "litrev.stageOutput.questions": "Got it—the research requirements are clear. I’ll organize the search keywords first, then use them to build the outline.", + "litrev.stageOutput.keywords": "The keywords are confirmed. I’ll structure the review around core concepts, method mechanics, and evaluation.", + "litrev.stageOutput.outline": "The outline is set. Next I’ll search for and screen papers that directly support each section.", + "litrev.stageOutput.references": "The reference scope is confirmed. I’ll create the task list, then begin organizing the material and drafting the review.", + "litrev.workflow.close": "Close and end this workflow", + "litrev.workflow.cancelled": "Workflow cancelled", + "litrev.workflow.cancelledDetail": "This workflow has stopped and no further steps will run.", + "litrev.wizard.step.keywords": "Search keywords", + "litrev.wizard.step.outline": "Review outline", + "litrev.wizard.step.references": "Reference sources", + "litrev.keywords.hint": "Confirm or adjust the search keywords; add or remove freely", + "litrev.keywords.itemLabel": "Keyword", + "litrev.keywords.weight": "Weight", + "litrev.keywords.add": "Add keyword", + "litrev.keywords.addPlaceholder": "Type a keyword and press Enter", + "litrev.keywords.confirm": "Confirm keywords", + "litrev.outline.hint": "Drag vertically to reorder; drag right for level two or left for level one", + "litrev.outline.itemLabel": "Section", + "litrev.outline.dragHint": "Drag to change order and level", + "litrev.outline.addSection": "Add section", + "litrev.outline.newSection": "New section", + "litrev.outline.confirm": "Confirm outline", + "litrev.refs.local": "Available locally", + "litrev.refs.fromWeb": "Needs download", + "litrev.refs.note": "Agent found related papers. Local ones are ready to use; web papers are fetched after you select them.", + "litrev.refs.policyTitle": "Optional: save to a knowledge base", + "litrev.refs.targetKb": "Choose a knowledge base", + "litrev.refs.useDefault": "Use Agent defaults", + "litrev.refs.addToKb": "Add to knowledge base", + "litrev.refs.addToKbHint": "Choose one or more target knowledge bases", + "litrev.refs.selectedKbCount": "{count} knowledge bases selected", + "litrev.refs.confirmStart": "Confirm and start", + "litrev.composer.setup": "Add requirements or reply to the question above…", + "litrev.composer.task": "Add requirements anytime; the agent adjusts in real time…", + "litrev.todo.progress": "Executing plan · {done}/{total}", + "litrev.todo.running": "Running", + "litrev.todo.output.downloaded": "The papers are downloaded and verified. I’ll now read the abstracts and full text, then organize evidence for each section.", + "litrev.todo.output.read": "Reading is complete. I’ve grouped the main methods, system differences, and evaluation results. Next I’ll draft the review.", + "litrev.todo.output.drafted": "The first draft is ready. I’ll now complete the bibliography and normalize the citation format.", + "litrev.todo.output.references": "The references are organized. I’ll finish by checking that every in-text citation has a matching source.", + "litrev.todo.output.checked": "Citation checks are complete, and the text maps cleanly to the bibliography. The document is ready.", + "litrev.preview.scope": "Browse scope", + "litrev.preview.taskFiles": "Task files", + "litrev.preview.knowledge": "Knowledge base", + "litrev.preview.projectSpace": "Project space", + "litrev.preview.knowledgeScope": "Choose knowledge base", + "litrev.preview.allFiles": "All files", + "litrev.preview.toggleFiles": "Expand or collapse file tree", + "litrev.preview.resizeFiles": "Resize file tree", + "litrev.preview.outputs": "Outputs", + "litrev.preview.files": "Files", + "litrev.preview.openFiles": "Open files", + "litrev.workspace.title": "Workspace", + "litrev.workspace.toggle": "Toggle workspace", + "litrev.workspace.resize": "Resize workspace", + "litrev.workspace.emptyTitle": "Nothing to preview yet", + "litrev.workspace.emptyDesc": "Files produced by the task will appear here" }; export type MessageKey = keyof typeof zhCNMessages; diff --git a/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts b/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts index de05c8cf1..2feacae45 100644 --- a/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts +++ b/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts @@ -10,6 +10,8 @@ const allowedSourceFiles = new Set([ "i18n/messages.ts", "lib/nickname.ts", "pages/memory/skill-demo-data.ts", + "pages/knowledge-demo-data.ts", + "pages/literature-review-demo-data.ts", // English ui coverage tests. "dev-agent-preview.tsx" ]); diff --git a/App/frontend/desktop/src/lib/agent-managed-files.ts b/App/frontend/desktop/src/lib/agent-managed-files.ts new file mode 100644 index 000000000..e0b1ca70b --- /dev/null +++ b/App/frontend/desktop/src/lib/agent-managed-files.ts @@ -0,0 +1,48 @@ +export type AgentManagedFileSource = "agent-generated" | "agent-downloaded"; + +export interface AgentManagedFile { + id: string; + path: string; + name: string; + size: string; + updated: string; + source: AgentManagedFileSource; +} + +export const AGENT_MANAGED_FILES_STORAGE_KEY = "memmy.agentManagedFiles"; +export const AGENT_MANAGED_FILES_CHANGED_EVENT = "memmy:agent-managed-files-changed"; + +export function readAgentManagedFiles(storage?: Storage): AgentManagedFile[] { + try { + const parsed = JSON.parse(storage?.getItem(AGENT_MANAGED_FILES_STORAGE_KEY) ?? "[]") as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is AgentManagedFile => ( + typeof item === "object" + && item != null + && typeof (item as AgentManagedFile).id === "string" + && typeof (item as AgentManagedFile).path === "string" + && typeof (item as AgentManagedFile).name === "string" + && ((item as AgentManagedFile).source === "agent-generated" + || (item as AgentManagedFile).source === "agent-downloaded") + )); + } catch { + return []; + } +} + +export function registerAgentManagedFiles( + files: AgentManagedFile[], + storage?: Storage, + eventTarget?: EventTarget +): AgentManagedFile[] { + const byId = new Map(readAgentManagedFiles(storage).map((file) => [file.id, file])); + for (const file of files) byId.set(file.id, file); + const next = [...byId.values()]; + try { + storage?.setItem(AGENT_MANAGED_FILES_STORAGE_KEY, JSON.stringify(next)); + } catch { + // The in-page event still lets an open file list update for this session. + } + eventTarget?.dispatchEvent(new Event(AGENT_MANAGED_FILES_CHANGED_EVENT)); + return next; +} diff --git a/App/frontend/desktop/src/lib/composer-file-reference.ts b/App/frontend/desktop/src/lib/composer-file-reference.ts new file mode 100644 index 000000000..e2c2285d0 --- /dev/null +++ b/App/frontend/desktop/src/lib/composer-file-reference.ts @@ -0,0 +1,49 @@ +import type { ComposerContextReference } from "../state/agent-composer-state.js"; + +export const MEMMY_COMPOSER_REFERENCE_MIME = "application/x-memmy-composer-reference+json"; + +export function mergeComposerContextReferences( + current: ComposerContextReference[], + incoming: ComposerContextReference[] +): ComposerContextReference[] { + const next = [...current]; + for (const reference of incoming) { + if (!next.some((item) => item.kind === reference.kind && item.id === reference.id)) { + next.push(reference); + } + } + return next; +} + +export function writeComposerReferenceDrag( + dataTransfer: DataTransfer, + reference: ComposerContextReference +): void { + dataTransfer.effectAllowed = "copy"; + dataTransfer.setData(MEMMY_COMPOSER_REFERENCE_MIME, JSON.stringify(reference)); + dataTransfer.setData("text/plain", reference.label); +} + +export function readComposerReferenceDrag(dataTransfer: DataTransfer): ComposerContextReference | null { + const payload = dataTransfer.getData(MEMMY_COMPOSER_REFERENCE_MIME); + if (!payload) return null; + try { + const value = JSON.parse(payload) as Partial; + if ( + (value.kind === "kb" || value.kind === "path") + && typeof value.id === "string" + && value.id.length > 0 + && typeof value.label === "string" + && value.label.length > 0 + ) { + return { kind: value.kind, id: value.id, label: value.label }; + } + } catch { + return null; + } + return null; +} + +export function dataTransferHasComposerReference(dataTransfer: DataTransfer): boolean { + return Array.from(dataTransfer.types).includes(MEMMY_COMPOSER_REFERENCE_MIME); +} diff --git a/App/frontend/desktop/src/lib/file-type.ts b/App/frontend/desktop/src/lib/file-type.ts new file mode 100644 index 000000000..7b6de8e0c --- /dev/null +++ b/App/frontend/desktop/src/lib/file-type.ts @@ -0,0 +1,181 @@ +/** Shared file display classification used across desktop surfaces. */ + +export type FileDisplayKind = + | "pdf" + | "word" + | "spreadsheet" + | "presentation" + | "markdown" + | "text" + | "code" + | "image" + | "video" + | "audio" + | "archive" + | "generic"; + +export interface ResolvedFileType { + kind: FileDisplayKind; + label: string; + shortLabel: string; + extension: string; +} + +const FILE_KIND_BY_EXTENSION: Readonly> = { + ".pdf": "pdf", + ".doc": "word", + ".docx": "word", + ".odt": "word", + ".rtf": "word", + ".xls": "spreadsheet", + ".xlsx": "spreadsheet", + ".ods": "spreadsheet", + ".csv": "spreadsheet", + ".ppt": "presentation", + ".pptx": "presentation", + ".odp": "presentation", + ".key": "presentation", + ".md": "markdown", + ".mdx": "markdown", + ".markdown": "markdown", + ".txt": "text", + ".log": "text", + ".tex": "text", + ".json": "code", + ".jsonl": "code", + ".xml": "code", + ".html": "code", + ".htm": "code", + ".css": "code", + ".scss": "code", + ".less": "code", + ".js": "code", + ".jsx": "code", + ".ts": "code", + ".tsx": "code", + ".py": "code", + ".java": "code", + ".go": "code", + ".rs": "code", + ".sh": "code", + ".sql": "code", + ".yaml": "code", + ".yml": "code", + ".toml": "code", + ".ini": "code", + ".cfg": "code", + ".png": "image", + ".jpg": "image", + ".jpeg": "image", + ".gif": "image", + ".webp": "image", + ".svg": "image", + ".bmp": "image", + ".heic": "image", + ".mp4": "video", + ".webm": "video", + ".mov": "video", + ".m4v": "video", + ".avi": "video", + ".mkv": "video", + ".mp3": "audio", + ".wav": "audio", + ".m4a": "audio", + ".aac": "audio", + ".flac": "audio", + ".ogg": "audio", + ".zip": "archive", + ".rar": "archive", + ".7z": "archive", + ".tar": "archive", + ".gz": "archive", + ".tgz": "archive" +}; + +const FILE_KIND_META: Readonly>> = { + pdf: { kind: "pdf", label: "PDF document", shortLabel: "PDF" }, + word: { kind: "word", label: "Word document", shortLabel: "DOC" }, + spreadsheet: { kind: "spreadsheet", label: "Spreadsheet", shortLabel: "XLS" }, + presentation: { kind: "presentation", label: "Presentation", shortLabel: "PPT" }, + markdown: { kind: "markdown", label: "Markdown document", shortLabel: "MD" }, + text: { kind: "text", label: "Text document", shortLabel: "TXT" }, + code: { kind: "code", label: "Code or configuration file", shortLabel: "CODE" }, + image: { kind: "image", label: "Image file", shortLabel: "IMG" }, + video: { kind: "video", label: "Video file", shortLabel: "VIDEO" }, + audio: { kind: "audio", label: "Audio file", shortLabel: "AUDIO" }, + archive: { kind: "archive", label: "Archive", shortLabel: "ZIP" }, + generic: { kind: "generic", label: "File", shortLabel: "FILE" } +}; + +const EXACT_EXTENSION_LABEL_KINDS = new Set([ + "markdown", + "text", + "code", + "image", + "video", + "audio", + "archive" +]); + +/** Resolve a display type without changing whether the file is uploadable. */ +export function resolveFileType(name: string, mime?: string): ResolvedFileType { + const extension = fileExtension(name); + const kind = fileKindFromMime(mime) ?? FILE_KIND_BY_EXTENSION[extension] ?? "generic"; + return { + ...FILE_KIND_META[kind], + shortLabel: shortLabelForFile(kind, extension), + extension + }; +} + +export function fileExtension(name: string): string { + const clean = String(name ?? "").split(/[?#]/, 1)[0] ?? ""; + const base = clean.split(/[\\/]/).pop() ?? ""; + const index = base.lastIndexOf("."); + return index > 0 && index < base.length - 1 ? base.slice(index).toLowerCase() : ""; +} + +function fileKindFromMime(mime?: string): FileDisplayKind | null { + const normalized = String(mime ?? "").toLowerCase().split(";", 1)[0]?.trim() ?? ""; + if (!normalized) return null; + if (normalized === "application/pdf") return "pdf"; + if (normalized.includes("wordprocessingml") || normalized === "application/msword") return "word"; + if ( + normalized.includes("spreadsheetml") + || normalized.includes("ms-excel") + || normalized === "text/csv" + ) return "spreadsheet"; + if (normalized.includes("presentationml") || normalized.includes("ms-powerpoint")) return "presentation"; + if (normalized.includes("markdown")) return "markdown"; + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("video/")) return "video"; + if (normalized.startsWith("audio/")) return "audio"; + if ( + normalized.includes("zip") + || normalized.includes("rar") + || normalized.includes("7z") + || normalized.includes("tar") + || normalized.includes("gzip") + ) return "archive"; + if ( + normalized.includes("json") + || normalized.includes("xml") + || normalized.includes("yaml") + || normalized.includes("toml") + || normalized === "text/html" + || normalized === "text/css" + || normalized === "application/javascript" + ) return "code"; + if (normalized.startsWith("text/")) return "text"; + return null; +} + +function shortLabelForFile(kind: FileDisplayKind, extension: string): string { + const extensionLabel = extension.replace(/^\./, "").toUpperCase(); + if (kind === "spreadsheet" && extension === ".csv") return "CSV"; + if (kind === "presentation" && extension === ".key") return "KEY"; + if (EXACT_EXTENSION_LABEL_KINDS.has(kind) && extensionLabel && extensionLabel.length <= 5) { + return extensionLabel; + } + return FILE_KIND_META[kind].shortLabel; +} diff --git a/App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts b/App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts new file mode 100644 index 000000000..1155a4388 --- /dev/null +++ b/App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { + AGENT_MANAGED_FILES_CHANGED_EVENT, + readAgentManagedFiles, + registerAgentManagedFiles, + type AgentManagedFile +} from "../agent-managed-files.js"; + +function memoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => { + values.delete(key); + }, + setItem: (key, value) => { + values.set(key, value); + } + }; +} + +const generated: AgentManagedFile = { + id: "generated-1", + path: "Memmy 文件/生成/综述.md", + name: "综述.md", + size: "—", + updated: "刚刚", + source: "agent-generated" +}; + +describe("agent managed files", () => { + it("persists generated files and replaces duplicate identities", () => { + const storage = memoryStorage(); + + registerAgentManagedFiles([generated], storage); + registerAgentManagedFiles([{ ...generated, path: "Memmy Files/Generated/review.md" }], storage); + + expect(readAgentManagedFiles(storage)).toEqual([{ ...generated, path: "Memmy Files/Generated/review.md" }]); + }); + + it("notifies an open all-files view after registration", () => { + const storage = memoryStorage(); + const target = new EventTarget(); + const listener = vi.fn(); + target.addEventListener(AGENT_MANAGED_FILES_CHANGED_EVENT, listener); + + registerAgentManagedFiles([generated], storage, target); + + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts b/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts new file mode 100644 index 000000000..a552a437b --- /dev/null +++ b/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + MEMMY_COMPOSER_REFERENCE_MIME, + mergeComposerContextReferences, + readComposerReferenceDrag, + writeComposerReferenceDrag +} from "../composer-file-reference.js"; + +function fakeDataTransfer(): DataTransfer { + const values = new Map(); + return { + effectAllowed: "uninitialized", + setData(type: string, value: string) { + values.set(type, value); + }, + getData(type: string) { + return values.get(type) ?? ""; + }, + get types() { + return [...values.keys()]; + } + } as DataTransfer; +} + +describe("composer file references", () => { + it("round-trips an internal drag payload", () => { + const transfer = fakeDataTransfer(); + const reference = { kind: "path" as const, id: "references/paper.pdf", label: "paper.pdf" }; + + writeComposerReferenceDrag(transfer, reference); + + expect(transfer.getData(MEMMY_COMPOSER_REFERENCE_MIME)).toContain("references/paper.pdf"); + expect(readComposerReferenceDrag(transfer)).toEqual(reference); + expect(transfer.effectAllowed).toBe("copy"); + }); + + it("deduplicates references by kind and id", () => { + const first = { kind: "path" as const, id: "paper.pdf", label: "paper.pdf" }; + const base = { kind: "kb" as const, id: "kb-1", label: "研究资料" }; + + expect(mergeComposerContextReferences([first], [first, base])).toEqual([first, base]); + }); +}); diff --git a/App/frontend/desktop/src/main.tsx b/App/frontend/desktop/src/main.tsx index 2304580b2..2573657ff 100644 --- a/App/frontend/desktop/src/main.tsx +++ b/App/frontend/desktop/src/main.tsx @@ -6,9 +6,12 @@ import { initGtag } from "./analytics/gtag-init.js"; import { NicknameModal } from "./components/nickname-modal.js"; import { I18nProvider } from "./i18n/i18n-provider.js"; import { randomNickname } from "./lib/nickname.js"; +import { KnowledgePage } from "./pages/knowledge-page.js"; +import { LiteratureReviewPage } from "./pages/literature-review-page.js"; import { MemoryPage } from "./pages/memory-page.js"; import { MemoryPluginConflictModal } from "./pages/memory-plugin-conflict-modal.js"; import { StartupScreen } from "./pages/startup-screen.js"; +import { KB_ONBOARDED_STORAGE_KEY } from "./pages/knowledge-demo-data.js"; import { applyWindowPlatformClass } from "./utils/window-fullscreen.js"; import rendererLog from "electron-log/renderer"; import "./styles.css"; @@ -73,6 +76,29 @@ function MemorySkillsPreview() { ); } +/** Design-complete KB mock: skips Electron bootstrap so product demos never hang. */ +function KnowledgePreview() { + try { + window.sessionStorage.setItem(KB_ONBOARDED_STORAGE_KEY, "1"); + } catch { + // Preview still works; the page just starts from onboarding. + } + return ( + + + + ); +} + +/** Design-complete literature-review mock: no backend required. */ +function LiteratureReviewPreview() { + return ( + + + + ); +} + const root = document.getElementById("root"); if (!root) { @@ -95,6 +121,10 @@ createRoot(root).render( ) : previewMode === "memory-skills" ? ( + ) : previewMode === "knowledge" ? ( + + ) : previewMode === "literature-review" ? ( + ) : ( )} diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index f0a6caf7a..a50378505 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -173,11 +173,8 @@ export function AgentCommandPalette(props: AgentCommandPaletteProps) { } export function slashQueryFromInput(input: string): string | null { - if (!input.startsWith("/")) { - return null; - } - const token = input.slice(1); - return /\s/.test(token) ? null : token.toLowerCase(); + const match = /(?:^|\s)\/([^\s/]*)$/.exec(input); + return match ? (match[1] ?? "").toLowerCase() : null; } export function buildVisibleSlashCommands( diff --git a/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx b/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx index 6e9c81fae..8d7d2afdf 100644 --- a/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx +++ b/App/frontend/desktop/src/pages/agent-file-attachment-chip.tsx @@ -1,17 +1,10 @@ import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from "react"; -import { BookText, FileSpreadsheet, FileText, NotepadText, Presentation, X, type LucideIcon } from "lucide-react"; +import { X } from "lucide-react"; +import { FileTypeIcon } from "../components/file-type-icon.js"; +import { resolveFileType, type FileDisplayKind, type ResolvedFileType } from "../lib/file-type.js"; -export type AgentFileDisplayKind = "pdf" | "docx" | "xlsx" | "pptx" | "file"; - -export interface AgentFileVisual { - kind: AgentFileDisplayKind; - label: string; - shortLabel: "PDF" | "DOC" | "XLS" | "PPT" | "FILE"; - typeLabel: string; - icon: LucideIcon; - tileClassName: string; - labelClassName: string; -} +export type AgentFileDisplayKind = FileDisplayKind; +export type AgentFileVisual = ResolvedFileType; export interface AgentAttachmentNameParts { displayName: string; @@ -22,12 +15,14 @@ export interface AgentAttachmentCardProps { kind: "image" | "file"; name: string; mime?: string; + filePath?: string; previewUrl?: string; subline?: string; busyLabel?: string; title?: string; removable?: boolean; removeLabel?: string; + leading?: ReactNode; thumbnailOverlay?: ReactNode; onRemove?: () => void; onClick?: () => void; @@ -38,39 +33,8 @@ export interface AgentAttachmentCardProps { align?: "left" | "right"; } -const TEXT_FILE_EXTENSIONS = new Set([ - ".txt", - ".md", - ".csv", - ".json", - ".xml", - ".html", - ".htm", - ".log", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", -]); - export function resolveAgentFileVisual(name: string, mime?: string): AgentFileVisual { - const extension = fileExtension(name); - const normalizedMime = String(mime ?? "").toLowerCase(); - const kind: AgentFileDisplayKind = - extension === ".pdf" || normalizedMime === "application/pdf" - ? "pdf" - : extension === ".docx" || normalizedMime.includes("wordprocessingml") - ? "docx" - : extension === ".xlsx" || normalizedMime.includes("spreadsheetml") - ? "xlsx" - : extension === ".pptx" || normalizedMime.includes("presentationml") - ? "pptx" - : TEXT_FILE_EXTENSIONS.has(extension) - ? "file" - : "file"; - - return visualForKind(kind, attachmentTypeLabel(name)); + return resolveFileType(name, mime); } export function splitAgentAttachmentName(name: string, fallbackExtension?: string): AgentAttachmentNameParts { @@ -88,23 +52,17 @@ export function splitAgentAttachmentName(name: string, fallbackExtension?: strin export function AgentFileIconTile(props: { name: string; mime?: string; + filePath?: string; size?: "sm" | "md"; }) { - const visual = resolveAgentFileVisual(props.name, props.mime); - const Icon = visual.icon; - const sizeClassName = props.size === "md" ? "agent-attachment-card__file-tile--md" : "agent-attachment-card__file-tile--sm"; - const iconSize = props.size === "md" ? 16 : 14; return ( - - - - {visual.shortLabel} - - + ); } @@ -126,7 +84,7 @@ export function AgentAttachmentCard(props: AgentAttachmentCardProps) { ].filter(Boolean).join(" "); const mainContent = ( <> - {props.kind === "image" ? ( + {props.leading ? props.leading : props.kind === "image" ? ( {props.previewUrl ? ( ) : ( - + )} @@ -235,72 +193,6 @@ export function AgentAttachmentCard(props: AgentAttachmentCardProps) { ); } -function visualForKind(kind: AgentFileDisplayKind, typeLabel: string): AgentFileVisual { - switch (kind) { - case "pdf": - return { - kind, - label: "PDF file", - shortLabel: "PDF", - typeLabel, - icon: FileText, - tileClassName: "agent-attachment-card__file-tile--pdf", - labelClassName: "agent-attachment-card__file-label" - }; - case "docx": - return { - kind, - label: "Word document", - shortLabel: "DOC", - typeLabel, - icon: BookText, - tileClassName: "agent-attachment-card__file-tile--docx", - labelClassName: "agent-attachment-card__file-label" - }; - case "xlsx": - return { - kind, - label: "Spreadsheet file", - shortLabel: "XLS", - typeLabel, - icon: FileSpreadsheet, - tileClassName: "agent-attachment-card__file-tile--xlsx", - labelClassName: "agent-attachment-card__file-label" - }; - case "pptx": - return { - kind, - label: "Presentation file", - shortLabel: "PPT", - typeLabel, - icon: Presentation, - tileClassName: "agent-attachment-card__file-tile--pptx", - labelClassName: "agent-attachment-card__file-label" - }; - case "file": - default: - return { - kind: "file", - label: "File attachment", - shortLabel: "FILE", - typeLabel, - icon: NotepadText, - tileClassName: "agent-attachment-card__file-tile--file", - labelClassName: "agent-attachment-card__file-label" - }; - } -} - -function attachmentTypeLabel(name: string): string { - return fileExtension(name).replace(/^\./, "").slice(0, 4).toUpperCase() || "FILE"; -} - -function fileExtension(name: string): string { - const base = basenameWithoutQuery(name); - const index = base.lastIndexOf("."); - return index > 0 ? base.slice(index).toLowerCase() : ""; -} - function basenameWithoutQuery(name: string): string { const withoutQuery = (name || "").split(/[?#]/)[0] ?? name; return withoutQuery.split(/[\\/]/).pop() || withoutQuery || ""; diff --git a/App/frontend/desktop/src/pages/agent-message-content.tsx b/App/frontend/desktop/src/pages/agent-message-content.tsx index 40bd1df5d..0a2cbbb76 100644 --- a/App/frontend/desktop/src/pages/agent-message-content.tsx +++ b/App/frontend/desktop/src/pages/agent-message-content.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; -import { ExternalLink, FileText, Image as ImageIcon } from "lucide-react"; +import { ExternalLink, Image as ImageIcon } from "lucide-react"; import ReactMarkdown, { type Components } from "react-markdown"; import { Prism as SyntaxHighlighter, type SyntaxHighlighterProps } from "react-syntax-highlighter"; import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; @@ -9,6 +9,7 @@ import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import type { MemmyAgentClient, ResolvedAgentArtifact } from "../api/memmy-agent-client.js"; +import { FileTypeIcon } from "../components/file-type-icon.js"; import { useTranslation } from "../i18n/use-translation.js"; export type AgentArtifactClient = { @@ -458,7 +459,7 @@ function FileReferenceChip(props: { disabled={actionState === "working" || (!resolved && !failed)} className={`agent-message-content__file-chip inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-tag border px-2 py-1 align-baseline text-xs disabled:cursor-wait disabled:opacity-70 ${failed || actionState === "error" ? "border-status-error/30 bg-status-error/5 text-status-error" : "border-border-stone/40 bg-canvas-oat/70 text-text-ink/65 hover:text-action-sky"}`} > - + {actionState === "working" ? t("agent.attachment.opening") : label} {actionState === "error" ? ( diff --git a/App/frontend/desktop/src/pages/agent-thread-messages.tsx b/App/frontend/desktop/src/pages/agent-thread-messages.tsx index cbf220c7b..2855d23c4 100644 --- a/App/frontend/desktop/src/pages/agent-thread-messages.tsx +++ b/App/frontend/desktop/src/pages/agent-thread-messages.tsx @@ -38,6 +38,7 @@ import { type AttachmentDownloadStarter, type AttachmentCopyTarget, } from "./agent-message-content.js"; +import { HomeContextChips } from "./home-composer-quick-actions.js"; import { Memmy } from "../components/mascot/memmy.js"; import { Tooltip } from "../components/tooltip.js"; import { useTranslation } from "../i18n/use-translation.js"; @@ -412,6 +413,9 @@ const SingleMessage = memo(function SingleMessage(props: SingleMessageProps) { return (
+ {message.contextReferences?.length ? ( + + ) : null} {message.media?.length ? ( ) : null} diff --git a/App/frontend/desktop/src/pages/app-frame.tsx b/App/frontend/desktop/src/pages/app-frame.tsx index 00d7457e6..7d2151cc7 100644 --- a/App/frontend/desktop/src/pages/app-frame.tsx +++ b/App/frontend/desktop/src/pages/app-frame.tsx @@ -56,7 +56,7 @@ import { Trash2, User } from "./memory/memory-prototype-icons.js"; -import { Check, CheckCheck, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Folder, FolderOpen, FolderPlus, ListFilter, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; +import { Check, CheckCheck, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Folder, FolderOpen, FolderPlus, LibraryBig, ListFilter, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; export interface AppFrameProps { title: string; @@ -171,6 +171,7 @@ const navItems: NavItem[] = [ { action: "search", icon: , labelKey: "appFrame.search" }, { path: "/tools", icon: }, { path: "/memory", icon: }, + { path: "/knowledge", icon: }, { action: "community", icon: , labelKey: "welcome.joinCommunity" } ]; diff --git a/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx b/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx new file mode 100644 index 000000000..632b4ca54 --- /dev/null +++ b/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx @@ -0,0 +1,421 @@ +/** + * Composer quick actions for the new-task screen (design-complete mock). + * + * Implements the prototype's composer affordances: attach (+), reference (@) + * and capability (/) triggers with popovers anchored under each button, plus + * selected context chips in the composer toolbar. + */ +import { + useEffect, + useRef, + useState, + type ChangeEventHandler, + type ClipboardEventHandler, + type CSSProperties, + type FormEventHandler, + type KeyboardEventHandler, + type ReactNode, + type Ref +} from "react"; +import { AtSign, LibraryBig, Plus, SquareSlash } from "lucide-react"; +import { FileTypeIcon, FolderTypeIcon } from "../components/file-type-icon.js"; +import { Tooltip } from "../components/tooltip.js"; +import { useTranslation } from "../i18n/use-translation.js"; +import type { ComposerContextReference } from "../state/agent-composer-state.js"; +import { AgentAttachmentCard } from "./agent-file-attachment-chip.js"; +import { buildDemoKnowledgeBases, type KbKnowledgeBase } from "./knowledge-demo-data.js"; +import { buildHomeReferenceItems, type HomeReferenceItem } from "./literature-review-demo-data.js"; + +export type ComposerContextChip = ComposerContextReference; + +const HOME_KNOWLEDGE_BASES: KbKnowledgeBase[] = buildDemoKnowledgeBases(); +const HOME_REFERENCE_ITEMS: HomeReferenceItem[] = buildHomeReferenceItems(); + +/** Extracts a trailing `@token` mention query from the composer input, or null. */ +export function mentionQueryFromInput(input: string): string | null { + const match = /(?:^|\s)@([^\s@]*)$/.exec(input); + return match ? (match[1] ?? "") : null; +} + +/** Replaces the trailing `@token` mention in the input after a pick. */ +export function stripMentionFromInput(input: string): string { + return input.replace(/(^|\s)@[^\s@]*$/, "$1"); +} + +interface ComposerHighlightSegment { + text: string; + command: boolean; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Splits selected slash commands from surrounding text without changing textarea layout. */ +export function composerHighlightSegments( + input: string, + highlightedCommands: readonly string[] +): ComposerHighlightSegment[] { + const commands = [...new Set(highlightedCommands.filter(Boolean))] + .sort((left, right) => right.length - left.length); + if (!commands.length || !input) return [{ text: input, command: false }]; + + const commandPattern = commands.map(escapeRegExp).join("|"); + const matcher = new RegExp(`(^|\\s)(${commandPattern})(?=\\s|$)`, "gi"); + const segments: ComposerHighlightSegment[] = []; + let cursor = 0; + + for (const match of input.matchAll(matcher)) { + const matchIndex = match.index ?? 0; + const leadingSpace = match[1] ?? ""; + const command = match[2] ?? ""; + const commandStart = matchIndex + leadingSpace.length; + if (commandStart > cursor) { + segments.push({ text: input.slice(cursor, commandStart), command: false }); + } + segments.push({ text: command, command: true }); + cursor = commandStart + command.length; + } + if (cursor < input.length) { + segments.push({ text: input.slice(cursor), command: false }); + } + return segments.length ? segments : [{ text: input, command: false }]; +} + +export function removeHighlightedCommandAtCaret( + input: string, + highlightedCommands: readonly string[], + caret: number, + key: "Backspace" | "Delete" +): { value: string; caret: number } | null { + const commands = [...new Set(highlightedCommands.filter(Boolean))] + .sort((left, right) => right.length - left.length); + if (!commands.length) return null; + const matcher = new RegExp( + `(^|\\s)(${commands.map(escapeRegExp).join("|")})(?=\\s|$)`, + "gi" + ); + for (const match of input.matchAll(matcher)) { + const start = (match.index ?? 0) + (match[1]?.length ?? 0); + const end = start + (match[2]?.length ?? 0); + const backspaceMatch = key === "Backspace" + && (caret === end || (caret === end + 1 && /\s/.test(input[end] ?? ""))); + const deleteMatch = key === "Delete" && caret === start; + if (!backspaceMatch && !deleteMatch) continue; + + const removeEnd = /\s/.test(input[end] ?? "") ? end + 1 : end; + const removeStart = removeEnd === end && start > 0 && /\s/.test(input[start - 1] ?? "") + ? start - 1 + : start; + return { + value: `${input.slice(0, removeStart)}${input.slice(removeEnd)}`, + caret: removeStart + }; + } + return null; +} + +/** Native textarea with an aligned backdrop that paints selected commands as inline chips. */ +export function ComposerHighlightedTextarea(props: { + value: string; + className?: string; + style?: CSSProperties; + placeholder?: string; + rows?: number; + highlightedCommands?: readonly string[]; + textareaRef?: Ref; + onChange?: ChangeEventHandler; + onKeyDown?: KeyboardEventHandler; + onPaste?: ClipboardEventHandler; + onInput?: FormEventHandler; +}) { + const [scrollTop, setScrollTop] = useState(0); + const segments = composerHighlightSegments(props.value, props.highlightedCommands ?? []); + + function handleKeyDown(event: Parameters>[0]) { + if ( + (event.key === "Backspace" || event.key === "Delete") + && event.currentTarget.selectionStart === event.currentTarget.selectionEnd + && !event.nativeEvent.isComposing + ) { + const edit = removeHighlightedCommandAtCaret( + props.value, + props.highlightedCommands ?? [], + event.currentTarget.selectionStart, + event.key + ); + if (edit && props.onChange) { + event.preventDefault(); + const textarea = event.currentTarget; + const targetProxy = new Proxy(textarea, { + get(target, property) { + if (property === "value") return edit.value; + if (property === "selectionStart" || property === "selectionEnd") return edit.caret; + const value = Reflect.get(target, property, target); + return typeof value === "function" + ? (value as (...args: unknown[]) => unknown).bind(target) + : value; + } + }) as HTMLTextAreaElement; + props.onChange({ + ...event, + target: targetProxy, + currentTarget: targetProxy + }); + window.requestAnimationFrame(() => { + textarea.setSelectionRange(edit.caret, edit.caret); + }); + return; + } + } + props.onKeyDown?.(event); + } + + return ( +
+ + "); + expect(html).toContain("分配一个任务或提问任何问题..."); + }); + + it("keeps unselected slash text editable instead of turning it into a capability chip", () => { + const html = renderToString( + + ); + + expect(html).not.toContain("composer-slash-chip"); + expect(html).toContain("/AI Memory"); + }); + + it("highlights and removes a selected capability at an inline caret position", () => { + expect(composerHighlightSegments( + "前文 /literature-review 后文", + ["/literature-review"] + )).toEqual([ + { text: "前文 ", command: false }, + { text: "/literature-review", command: true }, + { text: " 后文", command: false } + ]); + expect(removeHighlightedCommandAtCaret( + "前文 /literature-review 后文", + ["/literature-review"], + 21, + "Backspace" + )).toEqual({ value: "前文 后文", caret: 3 }); + }); + it("renders the first-phase agent input controls", () => { const html = renderToString( @@ -70,9 +319,14 @@ describe("HomePage", () => { ); expect(html).toContain("分配一个任务或提问任何问题..."); - expect(html).toContain("添加图片和文件"); + expect(html).toContain("添加文件"); + expect(html).toContain("引用"); + expect(html).toContain("能力"); expect(html).toContain("语音输入"); expect(html).toContain("发送"); + expect(html).toContain("帮我写一篇关于 AI Memory 研究的文献综述"); + expect(html).toContain("帮我总结一下本周的工作"); + expect(html).toContain("梳理我最近的一个任务,并列出可行的待办"); expect(html).toContain("Agent 正在连接"); expect(html).not.toContain('aria-haspopup="menu"'); expect(html).toContain('class="home-project-picker__trigger"'); @@ -80,7 +334,7 @@ describe("HomePage", () => { expect(html).toContain(`accept="${AGENT_MEDIA_ACCEPT}"`); expect(html).toContain("hidden"); expect(html).toContain('class="hidden"'); - expect(html).toContain('data-icon="plus"'); + expect(html).toContain("lucide-plus"); expect(html).toContain('data-icon="mic"'); expect(html).toContain('data-icon="send"'); expect(html).not.toContain("添加照片和文件"); @@ -88,7 +342,7 @@ describe("HomePage", () => { expect(html).not.toContain('data-icon="image-plus"'); expect(html).not.toContain('data-icon="pause"'); expect(html).toContain("内容由 AI 生成,请仔细甄别"); - expect(html).toContain("text-center text-[11px] text-text-ink/40 mt-4"); + expect(html).toContain("text-center text-[11px] text-text-ink/40 mt-3"); expect(html).not.toContain("未选择任何文件"); }); @@ -106,7 +360,7 @@ describe("HomePage", () => { source.indexOf(" useEffect(() => {\n if (!clients?.memmyAgent)") ); const updateComposerInputBlock = source.slice( - source.indexOf("function updateComposerInput(value: string)"), + source.indexOf("function updateComposerInput(value: string,"), source.indexOf(" /**\n * 自动收缩或展开输入框高度。") ); @@ -128,11 +382,22 @@ describe("HomePage", () => { expect(updateComposerInputBlock).toContain("loadSlashCommands({ resetAttempts: true });"); }); - it("keeps slash menu rendering and command panels on their existing boundaries", () => { + it("anchors typed slash menus at the caret and button-triggered menus at the button", () => { const source = readFileSync(homePageSourcePath, "utf8"); expect(source).toContain("const slashMenuOpen = filteredSlashCommands.length > 0;"); - expect(source.match(/\{slashMenuOpen && \(/g)).toHaveLength(2); + expect(source).toContain("function ComposerCaretMenu(props:"); + expect(source).toContain('mirror.style.whiteSpace = "pre-wrap";'); + expect(source).toContain("const caretTop = caretMarker.offsetTop;"); + expect(source).not.toContain("context?.measureText(currentLine)"); + expect(source.match(/\{slashMenuOpen && !slashPickerOpen \? \(/g)).toHaveLength(2); + expect(source).toContain("slashMenu={slashMenuOpen && slashPickerOpen ? ("); + expect(source).toContain("{slashMenuOpen && slashPickerOpen && ("); + expect(source).toContain("const mentionQuery = mentionMenuDismissed || slashPickerOpen"); + expect(source).not.toContain('slashPickerOpen || /^\\s*\\//.test(input)'); + expect(source).toContain("{referenceMenuOpen && !referencePickerOpen && !slashMenuOpen ? ("); + expect(source).toContain("{referenceMenuOpen && !slashMenuOpen ? ("); + expect(source).toContain("referenceMenu={referencePickerOpen && !slashMenuOpen ? ("); expect(source).toContain("const [lastCompactionPanel, setLastCompactionPanel] = useState({ open: false });"); expect(source).toContain("const lastCompactionSlashCommand: SlashCommandPaletteItem = {"); expect(source).toContain('command: "/last-compaction"'); @@ -367,6 +632,15 @@ describe("HomePage", () => { expect(sendBlock).toContain("if (runExactLocalSlashCommand(input))"); expect(sendBlock.indexOf("runExactLocalSlashCommand(input)")).toBeLessThan(sendBlock.indexOf("submitAgentComposerMessage({")); + expect(localSlashBlock).toContain("LITREV_CONTEXT_STORAGE_KEY"); + expect(localSlashBlock).toContain("LITREV_SOURCE_INPUT_STORAGE_KEY"); + expect(localSlashBlock).toContain("sourceInput"); + expect(localSlashBlock).toContain('/(?:^|\\s)\\/literature-review(?=\\s|$)/i'); + expect(localSlashBlock).toContain("JSON.stringify(contextChips)"); + expect(localSlashBlock).toContain("composerContextReferencesUpdated(chatScopeKey, [])"); + expect(localSlashBlock).toContain('draftTarget.kind === "project"'); + expect(localSlashBlock).toContain("LITREV_PROJECT_CONTEXT_STORAGE_KEY"); + expect(localSlashBlock).toContain("removeItem(LITREV_PROJECT_CONTEXT_STORAGE_KEY)"); expect(localSlashBlock).toContain("if (pendingAttachments.length > 0) return false;"); expect(localSlashBlock).toContain('normalized === "/last-compaction"'); expect(localSlashBlock).toContain("requestLastCompactionPanel();"); @@ -840,32 +1114,36 @@ describe("HomePage", () => { expect(html).toContain(">table<"); expect(html).toContain(">data<"); expect(html).toContain(">payload<"); - expect(html).toContain(">PDF<"); - expect(html).toContain(">DOC<"); - expect(html).toContain(">XLS<"); - expect(html).toContain(">PPT<"); - expect(html).toContain(">FILE<"); + expect(html).toContain("file-type-icon__paper"); + expect(html).toContain("file-type-icon__glyph"); + expect(html).toContain("file-type-icon__format-label"); + expect(html).toContain(">PDF"); + expect(html).toContain(">DOC"); + expect(html).toContain(">XLS"); + expect(html).toContain(">PPT"); expect(compactHtml).toContain("XLSX · 2.0 KB"); expect(compactHtml).toContain("PPTX · 1.5 KB"); expect(compactHtml).toContain("TXT · 512 B"); expect(compactHtml).toContain("CSV · 768 B"); expect(compactHtml).toContain("JSON · 1.0 KB"); expect(compactHtml).toContain("XML · 640 B"); - expect(html).toContain('data-testid="agent-file-icon-pdf"'); - expect(html).toContain('data-testid="agent-file-icon-docx"'); - expect(html).toContain('data-testid="agent-file-icon-xlsx"'); - expect(html).toContain('data-testid="agent-file-icon-pptx"'); - expect(html).toContain('data-testid="agent-file-icon-file"'); - expect(html).toContain("agent-attachment-card__file-tile--pdf"); - expect(html).toContain("agent-attachment-card__file-tile--docx"); - expect(html).toContain("agent-attachment-card__file-tile--xlsx"); - expect(html).toContain("agent-attachment-card__file-tile--pptx"); - expect(html).toContain("agent-attachment-card__file-tile--file"); - expect(html).toContain('aria-label="PDF file"'); + expect(html).toContain('data-testid="file-type-icon-pdf"'); + expect(html).toContain('data-testid="file-type-icon-word"'); + expect(html).toContain('data-testid="file-type-icon-spreadsheet"'); + expect(html).toContain('data-testid="file-type-icon-presentation"'); + expect(html).toContain('data-testid="file-type-icon-text"'); + expect(html).toContain('data-testid="file-type-icon-code"'); + expect(html).toContain("file-type-icon--pdf"); + expect(html).toContain("file-type-icon--word"); + expect(html).toContain("file-type-icon--spreadsheet"); + expect(html).toContain("file-type-icon--presentation"); + expect(html).toContain("file-type-icon--text"); + expect(html).toContain("file-type-icon--code"); + expect(html).toContain('aria-label="PDF document"'); expect(html).toContain('aria-label="Word document"'); - expect(html).toContain('aria-label="Spreadsheet file"'); - expect(html).toContain('aria-label="Presentation file"'); - expect(html).toContain('aria-label="File attachment"'); + expect(html).toContain('aria-label="Spreadsheet"'); + expect(html).toContain('aria-label="Presentation"'); + expect(html).toContain('aria-label="Text document"'); expect(html).not.toContain("absolute -right-1 -bottom-1"); expect(html).not.toContain('data-testid="composer-file-kind-'); expect(compactHtml).toContain("PNG · 2.0 KB"); diff --git a/App/frontend/desktop/src/pages/tests/knowledge-demo-data.test.ts b/App/frontend/desktop/src/pages/tests/knowledge-demo-data.test.ts new file mode 100644 index 000000000..8a961441c --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/knowledge-demo-data.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + buildDemoKnowledgeBases, + buildInitialKnowledgeBases, + buildDemoLibraryFiles, + buildDemoSourceFolders, + kbDefaultImportRoot, + moveFilesToKnowledgeFolder +} from "../knowledge-demo-data.js"; + +describe("knowledge demo platform folders", () => { + it("uses the Windows Documents label and root on win32", () => { + const folders = buildDemoSourceFolders("win32"); + + expect(folders[0]).toMatchObject({ + id: "documents", + name: "文档 (Documents)", + root: "文档" + }); + expect(kbDefaultImportRoot("win32")).toBe("文档"); + expect(buildDemoLibraryFiles("win32")[0]?.path).toMatch(/^文档\//u); + }); + + it("keeps the macOS Documents presentation on darwin", () => { + const folders = buildDemoSourceFolders("darwin"); + + expect(folders[0]).toMatchObject({ + id: "documents", + name: "文稿 (Documents)", + root: "文稿" + }); + expect(buildDemoLibraryFiles("darwin")[0]?.path).toMatch(/^文稿\//u); + }); + + it("moves files between virtual folders without changing knowledge-base membership", () => { + const base = buildDemoKnowledgeBases()[0]!; + const moved = moveFilesToKnowledgeFolder(base, ["d3"], "folder-core-papers"); + + expect(moved.fileIds).toEqual(base.fileIds); + expect(moved.folders[0]?.fileIds).toEqual(["d1", "d2", "d3"]); + + const returnedToRoot = moveFilesToKnowledgeFolder(moved, ["d1"], null); + expect(returnedToRoot.fileIds).toEqual(base.fileIds); + expect(returnedToRoot.folders[0]?.fileIds).toEqual(["d2", "d3"]); + }); + + it("does not seed knowledge bases before onboarding is complete", () => { + expect(buildInitialKnowledgeBases(false)).toEqual([]); + expect(buildInitialKnowledgeBases(true)).toHaveLength(5); + }); +}); diff --git a/App/frontend/desktop/src/pages/tests/literature-review-outline.test.ts b/App/frontend/desktop/src/pages/tests/literature-review-outline.test.ts new file mode 100644 index 000000000..59360d3de --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/literature-review-outline.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + moveOutlineItem, + type LitrevOutlineItem +} from "../literature-review-demo-data.js"; + +const outline: LitrevOutlineItem[] = [ + { id: "a", text: "A", level: 0 }, + { id: "a-1", text: "A.1", level: 1 }, + { id: "b", text: "B", level: 0 }, + { id: "c", text: "C", level: 0 } +]; + +describe("literature review outline hierarchy", () => { + it("changes hierarchy even when dropped at the same position", () => { + const moved = moveOutlineItem(outline, 2, 2, 1); + + expect(moved.map((item) => [item.id, item.level])).toEqual([ + ["a", 0], + ["a-1", 1], + ["b", 1], + ["c", 0] + ]); + }); + + it("prevents an orphan level-two item at the beginning", () => { + const moved = moveOutlineItem(outline, 2, 0, 1); + + expect(moved[0]).toMatchObject({ id: "b", level: 0 }); + }); + + it("moves existing children together with their level-one parent", () => { + const moved = moveOutlineItem(outline, 0, 3, 0); + + expect(moved.map((item) => item.id)).toEqual(["b", "c", "a", "a-1"]); + expect(moved.at(-1)?.level).toBe(1); + }); +}); diff --git a/App/frontend/desktop/src/pages/tools-page.tsx b/App/frontend/desktop/src/pages/tools-page.tsx index b590b808f..17d7dd637 100644 --- a/App/frontend/desktop/src/pages/tools-page.tsx +++ b/App/frontend/desktop/src/pages/tools-page.tsx @@ -134,11 +134,11 @@ export function ToolsPageView(props: ToolsPageViewProps) { return (
-
+
-

{t("tools.title")}

-

{t("tools.subtitle")}

+

{t("tools.title")}

+

{t("tools.subtitle")}

diff --git a/App/frontend/desktop/src/state/agent-chat-slice.ts b/App/frontend/desktop/src/state/agent-chat-slice.ts index 9a67587db..0cdb5f4d8 100644 --- a/App/frontend/desktop/src/state/agent-chat-slice.ts +++ b/App/frontend/desktop/src/state/agent-chat-slice.ts @@ -18,7 +18,7 @@ import type { WebuiSessionTarget } from "../api/memmy-agent-client.js"; import { chatIdToSessionKey } from "../api/memmy-agent-client.js"; -import type { PendingAttachment } from "./agent-composer-state.js"; +import type { ComposerContextReference, PendingAttachment } from "./agent-composer-state.js"; import { mergeFileEdits, mergeToolProgressEvents, @@ -105,6 +105,7 @@ export interface AgentChatMessage { reasoning?: string; reasoningStreaming?: boolean; media?: AgentChatMediaAttachment[]; + contextReferences?: ComposerContextReference[]; traces?: string[]; toolEvents?: AgentToolProgressEvent[]; fileEdits?: AgentFileEdit[]; @@ -181,6 +182,7 @@ export interface AgentState { goalState: AgentGoalState | null; composerDraftsByScope: Record; composerPendingAttachmentsByScope: Record; + composerContextReferencesByScope: Record; draftTargetsByScope: Record; draftTargetRevisionByScope: Record; messageSendInFlightByScope: Record; @@ -228,9 +230,10 @@ export type AgentAction = | { type: "agent/blankDraftReopened" } | { type: "agent/newChatCreated"; chatId: string } | { type: "agent/transientSendFailed"; chatId: string } - | { type: "agent/userMessageQueued"; chatId: string; content: string; media?: AgentChatMediaAttachment[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget } + | { type: "agent/userMessageQueued"; chatId: string; content: string; media?: AgentChatMediaAttachment[]; contextReferences?: ComposerContextReference[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget } | { type: "agent/composerDraftUpdated"; scopeKey: string; value: string } | { type: "agent/composerPendingAttachmentsUpdated"; scopeKey: string; attachments: PendingAttachment[] } + | { type: "agent/composerContextReferencesUpdated"; scopeKey: string; references: ComposerContextReference[] } | { type: "agent/draftTargetUpdated"; scopeKey: string; target: WebuiSessionTarget } | { type: "agent/messageSendLockUpdated"; scopeKey: string; clientRequestId: string | null } | { type: "agent/tasksMarkedRead"; chatIds: string[] } @@ -312,6 +315,7 @@ export const initialAgentState: AgentState = { goalState: null, composerDraftsByScope: {}, composerPendingAttachmentsByScope: {}, + composerContextReferencesByScope: {}, draftTargetsByScope: {}, draftTargetRevisionByScope: {}, messageSendInFlightByScope: {}, @@ -459,6 +463,8 @@ export function agentReducer(state: AgentState, action: AgentAction): AgentState return updateComposerDraft(state, action.scopeKey, action.value); case "agent/composerPendingAttachmentsUpdated": return updateComposerPendingAttachments(state, action.scopeKey, action.attachments); + case "agent/composerContextReferencesUpdated": + return updateComposerContextReferences(state, action.scopeKey, action.references); case "agent/draftTargetUpdated": return { ...state, @@ -581,7 +587,8 @@ function queueOptimisticUserMessage( role: "user", content: action.content, createdAt: now, - ...(action.media?.length ? { media: action.media } : {}) + ...(action.media?.length ? { media: action.media } : {}), + ...(action.contextReferences?.length ? { contextReferences: action.contextReferences } : {}) }; const messages = [...existingMessages, message]; const messagesByChatId = { ...nextState.messagesByChatId, [action.chatId]: messages }; @@ -879,23 +886,51 @@ function updateComposerPendingAttachments(state: AgentState, scopeKey: string, a }; } +function updateComposerContextReferences( + state: AgentState, + scopeKey: string, + references: ComposerContextReference[] +): AgentState { + if (!references.length) { + if (!(scopeKey in state.composerContextReferencesByScope)) { + return state; + } + const composerContextReferencesByScope = { ...state.composerContextReferencesByScope }; + delete composerContextReferencesByScope[scopeKey]; + return { ...state, composerContextReferencesByScope }; + } + if (state.composerContextReferencesByScope[scopeKey] === references) { + return state; + } + return { + ...state, + composerContextReferencesByScope: { + ...state.composerContextReferencesByScope, + [scopeKey]: references + } + }; +} + function clearComposerScope(state: AgentState, scopeKey: string): AgentState { const hasDraft = scopeKey in state.composerDraftsByScope; const hasPendingAttachments = scopeKey in state.composerPendingAttachmentsByScope; + const hasContextReferences = scopeKey in state.composerContextReferencesByScope; const hasTarget = scopeKey in state.draftTargetsByScope; const hasTargetRevision = scopeKey in state.draftTargetRevisionByScope; const hasSendLock = scopeKey in state.messageSendInFlightByScope; - if (!hasDraft && !hasPendingAttachments && !hasTarget && !hasTargetRevision && !hasSendLock) { + if (!hasDraft && !hasPendingAttachments && !hasContextReferences && !hasTarget && !hasTargetRevision && !hasSendLock) { return state; } const composerDraftsByScope = { ...state.composerDraftsByScope }; const composerPendingAttachmentsByScope = { ...state.composerPendingAttachmentsByScope }; + const composerContextReferencesByScope = { ...state.composerContextReferencesByScope }; const draftTargetsByScope = { ...state.draftTargetsByScope }; const draftTargetRevisionByScope = { ...state.draftTargetRevisionByScope }; const messageSendInFlightByScope = { ...state.messageSendInFlightByScope }; delete composerDraftsByScope[scopeKey]; delete composerPendingAttachmentsByScope[scopeKey]; + delete composerContextReferencesByScope[scopeKey]; delete draftTargetsByScope[scopeKey]; delete draftTargetRevisionByScope[scopeKey]; delete messageSendInFlightByScope[scopeKey]; @@ -903,6 +938,7 @@ function clearComposerScope(state: AgentState, scopeKey: string): AgentState { ...state, composerDraftsByScope, composerPendingAttachmentsByScope, + composerContextReferencesByScope, draftTargetsByScope, draftTargetRevisionByScope, messageSendInFlightByScope diff --git a/App/frontend/desktop/src/state/agent-composer-state.ts b/App/frontend/desktop/src/state/agent-composer-state.ts index 83a65bbee..e2c730269 100644 --- a/App/frontend/desktop/src/state/agent-composer-state.ts +++ b/App/frontend/desktop/src/state/agent-composer-state.ts @@ -11,6 +11,12 @@ import type { UploadedAgentMedia } from "../api/memmy-agent-client.js"; export type ComposerDraftValue = string | ((currentValue: string) => string); +export interface ComposerContextReference { + kind: "kb" | "path"; + id: string; + label: string; +} + export interface PendingAttachmentBase { id: string; sourceKey: string; @@ -33,6 +39,7 @@ export interface PendingImage extends PendingAttachmentBase { export interface PendingFileAttachment extends PendingAttachmentBase { kind: "file"; status: "ready" | "error"; + localPath?: string; uploadBlob?: Blob; uploadMime?: UploadedAgentMedia["mime"]; uploadBytes?: number; diff --git a/App/frontend/desktop/src/state/app-actions.ts b/App/frontend/desktop/src/state/app-actions.ts index 603b7ef5b..3f3caec09 100644 --- a/App/frontend/desktop/src/state/app-actions.ts +++ b/App/frontend/desktop/src/state/app-actions.ts @@ -18,7 +18,7 @@ import type { IntegrationsClient } from "../api/integrations-client.js"; import type { IntegrationConnection } from "../integrations/connection-state.js"; import type { IntegrationMeta } from "../integrations/integration-meta.js"; import type { MemmyAgentRunStatusSnapshot, MemmyAgentSessionSnapshot, MemmyAgentSessionSummary, MemmyAgentSidebarState, MemmyAgentWebuiThread, MemmyAgentWsEvent, WebuiSessionTarget } from "../api/memmy-agent-client.js"; -import type { PendingAttachment } from "./agent-composer-state.js"; +import type { ComposerContextReference, PendingAttachment } from "./agent-composer-state.js"; import type { AgentAction, AgentChatMediaAttachment, @@ -369,7 +369,7 @@ export const agentActions = { return { type: "agent/transientSendFailed", chatId }; }, - userMessageQueued(input: { chatId: string; content: string; media?: AgentChatMediaAttachment[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget }): AppAction { + userMessageQueued(input: { chatId: string; content: string; media?: AgentChatMediaAttachment[]; contextReferences?: ComposerContextReference[]; focus?: boolean; deliveryUncertain?: boolean; target?: WebuiSessionTarget }): AppAction { return { type: "agent/userMessageQueued", ...input }; }, @@ -381,6 +381,10 @@ export const agentActions = { return { type: "agent/composerPendingAttachmentsUpdated", scopeKey, attachments }; }, + composerContextReferencesUpdated(scopeKey: string, references: ComposerContextReference[]): AppAction { + return { type: "agent/composerContextReferencesUpdated", scopeKey, references }; + }, + draftTargetUpdated(scopeKey: string, target: WebuiSessionTarget): AppAction { return { type: "agent/draftTargetUpdated", scopeKey, target }; }, diff --git a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts index a156a7a0f..dc20b90be 100644 --- a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts +++ b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts @@ -2946,19 +2946,23 @@ describe("agent chat slice", () => { expect(state.tasks.find((task) => task.chatId === "chat-1")?.runStartedAt).toBe(1780732800); }); - it("keeps composer drafts and pending attachments isolated by scope", () => { + it("keeps composer drafts, pending attachments, and context references isolated by scope", () => { const attachment = readyPendingFile("report.pdf"); + const reference = { kind: "path" as const, id: "docs/report.pdf", label: "report.pdf" }; let state = agentReducer(initialAgentState, { type: "agent/composerDraftUpdated", scopeKey: "chat-a", value: "A 草稿" }); state = agentReducer(state, { type: "agent/composerDraftUpdated", scopeKey: "chat-b", value: "B 草稿" }); state = agentReducer(state, { type: "agent/composerPendingAttachmentsUpdated", scopeKey: "chat-a", attachments: [attachment] }); + state = agentReducer(state, { type: "agent/composerContextReferencesUpdated", scopeKey: "chat-a", references: [reference] }); expect(state.composerDraftsByScope).toEqual({ "chat-a": "A 草稿", "chat-b": "B 草稿" }); expect(state.composerPendingAttachmentsByScope["chat-a"]).toEqual([attachment]); + expect(state.composerContextReferencesByScope["chat-a"]).toEqual([reference]); state = agentReducer(state, { type: "agent/composerScopeCleared", scopeKey: "chat-a" }); expect(state.composerDraftsByScope).toEqual({ "chat-b": "B 草稿" }); expect(state.composerPendingAttachmentsByScope["chat-a"]).toBeUndefined(); + expect(state.composerContextReferencesByScope["chat-a"]).toBeUndefined(); }); it("newChatRequested does not clear composer scopes", () => { diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 32e6ab721..921dff7cf 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -194,7 +194,81 @@ body.memmy-window-fullscreen { } .home-empty-screen { + display: grid; + grid-template-rows: 1fr auto 1fr; + justify-items: center; + height: 100%; + min-height: 0; padding-bottom: 8%; + overflow-x: hidden; + overflow-y: auto; +} + +/* Brand + composer: true vertical center (same band as before capabilities). */ +.home-empty-mid { + grid-row: 2; + display: flex; + width: min(100%, 42rem); + flex-direction: column; + align-items: center; +} + +.home-empty-mid__composer { + width: 100%; +} + +.home-prompt-suggestions { + display: grid; + gap: 7px; + margin: 18px 2px 0; +} + +.home-prompt-suggestions button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 42px; + border: 0; + border-radius: 9px; + padding: 8px 11px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.home-prompt-suggestions button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 68%, transparent); + color: var(--color-text-ink); +} + +.home-prompt-suggestions__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 24px; + width: 24px; + height: 24px; + border-radius: 7px; + background: color-mix(in srgb, var(--color-accent-neon-mint) 16%, var(--color-background-paper)); + color: var(--color-action-sky); +} + +.home-prompt-suggestions button > span:nth-child(2) { + min-width: 0; + flex: 1; +} + +/* Capability cards + notice: top of the lower half, snug under the composer. */ +.home-empty-below { + grid-row: 3; + align-self: start; + width: min(100%, 42rem); + padding: 12px 0 28px; } .home-empty-composer, @@ -206,6 +280,74 @@ body.memmy-window-fullscreen { overflow: visible; } +/* Keep @ / popovers above the project toolbar while menus are open. */ +.home-empty-composer--menu-open { + z-index: 60; +} + +.composer-quick-actions { + position: absolute; + bottom: 0.75rem; + left: 1rem; + z-index: 50; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.composer-quick-actions__anchor { + position: relative; +} + +.composer-quick-actions__btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.composer-quick-actions__btn:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); +} + +.composer-quick-actions__btn--active { + background: color-mix(in srgb, var(--color-action-sky) 12%, transparent); + color: var(--color-action-sky-hover); +} + +.composer-quick-actions__popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 70; +} + +.composer-quick-actions__popover--reference { + width: min(22rem, calc(100vw - 2rem)); +} + +.composer-quick-actions__popover--slash { + width: min(26rem, calc(100vw - 2rem)); +} + +.composer-caret-menu { + position: absolute; + z-index: 70; +} + +.composer-quick-actions__popover .home-reference-panel { + width: 100%; + margin: 0; + max-height: min(20rem, 45vh); +} + .home-empty-composer textarea, .agent-composer-shell textarea { background-color: var(--color-background-paper); @@ -218,6 +360,50 @@ body.memmy-window-fullscreen { max-height: 352px; } +.composer-rich-input { + position: relative; +} + +.composer-rich-input--highlighted { + width: 100%; + border-radius: var(--radius-composer); + background-color: var(--color-background-paper); +} + +.composer-rich-input__backdrop { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + padding: 1rem 1.25rem 3rem; + border-radius: inherit; + color: transparent; + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: break-word; + pointer-events: none; +} + +.composer-rich-input__backdrop .composer-slash-chip { + border-radius: 4px; + padding: 1px 0; + background: color-mix(in srgb, var(--color-accent-neon-mint) 34%, var(--color-background-paper)); + color: var(--color-action-sky-hover); + line-height: 1.6; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + box-shadow: + 4px 0 0 color-mix(in srgb, var(--color-accent-neon-mint) 34%, var(--color-background-paper)), + -4px 0 0 color-mix(in srgb, var(--color-accent-neon-mint) 34%, var(--color-background-paper)); +} + +.composer-rich-input__field--highlighted { + position: relative; + z-index: 1; + background: transparent !important; +} + .agent-conversation-composer textarea { max-height: 400px; } @@ -929,6 +1115,30 @@ body:has(.memory-drawer) .window-drag-region { padding-right: var(--codex-content-padding-x); } +.app-page-hero { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); +} + +.app-page-hero h1 { + margin: 0; + color: var(--color-text-ink); + font-size: 18px; + font-weight: 700; + line-height: 28px; +} + +.app-page-hero p { + margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + font-size: 12px; + line-height: 16px; +} + .app-frame-content-topbar { position: absolute; top: 0; @@ -2269,6 +2479,20 @@ button { padding: 12px 16px 4px; } +.composer-context-attachments { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + overflow-y: auto; + padding: 12px 16px 4px; +} + +.composer-context-attachments > .composer-media-preview-strip, +.composer-context-attachments > .home-context-chips { + display: contents; +} + /* Keep this more specific than `.agent-composer-shell textarea` above so its generic line-height cannot shift the caret. Keep overflow-y:auto (not hidden): if single-line detection lags behind wrapped content, the field must stay scrollable. */ .agent-composer-shell textarea.agent-composer-input--single { @@ -2280,6 +2504,176 @@ button { line-height: 24px; } +.file-type-icon { + --file-icon-glyph: var(--file-icon-glyph-generic); + display: inline-grid; + flex: 0 0 auto; + place-items: center; + box-sizing: border-box; + min-width: 0; + overflow: visible; + background: transparent; + color: inherit; + line-height: 1; + vertical-align: middle; +} + +.file-type-icon > svg { + display: block; + flex: none; + width: auto; + height: 100%; + max-width: 100%; + max-height: 100%; + overflow: visible; + shape-rendering: geometricPrecision; +} + +.file-type-icon__native-image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +.file-type-icon--inline { + width: var(--file-icon-inline-size); + min-width: var(--file-icon-inline-size); + max-width: var(--file-icon-inline-size); + height: var(--file-icon-inline-size); + min-height: var(--file-icon-inline-size); + max-height: var(--file-icon-inline-size); + flex-basis: var(--file-icon-inline-size); + border-radius: var(--file-icon-inline-radius); + background: transparent; +} + +.file-type-icon--inline > svg { + height: var(--file-icon-inline-size); +} + +.file-type-icon--row { + width: var(--file-icon-row-size); + min-width: var(--file-icon-row-size); + max-width: var(--file-icon-row-size); + height: var(--file-icon-row-size); + min-height: var(--file-icon-row-size); + max-height: var(--file-icon-row-size); + flex-basis: var(--file-icon-row-size); + border-radius: var(--file-icon-row-radius); +} + +.file-type-icon--row > svg { + height: var(--file-icon-row-size); +} + +.file-type-icon--card { + width: var(--file-icon-card-size); + min-width: var(--file-icon-card-size); + max-width: var(--file-icon-card-size); + height: var(--file-icon-card-size); + min-height: var(--file-icon-card-size); + max-height: var(--file-icon-card-size); + flex-basis: var(--file-icon-card-size); + border-radius: var(--file-icon-card-radius); +} + +.file-type-icon--card > svg { + height: var(--file-icon-card-size); +} + +.file-type-icon__paper { + fill: var(--file-icon-paper); +} + +.file-type-icon__fold { + fill: var(--file-icon-paper-fold); +} + +.file-type-icon__fold-edge { + fill: none; + stroke: var(--file-icon-paper-fold-edge); + stroke-width: 0.5; +} + +.file-type-icon__glyph { + color: var(--file-icon-glyph); +} + +.file-type-icon--card .file-type-icon__glyph { + transform: translateY(-2px) scale(0.88); + transform-box: fill-box; + transform-origin: center; +} + +.file-type-icon__format-label { + fill: currentColor; + color: var(--file-icon-glyph); + font-family: var(--font-sans); + font-size: 5.2px; + font-weight: 700; + letter-spacing: 0.35px; +} + +.file-type-icon--pdf { --file-icon-glyph: var(--file-icon-glyph-pdf); } +.file-type-icon--word { --file-icon-glyph: var(--file-icon-glyph-word); } +.file-type-icon--spreadsheet { --file-icon-glyph: var(--file-icon-glyph-spreadsheet); } +.file-type-icon--presentation { --file-icon-glyph: var(--file-icon-glyph-presentation); } +.file-type-icon--markdown { --file-icon-glyph: var(--file-icon-glyph-markdown); } +.file-type-icon--text { --file-icon-glyph: var(--file-icon-glyph-text); } +.file-type-icon--code { --file-icon-glyph: var(--file-icon-glyph-code); } +.file-type-icon--image { --file-icon-glyph: var(--file-icon-glyph-image); } +.file-type-icon--video { --file-icon-glyph: var(--file-icon-glyph-video); } +.file-type-icon--audio { --file-icon-glyph: var(--file-icon-glyph-audio); } +.file-type-icon--archive { --file-icon-glyph: var(--file-icon-glyph-archive); } + +.file-type-icon--folder { + background: transparent; +} + +.file-type-icon--folder.file-type-icon--inline { + background: transparent; +} + +.file-type-icon--folder > svg { + width: 100%; + height: auto; +} + +.file-type-icon__folder-shadow { + fill: var(--file-icon-shadow); + transform: translate(0.6px, 0.6px); +} + +.file-type-icon__folder-back { + fill: var(--file-icon-folder-back); + stroke: var(--file-icon-folder-edge); + stroke-linejoin: round; + stroke-width: 0.7; +} + +.file-type-icon__folder-paper { + fill: var(--file-icon-folder-paper); + stroke: var(--file-icon-paper-edge); + stroke-width: 0.8; +} + +.file-type-icon__folder-front { + fill: var(--file-icon-folder-front); + stroke: var(--file-icon-folder-edge); + stroke-linejoin: round; + stroke-width: 0.7; +} + +.file-type-icon__folder-highlight { + fill: none; + stroke: var(--file-icon-folder-highlight); + stroke-linecap: round; + stroke-width: 1.1; +} + .agent-attachment-card { display: inline-flex; width: 220px; @@ -2363,8 +2757,7 @@ button { cursor: wait; } -.agent-attachment-card__preview, -.agent-attachment-card__file-tile { +.agent-attachment-card__preview { display: inline-flex; width: 40px; height: 40px; @@ -2397,89 +2790,35 @@ button { color: white; } -.agent-attachment-card__file-tile { - flex-direction: column; - gap: 2px; +.agent-attachment-card__body { + min-width: 0; + flex: 1 1 auto; } -.agent-attachment-card__file-tile--sm { - width: 32px; - height: 32px; - flex-basis: 32px; - border-radius: 10px; +.agent-attachment-card__name { + display: block; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; } -.agent-attachment-card__file-tile--md { - width: 40px; - height: 40px; - flex-basis: 40px; +.agent-attachment-card__meta { + display: block; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 10px; + font-weight: 600; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; } -.agent-attachment-card__file-tile--pdf { - background: #fff1f3; - color: #e54861; -} - -.agent-attachment-card__file-tile--docx { - background: #edf6ff; - color: #2179c7; -} - -.agent-attachment-card__file-tile--xlsx { - background: #eefaf2; - color: #238b58; -} - -.agent-attachment-card__file-tile--pptx { - background: #fff6e8; - color: #c96a16; -} - -.agent-attachment-card__file-tile--file { - background: #f4f8f7; - color: #5a6865; -} - -.agent-attachment-card__file-label { - display: block; - max-width: 100%; - overflow: hidden; - font-size: 7px; - font-weight: 800; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; -} - -.agent-attachment-card__body { - min-width: 0; - flex: 1 1 auto; -} - -.agent-attachment-card__name { - display: block; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); - font-size: 12px; - font-weight: 700; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.agent-attachment-card__meta { - display: block; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); - font-size: 10px; - font-weight: 600; - line-height: 14px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.agent-attachment-card__meta--error { - color: var(--color-status-error); +.agent-attachment-card__meta--error { + color: var(--color-status-error); } .agent-attachment-card__remove { @@ -5537,15 +5876,29 @@ input[type="checkbox"].check-sky:checked::after { } .button-md { + gap: 8px; padding: 9px 14px; } .button-sm { + gap: 6px; min-height: 30px; padding: 7px 10px; font-size: 12px; } +.button-md > svg { + width: 16px; + height: 16px; + flex: none; +} + +.button-sm > svg { + width: 14px; + height: 14px; + flex: none; +} + .button-primary { background: var(--color-action-sky); color: #fff; @@ -9335,3 +9688,3307 @@ input[type="checkbox"].check-sky:checked::after { font-size: 28px; } } + +/* ============================================================ + Home composer quick actions (attach / reference / capability) + ============================================================ */ + +.home-reference-panel { + display: flex; + flex-direction: column; + width: 100%; + max-height: min(22rem, 48vh); + margin: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 48%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + box-shadow: 0 10px 28px rgba(17, 29, 28, 0.08); + text-align: left; +} + +.home-reference-panel__head { + display: flex; + flex-direction: column; + gap: 8px; + flex: none; + padding: 12px 12px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 36%, transparent); +} + +.home-reference-panel__head > strong { + font-size: 13px; + font-weight: 650; + color: var(--color-text-ink); +} + +.home-reference-panel__search input { + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + border-radius: var(--radius-input, 8px); + background: var(--color-background-paper); + font-size: 13px; + color: var(--color-text-ink); + outline: none; +} + +.home-reference-panel__search input:focus { + border-color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); +} + +.home-reference-panel__list { + min-height: 0; + padding: 6px; + overflow-y: auto; +} + +.home-reference-panel__heading { + padding: 8px 8px 4px; + font-size: 11px; + font-weight: 600; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.home-reference-panel__item { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px; + border: 0; + border-radius: var(--radius-btn); + background: transparent; + text-align: left; + cursor: pointer; +} + +.home-reference-panel__item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); +} + +.home-reference-panel__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-btn); + background: color-mix(in srgb, var(--color-canvas-oat) 85%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} + +.home-reference-panel__text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.home-reference-panel__text strong { + overflow: hidden; + font-size: 13px; + font-weight: 500; + color: var(--color-text-ink); + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-reference-panel__text small { + overflow: hidden; + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-reference-panel__item code { + padding: 2px 6px; + border-radius: var(--radius-tag, 999px); + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + font-size: 10px; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); +} + +.home-reference-panel__empty { + padding: 18px 10px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + text-align: center; +} + +/* References and uploads share the same attachment-card visual language. */ +.home-context-chips { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + min-width: 0; + padding: 12px 16px 4px; +} + +.home-context-card__icon { + display: inline-grid; + place-items: center; + flex: 0 0 38px; + width: 38px; + height: 38px; + border-radius: 9px; +} + +.home-context-card__icon--kb { + background: color-mix(in srgb, var(--color-accent-neon-mint) 22%, var(--color-background-paper)); + color: var(--color-action-sky); +} + +.home-context-chips .agent-attachment-card { + display: inline-flex; + align-items: center; +} + +.home-capability-strip { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 0; +} + +.home-capability-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-width: 104px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.home-capability-card:hover { + border-color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); + box-shadow: 0 6px 16px rgba(17, 29, 28, 0.06); +} + +.home-capability-card--active { + border-color: var(--color-action-sky); + background: color-mix(in srgb, var(--color-accent-neon-mint) 10%, var(--color-background-paper)); +} + +.home-capability-card strong { + font-size: 12px; + font-weight: 600; + color: var(--color-text-ink); +} + +.home-capability-card small { + font-size: 10px; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + font-family: var(--font-mono, ui-monospace, monospace); +} + +.home-capability-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-bottom: 4px; + border-radius: var(--radius-btn); +} + +.home-capability-card__icon--literature { + background: color-mix(in srgb, var(--color-accent-neon-mint) 25%, transparent); + color: var(--color-action-sky-hover); +} + +.home-capability-card__icon--slides { + background: rgba(245, 158, 11, 0.14); + color: #b45309; +} + +.home-capability-card__icon--html { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; +} + +.home-capability-card__icon--sheet { + background: rgba(16, 185, 129, 0.12); + color: #047857; +} + +.home-capability-card__icon--doc { + background: rgba(139, 92, 246, 0.12); + color: #6d28d9; +} + +.home-capability-card__icon--more { + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); +} + +.home-capability-card__code { + font-size: 11px; + font-weight: 700; + font-family: var(--font-mono, ui-monospace, monospace); +} + +/* ============================================================ + Knowledge base workspace (/knowledge) + ============================================================ */ + +.kb-page { + height: 100%; + overflow-y: auto; + padding-bottom: 40px; +} + +.kb-page--onboarding { + display: flex; + flex-direction: column; +} + +.kb-onboarding-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + padding: 32px 0; +} + +.kb-page--onboarding .kb-onboarding-wrap { + flex: 1; + min-height: 0; +} + +.kb-onboarding { + display: flex; + flex-direction: column; + width: min(560px, 100%); + padding: 28px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + border-radius: var(--radius-card-lg, 16px); + background: var(--color-background-paper); + box-shadow: 0 16px 40px rgba(17, 29, 28, 0.06); +} + +.kb-onboarding__steps { + display: grid; + grid-template-columns: max-content minmax(28px, 1fr) max-content; + align-items: center; + gap: 10px; + margin: 0 0 22px; + padding: 0; + list-style: none; +} + +.kb-onboarding__step { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.kb-onboarding__step span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: 50%; + background: var(--color-background-paper); + font-size: 11px; + font-weight: 700; +} + +.kb-onboarding__step--active { + color: var(--color-text-ink); +} + +.kb-onboarding__step--active span { + border-color: var(--color-action-sky); + background: var(--color-action-sky); + color: #fff; +} + +.kb-onboarding__step--completed { + color: var(--color-action-sky-hover); +} + +.kb-onboarding__step--completed span { + border-color: color-mix(in srgb, var(--color-action-sky) 45%, transparent); + background: color-mix(in srgb, var(--color-accent-neon-mint) 16%, var(--color-background-paper)); + color: var(--color-action-sky-hover); +} + +.kb-onboarding__step-line { + height: 1px; + background: color-mix(in srgb, var(--color-border-stone) 55%, transparent); +} + +.kb-onboarding__header h2 { + margin: 0 0 6px; + font-size: 20px; + font-weight: 700; + color: var(--color-text-ink); +} + +.kb-onboarding__header p { + margin: 0; + font-size: 13px; + line-height: 1.7; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} + +.kb-onboarding__section-label { + margin-top: 20px; + font-size: 12px; + font-weight: 650; + color: var(--color-text-ink); +} + +.kb-onboarding__name-field { + display: grid; + gap: 7px; + margin-top: 20px; + font-size: 12px; + font-weight: 650; + color: var(--color-text-ink); +} + +.kb-onboarding__name-field input { + width: 100%; + height: 38px; + padding: 0 11px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: var(--radius-input, 8px); + background: var(--color-background-paper); + font-size: 13px; + color: var(--color-text-ink); + outline: none; +} + +.kb-onboarding__name-field input:focus { + border-color: var(--color-action-sky); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 12%, transparent); +} + +.kb-onboarding-file-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 178px; + margin-top: 9px; + overflow-y: auto; +} + +.kb-onboarding-file-row { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + border-radius: 8px; + background: var(--color-background-paper); + font-size: 12px; + text-align: left; + color: var(--color-text-ink); +} + +.kb-onboarding-file-row > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kb-onboarding-file-row--selected { + border-color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); + background: color-mix(in srgb, var(--color-accent-neon-mint) 8%, var(--color-background-paper)); +} + +.kb-onboarding__empty-files { + margin-top: 9px; + padding: 18px 12px; + border: 1px dashed color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: 8px; + font-size: 12px; + text-align: center; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.kb-onboarding__files-hint { + margin: 8px 0 0; + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); +} + +.kb-source-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 18px 0 10px; +} + +.kb-source-row { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 40px; + padding: 0 11px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.kb-source-row:hover { + border-color: color-mix(in srgb, var(--color-action-sky) 45%, transparent); +} + +.kb-source-row--selected { + border-color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); + background: color-mix(in srgb, var(--color-accent-neon-mint) 8%, var(--color-background-paper)); +} + +.kb-source-row__name { + overflow: hidden; + font-size: 13px; + font-weight: 600; + color: var(--color-text-ink); + text-overflow: ellipsis; + white-space: nowrap; +} + +.kb-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 16px; + height: 16px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 90%, transparent); + border-radius: 4px; + background: var(--color-background-paper); + color: #fff; + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease; +} + +.kb-checkbox--checked { + border-color: var(--color-action-sky); + background: var(--color-action-sky); +} + +.kb-add-source { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 9px 12px; + border: 1px dashed color-mix(in srgb, var(--color-border-stone) 90%, transparent); + border-radius: var(--radius-card); + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + cursor: pointer; +} + +.kb-add-source:hover { + border-color: var(--color-action-sky); + color: var(--color-action-sky-hover); +} + +.kb-add-source--compact { + padding: 7px 10px; + font-size: 11px; +} + +.kb-add-source-form { + display: flex; + gap: 8px; +} + +.kb-add-source-form input { + flex: 1; + min-width: 0; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: var(--radius-input, 8px); + background: var(--color-background-paper); + font-size: 13px; + color: var(--color-text-ink); +} + +.kb-onboarding__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 36%, transparent); +} + +.kb-onboarding__actions { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +.kb-onboarding__footer > .button:first-child { + margin-right: auto; +} + +.kb-onboarding__footer .button { + min-height: 31px; + padding: 0 12px; + font-size: 12px; + font-weight: 600; + box-shadow: none; +} + +.kb-onboarding__footer .button:hover:not(:disabled) { + transform: none; +} + +.kb-dashboard { + display: flex; + flex-direction: column; + gap: 28px; + width: 100%; +} + +.kb-section { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.kb-section + .kb-section { + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 28%, transparent); +} + +.kb-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding-top: 18px; +} + +.kb-dashboard > .kb-section:first-child .kb-section-head { + padding-top: 4px; +} + +.kb-section-head h3 { + font-size: 16px; + font-weight: 600; + color: var(--color-text-ink); +} + +.kb-section-head p { + margin-top: 2px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.kb-section-head__actions { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} + +.kb-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.kb-base-card { + position: relative; + min-width: 0; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.kb-base-card__main { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 100%; + padding: 11px 42px 11px 12px; + border: 0; + border-radius: inherit; + background: transparent; + text-align: left; + cursor: pointer; +} + +.kb-base-card:hover { + border-color: color-mix(in srgb, var(--color-action-sky) 50%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 40%, var(--color-background-paper)); + box-shadow: none; +} + +.kb-base-card__main strong { + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.kb-base-card__icon { + display: inline-flex; + width: 32px; + height: 32px; + flex: 0 0 32px; + align-items: center; + justify-content: center; + border-radius: 8px; + background: color-mix(in srgb, var(--color-accent-neon-mint) 18%, var(--color-background-paper)); + color: var(--color-action-sky); +} + +.kb-base-card__copy { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; +} + +.kb-base-card__copy strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kb-base-card__meta { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); +} + +.kb-base-card__menu-anchor { + position: absolute; + top: 10px; + right: 10px; +} + +.kb-base-card__menu-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + cursor: pointer; +} + +.kb-base-card__menu-button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 75%, transparent); + color: var(--color-text-ink); +} + +.kb-base-card__menu { + min-width: 150px; +} + +.kb-base-card__menu-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: var(--color-text-ink); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.kb-base-card__menu-item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); +} + +.kb-base-card__menu-item--danger { + color: var(--color-danger, #dc2626); +} + +@media (max-width: 900px) { + .kb-card-grid { + grid-template-columns: 1fr; + } +} + +.kb-empty-hint { + padding: 26px 12px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + text-align: center; +} + +.kb-empty-hint--boxed { + border: 1px dashed color-mix(in srgb, var(--color-border-stone) 60%, transparent); + border-radius: var(--radius-card); +} + +.kb-popover-anchor { + position: relative; +} + +.kb-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 60; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 240px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 55%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + box-shadow: 0 12px 32px rgba(17, 29, 28, 0.12); +} + +.modal.kb-sync-modal-dialog { + width: min(560px, calc(100vw - 64px)); +} + +.kb-sync-modal { + display: flex; + flex-direction: column; + gap: 14px; +} + +.kb-sync-modal__section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.kb-sync-modal__section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.kb-sync-modal__section-head strong { + font-size: 13px; + font-weight: 650; + color: var(--color-text-ink); +} + +.kb-sync-modal__section-head span { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.kb-sync-modal__list { + display: flex; + flex-direction: column; + max-height: 280px; + overflow: auto; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 48%, transparent); + border-radius: var(--radius-card); +} + +.kb-sync-modal__row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 32%, transparent); + background: var(--color-background-paper); +} + +.kb-sync-modal__row:last-child { + border-bottom: 0; +} + +.kb-sync-modal__icon { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} + +.kb-sync-modal__icon--active { + background: color-mix(in srgb, var(--color-accent-neon-mint) 22%, transparent); + color: var(--color-action-sky); +} + +.kb-sync-modal__text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.kb-sync-modal__text strong { + font-size: 13px; + font-weight: 600; + color: var(--color-text-ink); +} + +.kb-sync-modal__text small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kb-sync-modal__empty { + padding: 10px 12px; + border: 1px dashed color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: var(--radius-card); + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 28%, transparent); +} + +.kb-sync-modal__add-btn { + width: max-content; + align-self: flex-start; +} + +.kb-files-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.kb-files-toolbar__actions, +.kb-files-toolbar__filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.kb-files-toolbar__icon-btn { + position: relative; + min-width: 32px; + padding-inline: 8px; +} + +.kb-files-toolbar__filter-dot { + position: absolute; + top: 6px; + right: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-action-sky); +} + +.kb-search-box--toolbar { + min-width: 180px; +} + +.kb-filter-menu { + min-width: 140px; + padding: 4px; +} + +.kb-filter-menu__item { + display: block; + width: 100%; + padding: 7px 10px; + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: var(--color-text-ink); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.kb-filter-menu__item:hover, +.kb-filter-menu__item--active { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); +} + +.kb-files-meta { + margin-bottom: 8px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.kb-join-menu { + min-width: 220px; +} + +.kb-join-menu__item { + display: flex; + flex-direction: column; + gap: 1px; + padding: 8px 10px; + border-radius: var(--radius-btn); + text-align: left; + cursor: pointer; +} + +.kb-join-menu__item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); +} + +.kb-join-menu__item span { + font-size: 13px; + font-weight: 500; + color: var(--color-text-ink); +} + +.kb-join-menu__item small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.kb-join-menu__divider { + height: 1px; + margin: 4px 6px; + background: color-mix(in srgb, var(--color-border-stone) 50%, transparent); +} + +.kb-file-browser { + display: flex; + flex-direction: column; + gap: 8px; +} + +.kb-file-pagination { + display: flex; + min-height: 30px; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.kb-file-pagination button { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 55%, transparent); + border-radius: var(--radius-btn); + background: var(--color-background-paper); + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + cursor: pointer; +} + +.kb-file-pagination button:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-action-sky) 45%, transparent); + color: var(--color-action-sky-hover); +} + +.kb-file-pagination button:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.kb-file-pagination span { + min-width: 40px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 11px; + text-align: center; +} + +.kb-file-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.kb-file-breadcrumb__segment { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.kb-file-breadcrumb__item { + border: 0; + padding: 2px 4px; + border-radius: 4px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + cursor: pointer; +} + +.kb-file-breadcrumb__item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: var(--color-text-ink); +} + +.kb-file-breadcrumb__item--drop-target { + background: color-mix(in srgb, var(--color-accent-neon-mint) 16%, var(--color-background-paper)); + color: var(--color-action-sky-hover); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-action-sky) 52%, transparent); +} + +.kb-file-breadcrumb__current { + padding: 2px 4px; + color: var(--color-text-ink); + font-weight: 600; +} + +.kb-file-table { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + overflow: hidden; +} + +.kb-file-table__head, +.kb-file-table__row { + display: grid; + grid-template-columns: 28px minmax(140px, 1.4fr) minmax(120px, 1.2fr) 64px 72px; + align-items: center; + gap: 10px; + width: 100%; + padding: 0 12px; + border: 0; + background: transparent; + text-align: left; +} + +.kb-file-table__row .kb-status-badge, +.kb-file-table__dash { + justify-self: start; +} + +.kb-file-table--browse .kb-file-table__head, +.kb-file-table--browse .kb-file-table__row { + grid-template-columns: 28px minmax(160px, 1fr) 72px 128px 56px; +} + +.kb-file-table--detail .kb-file-table__head, +.kb-file-table--detail .kb-file-table__row { + grid-template-columns: 28px minmax(140px, 1.4fr) minmax(160px, 1.6fr) 72px 28px; +} + +.kb-file-table__head { + min-height: 34px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + font-size: 11px; + font-weight: 500; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 40%, var(--color-background-paper)); +} + +.kb-file-table__row { + min-height: 36px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 22%, transparent); + color: var(--color-text-ink); + cursor: pointer; +} + +.kb-file-table__row:last-child { + border-bottom: 0; +} + +.kb-file-table__row:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 50%, transparent); +} + +.kb-file-table__row--selected { + background: color-mix(in srgb, var(--color-accent-neon-mint) 10%, transparent); +} + +.kb-file-table__row--drop-target { + position: relative; + background: color-mix(in srgb, var(--color-accent-neon-mint) 16%, var(--color-background-paper)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-action-sky) 52%, transparent); +} + +.kb-file-table__row--static { + cursor: default; +} + +.kb-file-table__row--inline-folder { + background: color-mix(in srgb, var(--color-canvas-oat) 30%, var(--color-background-paper)); +} + +.kb-file-table__row--unsupported { + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.kb-file-table__row--unsupported .kb-file-table__name, +.kb-file-table__row--unsupported .kb-file-table__size, +.kb-file-table__row--unsupported .kb-file-table__updated { + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +} + +.kb-file-table__row--unsupported .kb-checkbox { + opacity: 0.4; + cursor: not-allowed; +} + +.kb-file-table__name { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 13px; + color: var(--color-text-ink); +} + +.kb-file-table__name > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kb-file-table__name[draggable="true"] { + cursor: grab; +} + +.kb-file-table__name[draggable="true"]:active { + cursor: grabbing; +} + +.kb-file-table__name > .kb-file-table__unsupported { + display: inline-flex; + align-items: center; + gap: 3px; + flex: none; + overflow: visible; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + font-size: 11px; +} + +.kb-file-table__name--folder { + justify-self: start; + max-width: 100%; + border: 0; + padding: 0; + background: transparent; + cursor: pointer; + text-align: left; +} + +.kb-file-table__name--folder:hover > span:last-child { + text-decoration: underline; + text-underline-offset: 2px; +} + +.kb-inline-folder-name { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.kb-inline-folder-name input { + min-width: 120px; + width: min(260px, 100%); + height: 26px; + border: 1px solid var(--color-action-sky); + border-radius: 5px; + padding: 0 7px; + background: var(--color-background-paper); + color: var(--color-text-ink); + font-family: inherit; + font-size: 12px; + font-weight: 400; + line-height: 1; + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 14%, transparent); +} + +.kb-file-table__path, +.kb-file-table__size, +.kb-file-table__updated, +.kb-file-table__dash { + overflow: hidden; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.kb-file-table__size, +.kb-file-table__updated { + font-variant-numeric: tabular-nums; +} + +.kb-file-table__operate { + justify-self: start; + border: 0; + padding: 0; + background: transparent; + color: var(--color-action-sky); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.kb-file-table__operate:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +.kb-file-table__operate--icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); +} + +.kb-file-table__operate--icon:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: var(--color-text-ink); + text-decoration: none; +} + +.kb-status-badge { + flex: none; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; +} + +.kb-status-badge--processed { + background: color-mix(in srgb, var(--color-accent-neon-mint) 22%, transparent); + color: var(--color-action-sky-hover); +} + +.kb-status-badge--processing { + background: rgba(245, 158, 11, 0.14); + color: #b45309; +} + +.kb-status-badge--uploading { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; +} + +.kb-detail { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; +} + +.kb-detail-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); +} + +.kb-detail-header__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 28px; + width: 28px; + height: 28px; + border-radius: 7px; + background: color-mix(in srgb, var(--color-accent-neon-mint) 18%, var(--color-background-paper)); + color: var(--color-action-sky); +} + +.kb-detail-header h1 { + margin: 0; + color: var(--color-text-ink); + font-size: 18px; + font-weight: 700; + line-height: 24px; +} + +.kb-back-link { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 28px; + width: 28px; + height: 28px; + margin: 0; + border: 0; + border-radius: var(--radius-btn); + padding: 0; + background: transparent; + font-family: var(--font-sans); + font-size: 13px; + font-weight: 500; + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); + cursor: pointer; + transition: + color var(--duration-fast), + background var(--duration-fast); +} + +.kb-back-link:hover { + background: color-mix(in srgb, var(--color-text-ink) 6%, transparent); + color: var(--color-text-ink); +} + +.kb-back-link:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action-sky) 35%, transparent); + outline-offset: 2px; +} + +.kb-search-box { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + max-width: 320px; + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 60%, transparent); + border-radius: var(--radius-input, 8px); + background: var(--color-background-paper); + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.kb-search-box input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + font-size: 13px; + color: var(--color-text-ink); + outline: none; +} + +.kb-detail-stats { + display: flex; + gap: 14px; +} + +.kb-stat { + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.kb-stat b { + margin-right: 3px; + font-size: 16px; + color: var(--color-text-ink); +} + +.kb-doc-row__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-btn); + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + cursor: pointer; +} + +.kb-doc-row__remove:hover { + background: rgba(220, 38, 38, 0.08); + color: #b91c1c; +} + +.kb-modal-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.modal.kb-name-modal-dialog { + width: min(360px, calc(100vw - 48px)); +} + +.kb-name-modal-dialog .modal-header { + padding: 14px 14px 10px; +} + +.kb-name-modal-dialog .modal-header h2 { + font-size: 16px; +} + +.kb-name-modal-dialog .modal-body { + padding: 0 14px 10px; +} + +.kb-name-modal-dialog .modal-footer { + padding: 8px 14px 14px; +} + +.modal.kb-folder-delete-dialog { + width: min(440px, calc(100vw - 48px)); + border-radius: 14px; +} + +.kb-folder-delete-dialog .modal-header { + padding: 20px 20px 10px; +} + +.kb-folder-delete-dialog .modal-header h2 { + font-size: 16px; + font-weight: 650; + line-height: 1.4; +} + +.kb-folder-delete-dialog .modal-header .button-ghost { + width: 28px; + min-height: 28px; + padding: 0; + border-radius: 8px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.kb-folder-delete-dialog .modal-body { + padding: 0 20px 4px; +} + +.kb-folder-delete-dialog__message { + margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); + font-size: 13px; + line-height: 1.7; +} + +.kb-folder-delete-dialog .kb-folder-delete-dialog__footer { + justify-content: flex-end; + gap: 8px; + padding: 20px; +} + +.kb-folder-delete-dialog__action.button { + min-height: 36px; + padding: 0 14px; + border-radius: 9px; + font-size: 12px; + font-weight: 600; + box-shadow: none; +} + +.kb-folder-delete-dialog__action.button:hover:not(:disabled) { + transform: none; +} + +.kb-folder-delete-dialog__action--danger.button-danger { + border: 1px solid color-mix(in srgb, var(--color-status-error) 18%, transparent); + background: var(--color-status-error-soft); + color: var(--color-status-error); +} + +.kb-folder-delete-dialog__action--danger.button-danger:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-status-error) 28%, transparent); + background: color-mix(in srgb, var(--color-status-error-soft) 78%, var(--color-status-error) 22%); +} + +.kb-modal-field span { + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} + +.kb-modal-field input { + padding: 9px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: var(--radius-input, 8px); + background: var(--color-background-paper); + font-size: 13px; + color: var(--color-text-ink); +} + +.kb-modal-field input:focus { + border-color: var(--color-action-sky); + outline: none; +} + +/* ============================================================ + Literature review workflow (/literature-review) + ============================================================ */ + +@keyframes litrev-spin { + to { + transform: rotate(360deg); + } +} + +.litrev-spin { + animation: litrev-spin 1.1s linear infinite; +} + +.litrev-split { + position: relative; + display: flex; + height: 100%; + overflow: hidden; +} + +.litrev-workspace-toggle { + position: absolute; + top: calc((var(--codex-toolbar-height) - var(--codex-toolbar-button-size)) / 2); + right: 8px; + z-index: 60; + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: var(--codex-toolbar-button-size); + height: var(--codex-toolbar-button-size); + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + cursor: pointer; + -webkit-app-region: no-drag; +} + +.litrev-workspace-toggle:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); + color: var(--color-text-ink); +} + +.litrev-workspace-toggle--active { + background: transparent; + color: var(--color-action-sky-hover); +} + +.litrev-workspace-toggle--active:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 20px 4px 12px; +} + +.litrev-conversation { + display: flex; + flex-direction: column; + gap: 14px; +} + +.litrev-user-message { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + width: min(75%, 520px); + margin-left: auto; + align-self: flex-end; +} + +.litrev-user-bubble { + display: inline-block; + width: fit-content; + max-width: 100%; + padding: 10px 13px; + border-radius: 12px 12px 3px 12px; + line-height: 1.6; + white-space: pre-wrap; +} + +.litrev-user-command { + display: inline-flex; + margin: 0 3px; + padding: 1px 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--color-action-sky) 14%, transparent); + color: var(--color-action-sky-hover); + font-size: 12px; + font-weight: 600; + line-height: 1.5; + vertical-align: baseline; +} + +.litrev-user-message__contexts { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.litrev-user-message__contexts .agent-attachment-card { + width: 210px; + height: 50px; +} + +.litrev-user-message__kb-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 34px; + height: 34px; + border-radius: 9px; + background: color-mix(in srgb, var(--color-action-sky) 12%, var(--color-background-paper)); + color: var(--color-action-sky-hover); +} + +.litrev-assistant-copy { + font-size: 14px; + line-height: 1.7; + color: var(--color-text-ink); +} + +.litrev-assistant-copy--muted { + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); +} + +.litrev-qa-summary { + display: grid; + width: min(560px, 92%); + gap: 10px; + margin-top: 4px; + padding: 12px 14px; + border: 0; + border-radius: 14px; + background: color-mix(in srgb, var(--color-canvas-oat) 55%, var(--color-background-paper)); +} + +.litrev-qa-summary > div { + display: block; +} + +.litrev-qa-summary small { + display: block; + font-size: 12px; + line-height: 1.4; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.litrev-qa-summary strong { + display: block; + margin-top: 2px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + color: var(--color-text-ink); +} + +.litrev-supplement { + display: flex; + flex-direction: column; + gap: 10px; +} + +.litrev-supplement > .litrev-user-bubble { + margin-left: auto; + align-self: flex-end; +} + +.litrev-supplement > .litrev-assistant-copy { + align-self: flex-start; +} + +.composer-file-context-menu { + position: fixed; + z-index: 120; + min-width: 170px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + box-shadow: 0 8px 24px rgba(17, 29, 28, 0.12); +} + +.composer-file-context-menu button { + display: flex; + align-items: center; + width: 100%; + min-height: 32px; + border: 0; + border-radius: 6px; + padding: 0 9px; + background: transparent; + color: var(--color-text-ink); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.composer-file-context-menu button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); +} + +.composer-file-context-menu__submenu-trigger svg { + margin-left: auto; +} + +.composer-file-context-menu__submenu-anchor { + position: relative; +} + +.composer-file-context-menu__submenu { + position: absolute; + z-index: 1; + top: -5px; + left: calc(100% + 6px); + display: grid; + gap: 1px; + width: 180px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 9px; + background: var(--color-background-paper); + box-shadow: 0 8px 24px rgba(17, 29, 28, 0.12); +} + +.composer-file-context-menu--submenu-left .composer-file-context-menu__submenu { + right: calc(100% + 6px); + left: auto; +} + +.composer-file-context-menu__submenu button { + min-height: 30px; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); +} + +.composer-file-context-menu .composer-file-context-menu__danger { + color: var(--color-danger); +} + +/* Prototype `.tool-history` — keep this chrome fixed; do not card-ify. */ +/* Literature-review mock reuses session agent-activity chrome; keep spacing only. */ +.litrev-agent-activity { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; + width: min(720px, 100%); +} + +.litrev-conversation .agent-activity-timeline-item, +.litrev-conversation .agent-activity-cluster { + margin-top: 8px; +} + +.litrev-activity-history-item { + margin-top: 6px; + padding: 2px 0; + color: color-mix(in srgb, var(--color-text-ink) 56%, transparent); + font-size: 13px; + font-weight: 400; + line-height: 1.55; +} + +.litrev-status-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + max-width: 100%; + min-height: 30px; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.litrev-status-toggle > svg:last-child { + flex: none; + opacity: 0.55; + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + transition: transform 150ms ease; +} + +.litrev-status-toggle--static { + cursor: default; +} + +.agent-activity-cluster--open > * > .litrev-status-toggle > svg:last-child, +.agent-activity-cluster--open > .litrev-status-toggle > svg:last-child { + transform: rotate(180deg); +} + +.litrev-completed-activity, +.litrev-cancelled-activity { + width: fit-content; + max-width: 100%; +} + +.litrev-completed-activity .agent-activity-cluster__body, +.litrev-cancelled-activity .agent-activity-cluster__body { + width: min(560px, 100%); +} + +.litrev-stage-activity { + width: min(620px, 100%); +} + +.litrev-preparation-summary { + width: min(620px, 100%); +} + +.litrev-task-process { + width: min(620px, 100%); +} + +.litrev-task-process__body { + display: flex; + flex-direction: column; + width: 100%; + margin-top: 6px; +} + +.litrev-task-process__body > .litrev-assistant-copy:first-child { + margin-top: 0; +} + +.litrev-preparation-summary__body { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + margin-top: 6px; +} + +.litrev-preparation-summary__body > .litrev-stage-output-message { + margin: 0 0 8px; + padding-left: 2px; +} + +.litrev-preparation-stage > button { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + max-width: 100%; + min-height: 30px; + padding: 0 2px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.litrev-preparation-stage > button span { + min-width: 0; +} + +.litrev-preparation-stage > button svg { + flex: none; + opacity: 0.45; + transition: transform 150ms ease; +} + +.litrev-preparation-stage--open > button svg { + transform: rotate(180deg); +} + +.litrev-preparation-stage > .litrev-stage-text-card, +.litrev-preparation-stage > .litrev-qa-summary { + margin: 2px 0 8px; +} + +.litrev-preparation-stage > .litrev-stage-text-card { + width: min(520px, 100%); +} + +.litrev-stage-activity__body { + width: 100%; + margin-top: 8px; +} + +.litrev-stage-activity__body > .litrev-question-card, +.litrev-stage-activity__body > .litrev-wizard-card { + width: 100%; +} + +.litrev-stage-thinking-copy { + margin: 0; + padding: 2px 0 4px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 12px; + line-height: 1.5; +} + +.litrev-stage-text-card { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + padding: 9px 14px; + border-radius: 14px; + background: color-mix(in srgb, var(--color-canvas-oat) 72%, var(--color-background-paper)); +} + +.litrev-stage-text-card > div { + display: flex; + align-items: baseline; + gap: 10px; + min-height: 32px; + padding: 7px 2px; +} + +.litrev-stage-text-card > div + div { + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 32%, transparent); +} + +.litrev-stage-text-card strong { + min-width: 0; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); + font-size: 12px; + font-weight: 500; + line-height: 1.4; +} + +.litrev-stage-text-card small { + margin-left: auto; + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); + font-size: 11px; + white-space: nowrap; +} + +.litrev-stage-text-card--outline > div[data-level="1"] { + padding-left: 22px; +} + +.litrev-cancelled-activity__body { + margin: 4px 0 0; + padding: 4px 0 2px; + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); + font-size: 12px; +} + +.litrev-dock { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 4px 32px; +} + +.litrev-dock:empty { + display: none; +} + +.litrev-question-status { + display: inline-flex; + align-items: center; + gap: 7px; + width: fit-content; + min-height: 30px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 13px; +} + +.litrev-question-status--cancelled { + opacity: 0.72; +} + +.litrev-question-card { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + border-radius: var(--radius-card-lg, 16px); + background: var(--color-background-paper); + box-shadow: 0 12px 32px rgba(17, 29, 28, 0.08); + overflow: hidden; +} + +.litrev-question-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 48px; + padding: 10px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-question-card__meta { + display: inline-flex; + align-items: center; + gap: 8px; + flex: none; + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-question-card__meta button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 7px; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + cursor: pointer; +} + +.litrev-question-card__meta button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: var(--color-text-ink); +} + +.litrev-question-card__head h2 { + min-width: 0; + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-question-list { + max-height: min(54vh, 440px); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.litrev-question-item { + padding: 12px 0 4px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); +} + +.litrev-question-item:last-child { + border-bottom: 0; +} + +.litrev-question-item__title { + display: block; + padding: 0 14px 8px; +} + +.litrev-question-item__title h3 { + margin: 0; + color: var(--color-text-ink); + font-size: 13px; + font-weight: 600; + line-height: 1.45; +} + +.litrev-question-options { + display: flex; + flex-direction: column; + gap: 0; + padding: 0 12px; +} + +.litrev-question-option { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + min-height: 42px; + padding: 8px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 35%, transparent); + border-radius: 0; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.litrev-question-option:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 56%, transparent); +} + +.litrev-question-option--selected { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, var(--color-background-paper)); + box-shadow: inset 2px 0 0 var(--color-action-sky); +} + +.litrev-question-option__number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} + +.litrev-question-option__label { + min-width: 0; + font-size: 13px; + color: var(--color-text-ink); +} + +.litrev-question-option__state { + display: inline-flex; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +} + +.litrev-question-option--selected .litrev-question-option__state { + color: var(--color-action-sky-hover); +} + +.litrev-question-supplement { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 9px; + min-height: 42px; + margin: 0 12px 10px; + padding: 7px 8px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.litrev-question-supplement input { + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--color-text-ink); + font-size: 13px; +} + +.litrev-question-supplement input::placeholder { + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +} + +.litrev-question-supplement button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 7px; + color: var(--color-action-sky-hover); + cursor: pointer; +} + +.litrev-question-supplement button:disabled { + opacity: 0; + pointer-events: none; +} + +.litrev-question-card__foot { + display: flex; + justify-content: flex-end; + padding: 10px 14px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: var(--color-background-paper); +} + +.litrev-wizard-card { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + border-radius: var(--radius-card-lg, 16px); + background: var(--color-background-paper); + box-shadow: 0 12px 32px rgba(17, 29, 28, 0.08); + overflow: hidden; +} + +.litrev-wizard-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 13px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-wizard-card__head strong { + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-wizard-card__count { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-wizard-card__body { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 300px; + padding: 14px 16px; + overflow-y: auto; +} + +.litrev-wizard-card__foot { + display: flex; + min-height: 58px; + align-items: center; + gap: 7px; + padding: 8px 14px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-wizard-card__foot i { + flex: 1; +} + +/* Prototype `.btn`: ~31px tall, light weight — not the global md primary. */ +.litrev-wizard-card__foot .button, +.litrev-question-card__foot .button, +.litrev-add-row .button { + min-height: 31px; + padding: 0 11px; + font-size: 12px; + font-weight: 600; + box-shadow: none; +} + +.litrev-wizard-card__foot .button:hover:not(:disabled), +.litrev-question-card__foot .button:hover:not(:disabled), +.litrev-add-row .button:hover:not(:disabled) { + transform: none; +} + +.litrev-section-hint { + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.litrev-keyword-rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.litrev-keyword-row { + display: grid; + grid-template-columns: 1fr auto 110px 16px 28px; + align-items: center; + gap: 8px; +} + +.litrev-keyword-row input[type="text"], +.litrev-add-row input, +.litrev-outline-row input { + min-width: 0; + padding: 7px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 60%, transparent); + border-radius: var(--radius-input, 8px); + background: var(--color-background-paper); + font-size: 13px; + color: var(--color-text-ink); +} + +.litrev-keyword-row input[type="text"]:focus, +.litrev-add-row input:focus, +.litrev-outline-row input:focus { + border-color: var(--color-action-sky); + outline: none; +} + +.litrev-keyword-row label { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-keyword-row input[type="range"] { + accent-color: var(--color-action-sky); +} + +.litrev-keyword-row strong { + font-size: 12px; + color: var(--color-text-ink); + text-align: center; +} + +.litrev-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-btn); + color: color-mix(in srgb, var(--color-text-ink) 40%, transparent); + cursor: pointer; +} + +.litrev-icon-button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 80%, transparent); + color: var(--color-text-ink); +} + +.litrev-add-row { + display: flex; + gap: 8px; +} + +.litrev-add-row input { + flex: 1; +} + +.litrev-outline-rows { + display: flex; + flex-direction: column; + gap: 6px; +} + +.litrev-outline-row { + position: relative; + display: flex; + align-items: center; + gap: 6px; +} + +.litrev-outline-row--child { + padding-left: 26px; +} + +.litrev-outline-row input { + flex: 1; +} + +.litrev-outline-row__grip { + display: inline-flex; + padding: 0; + border: 0; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 30%, transparent); + cursor: grab; + user-select: none; +} + +.litrev-outline-row__grip:active { + cursor: grabbing; +} + +.litrev-outline-row--drop::before { + position: absolute; + top: -4px; + right: 0; + left: 0; + height: 2px; + border-radius: 999px; + background: var(--color-action-sky); + content: ""; + pointer-events: none; +} + +.litrev-outline-row--drop-child::before { + left: 26px; +} + +.litrev-wizard-card__head-actions { + display: inline-flex; + align-items: center; + gap: 12px; + flex: none; +} + +.litrev-wizard-card__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 7px; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + cursor: pointer; +} + +.litrev-wizard-card__close:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: var(--color-text-ink); +} + +.litrev-wizard-card__kb-picker { + position: relative; +} + +.litrev-wizard-card__kb-picker > summary { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 8px; + border-radius: 7px; + font-size: 11px; + font-weight: 500; + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); + cursor: pointer; + list-style: none; + white-space: nowrap; +} + +.litrev-wizard-card__kb-picker > summary::-webkit-details-marker { + display: none; +} + +.litrev-wizard-card__kb-picker > summary:hover, +.litrev-wizard-card__kb-picker[open] > summary { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: var(--color-text-ink); +} + +.litrev-wizard-card__kb-picker[open] > summary svg { + transform: rotate(180deg); +} + +.litrev-wizard-card__kb-menu { + position: absolute; + z-index: 12; + top: calc(100% + 6px); + right: 0; + display: grid; + gap: 3px; + width: 220px; + padding: 8px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 55%, transparent); + border-radius: 10px; + background: var(--color-background-paper); + box-shadow: 0 12px 30px rgba(17, 29, 28, 0.12); +} + +.litrev-wizard-card__kb-menu > strong { + padding: 3px 6px 6px; + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); +} + +.litrev-wizard-card__kb-menu > button { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 31px; + padding: 0 6px; + border-radius: 7px; + font-size: 12px; + text-align: left; + color: var(--color-text-ink); +} + +.litrev-wizard-card__kb-menu > button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); +} + +.litrev-wizard-card__kb-menu > button > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-ref-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.litrev-ref-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: var(--radius-card); + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.litrev-ref-row:hover { + border-color: color-mix(in srgb, var(--color-action-sky) 45%, transparent); +} + +.litrev-ref-row--selected { + border-color: color-mix(in srgb, var(--color-action-sky) 60%, transparent); + background: color-mix(in srgb, var(--color-accent-neon-mint) 8%, var(--color-background-paper)); +} + +.litrev-ref-row > input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.litrev-ref-row__text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.litrev-ref-row__text strong { + overflow: hidden; + font-size: 13px; + font-weight: 500; + color: var(--color-text-ink); + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-ref-row__text small { + overflow: hidden; + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-ref-row__source { + flex: none; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + font-size: 10px; + font-weight: 600; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); +} + +.litrev-ref-row__source--web { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; +} + +.litrev-composer { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 0; + border: 1px solid var(--border-content-panel, var(--color-border-stone)); + border-radius: var(--radius-composer); + background: var(--color-background-paper); + box-shadow: 0 8px 20px rgba(17, 29, 28, 0.03); +} + +.litrev-composer textarea { + display: block; + width: 100%; + min-width: 0; + max-height: 92px; + min-height: 88px; + padding: 12px 68px 44px 20px; + border: 0; + border-radius: var(--radius-composer); + background: transparent; + font-size: 13px; + line-height: 1.6; + color: var(--color-text-ink); + resize: none; + outline: none; +} + +.litrev-composer .composer-quick-actions__popover { + top: auto; + bottom: calc(100% + 8px); +} + +.litrev-composer__actions { + position: absolute; + right: 16px; + bottom: 12px; + z-index: 50; + display: flex; + align-items: center; + gap: 8px; +} + +.litrev-composer__voice { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + cursor: pointer; +} + +.litrev-composer__voice:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); +} + +.litrev-composer__voice--active { + color: var(--color-action-sky); +} + +.litrev-composer__voice:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.litrev-composer__send { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 32px; + height: 32px; + border: 0; + border-radius: var(--radius-btn); + background: color-mix(in srgb, var(--color-canvas-oat) 90%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 35%, transparent); + cursor: pointer; +} + +.litrev-composer__send:disabled { + cursor: not-allowed; +} + +.litrev-composer__send--ready { + background: var(--color-action-sky); + color: #fff; +} + +.litrev-composer__send--ready:hover { + background: var(--color-action-sky-hover); +} + +.litrev-todo { + width: min(620px, 100%); + padding: 12px 16px 14px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 46%, transparent); + border-radius: var(--radius-card, 12px); + background: color-mix(in srgb, var(--color-background-paper) 96%, var(--color-canvas-oat)); +} + +.litrev-todo--finished { + width: fit-content; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.litrev-todo__toggle { + display: inline-flex; + align-items: center; + gap: 7px; + width: auto; + padding: 2px 0; + font-size: 13px; + font-weight: 400; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + cursor: pointer; +} + +.litrev-todo__list { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + padding: 2px 0; +} + +.litrev-todo__list.litrev-stage-text-card { + gap: 0; + margin-top: 0; + padding: 9px 14px; +} + +.litrev-todo__item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} + +.litrev-task-output-message { + max-width: 620px; + margin-top: 10px; +} + +.litrev-stage-output-message { + max-width: 620px; + margin-top: 8px; +} + +.litrev-todo__item strong { + font-size: 12px; + font-weight: 500; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} + +.litrev-todo__item--done strong { + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); + text-decoration: line-through; + text-decoration-thickness: 1px; +} + +.litrev-todo__item--current strong { + color: var(--color-action-sky-hover); +} + +.litrev-todo__status { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 16px; + height: 16px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: 999px; + color: var(--color-action-sky-hover); +} + +.litrev-todo__item--done .litrev-todo__status { + border-color: transparent; + background: color-mix(in srgb, var(--color-accent-neon-mint) 30%, transparent); +} + +.litrev-todo__item--current .litrev-todo__status { + border-color: color-mix(in srgb, var(--color-action-sky) 50%, transparent); +} + +.litrev-todo__item small { + margin-left: auto; + font-size: 11px; + color: var(--color-action-sky-hover); +} + +.litrev-file-cards { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.litrev-file-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + border-radius: var(--radius-card); + background: var(--color-background-paper); + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.litrev-file-card:hover { + border-color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); + box-shadow: 0 6px 16px rgba(17, 29, 28, 0.06); +} + +.litrev-file-card__text strong { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--color-text-ink); +} + +.litrev-file-card__text small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-chat-pane { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + height: 100%; + max-width: 780px; + margin: 0 auto; + padding: 0 20px; +} + +.litrev-chat-pane--with-side { + max-width: none; + margin: 0; +} + +.litrev-chat-pane__topbar { + display: flex; + align-items: center; + gap: 10px; + flex: none; + height: var(--codex-toolbar-height); + min-height: var(--codex-toolbar-height); + -webkit-app-region: drag; + user-select: none; +} + +.litrev-chat-pane__topbar .agent-conversation-title { + position: absolute; + top: 0; + left: 20px; + display: flex; + align-items: center; + height: var(--codex-toolbar-height); + min-width: 0; + max-width: calc(100% - 76px); +} + +.litrev-preview-pane { + display: flex; + flex-direction: column; + flex: none; + width: 44%; + min-width: 380px; + height: 100%; + border-left: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + background: var(--color-background-paper); +} + +.litrev-preview-pane--lifted { + -webkit-app-region: no-drag; +} + +.litrev-preview-toolbar { + display: flex; + align-items: center; + gap: 4px; + flex: none; + height: var(--codex-toolbar-height); + min-height: var(--codex-toolbar-height); + padding: 0 44px 0 8px; + overflow: hidden; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: var(--color-background-paper); + -webkit-app-region: no-drag; +} + +.litrev-file-tabs { + display: flex; + align-items: stretch; + gap: 2px; + flex: 1; + min-width: 0; + height: 100%; + overflow-x: auto; +} + +.litrev-file-tab { + display: inline-flex; + align-items: center; + gap: 2px; + flex: none; + max-width: 190px; + height: 100%; + padding: 0 2px 0 8px; + border-bottom: 2px solid transparent; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + -webkit-app-region: no-drag; +} + +.litrev-file-tab > button[role="tab"] { + min-width: 0; + overflow: hidden; + color: inherit; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.litrev-file-tab__close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 18px; + height: 18px; + border-radius: 4px; + color: inherit; + opacity: 0.55; + cursor: pointer; +} + +.litrev-file-tab__close:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + opacity: 1; +} + +.litrev-file-tab--active { + border-bottom-color: var(--color-action-sky); + color: var(--color-action-sky-hover); + font-weight: 600; +} + +.litrev-file-tab:hover:not(.litrev-file-tab--active) { + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 45%, transparent); +} + +.litrev-preview-scope { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + flex: none; + padding: 4px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 36%, transparent); +} + +.litrev-preview-body { + display: flex; + flex: 1; + min-height: 0; +} + +.litrev-file-browser { + display: flex; + flex: none; + flex-direction: column; + width: 200px; + min-height: 0; + border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 12%, var(--color-background-paper)); +} + +.litrev-file-browser--collapsed { + display: none; +} + +.litrev-file-browser__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: var(--codex-toolbar-button-size); + height: var(--codex-toolbar-button-size); + padding: 0; + border-radius: var(--radius-btn); + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + cursor: pointer; +} + +.litrev-file-browser__toggle:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-preview-scope-switcher { + position: relative; + z-index: 19; + flex: none; + padding: 4px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 36%, transparent); +} + +.litrev-preview-scope-menu { + position: relative; + flex: 1; + width: 100%; + min-width: 0; +} + +.litrev-preview-scope-menu > summary { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + height: 30px; + min-width: 0; + padding: 0 7px; + border-radius: 7px; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 12px; + line-height: 16px; + cursor: pointer; + list-style: none; +} + +.litrev-preview-scope-menu > summary::-webkit-details-marker { + display: none; +} + +.litrev-preview-scope-menu > summary:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-preview-scope-menu > summary strong { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--color-text-ink); + font-size: inherit; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-preview-scope-menu > summary > svg { + flex: none; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + transition: transform 140ms ease; +} + +.litrev-preview-scope-menu[open] > summary > svg { + transform: rotate(180deg); +} + +.litrev-preview-scope-menu__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); +} + +.litrev-preview-scope-menu__popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 50; + display: grid; + width: 100%; + gap: 2px; + padding: 5px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 55%, transparent); + border-radius: 10px; + background: var(--color-background-paper); + box-shadow: 0 12px 30px rgba(17, 29, 28, 0.16); +} + +.litrev-preview-scope-menu__option { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 16px; + align-items: center; + gap: 7px; + min-height: 32px; + padding: 0 7px; + border-radius: 7px; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.litrev-preview-scope-menu__option:hover, +.litrev-preview-scope-menu__option--active { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); +} + +.litrev-preview-scope-menu__option > span:first-child { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.litrev-preview-scope-menu__option > svg { + color: var(--color-action-sky-hover); +} + +.litrev-preview-scope-menu--knowledge .litrev-preview-scope-menu__option { + grid-template-columns: minmax(0, 1fr) 16px; +} + +.litrev-preview-scope-menu--knowledge .litrev-preview-scope-menu__option > span:first-child { + justify-content: flex-start; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-file-list { + flex: 1; + width: auto; + min-height: 0; + padding: 8px; + overflow-y: auto; +} + +.litrev-preview-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.litrev-file-folder__toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-height: 36px; + padding: 7px 6px; + border-radius: var(--radius-btn); + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + cursor: pointer; +} + +.litrev-file-folder__toggle:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-file-folder__toggle strong { + font-size: 12px; + font-weight: 600; +} + +.litrev-file-folder__children { + display: flex; + flex-direction: column; + margin-left: 14px; +} + +.litrev-file-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 36px; + padding: 7px 8px; + border-radius: var(--radius-btn); + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); + text-align: left; + cursor: pointer; +} + +.litrev-file-item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 65%, transparent); +} + +.litrev-file-item--active { + background: color-mix(in srgb, var(--color-accent-neon-mint) 14%, transparent); + color: var(--color-action-sky-hover); + font-weight: 500; +} + +.litrev-file-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.litrev-preview-document { + flex: 1; + min-width: 0; + padding: 16px 22px 28px; + overflow-y: auto; +} + +.litrev-preview-crumb { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-preview-document h2 { + margin: 8px 0 4px; + font-size: 17px; + font-weight: 700; + color: var(--color-text-ink); +} + +.litrev-preview-document h3 { + margin-top: 16px; + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-preview-document p { + margin-top: 6px; + font-size: 13px; + line-height: 1.85; + color: color-mix(in srgb, var(--color-text-ink) 80%, transparent); +} + +.litrev-workspace-drawer { + display: flex; + flex-direction: column; + flex: none; + width: 400px; + height: 100%; + border-left: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + background: var(--color-background-paper); +} + +.litrev-workspace-drawer__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex: none; + padding: 12px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +} + +.litrev-workspace-drawer__head strong { + font-size: 13px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-workspace-drawer__body { + display: flex; + flex: 1; + min-height: 0; +} + +.litrev-workspace-drawer__files { + flex: none; + width: 160px; + padding: 8px; + border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + overflow-y: auto; +} + +.litrev-workspace-drawer__preview { + flex: 1; + min-width: 0; + padding: 14px 16px; + overflow-y: auto; +} + +.litrev-workspace-drawer__preview small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +} + +.litrev-workspace-drawer__preview h3 { + margin: 6px 0 4px; + font-size: 14px; + font-weight: 600; + color: var(--color-text-ink); +} + +.litrev-workspace-drawer__preview p { + margin-top: 6px; + font-size: 12px; + line-height: 1.75; + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +} + +.litrev-workspace-empty { + display: flex; + flex-direction: column; + gap: 4px; + padding: 30px 10px; + text-align: center; +} + +.litrev-workspace-empty strong { + font-size: 13px; + color: var(--color-text-ink); +} + +.litrev-workspace-empty small { + font-size: 11px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +} diff --git a/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts b/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts index 43021965f..9689403f4 100644 --- a/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts +++ b/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts @@ -36,19 +36,19 @@ describe("prototype page structure alignment", () => { expect(source("pages/app-frame.tsx")).toContain("LayoutList"); expect(source("pages/app-frame.tsx")).not.toContain('icon: "+"'); expect(source("pages/app-frame.tsx")).not.toContain('icon: "M"'); - expect(source("pages/home-page.tsx")).toContain("app-frame-page-content home-empty-screen flex flex-col items-center justify-center h-full"); + expect(source("pages/home-page.tsx")).toContain('className="app-frame-page-content home-empty-screen"'); expect(source("styles.css")).toContain(".home-empty-screen"); expect(source("styles.css")).toContain("padding-bottom: 8%;"); expect(source("pages/home-page.tsx")).toContain("text-center mb-8"); expect(source("pages/home-page.tsx")).toContain("home-empty-brand-mascot flex justify-center"); expect(source("pages/home-page.tsx")).toContain("text-2xl font-bold text-text-ink"); - expect(source("pages/home-page.tsx")).toContain("w-full max-w-2xl"); + expect(source("pages/home-page.tsx")).toContain('className="home-empty-composer-stack"'); expect(source("styles.css")).toContain(".home-empty-composer"); expect(source("styles.css")).toContain(".agent-composer-shell"); expect(source("pages/home-page.tsx")).toContain("relative home-empty-composer agent-composer-shell rounded-card-lg"); expect(source("pages/home-page.tsx")).toContain("w-full px-5 pt-4 pb-12 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40"); expect(source("pages/home-page.tsx")).toContain("absolute bottom-3 right-4 flex items-center gap-2"); - expect(source("pages/home-page.tsx")).not.toContain("home.suggestion."); + expect(source("pages/home-page.tsx")).toContain('t("home.suggestion.one")'); expect(source("pages/home-page.tsx")).toContain("agent-conversation-panel flex flex-col h-full"); expect(source("pages/home-page.tsx")).toContain("app-frame-page-content agent-conversation-scroll flex-1 overflow-y-auto"); expect(source("pages/home-page.tsx")).toContain("max-w-3xl mx-auto space-y-3"); diff --git a/App/frontend/desktop/src/theme/tokens.css b/App/frontend/desktop/src/theme/tokens.css index 8fb99e0bd..6a210a00d 100644 --- a/App/frontend/desktop/src/theme/tokens.css +++ b/App/frontend/desktop/src/theme/tokens.css @@ -52,6 +52,37 @@ --color-role-assistant: #8ba1f3; --color-role-assistant-soft: #e2e8fc; + /* File resources: light paper with familiar, full-strength format colors. */ + --file-icon-inline-size: 16px; + --file-icon-row-size: 22px; + --file-icon-card-size: 36px; + --file-icon-inline-radius: 0; + --file-icon-row-radius: 0; + --file-icon-card-radius: 0; + --file-icon-paper: #f3f6f5; + --file-icon-paper-sheen: #ffffff; + --file-icon-paper-edge: #c8d0ce; + --file-icon-paper-fold: #e1e8e6; + --file-icon-paper-fold-edge: #b9c4c1; + --file-icon-shadow: rgba(17, 29, 28, 0.16); + --file-icon-glyph-generic: #5f6b73; + --file-icon-glyph-pdf: #e5484d; + --file-icon-glyph-word: #2f6fdb; + --file-icon-glyph-spreadsheet: #1f9d63; + --file-icon-glyph-presentation: #e56f2d; + --file-icon-glyph-markdown: #3f6f9e; + --file-icon-glyph-text: #5f6b73; + --file-icon-glyph-code: #7357b8; + --file-icon-glyph-image: #8a55b5; + --file-icon-glyph-video: #7658b6; + --file-icon-glyph-audio: #d06b32; + --file-icon-glyph-archive: #a56b28; + --file-icon-folder-back: #79c5ef; + --file-icon-folder-front: #49a9e2; + --file-icon-folder-edge: #2e89bd; + --file-icon-folder-highlight: rgba(255, 255, 255, 0.72); + --file-icon-folder-paper: #f7f4ec; + /* Font sans stack. */ --font-sans: "Nunito", "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable", "Segoe UI", "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; /* Font chat stack. */ diff --git a/App/shell/desktop/interface/src/index.ts b/App/shell/desktop/interface/src/index.ts index 39af7ddd6..329b7dc8d 100644 --- a/App/shell/desktop/interface/src/index.ts +++ b/App/shell/desktop/interface/src/index.ts @@ -65,6 +65,10 @@ export interface DesktopImageActionRequest { data?: Uint8Array; } +export type DesktopSystemFileIconResult = string | null; + +export type DesktopSystemFolderIconKind = "folder" | "documents" | "downloads" | "desktop"; + export type DesktopImageSaveResult = | { canceled: true } | { canceled: false; filePath: string; bytes: number }; diff --git a/App/shell/desktop/src/main/main.ts b/App/shell/desktop/src/main/main.ts index 9434a3056..c0c314f35 100644 --- a/App/shell/desktop/src/main/main.ts +++ b/App/shell/desktop/src/main/main.ts @@ -7,6 +7,8 @@ import type { DesktopMemoryServiceRestartResult, DesktopProjectDirectorySelection, DesktopRuntimeConfig, + DesktopSystemFileIconResult, + DesktopSystemFolderIconKind, DesktopUpdateCheckResult, DesktopUpdateDownloadProgress, DesktopUpdateDownloadOptions, @@ -19,7 +21,7 @@ import { spawn } from "node:child_process"; import { constants as fsConstants, cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { access, appendFile, chmod, copyFile, lstat, mkdir, open, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import YAML from "yaml"; import { fullWindowOptions, @@ -798,6 +800,32 @@ async function restartMemoryService(): Promise { + const [rootName, ...relativeParts] = trimmed.split(/[\\/]+/).filter(Boolean); + const root = rootName === "文稿" || rootName === "Documents" + ? app.getPath("documents") + : rootName === "下载" || rootName === "Downloads" + ? app.getPath("downloads") + : rootName === "桌面" || rootName === "Desktop" + ? app.getPath("desktop") + : join(homedir(), rootName ?? ""); + return join(root, ...relativeParts); + })(); + + let existingTarget = target; + while (!existsSync(existingTarget)) { + const parent = dirname(existingTarget); + if (parent === existingTarget) return null; + existingTarget = parent; + } + return existingTarget; +} + /** * Registers the IPC handlers the renderer uses to read the runtime config. * @returns Nothing. @@ -842,6 +870,44 @@ function registerIpcHandlers(): void { saveDesktopImage(request, event.sender.getURL(), BrowserWindow.fromWebContents(event.sender)) )); + ipcMain.handle("memmy:get-system-file-icon", async (_event, filePath: string): Promise => { + if (typeof filePath !== "string" || !isAbsolute(filePath) || !existsSync(filePath)) { + return null; + } + try { + const icon = await app.getFileIcon(filePath, { size: "large" }); + return icon.isEmpty() ? null : icon.toDataURL(); + } catch { + return null; + } + }); + + ipcMain.handle("memmy:get-system-folder-icon", async (_event, kind: DesktopSystemFolderIconKind): Promise => { + // Generic "folder" used to call getFileIcon(temp) and could SIGTRAP under load; skip it. + const pathName = kind === "documents" + ? "documents" + : kind === "downloads" + ? "downloads" + : kind === "desktop" + ? "desktop" + : null; + if (!pathName) return null; + try { + const folderPath = app.getPath(pathName); + if (!existsSync(folderPath)) return null; + const icon = await app.getFileIcon(folderPath, { size: "normal" }); + return icon.isEmpty() ? null : icon.toDataURL(); + } catch { + return null; + } + }); + + ipcMain.handle("memmy:show-item-in-folder", async (_event, filePath: string): Promise => { + if (typeof filePath !== "string") return; + const target = resolveShowItemInFolderPath(filePath); + if (target) shell.showItemInFolder(target); + }); + ipcMain.handle("memmy:notify-task-done", (_event, payload: { title: string; body: string; silent: boolean }) => { showTaskDoneNotification(payload); }); @@ -4719,6 +4785,9 @@ async function cleanupBeforeQuit(): Promise { ipcMain.removeHandler("memmy:openMailto"); ipcMain.removeHandler("memmy:copy-image-to-clipboard"); ipcMain.removeHandler("memmy:save-image"); + ipcMain.removeHandler("memmy:get-system-file-icon"); + ipcMain.removeHandler("memmy:get-system-folder-icon"); + ipcMain.removeHandler("memmy:show-item-in-folder"); ipcMain.removeHandler("memmy:export-memory-database"); ipcMain.removeHandler("memmy:install-cli-tools"); ipcMain.removeHandler("memmy:restart-memory-service"); diff --git a/App/shell/desktop/src/preload/preload.cts b/App/shell/desktop/src/preload/preload.cts index e8ede4299..96d72fcf7 100644 --- a/App/shell/desktop/src/preload/preload.cts +++ b/App/shell/desktop/src/preload/preload.cts @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer }: typeof import("electron") = require("electron"); +const { contextBridge, ipcRenderer, webUtils }: typeof import("electron") = require("electron"); type IpcRendererEvent = import("electron").IpcRendererEvent; type DesktopAppInfo = import("@memmy/desktop-interface").DesktopAppInfo; type DesktopUpdateCheckResult = import("@memmy/desktop-interface").DesktopUpdateCheckResult; @@ -8,6 +8,8 @@ type DesktopUpdateInstallResult = import("@memmy/desktop-interface").DesktopUpda type DesktopMenuBarIconResult = import("@memmy/desktop-interface").DesktopMenuBarIconResult; type DesktopImageActionRequest = import("@memmy/desktop-interface").DesktopImageActionRequest; type DesktopImageSaveResult = import("@memmy/desktop-interface").DesktopImageSaveResult; +type DesktopSystemFileIconResult = import("@memmy/desktop-interface").DesktopSystemFileIconResult; +type DesktopSystemFolderIconKind = import("@memmy/desktop-interface").DesktopSystemFolderIconKind; type DesktopMemoryServiceRestartResult = import("@memmy/desktop-interface").DesktopMemoryServiceRestartResult; type DesktopProjectDirectorySelection = import("@memmy/desktop-interface").DesktopProjectDirectorySelection; type MicrophoneAccessStatus = import("@memmy/desktop-interface").MicrophoneAccessStatus; @@ -34,6 +36,10 @@ interface MemmyPreloadApi { openMailto(mailtoUrl: string): Promise; copyImageToClipboard(request: DesktopImageActionRequest): Promise; saveImage(request: DesktopImageActionRequest): Promise; + getPathForFile(file: File): string; + getSystemFileIcon(filePath: string): Promise; + getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise; + showItemInFolder(filePath: string): Promise; exportMemoryDatabase(): Promise; installCliTools(): Promise; restartMemoryService(): Promise; @@ -158,6 +164,22 @@ const memmyPreloadApi: MemmyPreloadApi = { return ipcRenderer.invoke("memmy:save-image", request); }, + getPathForFile(file: File): string { + return webUtils.getPathForFile(file); + }, + + async getSystemFileIcon(filePath: string): Promise { + return ipcRenderer.invoke("memmy:get-system-file-icon", filePath); + }, + + async getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise { + return ipcRenderer.invoke("memmy:get-system-folder-icon", kind); + }, + + async showItemInFolder(filePath: string): Promise { + return ipcRenderer.invoke("memmy:show-item-in-folder", filePath); + }, + async notifyTaskDone(payload: { title: string; body: string; silent: boolean }): Promise { return ipcRenderer.invoke("memmy:notify-task-done", payload); }, diff --git a/App/shell/desktop/tests/main-window-action-buffer.test.ts b/App/shell/desktop/tests/main-window-action-buffer.test.ts index bf3cc32d5..70f8b89f4 100644 --- a/App/shell/desktop/tests/main-window-action-buffer.test.ts +++ b/App/shell/desktop/tests/main-window-action-buffer.test.ts @@ -10,6 +10,7 @@ interface MainWindowActionRequest { interface ExposedMemmyApi { platform: string; + getPathForFile(file: File): string; onMainWindowActionRequest(callback: (request: MainWindowActionRequest) => void): () => void; } @@ -40,6 +41,9 @@ function loadPreload(): { removeListener(channel: string, listener: (event: unknown, payload: unknown) => void): void { listeners.get(channel)?.delete(listener); } + }, + webUtils: { + getPathForFile: vi.fn(() => "/Users/example/report.docx") } }; const module = { exports: {} }; @@ -72,6 +76,13 @@ describe("main window action preload buffer", () => { expect(preload.memmy.platform).toBe(process.platform); }); + it("exposes the native local path for a renderer File", () => { + const preload = loadPreload(); + const file = new File(["report"], "report.docx"); + + expect(preload.memmy.getPathForFile(file)).toBe("/Users/example/report.docx"); + }); + it("delivers a close request that arrives before the renderer subscribes", () => { const preload = loadPreload(); const callback = vi.fn(); diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 62274a22f..6e34b0cb2 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -570,6 +570,30 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("if (response.status === 401)"); }); + it("exposes native operating-system file icons for real local files", () => { + const mainSource = readFileSync(mainSourcePath, "utf8"); + const preloadSource = readFileSync(preloadSourcePath, "utf8"); + const interfaceSource = readFileSync(desktopInterfacePath, "utf8"); + + expect(interfaceSource).toContain("export type DesktopSystemFileIconResult = string | null;"); + expect(preloadSource).toContain("getPathForFile(file: File): string;"); + expect(preloadSource).toContain("getSystemFileIcon(filePath: string): Promise;"); + expect(preloadSource).toContain("getSystemFolderIcon(kind: DesktopSystemFolderIconKind): Promise;"); + expect(preloadSource).toContain("showItemInFolder(filePath: string): Promise;"); + expect(preloadSource).toContain("webUtils.getPathForFile(file)"); + expect(preloadSource).toContain('ipcRenderer.invoke("memmy:get-system-file-icon", filePath)'); + expect(preloadSource).toContain('ipcRenderer.invoke("memmy:get-system-folder-icon", kind)'); + expect(preloadSource).toContain('ipcRenderer.invoke("memmy:show-item-in-folder", filePath)'); + expect(mainSource).toContain('ipcMain.handle("memmy:get-system-file-icon"'); + expect(mainSource).toContain('ipcMain.handle("memmy:get-system-folder-icon"'); + expect(mainSource).toContain('ipcMain.handle("memmy:show-item-in-folder"'); + expect(mainSource).toContain("shell.showItemInFolder(target)"); + expect(mainSource).toContain('app.getFileIcon(filePath, { size: "large" })'); + expect(mainSource).toContain('ipcMain.removeHandler("memmy:get-system-file-icon")'); + expect(mainSource).toContain('ipcMain.removeHandler("memmy:get-system-folder-icon")'); + expect(mainSource).toContain('ipcMain.removeHandler("memmy:show-item-in-folder")'); + }); + it("installs memmy-memory into ~/.local/bin through the desktop bridge", () => { const mainSource = readFileSync(mainSourcePath, "utf8"); const preloadSource = readFileSync(preloadSourcePath, "utf8"); diff --git a/package-lock.json b/package-lock.json index 310d96da8..2d4bcbb07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -555,7 +555,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -577,7 +576,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -587,31 +585,10 @@ "node": ">=14.14" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -2465,6 +2442,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", @@ -2768,6 +2776,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2778,6 +2787,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2887,6 +2897,7 @@ "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", @@ -3401,6 +3412,40 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -3474,6 +3519,7 @@ "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", @@ -3724,6 +3770,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5024,8 +5071,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5104,6 +5150,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5384,6 +5431,7 @@ "integrity": "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", @@ -5591,7 +5639,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -5612,7 +5659,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -5628,7 +5674,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -5639,7 +5684,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -5924,6 +5968,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6094,6 +6139,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7447,6 +7493,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9900,7 +9947,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -10563,6 +10609,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10713,7 +10760,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10731,7 +10777,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11044,6 +11089,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11053,6 +11099,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11461,7 +11508,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12544,7 +12590,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12952,6 +12997,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13159,6 +13205,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -13307,6 +13354,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -13385,6 +13433,7 @@ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", @@ -13800,6 +13849,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 96c0a45187314e0a998b3ad36e8b825c88beae0e Mon Sep 17 00:00:00 2001 From: shsfcx Date: Thu, 13 Aug 2026 18:26:43 +0800 Subject: [PATCH 2/4] refactor: separate literature review from knowledge base Keep literature research focused on topic, timeframe, and local sources while preserving knowledge features on their own branch. Co-authored-by: Cursor --- .../desktop/src/analytics/page-view.ts | 1 - App/frontend/desktop/src/app/router.tsx | 3 - App/frontend/desktop/src/app/routes.ts | 4 +- .../desktop/src/app/tests/routes.test.ts | 1 - .../desktop/src/components/file-type-icon.tsx | 2 +- App/frontend/desktop/src/i18n/messages.ts | 310 +-- .../i18n/tests/english-ui-coverage.test.ts | 1 - .../desktop/src/lib/agent-managed-files.ts | 48 - .../src/lib/composer-file-reference.ts | 42 +- .../src/lib/tests/agent-managed-files.test.ts | 56 - .../lib/tests/composer-file-reference.test.ts | 35 +- App/frontend/desktop/src/main.tsx | 18 - App/frontend/desktop/src/pages/app-frame.tsx | 3 +- .../src/pages/home-composer-quick-actions.tsx | 113 +- App/frontend/desktop/src/pages/home-page.tsx | 103 +- .../desktop/src/pages/knowledge-demo-data.ts | 163 -- .../desktop/src/pages/knowledge-page.tsx | 1712 ---------------- .../src/pages/literature-review-demo-data.ts | 40 +- .../src/pages/literature-review-page.tsx | 361 ++-- .../src/pages/tests/file-icon-system.test.tsx | 5 - .../src/pages/tests/home-page.test.tsx | 76 +- .../pages/tests/knowledge-demo-data.test.ts | 51 - .../desktop/src/state/agent-chat-slice.ts | 9 +- .../desktop/src/state/agent-composer-state.ts | 2 +- App/frontend/desktop/src/styles.css | 1769 +++-------------- 25 files changed, 722 insertions(+), 4206 deletions(-) delete mode 100644 App/frontend/desktop/src/lib/agent-managed-files.ts delete mode 100644 App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts delete mode 100644 App/frontend/desktop/src/pages/knowledge-demo-data.ts delete mode 100644 App/frontend/desktop/src/pages/knowledge-page.tsx delete mode 100644 App/frontend/desktop/src/pages/tests/knowledge-demo-data.test.ts diff --git a/App/frontend/desktop/src/analytics/page-view.ts b/App/frontend/desktop/src/analytics/page-view.ts index ef1129488..d224dd2ce 100644 --- a/App/frontend/desktop/src/analytics/page-view.ts +++ b/App/frontend/desktop/src/analytics/page-view.ts @@ -16,7 +16,6 @@ const ROUTE_PAGE_TITLES: Record = { "/tools": "Tools", "/memory": "Memory", "/memory-sources": "Memory Sources", - "/knowledge": "Knowledge Base", "/literature-review": "Literature Review", "/settings": "Settings" }; diff --git a/App/frontend/desktop/src/app/router.tsx b/App/frontend/desktop/src/app/router.tsx index 849ea36d0..fb0b645c0 100644 --- a/App/frontend/desktop/src/app/router.tsx +++ b/App/frontend/desktop/src/app/router.tsx @@ -56,7 +56,6 @@ import { ApiKeyPage } from "../pages/api-key-page.js"; import { ApiKeyOptionalPage } from "../pages/api-key-optional-page.js"; import { ModelPage } from "../pages/model-page.js"; import { HomePage } from "../pages/home-page.js"; -import { KnowledgePage } from "../pages/knowledge-page.js"; import { LiteratureReviewPage } from "../pages/literature-review-page.js"; import { LoginPage } from "../pages/login-page.js"; import { MemoryPage, writeMemorySubPage } from "../pages/memory-page.js"; @@ -376,8 +375,6 @@ function renderRoute(path: AppRoutePath) { return ; case "/memory-sources": return ; - case "/knowledge": - return ; case "/literature-review": return ; case "/settings": diff --git a/App/frontend/desktop/src/app/routes.ts b/App/frontend/desktop/src/app/routes.ts index ac18f115f..303e09036 100644 --- a/App/frontend/desktop/src/app/routes.ts +++ b/App/frontend/desktop/src/app/routes.ts @@ -18,7 +18,6 @@ export type AppRoutePath = | "/tools" | "/memory" | "/memory-sources" - | "/knowledge" | "/literature-review" | "/settings"; @@ -56,7 +55,6 @@ export const routeTable: Record = { "/tools": { path: "/tools", navKey: "nav.tools", requiresBootstrap: true }, "/memory": { path: "/memory", navKey: "nav.memory", requiresBootstrap: true }, "/memory-sources": { path: "/memory-sources", navKey: "nav.memory", requiresBootstrap: true }, - "/knowledge": { path: "/knowledge", navKey: "nav.knowledge", requiresBootstrap: true }, "/literature-review": { path: "/literature-review", navKey: "nav.literatureReview", requiresBootstrap: true }, "/settings": { path: "/settings", navKey: "nav.settings", requiresBootstrap: true } }; @@ -632,7 +630,7 @@ function isAppRoutePath(value: string | null): value is AppRoutePath { } function isRestorableRoute(path: AppRoutePath): boolean { - return path === "/main" || path === "/pet" || path === "/tools" || path === "/memory" || path === "/memory-sources" || path === "/knowledge" || path === "/settings"; + return path === "/main" || path === "/pet" || path === "/tools" || path === "/memory" || path === "/memory-sources" || path === "/settings"; } function isPostOnboardingRoute(path: AppRoutePath): boolean { diff --git a/App/frontend/desktop/src/app/tests/routes.test.ts b/App/frontend/desktop/src/app/tests/routes.test.ts index 7670e65e0..0b3bd6b39 100644 --- a/App/frontend/desktop/src/app/tests/routes.test.ts +++ b/App/frontend/desktop/src/app/tests/routes.test.ts @@ -98,7 +98,6 @@ describe("desktop route table", () => { "/tools", "/memory", "/memory-sources", - "/knowledge", "/literature-review", "/settings" ]); diff --git a/App/frontend/desktop/src/components/file-type-icon.tsx b/App/frontend/desktop/src/components/file-type-icon.tsx index 283a89e80..9e2f716cc 100644 --- a/App/frontend/desktop/src/components/file-type-icon.tsx +++ b/App/frontend/desktop/src/components/file-type-icon.tsx @@ -267,7 +267,7 @@ export function FileTypeIcon(props: { export function FolderTypeIcon(props: { open?: boolean; - /** Opt-in only; knowledge lists stay on the custom SVG to avoid native icon IPC storms. */ + /** Opt-in only so repeated folder rows do not trigger native icon IPC storms. */ preferSystemIcon?: boolean; systemKind?: DesktopSystemFolderIconKind; surface?: FileTypeIconSurface; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 51e074ad8..8bd3ad1b0 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -66,7 +66,6 @@ export const zhCNMessages = { "nav.pet": "桌宠", "nav.tools": "连接与工具", "nav.memory": "记忆管理", - "nav.knowledge": "知识库", "nav.literatureReview": "文献综述", "nav.settings": "设置", "legal.back": "返回", @@ -1378,140 +1377,25 @@ export const zhCNMessages = { "pet.asrEmptyAudio": "没有录到有效音频,请稍微说久一点再发送", "pet.asrFailedWithMessage": "语音识别失败:{message}", "pet.asrFailed": "语音识别失败,请重试", - "home.quick.attach": "添加文件", - "home.quick.attachHint": "上传文件", + "home.quick.attach": "添加资料", + "home.quick.attachHint": "添加文件或文件夹", + "home.quick.uploadFile": "上传文件", + "home.quick.uploadFolder": "上传文件夹", "composer.addToChat": "添加到聊天", "home.quick.reference": "引用", - "home.quick.referenceHint": "引用知识库或文件", - "home.quick.referenceSearch": "搜索知识库或文件", + "home.quick.referenceHint": "引用本机文件或文件夹", + "home.quick.referenceSearch": "搜索文件或文件夹", "home.quick.capability": "能力", "home.quick.capabilityHint": "选择 Agent 能力", "home.quick.kind.file": "文件", "home.quick.kind.folder": "文件夹", - "home.quick.kind.kb": "知识库", - "home.quick.kbSection": "知识库", - "home.quick.fileSection": "文件", - "home.quick.kbSearchScope": "{count} 个文件 · 作为检索范围", + "home.quick.localSection": "本机资料", "home.quick.noMatches": "没有匹配的引用项", "home.capability.literatureReview": "文献综述", - "home.capability.literatureReviewHint": "基于知识库生成结构化文献综述", + "home.capability.literatureReviewHint": "检索、阅读并生成结构化文献综述", "home.capability.literatureReviewArgHint": "[综述主题]", "home.capability.more": "更多能力", "home.capability.moreHint": "输入 / 查看全部能力", - "kb.title": "知识库", - "kb.subtitle": "整理知识,让 Memmy 随时检索知识", - "kb.create": "新建知识库", - "kb.itemCount": "{count} 项", - "kb.updatedJustNow": "刚刚更新", - "kb.onboarding.flowLabel": "创建知识库流程", - "kb.onboarding.stepSync": "同步文件夹", - "kb.onboarding.stepCreate": "创建知识库", - "kb.onboarding.title": "同步本地文件", - "kb.onboarding.desc": "同步后,与 Memmy 对话时,可随时引用本地文件,并支持从文档中提取洞察,所有数据本地存储。", - "kb.onboarding.chooseFolders": "推荐文件夹", - "kb.onboarding.addFolder": "选择其他文件夹", - "kb.onboarding.addFolderPlaceholder": "例如 ~/Papers 或 ~/Documents/文献", - "kb.onboarding.start": "开始同步", - "kb.onboarding.skip": "暂不同步", - "kb.onboarding.createTitle": "创建第一个知识库", - "kb.onboarding.createDesc": "为一组相关文件创建独立的检索范围,之后可在任务中随时引用。", - "kb.onboarding.nameLabel": "知识库名称", - "kb.onboarding.namePlaceholder": "例如:大模型长期记忆", - "kb.onboarding.filesLabel": "选择文件(可选)", - "kb.onboarding.filesHint": "这里只展示部分已同步文件,创建后可继续添加。", - "kb.onboarding.noFiles": "暂时没有已同步文件,可先创建空知识库。", - "kb.onboarding.back": "上一步", - "kb.onboarding.skipCreate": "暂不创建", - "kb.onboarding.createAction": "创建知识库", - "kb.recent.title": "我的知识库", - "kb.base.actions": "知识库操作", - "kb.recent.desc": "常用的知识库会显示在这里", - "kb.recent.empty": "还没有知识库", - "kb.recent.viewAll": "查看全部 {count} 个", - "kb.recent.collapse": "收起", - "kb.files.title": "全部文件", - "kb.files.desc": "所有已导入的文件,可上传、删除或加入知识库", - "kb.files.add": "添加文件", - "kb.files.empty": "还没有文件,先同步一个文件夹", - "kb.files.emptyFolder": "这个文件夹是空的", - "kb.files.agentRoot": "Memmy 文件", - "kb.files.generatedFolder": "生成", - "kb.files.downloadedFolder": "下载", - "kb.files.showInFolder": "在文件夹中显示", - "kb.files.breadcrumbRoot": "根目录", - "kb.files.col.name": "名称", - "kb.files.col.path": "路径", - "kb.files.col.size": "大小", - "kb.files.col.updated": "更新时间", - "kb.files.col.status": "状态", - "kb.files.col.operate": "操作", - "kb.toolbar.upload": "上传文件", - "kb.toolbar.delete": "删除文件", - "kb.toolbar.filter": "筛选", - "kb.toolbar.filterAll": "全部状态", - "kb.toolbar.searchPlaceholder": "搜索文件", - "kb.selection.count": "已选 {count} 项", - "kb.selection.join": "添加到知识库", - "kb.selection.clear": "取消选择", - "kb.selection.createNew": "新建知识库", - "kb.selection.createNewHint": "用所选文件创建", - "kb.source.customName": "自定义文件夹", - "kb.source.customHint": "选择本地任意文件夹", - "kb.status.processed": "已解析", - "kb.status.processing": "解析中", - "kb.status.uploading": "上传中", - "kb.status.unsupported": "不支持的文件类型", - "kb.sync.title": "同步设置", - "kb.sync.desc": "这些文件夹中的内容会自动更新到全部文件", - "kb.sync.button": "同步设置", - "kb.sync.add": "添加文件夹", - "kb.sync.active": "同步中", - "kb.sync.paused": "已暂停", - "kb.sync.pause": "停止同步", - "kb.sync.recommended": "推荐文件夹", - "kb.sync.recommendedHint": "常见本地目录,一键加入同步", - "kb.sync.recommendedEmpty": "推荐目录都已同步", - "kb.sync.recommendAction": "同步", - "kb.sync.addSection": "添加文件夹", - "kb.sync.addSectionHint": "从本机直接选择要同步的文件夹", - "kb.sync.syncedSection": "同步文件夹", - "kb.sync.syncedSectionHint": "{count} 个", - "kb.sync.syncedEmpty": "还没有同步中的文件夹", - "kb.detail.back": "返回", - "kb.detail.empty": "这个知识库还没有文件", - "kb.detail.emptySearch": "没有匹配的文件", - "kb.detail.filesStat": "个文件", - "kb.detail.pathColumn": "路径", - "kb.detail.statusColumn": "状态", - "kb.detail.remove": "移出知识库", - "kb.detail.removeShort": "移出", - "kb.detail.searchPlaceholder": "搜索文件名", - "kb.folder.create": "新建文件夹", - "kb.folder.createTitle": "新建文件夹", - "kb.folder.defaultName": "新建文件夹", - "kb.folder.nameLabel": "文件夹名称", - "kb.folder.namePlaceholder": "例如:核心论文", - "kb.folder.delete": "删除文件夹", - "kb.folder.deleteTitle": "删除文件夹“{name}”?", - "kb.folder.deleteDesc": "文件夹中有 {count} 个文件。你可以仅删除文件夹并将文件移到知识库根目录,或同时从本机删除这些文件。", - "kb.folder.deleteEmptyDesc": "这是一个空文件夹,删除后无法恢复。", - "kb.folder.deleteKeepFiles": "仅删除文件夹", - "kb.folder.deleteWithFiles": "删除文件夹和本机文件", - "kb.pagination.label": "文件分页", - "kb.pagination.previous": "上一页", - "kb.pagination.next": "下一页", - "kb.removeConfirm.title": "移出知识库", - "kb.removeConfirm.desc": "确定将这 {count} 个文件移出“{name}”吗?仅从当前知识库移出,不会删除原文件。", - "kb.removeConfirm.confirm": "确认移出", - "kb.deleteFilesConfirm.title": "从本机删除文件?", - "kb.deleteFilesConfirm.desc": "确定删除这 {count} 个文件吗?文件将从本机删除,并从所有知识库中移除。此操作无法撤销。", - "kb.deleteFilesConfirm.confirm": "从本机删除", - "kb.deleteConfirm.title": "删除知识库", - "kb.deleteConfirm.desc": "确定删除“{name}”吗?文件本身不会被删除。", - "kb.modal.createTitle": "新建知识库", - "kb.modal.renameTitle": "重命名知识库", - "kb.modal.nameLabel": "名称", - "kb.modal.namePlaceholder": "例如:具身智能文献库", "litrev.title.setup": "需求确认", "litrev.title.execution": "执行中", "litrev.scene": "文献综述", @@ -1531,6 +1415,8 @@ export const zhCNMessages = { "litrev.stageActivity.questions.generating": "生成问题中", "litrev.stageActivity.questions.waiting": "等待用户回答", "litrev.stageActivity.questions.done": "向用户提问", + "litrev.stageActivity.sources.waiting": "等待添加本地文献", + "litrev.stageActivity.sources.done": "本地文献", "litrev.stageActivity.keywords.generating": "生成关键词中", "litrev.stageActivity.keywords.waiting": "等待用户调整", "litrev.stageActivity.keywords.done": "关键词列表", @@ -1544,13 +1430,24 @@ export const zhCNMessages = { "litrev.stageActivity.tasks.done": "任务列表", "litrev.stageActivity.preparation.done": "已完成研究准备", "litrev.activity.durationSeconds": "{seconds} 秒", - "litrev.stageOutput.questions": "收到,研究要求已经明确。我先整理检索关键词,再据此生成大纲。", + "litrev.stageOutput.questions": "收到,研究主题和时间范围已经明确。你可以先补充本机已有文献。", + "litrev.stageOutput.sources": "本机文献已确认。我会结合这些资料生成检索关键词。", "litrev.stageOutput.keywords": "关键词已经确认。我会围绕核心概念、方法机制和评测维度组织综述结构。", "litrev.stageOutput.outline": "大纲已经确定。接下来检索并筛选与各章节直接相关的文献。", "litrev.stageOutput.references": "参考文献范围已经确认。我先把任务列出来,然后开始整理资料和撰写正文。", "litrev.workflow.close": "关闭并结束当前流程", "litrev.workflow.cancelled": "流程已取消", "litrev.workflow.cancelledDetail": "已停止当前流程,不会继续执行后续步骤。", + "litrev.sources.title": "添加本地文献", + "litrev.sources.hint": "如果本机已有相关文献,可以先添加文件或文件夹,Agent 会优先阅读并在后续检索中补充缺失内容。", + "litrev.sources.existingHint": "已带入前面上传的资料。你可以继续补充文件或文件夹,再进入关键词确认。", + "litrev.sources.empty": "尚未添加本地文献,也可以跳过并直接开始联网检索。", + "litrev.sources.none": "未添加本地文献", + "litrev.sources.count": "已添加 {count} 项", + "litrev.sources.addFiles": "添加文件", + "litrev.sources.addFolder": "添加文件夹", + "litrev.sources.confirm": "确认资料并继续", + "litrev.sources.skip": "暂不添加,继续", "litrev.wizard.step.keywords": "检索关键词", "litrev.wizard.step.outline": "综述大纲", "litrev.wizard.step.references": "参考文献来源", @@ -1569,12 +1466,7 @@ export const zhCNMessages = { "litrev.refs.local": "本机已有", "litrev.refs.fromWeb": "需联网获取", "litrev.refs.note": "Agent 找到了一些相关文献。本机已有的可直接使用;联网文献会在你勾选后由 Agent 自动获取。", - "litrev.refs.policyTitle": "可选:整理到知识库", - "litrev.refs.targetKb": "选择目标知识库", "litrev.refs.useDefault": "采用 Agent 默认选择", - "litrev.refs.addToKb": "收录到知识库", - "litrev.refs.addToKbHint": "选择一个或多个目标知识库", - "litrev.refs.selectedKbCount": "已选 {count} 个知识库", "litrev.refs.confirmStart": "确认并开始", "litrev.composer.setup": "补充要求或直接回复上方问题…", "litrev.composer.task": "随时补充要求,Agent 会实时调整…", @@ -1587,10 +1479,7 @@ export const zhCNMessages = { "litrev.todo.output.checked": "引用检查完成,正文与参考文献可以一一对应。文档已经可以交付。", "litrev.preview.scope": "浏览范围", "litrev.preview.taskFiles": "任务文件", - "litrev.preview.knowledge": "知识库", "litrev.preview.projectSpace": "项目空间", - "litrev.preview.knowledgeScope": "选择知识库", - "litrev.preview.allFiles": "全部文件", "litrev.preview.toggleFiles": "展开或收起文件树", "litrev.preview.resizeFiles": "调整文件树宽度", "litrev.preview.outputs": "成果", @@ -1658,7 +1547,6 @@ export const enUSMessages: Record = { "nav.pet": "Pet", "nav.tools": "Tool connections", "nav.memory": "Memory", - "nav.knowledge": "Knowledge", "nav.literatureReview": "Literature review", "nav.settings": "Settings", "legal.back": "Back", @@ -2970,140 +2858,25 @@ export const enUSMessages: Record = { "pet.asrEmptyAudio": "No valid audio was recorded. Please speak a little longer and try again.", "pet.asrFailedWithMessage": "Speech recognition failed: {message}", "pet.asrFailed": "Speech recognition failed. Please try again.", - "home.quick.attach": "Add files", - "home.quick.attachHint": "Upload files", + "home.quick.attach": "Add sources", + "home.quick.attachHint": "Add files or folders", + "home.quick.uploadFile": "Upload files", + "home.quick.uploadFolder": "Upload folder", "composer.addToChat": "Add to chat", "home.quick.reference": "Reference", - "home.quick.referenceHint": "Reference a knowledge base or file", - "home.quick.referenceSearch": "Search knowledge bases or files", + "home.quick.referenceHint": "Reference local files or folders", + "home.quick.referenceSearch": "Search files or folders", "home.quick.capability": "Capabilities", "home.quick.capabilityHint": "Choose an Agent capability", "home.quick.kind.file": "File", "home.quick.kind.folder": "Folder", - "home.quick.kind.kb": "KB", - "home.quick.kbSection": "Knowledge bases", - "home.quick.fileSection": "Files", - "home.quick.kbSearchScope": "{count} files · used as search scope", + "home.quick.localSection": "Local sources", "home.quick.noMatches": "No matching references", "home.capability.literatureReview": "Literature review", - "home.capability.literatureReviewHint": "Generate a structured literature review from your knowledge base", + "home.capability.literatureReviewHint": "Search, read, and generate a structured literature review", "home.capability.literatureReviewArgHint": "[topic]", "home.capability.more": "More capabilities", "home.capability.moreHint": "Type / to see all capabilities", - "kb.title": "Knowledge bases", - "kb.subtitle": "Organize materials Agents can search and cite", - "kb.create": "New knowledge base", - "kb.itemCount": "{count} items", - "kb.updatedJustNow": "Updated just now", - "kb.onboarding.flowLabel": "Knowledge base setup", - "kb.onboarding.stepSync": "Sync folders", - "kb.onboarding.stepCreate": "Create a knowledge base", - "kb.onboarding.title": "Sync local files", - "kb.onboarding.desc": "Once synced, you can reference local files anytime while chatting with Memmy and extract insights from documents. All data stays on your device.", - "kb.onboarding.chooseFolders": "Recommended folders", - "kb.onboarding.addFolder": "Choose another folder", - "kb.onboarding.addFolderPlaceholder": "e.g. ~/Papers or ~/Documents/references", - "kb.onboarding.start": "Start syncing", - "kb.onboarding.skip": "Not now", - "kb.onboarding.createTitle": "Create your first knowledge base", - "kb.onboarding.createDesc": "Create a focused search scope for related files, then reference it anytime in a task.", - "kb.onboarding.nameLabel": "Knowledge base name", - "kb.onboarding.namePlaceholder": "e.g. Long-term memory research", - "kb.onboarding.filesLabel": "Choose files (optional)", - "kb.onboarding.filesHint": "Only some synced files are shown here. You can add more after creating it.", - "kb.onboarding.noFiles": "No files are synced yet. You can create an empty knowledge base first.", - "kb.onboarding.back": "Back", - "kb.onboarding.skipCreate": "Not now", - "kb.onboarding.createAction": "Create knowledge base", - "kb.recent.title": "My knowledge bases", - "kb.base.actions": "Knowledge base actions", - "kb.recent.desc": "Your frequently used knowledge bases appear here", - "kb.recent.empty": "No knowledge bases yet", - "kb.recent.viewAll": "View all {count}", - "kb.recent.collapse": "Collapse", - "kb.files.title": "All files", - "kb.files.desc": "Every imported file — upload, delete, or add to a knowledge base", - "kb.files.add": "Add files", - "kb.files.empty": "No files yet — sync a folder first", - "kb.files.emptyFolder": "This folder is empty", - "kb.files.agentRoot": "Memmy Files", - "kb.files.generatedFolder": "Generated", - "kb.files.downloadedFolder": "Downloads", - "kb.files.showInFolder": "Show in folder", - "kb.files.breadcrumbRoot": "Root", - "kb.files.col.name": "Name", - "kb.files.col.path": "Path", - "kb.files.col.size": "Size", - "kb.files.col.updated": "Updated", - "kb.files.col.status": "Status", - "kb.files.col.operate": "Operate", - "kb.toolbar.upload": "Upload file", - "kb.toolbar.delete": "Delete files", - "kb.toolbar.filter": "Filter", - "kb.toolbar.filterAll": "All statuses", - "kb.toolbar.searchPlaceholder": "Search files", - "kb.selection.count": "{count} selected", - "kb.selection.join": "Add to knowledge base", - "kb.selection.clear": "Clear selection", - "kb.selection.createNew": "New knowledge base", - "kb.selection.createNewHint": "Create from selected files", - "kb.source.customName": "Custom folder", - "kb.source.customHint": "Choose any local folder", - "kb.status.processed": "Parsed", - "kb.status.processing": "Parsing", - "kb.status.uploading": "Uploading", - "kb.status.unsupported": "Unsupported file type", - "kb.sync.title": "Sync settings", - "kb.sync.desc": "Contents in these folders update automatically in All files", - "kb.sync.button": "Sync settings", - "kb.sync.add": "Add folder", - "kb.sync.active": "Syncing", - "kb.sync.paused": "Paused", - "kb.sync.pause": "Stop syncing", - "kb.sync.recommended": "Recommended folders", - "kb.sync.recommendedHint": "Common local folders — sync in one click", - "kb.sync.recommendedEmpty": "All recommended folders are already synced", - "kb.sync.recommendAction": "Sync", - "kb.sync.addSection": "Add folder", - "kb.sync.addSectionHint": "Pick a local folder to sync", - "kb.sync.syncedSection": "Synced folders", - "kb.sync.syncedSectionHint": "{count}", - "kb.sync.syncedEmpty": "No folders syncing yet", - "kb.detail.back": "Back", - "kb.detail.empty": "This knowledge base has no files yet", - "kb.detail.emptySearch": "No matching files", - "kb.detail.filesStat": " files", - "kb.detail.pathColumn": "Path", - "kb.detail.statusColumn": "Status", - "kb.detail.remove": "Remove from knowledge base", - "kb.detail.removeShort": "Remove", - "kb.detail.searchPlaceholder": "Search file names", - "kb.folder.create": "New folder", - "kb.folder.createTitle": "New folder", - "kb.folder.defaultName": "New folder", - "kb.folder.nameLabel": "Folder name", - "kb.folder.namePlaceholder": "e.g. Core papers", - "kb.folder.delete": "Delete folder", - "kb.folder.deleteTitle": "Delete “{name}”?", - "kb.folder.deleteDesc": "This folder contains {count} files. You can delete only the folder and move its files to the knowledge-base root, or also delete those files from this device.", - "kb.folder.deleteEmptyDesc": "This folder is empty. Deleting it cannot be undone.", - "kb.folder.deleteKeepFiles": "Delete folder only", - "kb.folder.deleteWithFiles": "Delete folder and device files", - "kb.pagination.label": "File pagination", - "kb.pagination.previous": "Previous page", - "kb.pagination.next": "Next page", - "kb.removeConfirm.title": "Remove from knowledge base", - "kb.removeConfirm.desc": "Remove these {count} files from “{name}”? The original files will not be deleted.", - "kb.removeConfirm.confirm": "Remove", - "kb.deleteFilesConfirm.title": "Delete files from this device?", - "kb.deleteFilesConfirm.desc": "Delete these {count} files? They will be deleted from this device and removed from every knowledge base. This cannot be undone.", - "kb.deleteFilesConfirm.confirm": "Delete from device", - "kb.deleteConfirm.title": "Delete knowledge base", - "kb.deleteConfirm.desc": "Delete \"{name}\"? The files themselves are kept.", - "kb.modal.createTitle": "New knowledge base", - "kb.modal.renameTitle": "Rename knowledge base", - "kb.modal.nameLabel": "Name", - "kb.modal.namePlaceholder": "e.g. Embodied AI papers", "litrev.title.setup": "Scoping", "litrev.title.execution": "Running", "litrev.scene": "Literature review", @@ -3123,6 +2896,8 @@ export const enUSMessages: Record = { "litrev.stageActivity.questions.generating": "Generating questions", "litrev.stageActivity.questions.waiting": "Waiting for your answers", "litrev.stageActivity.questions.done": "Asked the user", + "litrev.stageActivity.sources.waiting": "Waiting for local papers", + "litrev.stageActivity.sources.done": "Local papers", "litrev.stageActivity.keywords.generating": "Generating keywords", "litrev.stageActivity.keywords.waiting": "Waiting for adjustments", "litrev.stageActivity.keywords.done": "Keyword list", @@ -3136,13 +2911,24 @@ export const enUSMessages: Record = { "litrev.stageActivity.tasks.done": "Task list", "litrev.stageActivity.preparation.done": "Research preparation completed", "litrev.activity.durationSeconds": "{seconds}s", - "litrev.stageOutput.questions": "Got it—the research requirements are clear. I’ll organize the search keywords first, then use them to build the outline.", + "litrev.stageOutput.questions": "Got it—the research topic and date range are clear. You can add local papers before we continue.", + "litrev.stageOutput.sources": "The local sources are confirmed. I’ll use them while generating the search keywords.", "litrev.stageOutput.keywords": "The keywords are confirmed. I’ll structure the review around core concepts, method mechanics, and evaluation.", "litrev.stageOutput.outline": "The outline is set. Next I’ll search for and screen papers that directly support each section.", "litrev.stageOutput.references": "The reference scope is confirmed. I’ll create the task list, then begin organizing the material and drafting the review.", "litrev.workflow.close": "Close and end this workflow", "litrev.workflow.cancelled": "Workflow cancelled", "litrev.workflow.cancelledDetail": "This workflow has stopped and no further steps will run.", + "litrev.sources.title": "Add local papers", + "litrev.sources.hint": "If you already have relevant papers, add files or a folder. The Agent will read them first and search for anything missing.", + "litrev.sources.existingHint": "Sources uploaded earlier are already included. Add more files or folders before confirming keywords.", + "litrev.sources.empty": "No local papers yet. You can also skip this step and search online.", + "litrev.sources.none": "No local papers added", + "litrev.sources.count": "{count} items added", + "litrev.sources.addFiles": "Add files", + "litrev.sources.addFolder": "Add folder", + "litrev.sources.confirm": "Confirm sources", + "litrev.sources.skip": "Skip and continue", "litrev.wizard.step.keywords": "Search keywords", "litrev.wizard.step.outline": "Review outline", "litrev.wizard.step.references": "Reference sources", @@ -3161,12 +2947,7 @@ export const enUSMessages: Record = { "litrev.refs.local": "Available locally", "litrev.refs.fromWeb": "Needs download", "litrev.refs.note": "Agent found related papers. Local ones are ready to use; web papers are fetched after you select them.", - "litrev.refs.policyTitle": "Optional: save to a knowledge base", - "litrev.refs.targetKb": "Choose a knowledge base", "litrev.refs.useDefault": "Use Agent defaults", - "litrev.refs.addToKb": "Add to knowledge base", - "litrev.refs.addToKbHint": "Choose one or more target knowledge bases", - "litrev.refs.selectedKbCount": "{count} knowledge bases selected", "litrev.refs.confirmStart": "Confirm and start", "litrev.composer.setup": "Add requirements or reply to the question above…", "litrev.composer.task": "Add requirements anytime; the agent adjusts in real time…", @@ -3179,10 +2960,7 @@ export const enUSMessages: Record = { "litrev.todo.output.checked": "Citation checks are complete, and the text maps cleanly to the bibliography. The document is ready.", "litrev.preview.scope": "Browse scope", "litrev.preview.taskFiles": "Task files", - "litrev.preview.knowledge": "Knowledge base", "litrev.preview.projectSpace": "Project space", - "litrev.preview.knowledgeScope": "Choose knowledge base", - "litrev.preview.allFiles": "All files", "litrev.preview.toggleFiles": "Expand or collapse file tree", "litrev.preview.resizeFiles": "Resize file tree", "litrev.preview.outputs": "Outputs", diff --git a/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts b/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts index 2feacae45..08e0a5662 100644 --- a/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts +++ b/App/frontend/desktop/src/i18n/tests/english-ui-coverage.test.ts @@ -10,7 +10,6 @@ const allowedSourceFiles = new Set([ "i18n/messages.ts", "lib/nickname.ts", "pages/memory/skill-demo-data.ts", - "pages/knowledge-demo-data.ts", "pages/literature-review-demo-data.ts", // English ui coverage tests. "dev-agent-preview.tsx" diff --git a/App/frontend/desktop/src/lib/agent-managed-files.ts b/App/frontend/desktop/src/lib/agent-managed-files.ts deleted file mode 100644 index e0b1ca70b..000000000 --- a/App/frontend/desktop/src/lib/agent-managed-files.ts +++ /dev/null @@ -1,48 +0,0 @@ -export type AgentManagedFileSource = "agent-generated" | "agent-downloaded"; - -export interface AgentManagedFile { - id: string; - path: string; - name: string; - size: string; - updated: string; - source: AgentManagedFileSource; -} - -export const AGENT_MANAGED_FILES_STORAGE_KEY = "memmy.agentManagedFiles"; -export const AGENT_MANAGED_FILES_CHANGED_EVENT = "memmy:agent-managed-files-changed"; - -export function readAgentManagedFiles(storage?: Storage): AgentManagedFile[] { - try { - const parsed = JSON.parse(storage?.getItem(AGENT_MANAGED_FILES_STORAGE_KEY) ?? "[]") as unknown; - if (!Array.isArray(parsed)) return []; - return parsed.filter((item): item is AgentManagedFile => ( - typeof item === "object" - && item != null - && typeof (item as AgentManagedFile).id === "string" - && typeof (item as AgentManagedFile).path === "string" - && typeof (item as AgentManagedFile).name === "string" - && ((item as AgentManagedFile).source === "agent-generated" - || (item as AgentManagedFile).source === "agent-downloaded") - )); - } catch { - return []; - } -} - -export function registerAgentManagedFiles( - files: AgentManagedFile[], - storage?: Storage, - eventTarget?: EventTarget -): AgentManagedFile[] { - const byId = new Map(readAgentManagedFiles(storage).map((file) => [file.id, file])); - for (const file of files) byId.set(file.id, file); - const next = [...byId.values()]; - try { - storage?.setItem(AGENT_MANAGED_FILES_STORAGE_KEY, JSON.stringify(next)); - } catch { - // The in-page event still lets an open file list update for this session. - } - eventTarget?.dispatchEvent(new Event(AGENT_MANAGED_FILES_CHANGED_EVENT)); - return next; -} diff --git a/App/frontend/desktop/src/lib/composer-file-reference.ts b/App/frontend/desktop/src/lib/composer-file-reference.ts index e2c2285d0..7a961ff8f 100644 --- a/App/frontend/desktop/src/lib/composer-file-reference.ts +++ b/App/frontend/desktop/src/lib/composer-file-reference.ts @@ -15,6 +15,46 @@ export function mergeComposerContextReferences( return next; } +export function composerFolderReferenceFromFiles( + files: File[], + resolvePath?: (file: File) => string +): ComposerContextReference | null { + const first = files[0]; + if (!first) return null; + const relativePath = first.webkitRelativePath.replace(/\\/g, "/"); + const rootName = relativePath.split("/")[0] || first.name; + const fullPath = (resolvePath?.(first) || first.name).replace(/\\/g, "/"); + const rootPath = relativePath && fullPath.endsWith(relativePath) + ? `${fullPath.slice(0, -relativePath.length)}${rootName}` + : rootName; + return { + kind: "path", + id: rootPath, + label: `${rootName}/` + }; +} + +export function parsePathReferencesFromComposerContent(content: string): { + content: string; + references: ComposerContextReference[]; +} { + const match = /(?:\r?\n){0,2}\r?\n([\s\S]*?)\r?\n<\/memmy-context>\s*$/.exec(content); + if (!match) return { content, references: [] }; + const references = (match[1] ?? "").split(/\r?\n/).flatMap((line) => { + const prefix = /^- (file|folder): /.exec(line); + const idStart = line.lastIndexOf(" ("); + if (!prefix || idStart <= prefix[0].length || !line.endsWith(")")) return []; + const label = line.slice(prefix[0].length, idStart); + const id = line.slice(idStart + 2, -1); + return label && id ? [{ kind: "path" as const, id, label }] : []; + }); + if (!references.length) return { content, references: [] }; + return { + content: content.slice(0, match.index).trimEnd(), + references + }; +} + export function writeComposerReferenceDrag( dataTransfer: DataTransfer, reference: ComposerContextReference @@ -30,7 +70,7 @@ export function readComposerReferenceDrag(dataTransfer: DataTransfer): ComposerC try { const value = JSON.parse(payload) as Partial; if ( - (value.kind === "kb" || value.kind === "path") + value.kind === "path" && typeof value.id === "string" && value.id.length > 0 && typeof value.label === "string" diff --git a/App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts b/App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts deleted file mode 100644 index 1155a4388..000000000 --- a/App/frontend/desktop/src/lib/tests/agent-managed-files.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - AGENT_MANAGED_FILES_CHANGED_EVENT, - readAgentManagedFiles, - registerAgentManagedFiles, - type AgentManagedFile -} from "../agent-managed-files.js"; - -function memoryStorage(): Storage { - const values = new Map(); - return { - get length() { - return values.size; - }, - clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, - key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => { - values.delete(key); - }, - setItem: (key, value) => { - values.set(key, value); - } - }; -} - -const generated: AgentManagedFile = { - id: "generated-1", - path: "Memmy 文件/生成/综述.md", - name: "综述.md", - size: "—", - updated: "刚刚", - source: "agent-generated" -}; - -describe("agent managed files", () => { - it("persists generated files and replaces duplicate identities", () => { - const storage = memoryStorage(); - - registerAgentManagedFiles([generated], storage); - registerAgentManagedFiles([{ ...generated, path: "Memmy Files/Generated/review.md" }], storage); - - expect(readAgentManagedFiles(storage)).toEqual([{ ...generated, path: "Memmy Files/Generated/review.md" }]); - }); - - it("notifies an open all-files view after registration", () => { - const storage = memoryStorage(); - const target = new EventTarget(); - const listener = vi.fn(); - target.addEventListener(AGENT_MANAGED_FILES_CHANGED_EVENT, listener); - - registerAgentManagedFiles([generated], storage, target); - - expect(listener).toHaveBeenCalledOnce(); - }); -}); diff --git a/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts b/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts index a552a437b..4d809b500 100644 --- a/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts +++ b/App/frontend/desktop/src/lib/tests/composer-file-reference.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + composerFolderReferenceFromFiles, MEMMY_COMPOSER_REFERENCE_MIME, mergeComposerContextReferences, + parsePathReferencesFromComposerContent, readComposerReferenceDrag, writeComposerReferenceDrag } from "../composer-file-reference.js"; @@ -36,8 +38,37 @@ describe("composer file references", () => { it("deduplicates references by kind and id", () => { const first = { kind: "path" as const, id: "paper.pdf", label: "paper.pdf" }; - const base = { kind: "kb" as const, id: "kb-1", label: "研究资料" }; + const folder = { kind: "path" as const, id: "研究资料", label: "研究资料/" }; - expect(mergeComposerContextReferences([first], [first, base])).toEqual([first, base]); + expect(mergeComposerContextReferences([first], [first, folder])).toEqual([first, folder]); + }); + + it("collapses a directory selection into one folder reference", () => { + const file = new File(["paper"], "paper.pdf"); + Object.defineProperty(file, "webkitRelativePath", { value: "Papers/2026/paper.pdf" }); + + expect(composerFolderReferenceFromFiles( + [file], + () => "/Users/memmy/Papers/2026/paper.pdf" + )).toEqual({ + kind: "path", + id: "/Users/memmy/Papers", + label: "Papers/" + }); + }); + + it("restores sent references from canonical thread content", () => { + expect(parsePathReferencesFromComposerContent( + "请结合这些资料\n\n\n" + + "- file: paper.pdf (/Users/memmy/paper.pdf)\n" + + "- folder: Papers/ (/Users/memmy/Papers)\n" + + "" + )).toEqual({ + content: "请结合这些资料", + references: [ + { kind: "path", id: "/Users/memmy/paper.pdf", label: "paper.pdf" }, + { kind: "path", id: "/Users/memmy/Papers", label: "Papers/" } + ] + }); }); }); diff --git a/App/frontend/desktop/src/main.tsx b/App/frontend/desktop/src/main.tsx index 2573657ff..0d1b91a39 100644 --- a/App/frontend/desktop/src/main.tsx +++ b/App/frontend/desktop/src/main.tsx @@ -6,12 +6,10 @@ import { initGtag } from "./analytics/gtag-init.js"; import { NicknameModal } from "./components/nickname-modal.js"; import { I18nProvider } from "./i18n/i18n-provider.js"; import { randomNickname } from "./lib/nickname.js"; -import { KnowledgePage } from "./pages/knowledge-page.js"; import { LiteratureReviewPage } from "./pages/literature-review-page.js"; import { MemoryPage } from "./pages/memory-page.js"; import { MemoryPluginConflictModal } from "./pages/memory-plugin-conflict-modal.js"; import { StartupScreen } from "./pages/startup-screen.js"; -import { KB_ONBOARDED_STORAGE_KEY } from "./pages/knowledge-demo-data.js"; import { applyWindowPlatformClass } from "./utils/window-fullscreen.js"; import rendererLog from "electron-log/renderer"; import "./styles.css"; @@ -76,20 +74,6 @@ function MemorySkillsPreview() { ); } -/** Design-complete KB mock: skips Electron bootstrap so product demos never hang. */ -function KnowledgePreview() { - try { - window.sessionStorage.setItem(KB_ONBOARDED_STORAGE_KEY, "1"); - } catch { - // Preview still works; the page just starts from onboarding. - } - return ( - - - - ); -} - /** Design-complete literature-review mock: no backend required. */ function LiteratureReviewPreview() { return ( @@ -121,8 +105,6 @@ createRoot(root).render( ) : previewMode === "memory-skills" ? ( - ) : previewMode === "knowledge" ? ( - ) : previewMode === "literature-review" ? ( ) : ( diff --git a/App/frontend/desktop/src/pages/app-frame.tsx b/App/frontend/desktop/src/pages/app-frame.tsx index 7d2151cc7..00d7457e6 100644 --- a/App/frontend/desktop/src/pages/app-frame.tsx +++ b/App/frontend/desktop/src/pages/app-frame.tsx @@ -56,7 +56,7 @@ import { Trash2, User } from "./memory/memory-prototype-icons.js"; -import { Check, CheckCheck, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Folder, FolderOpen, FolderPlus, LibraryBig, ListFilter, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; +import { Check, CheckCheck, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Folder, FolderOpen, FolderPlus, ListFilter, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; export interface AppFrameProps { title: string; @@ -171,7 +171,6 @@ const navItems: NavItem[] = [ { action: "search", icon: , labelKey: "appFrame.search" }, { path: "/tools", icon: }, { path: "/memory", icon: }, - { path: "/knowledge", icon: }, { action: "community", icon: , labelKey: "welcome.joinCommunity" } ]; diff --git a/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx b/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx index 632b4ca54..e14e8dbbc 100644 --- a/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx +++ b/App/frontend/desktop/src/pages/home-composer-quick-actions.tsx @@ -17,18 +17,16 @@ import { type ReactNode, type Ref } from "react"; -import { AtSign, LibraryBig, Plus, SquareSlash } from "lucide-react"; +import { AtSign, Plus, SquareSlash } from "lucide-react"; import { FileTypeIcon, FolderTypeIcon } from "../components/file-type-icon.js"; import { Tooltip } from "../components/tooltip.js"; import { useTranslation } from "../i18n/use-translation.js"; import type { ComposerContextReference } from "../state/agent-composer-state.js"; import { AgentAttachmentCard } from "./agent-file-attachment-chip.js"; -import { buildDemoKnowledgeBases, type KbKnowledgeBase } from "./knowledge-demo-data.js"; import { buildHomeReferenceItems, type HomeReferenceItem } from "./literature-review-demo-data.js"; export type ComposerContextChip = ComposerContextReference; -const HOME_KNOWLEDGE_BASES: KbKnowledgeBase[] = buildDemoKnowledgeBases(); const HOME_REFERENCE_ITEMS: HomeReferenceItem[] = buildHomeReferenceItems(); /** Extracts a trailing `@token` mention query from the composer input, or null. */ @@ -199,9 +197,9 @@ export function ComposerHighlightedTextarea(props: {
); } - export function ComposerQuickActionButtons(props: { onAttach: () => void; + onAttachFolder?: () => void; onInsertMention: () => void; onInsertSlash: () => void; /** Anchored directly under the @ button. */ @@ -211,20 +209,62 @@ export function ComposerQuickActionButtons(props: { }) { const { t } = useTranslation(); const buttonClass = "composer-quick-actions__btn"; + const [attachOpen, setAttachOpen] = useState(false); + const attachAnchorRef = useRef(null); const referenceOpen = Boolean(props.referenceMenu); const slashOpen = Boolean(props.slashMenu); + + useEffect(() => { + if (!attachOpen) return; + const close = (event: PointerEvent) => { + if (!attachAnchorRef.current?.contains(event.target as Node)) setAttachOpen(false); + }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, [attachOpen]); + return (
- - - +
+ + + + {attachOpen && props.onAttachFolder ? ( +
+ + +
+ ) : null} +
- ))} -
{t("home.quick.fileSection")}
+
{t("home.quick.localSection")}
{items.map((item) => ( ))} - {!bases.length && !items.length ? ( + {!items.length ? (
{t("home.quick.noMatches")}
) : null}
@@ -386,11 +409,9 @@ export function HomeContextChips(props: {
{props.chips.map((chip) => { const folder = chip.kind === "path" && chip.label.endsWith("/"); - const kindLabel = chip.kind === "kb" - ? t("home.quick.kind.kb") - : folder - ? t("home.quick.kind.folder") - : t("home.quick.kind.file"); + const kindLabel = folder + ? t("home.quick.kind.folder") + : t("home.quick.kind.file"); return ( props.onRemove?.(chip) : undefined} leading={( - chip.kind === "kb" ? ( - - - ) : folder ? ( + folder ? ( ) : ( diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index 59c717cfc..fc73797e4 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -33,6 +33,7 @@ import { import { encodeAgentImage, type AgentImageMime } from "../lib/agent-image-encode.js"; import { formatConversationTitleForDisplay } from "../lib/format-conversation-title.js"; import { + composerFolderReferenceFromFiles, dataTransferHasComposerReference, mergeComposerContextReferences, readComposerReferenceDrag @@ -89,7 +90,6 @@ import { stripMentionFromInput, type ComposerContextChip } from "./home-composer-quick-actions.js"; -import type { KbKnowledgeBase } from "./knowledge-demo-data.js"; import { LITREV_CONTEXT_STORAGE_KEY, LITREV_PROJECT_CONTEXT_STORAGE_KEY, @@ -244,9 +244,7 @@ export function composerContentWithReferences( const text = content.trim(); if (!references.length) return text; const context = references.map((reference) => ( - reference.kind === "kb" - ? `- knowledge-base: ${reference.label} (${reference.id})` - : `- file: ${reference.label} (${reference.id})` + `- ${reference.label.endsWith("/") ? "folder" : "file"}: ${reference.label} (${reference.id})` )).join("\n"); return [text, `\n${context}\n`].filter(Boolean).join("\n\n"); } @@ -876,6 +874,7 @@ export function HomePage() { const inputRef = useRef(null); const composerShellRef = useRef(null); const fileInputRef = useRef(null); + const folderInputRef = useRef(null); const scrollRef = useRef(null); const pendingStatusChatRef = useRef(null); const pendingLastCompactionChatRef = useRef(null); @@ -898,7 +897,7 @@ export function HomePage() { const chatScopeKey = agentChatScopeKey(state.agent.currentChatId, state.agent.newChatRequestId); const input = composerDrafts[chatScopeKey] ?? ""; const pendingAttachments = pendingAttachmentsByScope[chatScopeKey] ?? []; - const contextChips = contextReferencesByScope[chatScopeKey] ?? []; + const contextChips = (contextReferencesByScope[chatScopeKey] ?? []).filter((reference) => reference.kind === "path"); const draftTarget = state.agent.draftTargetsByScope[chatScopeKey] ?? { kind: "standalone" as const }; const selectedDraftProject = draftTarget.kind === "project" ? state.agent.projects.find((project) => project.id === draftTarget.projectId) ?? null @@ -1914,14 +1913,6 @@ export function HomePage() { }); } - function pickMentionKnowledgeBase(base: KbKnowledgeBase) { - addComposerContextChip({ kind: "kb", id: base.id, label: base.name }); - setCurrentComposerDraft((draft) => stripMentionFromInput(draft)); - setMentionMenuDismissed(true); - setReferencePickerOpen(false); - inputRef.current?.focus(); - } - function pickMentionReference(item: HomeReferenceItem) { addComposerContextChip({ kind: "path", id: item.path, label: item.path }); setCurrentComposerDraft((draft) => stripMentionFromInput(draft)); @@ -2202,6 +2193,10 @@ export function HomePage() { fileInputRef.current?.click(); } + function openFolderPicker() { + folderInputRef.current?.click(); + } + /** * Starts voice input on the main interface. */ @@ -2339,6 +2334,22 @@ export function HomePage() { } } + function selectFolder(event: ChangeEvent) { + const reference = composerFolderReferenceFromFiles( + Array.from(event.target.files ?? []), + (file) => { + try { + return window.memmy?.getPathForFile(file) || file.name; + } catch { + return file.name; + } + } + ); + if (reference) addComposerContextChip(reference); + event.target.value = ""; + inputRef.current?.focus(); + } + function handleComposerPaste(event: ClipboardEvent) { const files = clipboardImageFilesFromDataTransfer(event.clipboardData); if (!files.length) { @@ -2538,13 +2549,13 @@ export function HomePage() { open externalQuery={mentionQuery} onClose={() => setMentionMenuDismissed(true)} - onPickKnowledgeBase={pickMentionKnowledgeBase} onPickReference={pickMentionReference} /> ) : null} ) : null} @@ -2639,6 +2649,17 @@ export function HomePage() {

{t("home.notice")}

void selectMedia(event)} /> + { + folderInputRef.current = node; + node?.setAttribute("webkitdirectory", ""); + }} + type="file" + multiple + hidden + className="hidden" + onChange={selectFolder} + />
) : ( @@ -2781,21 +2802,42 @@ export function HomePage() { open externalQuery={mentionQuery} onClose={() => setMentionMenuDismissed(true)} - onPickKnowledgeBase={pickMentionKnowledgeBase} onPickReference={pickMentionReference} /> ) : null}
- +
+ + + +
+ + +
+
diff --git a/App/frontend/desktop/src/pages/knowledge-demo-data.ts b/App/frontend/desktop/src/pages/knowledge-demo-data.ts deleted file mode 100644 index 782f51a9f..000000000 --- a/App/frontend/desktop/src/pages/knowledge-demo-data.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Demo dataset for the knowledge base workspace. - * - * The knowledge page is currently a design-complete mock driven by this file: - * it walks from the first-run folder authorization all the way to populated - * knowledge bases so the whole flow can be reviewed without a backend. - */ - -export type KbFileStatus = "uploading" | "processing" | "processed" | "unsupported"; - -export interface KbSourceFolder { - id: string; - name: string; - root: string; - hint: string; - selected: boolean; -} - -export interface KbLibraryFile { - id: string; - /** Path relative to the synced source root, e.g. `文稿/论文资料/MemGPT.pdf`. */ - path: string; - name: string; - size: string; - updated: string; - status: KbFileStatus; - source?: "agent-generated" | "agent-downloaded"; -} - -export interface KbKnowledgeBase { - id: string; - name: string; - updated: string; - fileIds: string[]; - folders: KbKnowledgeFolder[]; -} - -export interface KbKnowledgeFolder { - id: string; - name: string; - fileIds: string[]; -} - -/** Moves knowledge-base members between virtual folders without changing source files. */ -export function moveFilesToKnowledgeFolder( - base: KbKnowledgeBase, - fileIds: string[], - folderId: string | null -): KbKnowledgeBase { - if (folderId && !base.folders.some((folder) => folder.id === folderId)) return base; - const moving = new Set(fileIds.filter((id) => base.fileIds.includes(id))); - if (!moving.size) return base; - return { - ...base, - folders: base.folders.map((folder) => ({ - ...folder, - fileIds: [ - ...folder.fileIds.filter((id) => !moving.has(id)), - ...(folder.id === folderId ? [...moving] : []) - ] - })) - }; -} - -export const KB_ONBOARDED_STORAGE_KEY = "memmy.knowledgeDemoOnboarded"; - -/** Fallback source root used when no synced folder is selected. */ -export const KB_DEFAULT_IMPORT_ROOT = "文稿"; - -/** Returns the localized demo root used by the current desktop platform. */ -export function kbDefaultImportRoot(platform?: string): string { - return platform === "win32" ? "文档" : KB_DEFAULT_IMPORT_ROOT; -} - -/** Builds the mock file entry produced by the "add files" action. */ -export function kbImportedFileMock(root: string, seed: number): KbLibraryFile { - const name = `新导入文献-${seed % 100}.pdf`; - return { - id: `import-${seed}`, - path: `${root}/${name}`, - name, - size: "2.4 MB", - updated: "刚刚", - status: "uploading" - }; -} - -/** Suggested local folders shown in the sync modal before they are synced. */ -export const KB_RECOMMENDED_FOLDERS: Array> = [ - { id: "documents", name: "文稿 (Documents)", root: "文稿", hint: "工作文件和项目" }, - { id: "downloads", name: "下载 (Downloads)", root: "下载", hint: "下载的文件" }, - { id: "desktop", name: "桌面 (Desktop)", root: "桌面", hint: "桌面文件" }, - { id: "papers", name: "论文", root: "论文", hint: "论文 PDF 与笔记" }, - { id: "notes", name: "备忘录", root: "备忘录", hint: "本地备忘与草稿" } -]; - -export function buildDemoSourceFolders(platform?: string): KbSourceFolder[] { - return KB_RECOMMENDED_FOLDERS.slice(0, 3).map((folder) => { - if (folder.id !== "documents" || platform !== "win32") { - return { ...folder, selected: true }; - } - return { - ...folder, - name: "文档 (Documents)", - root: "文档", - selected: true - }; - }); -} - -export function buildDemoLibraryFiles(platform?: string): KbLibraryFile[] { - const documents = kbDefaultImportRoot(platform); - return [ - { id: "d1", path: `${documents}/论文资料/references/MemGPT.pdf`, name: "MemGPT.pdf", size: "4.8 MB", updated: "2025.03.16 18:00", status: "processed" }, - { id: "d2", path: `${documents}/论文资料/references/MemoryBank.pdf`, name: "MemoryBank.pdf", size: "3.2 MB", updated: "2025.03.16 17:20", status: "processed" }, - { id: "d3", path: "下载/LongMemEval.pdf", name: "LongMemEval.pdf", size: "2.7 MB", updated: "2025.03.15 21:08", status: "processing" }, - { id: "d4", path: `${documents}/论文资料/长期记忆阅读笔记.md`, name: "长期记忆阅读笔记.md", size: "28 KB", updated: "2025.03.14 11:30", status: "processed" }, - { id: "d5", path: `${documents}/医学影像/U-Net.pdf`, name: "U-Net.pdf", size: "1.9 MB", updated: "2025.03.12 09:15", status: "processed" }, - { id: "d6", path: `${documents}/医学影像/影像分割资料汇总.docx`, name: "影像分割资料汇总.docx", size: "740 KB", updated: "2025.03.11 16:42", status: "processed" }, - { id: "d7", path: "桌面/Agent评测/AgentBench.pdf", name: "AgentBench.pdf", size: "5.1 MB", updated: "2025.03.10 14:05", status: "processed" }, - { id: "d8", path: "桌面/Agent评测/评测指标草稿.md", name: "评测指标草稿.md", size: "16 KB", updated: "2025.03.09 20:18", status: "processed" }, - { id: "d9", path: "下载/待整理/研究方法说明.pdf", name: "研究方法说明.pdf", size: "860 KB", updated: "刚刚", status: "uploading" }, - { id: "d10", path: "下载/待整理/会议笔记.md", name: "会议笔记.md", size: "12 KB", updated: "今天 10:22", status: "processing" }, - { id: "d11", path: `${documents}/pyproject.toml`, name: "pyproject.toml", size: "3 KB", updated: "昨天 23:58", status: "unsupported" } - ]; -} - -export function buildDemoKnowledgeBases(): KbKnowledgeBase[] { - return [ - { - id: "kb1", - name: "大模型长期记忆", - updated: "刚刚更新", - fileIds: ["d1", "d2", "d3", "d4"], - folders: [{ id: "folder-core-papers", name: "核心论文", fileIds: ["d1", "d2"] }] - }, - { id: "kb2", name: "医学影像综述", updated: "昨天", fileIds: ["d5", "d6"], folders: [] }, - { id: "kb3", name: "Agent 评测", updated: "3 天前", fileIds: ["d7", "d8"], folders: [] }, - { id: "kb4", name: "研究方法", updated: "上周", fileIds: ["d4", "d8"], folders: [] }, - { id: "kb5", name: "具身智能", updated: "2 周前", fileIds: ["d7"], folders: [] } - ]; -} - -/** Existing demo sessions keep sample content; first-run onboarding starts empty. */ -export function buildInitialKnowledgeBases(onboarded: boolean): KbKnowledgeBase[] { - return onboarded ? buildDemoKnowledgeBases() : []; -} - -export function readKbOnboarded(storage: Storage | undefined): boolean { - try { - return storage?.getItem(KB_ONBOARDED_STORAGE_KEY) === "1"; - } catch { - return false; - } -} - -export function writeKbOnboarded(storage: Storage | undefined): void { - try { - storage?.setItem(KB_ONBOARDED_STORAGE_KEY, "1"); - } catch { - // Demo flag only; losing it just replays onboarding. - } -} diff --git a/App/frontend/desktop/src/pages/knowledge-page.tsx b/App/frontend/desktop/src/pages/knowledge-page.tsx deleted file mode 100644 index d3149de11..000000000 --- a/App/frontend/desktop/src/pages/knowledge-page.tsx +++ /dev/null @@ -1,1712 +0,0 @@ -/** - * Knowledge base workspace (design-complete mock). - * - * Walks the full journey defined by the interaction prototype: first-run - * folder authorization -> knowledge base dashboard (recent bases + all synced - * files) -> base detail with document management. All data lives in - * `knowledge-demo-data.ts`; no backend calls are made. - */ -import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type MouseEvent, type ReactNode } from "react"; -import { - AlertCircle, - Check, - ChevronDown, - ChevronLeft, - ChevronRight, - Filter, - FolderPlus, - FolderSync, - LibraryBig, - MoreHorizontal, - Pencil, - Plus, - Search, - Trash2, - Upload, - X -} from "lucide-react"; -import { AppFrame } from "./app-frame.js"; -import { Button } from "../components/button.js"; -import { ConfirmDialog } from "../components/confirm-dialog.js"; -import { FileTypeIcon, FolderTypeIcon } from "../components/file-type-icon.js"; -import { Memmy } from "../components/mascot/memmy.js"; -import { Modal } from "../components/modal.js"; -import { useTranslation } from "../i18n/use-translation.js"; -import { - AGENT_MANAGED_FILES_CHANGED_EVENT, - readAgentManagedFiles, - type AgentManagedFile -} from "../lib/agent-managed-files.js"; -import { mergeComposerContextReferences, writeComposerReferenceDrag } from "../lib/composer-file-reference.js"; -import { agentActions, appActions } from "../state/app-actions.js"; -import { agentChatScopeKey, type ComposerContextReference } from "../state/agent-composer-state.js"; -import { useAppState } from "../state/app-state.js"; -import { - buildInitialKnowledgeBases, - buildDemoLibraryFiles, - buildDemoSourceFolders, - kbDefaultImportRoot, - kbImportedFileMock, - moveFilesToKnowledgeFolder, - readKbOnboarded, - writeKbOnboarded, - type KbFileStatus, - type KbKnowledgeBase, - type KbKnowledgeFolder, - type KbLibraryFile, - type KbSourceFolder -} from "./knowledge-demo-data.js"; - -const RECENT_BASE_LIMIT = 3; -const FILES_PAGE_SIZE = 8; -const ROOT_DROP_TARGET = "__root__"; - -interface NameModalState { - kind: "create" | "create-from-selection" | "rename"; - value: string; - baseId?: string; -} - -interface KbFileActionMenuState { - reference: ComposerContextReference; - fileIds: string[]; - path: string; - kind: "file" | "folder"; - context: "library" | "detail"; - virtualFolderId?: string; - virtualFolderName?: string; - submenuSide: "left" | "right"; - x: number; - y: number; -} - -type KbFileActionTarget = Omit; - -interface DeleteFolderState { - id: string; - name: string; - fileIds: string[]; -} - -type KbBrowseEntry = - | { - kind: "folder"; - path: string; - name: string; - fileIds: string[]; - itemCount: number; - allowFileActions?: boolean; - moveTargetId?: string; - } - | { kind: "file"; file: KbLibraryFile }; - -function entriesAtBrowsePath(files: KbLibraryFile[], browsePath: string): KbBrowseEntry[] { - const prefix = browsePath ? `${browsePath}/` : ""; - const folders = new Map; childNames: Set }>(); - const directFiles: KbLibraryFile[] = []; - - for (const file of files) { - if (browsePath && !file.path.startsWith(prefix)) continue; - const rest = browsePath ? file.path.slice(prefix.length) : file.path; - if (!rest) continue; - const slash = rest.indexOf("/"); - if (slash === -1) { - directFiles.push(file); - continue; - } - const name = rest.slice(0, slash); - const path = browsePath ? `${browsePath}/${name}` : name; - let folder = folders.get(path); - if (!folder) { - folder = { name, path, fileIds: new Set(), childNames: new Set() }; - folders.set(path, folder); - } - folder.fileIds.add(file.id); - const next = rest.slice(slash + 1); - const nextSlash = next.indexOf("/"); - folder.childNames.add(nextSlash === -1 ? next : next.slice(0, nextSlash)); - } - - const folderEntries: KbBrowseEntry[] = [...folders.values()] - .sort((a, b) => a.name.localeCompare(b.name, "zh-CN")) - .map((folder) => ({ - kind: "folder" as const, - path: folder.path, - name: folder.name, - fileIds: [...folder.fileIds], - itemCount: folder.childNames.size - })); - const fileEntries: KbBrowseEntry[] = directFiles - .slice() - .sort((a, b) => a.name.localeCompare(b.name, "zh-CN")) - .map((file) => ({ kind: "file" as const, file })); - return [...folderEntries, ...fileEntries]; -} - -function entriesInKnowledgeFolder( - base: KbKnowledgeBase, - files: KbLibraryFile[], - folderId: string -): KbBrowseEntry[] { - const filesById = new Map(files.map((file) => [file.id, file])); - if (folderId) { - const folder = base.folders.find((item) => item.id === folderId); - return (folder?.fileIds ?? []) - .map((id) => filesById.get(id)) - .filter((file): file is KbLibraryFile => Boolean(file)) - .map((file) => ({ kind: "file" as const, file })); - } - - const filedIds = new Set(base.folders.flatMap((folder) => folder.fileIds)); - const folders: KbBrowseEntry[] = base.folders.map((folder) => ({ - kind: "folder", - path: folder.id, - name: folder.name, - fileIds: folder.fileIds, - itemCount: folder.fileIds.length, - allowFileActions: false, - moveTargetId: folder.id - })); - const unfiled: KbBrowseEntry[] = base.fileIds - .filter((id) => !filedIds.has(id)) - .map((id) => filesById.get(id)) - .filter((file): file is KbLibraryFile => Boolean(file)) - .map((file) => ({ kind: "file", file })); - return [...folders, ...unfiled]; -} - -function agentManagedFileToLibraryFile(file: AgentManagedFile): KbLibraryFile { - return { - id: file.id, - path: file.path, - name: file.name, - size: file.size, - updated: file.updated, - status: "processed", - source: file.source - }; -} - -export function KnowledgePage() { - const { t } = useTranslation(); - const { state: appState, dispatch } = useAppState(); - const desktopPlatform = typeof window === "undefined" ? undefined : window.memmy?.platform; - const [onboarded, setOnboarded] = useState(() => readKbOnboarded(typeof window === "undefined" ? undefined : window.sessionStorage)); - const [onboardingStep, setOnboardingStep] = useState<1 | 2>(1); - const [onboardingBaseName, setOnboardingBaseName] = useState(""); - const [onboardingFileIds, setOnboardingFileIds] = useState([]); - const [sourceFolders, setSourceFolders] = useState(() => buildDemoSourceFolders(desktopPlatform)); - const [libraryFiles, setLibraryFiles] = useState(() => [ - ...buildDemoLibraryFiles(desktopPlatform), - ...readAgentManagedFiles(typeof window === "undefined" ? undefined : window.localStorage) - .map(agentManagedFileToLibraryFile) - ]); - const [bases, setBases] = useState(() => buildInitialKnowledgeBases( - readKbOnboarded(typeof window === "undefined" ? undefined : window.sessionStorage) - )); - const [activeBaseId, setActiveBaseId] = useState(null); - const [showAllBases, setShowAllBases] = useState(false); - const [browsePath, setBrowsePath] = useState(""); - const [detailBrowsePath, setDetailBrowsePath] = useState(""); - const [libraryPage, setLibraryPage] = useState(1); - const [detailPage, setDetailPage] = useState(1); - const [selectedFileIds, setSelectedFileIds] = useState([]); - const [joinMenuOpen, setJoinMenuOpen] = useState(false); - const [syncModalOpen, setSyncModalOpen] = useState(false); - const [detailSearch, setDetailSearch] = useState(""); - const [librarySearch, setLibrarySearch] = useState(""); - const [libraryFilter, setLibraryFilter] = useState<"all" | KbFileStatus>("all"); - const [filterMenuOpen, setFilterMenuOpen] = useState(false); - const [baseMenuId, setBaseMenuId] = useState(null); - const [nameModal, setNameModal] = useState(null); - const [inlineFolderDraft, setInlineFolderDraft] = useState(null); - const [renamingFolder, setRenamingFolder] = useState<{ id: string; value: string } | null>(null); - const [dropFolderId, setDropFolderId] = useState(null); - const [contextJoinMenuOpen, setContextJoinMenuOpen] = useState(false); - const [deleteBaseId, setDeleteBaseId] = useState(null); - const [removeFileIds, setRemoveFileIds] = useState(null); - const [deleteFileIds, setDeleteFileIds] = useState(null); - const [deleteFolder, setDeleteFolder] = useState(null); - const [fileContextMenu, setFileContextMenu] = useState(null); - const nameInputRef = useRef(null); - const inlineFolderInputRef = useRef(null); - const folderNameInputRef = useRef(null); - const draggedKbFileIdsRef = useRef([]); - const syncFolderPickerRef = useRef(null); - - const activeBase = activeBaseId ? bases.find((base) => base.id === activeBaseId) ?? null : null; - const selectedSourceRoots = sourceFolders.filter((folder) => folder.selected).map((folder) => folder.root); - const visibleFiles = useMemo(() => { - const inSources = libraryFiles.filter((file) => ( - file.source != null - || selectedSourceRoots.some((root) => file.path === root || file.path.startsWith(`${root}/`)) - )); - if (libraryFilter === "all") return inSources; - return inSources.filter((file) => file.status === libraryFilter); - }, [libraryFiles, libraryFilter, selectedSourceRoots]); - - useEffect(() => { - const refreshManagedFiles = () => { - const managed = readAgentManagedFiles(window.localStorage).map(agentManagedFileToLibraryFile); - setLibraryFiles((files) => [ - ...files.filter((file) => file.source == null), - ...managed - ]); - }; - window.addEventListener(AGENT_MANAGED_FILES_CHANGED_EVENT, refreshManagedFiles); - return () => window.removeEventListener(AGENT_MANAGED_FILES_CHANGED_EVENT, refreshManagedFiles); - }, []); - const activeBaseDocs = useMemo( - () => (activeBase ? activeBase.fileIds.map((id) => libraryFiles.find((file) => file.id === id)).filter((file): file is KbLibraryFile => Boolean(file)) : []), - [activeBase, libraryFiles] - ); - const activeVirtualFolder = activeBase?.folders.find((folder) => folder.id === detailBrowsePath) ?? null; - const browseEntries = useMemo(() => entriesAtBrowsePath(visibleFiles, browsePath), [visibleFiles, browsePath]); - const detailBrowseEntries = useMemo( - () => (activeBase ? entriesInKnowledgeFolder(activeBase, activeBaseDocs, detailBrowsePath) : []), - [activeBase, activeBaseDocs, detailBrowsePath] - ); - const browseCrumbs = useMemo(() => (browsePath ? browsePath.split("/") : []), [browsePath]); - const detailBrowseCrumbs = useMemo(() => (activeVirtualFolder ? [activeVirtualFolder.name] : []), [activeVirtualFolder]); - - useEffect(() => { - if (!browsePath) return; - const stillVisible = visibleFiles.some((file) => file.path === browsePath || file.path.startsWith(`${browsePath}/`)); - if (!stillVisible) setBrowsePath(""); - }, [browsePath, visibleFiles]); - - useEffect(() => { - if (!detailBrowsePath) return; - const stillVisible = activeBase?.folders.some((folder) => folder.id === detailBrowsePath) ?? false; - if (!stillVisible) setDetailBrowsePath(""); - }, [activeBase, detailBrowsePath]); - - useEffect(() => { - setDetailBrowsePath(""); - setDetailSearch(""); - setDetailPage(1); - setSelectedFileIds([]); - setInlineFolderDraft(null); - setRenamingFolder(null); - }, [activeBaseId]); - - useEffect(() => { - setLibraryPage(1); - }, [browsePath, libraryFilter, librarySearch]); - - useEffect(() => { - setDetailPage(1); - }, [detailBrowsePath, detailSearch]); - - useEffect(() => { - if (!fileContextMenu) return; - const close = () => setFileContextMenu(null); - window.addEventListener("pointerdown", close); - window.addEventListener("resize", close); - return () => { - window.removeEventListener("pointerdown", close); - window.removeEventListener("resize", close); - }; - }, [fileContextMenu]); - - useEffect(() => { - if (inlineFolderDraft == null) return; - inlineFolderInputRef.current?.focus(); - inlineFolderInputRef.current?.select(); - }, [inlineFolderDraft == null]); - - useEffect(() => { - if (!renamingFolder) return; - folderNameInputRef.current?.focus(); - folderNameInputRef.current?.select(); - }, [renamingFolder?.id]); - - function composerReferenceForFile(file: KbLibraryFile): ComposerContextReference { - return { kind: "path", id: file.path, label: file.name }; - } - - function beginFileDrag(event: DragEvent, file: KbLibraryFile, internalFileIds: string[] = []) { - writeComposerReferenceDrag(event.dataTransfer, composerReferenceForFile(file)); - draggedKbFileIdsRef.current = internalFileIds; - if (internalFileIds.length) { - event.dataTransfer.effectAllowed = "copyMove"; - event.dataTransfer.setData("application/x-memmy-kb-file-ids+json", JSON.stringify(internalFileIds)); - } - } - - function openFileContextMenu( - event: MouseEvent, - target: KbFileActionTarget - ) { - event.preventDefault(); - event.stopPropagation(); - const rect = event.currentTarget.getBoundingClientRect(); - const fromContextMenu = event.type === "contextmenu"; - const anchorX = fromContextMenu ? event.clientX : rect.right; - const anchorY = fromContextMenu ? event.clientY : rect.bottom + 4; - const menuWidth = 182; - const menuX = Math.max( - 8, - Math.min(anchorX - (fromContextMenu ? 0 : menuWidth), window.innerWidth - menuWidth - 8) - ); - const submenuSide = menuX + menuWidth * 2 + 6 <= window.innerWidth ? "right" : "left"; - setFileContextMenu({ - ...target, - submenuSide, - x: menuX, - y: Math.max(8, Math.min(anchorY, window.innerHeight - 280)) - }); - setContextJoinMenuOpen(false); - } - - function showActionTargetInFolder(menu: KbFileActionMenuState) { - setFileContextMenu(null); - void window.memmy?.showItemInFolder(menu.path); - } - - function addReferenceToChat(reference: ComposerContextReference) { - const createDraft = !appState.agent.currentChatId && !appState.agent.blankDraftActive; - const scopeKey = agentChatScopeKey( - appState.agent.currentChatId, - appState.agent.newChatRequestId + (createDraft ? 1 : 0) - ); - const current = appState.agent.composerContextReferencesByScope[scopeKey] ?? []; - if (createDraft) { - dispatch(agentActions.newChatRequested()); - } - dispatch(agentActions.composerContextReferencesUpdated( - scopeKey, - mergeComposerContextReferences(current, [reference]) - )); - dispatch(appActions.navigate("/main")); - } - - function fileById(id: string): KbLibraryFile | undefined { - return libraryFiles.find((file) => file.id === id); - } - - function continueOnboarding(skipSync: boolean) { - if (skipSync) { - setSourceFolders((folders) => folders.map((folder) => ({ ...folder, selected: false }))); - } - setOnboardingFileIds([]); - setOnboardingStep(2); - } - - function finishOnboarding() { - writeKbOnboarded(typeof window === "undefined" ? undefined : window.sessionStorage); - setOnboarded(true); - } - - function createFirstKnowledgeBase() { - const name = onboardingBaseName.trim(); - if (!name) return; - setBases([{ - id: `kb-${Date.now()}`, - name, - updated: t("kb.updatedJustNow"), - fileIds: [...onboardingFileIds], - folders: [] - }]); - finishOnboarding(); - } - - function skipFirstKnowledgeBase() { - setBases([]); - finishOnboarding(); - } - - function toggleOnboardingFile(id: string) { - setOnboardingFileIds((ids) => ( - ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id] - )); - } - - function toggleSourceFolder(id: string) { - setSourceFolders((folders) => folders.map((folder) => (folder.id === id ? { ...folder, selected: !folder.selected } : folder))); - } - - function addSourceFolder(name: string) { - const trimmed = name.trim(); - if (!trimmed) return; - setSourceFolders((folders) => { - const existing = folders.find((folder) => folder.root === trimmed || folder.name === trimmed); - if (existing) { - return folders.map((folder) => (folder.id === existing.id ? { ...folder, selected: true } : folder)); - } - return [ - ...folders, - { id: `source-${Date.now()}`, name: trimmed, root: trimmed, hint: t("kb.source.customHint"), selected: true } - ]; - }); - } - - function pickSyncFolderFromDisk() { - syncFolderPickerRef.current?.click(); - } - - function handleSyncFolderPicked(event: ChangeEvent) { - const file = event.target.files?.[0]; - const folderName = file?.webkitRelativePath.split("/")[0]?.trim(); - if (folderName) addSourceFolder(folderName); - event.target.value = ""; - } - - function toggleFileSelection(id: string) { - if (fileById(id)?.status === "unsupported") return; - setJoinMenuOpen(false); - setSelectedFileIds((ids) => (ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id])); - } - - function toggleFolderSelection(fileIds: string[]) { - const selectableIds = fileIds.filter((id) => fileById(id)?.status !== "unsupported"); - if (!selectableIds.length) return; - setJoinMenuOpen(false); - setSelectedFileIds((ids) => { - const allSelected = selectableIds.every((id) => ids.includes(id)); - if (allSelected) return ids.filter((id) => !selectableIds.includes(id)); - const merged = new Set(ids); - for (const id of selectableIds) merged.add(id); - return [...merged]; - }); - } - - function addFileIdsToBase(fileIds: string[], baseId: string) { - const supportedSelection = fileIds.filter((id) => fileById(id)?.status !== "unsupported"); - setBases((current) => current.map((base) => ( - base.id === baseId - ? { - ...base, - fileIds: [...base.fileIds, ...supportedSelection.filter((id) => !base.fileIds.includes(id))], - updated: t("kb.updatedJustNow") - } - : base - ))); - } - - function addSelectionToBase(baseId: string) { - addFileIdsToBase(selectedFileIds, baseId); - setSelectedFileIds([]); - setJoinMenuOpen(false); - } - - function submitNameModal() { - const name = nameModal?.value.trim(); - if (!nameModal || !name) return; - if (nameModal.kind === "rename") { - const baseId = nameModal.baseId ?? activeBase?.id; - if (!baseId) return; - setBases((current) => current.map((base) => (base.id === baseId ? { ...base, name } : base))); - } else { - const id = `kb-${Date.now()}`; - const fromSelection = nameModal.kind === "create-from-selection"; - setBases((current) => [ - { - id, - name, - updated: t("kb.updatedJustNow"), - fileIds: fromSelection ? [...selectedFileIds] : [], - folders: [] - }, - ...current - ]); - if (fromSelection) { - setSelectedFileIds([]); - setJoinMenuOpen(false); - } - setShowAllBases(true); - } - setNameModal(null); - } - - function startInlineFolderCreation() { - if (!activeBase) return; - if (inlineFolderDraft != null) { - inlineFolderInputRef.current?.focus(); - inlineFolderInputRef.current?.select(); - return; - } - setDetailBrowsePath(""); - setDetailSearch(""); - setDetailPage(1); - setInlineFolderDraft(t("kb.folder.defaultName")); - } - - function commitInlineFolder() { - const name = inlineFolderDraft?.trim(); - if (!activeBase || !name) { - setInlineFolderDraft(null); - return; - } - const folder: KbKnowledgeFolder = { - id: `kb-folder-${Date.now()}`, - name, - fileIds: [] - }; - setBases((current) => current.map((base) => ( - base.id === activeBase.id - ? { - ...base, - folders: [...base.folders, folder], - updated: t("kb.updatedJustNow") - } - : base - ))); - setInlineFolderDraft(null); - } - - function startFolderRename(folderId: string, name: string) { - setInlineFolderDraft(null); - setRenamingFolder({ id: folderId, value: name }); - setFileContextMenu(null); - } - - function commitFolderRename() { - const name = renamingFolder?.value.trim(); - if (!activeBase || !renamingFolder || !name) { - setRenamingFolder(null); - return; - } - setBases((current) => current.map((base) => ( - base.id === activeBase.id - ? { - ...base, - folders: base.folders.map((folder) => ( - folder.id === renamingFolder.id ? { ...folder, name } : folder - )), - updated: t("kb.updatedJustNow") - } - : base - ))); - setRenamingFolder(null); - } - - function moveFilesToFolder(fileIds: string[], folderId: string | null) { - if (!activeBase || !fileIds.length) return; - setBases((current) => current.map((base) => ( - base.id === activeBase.id - ? { - ...moveFilesToKnowledgeFolder(base, fileIds, folderId), - updated: t("kb.updatedJustNow") - } - : base - ))); - } - - function deleteSelectedBase() { - if (!deleteBaseId) return; - setBases((current) => current.filter((base) => base.id !== deleteBaseId)); - if (activeBaseId === deleteBaseId) setActiveBaseId(null); - setDeleteBaseId(null); - } - - function confirmRemoveFromActiveBase() { - if (!activeBase || !removeFileIds?.length) return; - const remove = new Set(removeFileIds); - setBases((current) => current.map((base) => ( - base.id === activeBase.id - ? { - ...base, - fileIds: base.fileIds.filter((id) => !remove.has(id)), - folders: base.folders.map((folder) => ({ - ...folder, - fileIds: folder.fileIds.filter((id) => !remove.has(id)) - })) - } - : base - ))); - setSelectedFileIds((ids) => ids.filter((id) => !remove.has(id))); - setRemoveFileIds(null); - } - - function addMockImportedFile(baseId?: string) { - const root = selectedSourceRoots[0] ?? sourceFolders[0]?.root ?? kbDefaultImportRoot(desktopPlatform); - const imported = kbImportedFileMock(root, Date.now()); - setLibraryFiles((files) => [...files, imported]); - if (baseId) { - setBases((current) => current.map((base) => ( - base.id === baseId - ? { ...base, fileIds: [...base.fileIds, imported.id], updated: t("kb.updatedJustNow") } - : base - ))); - setDetailBrowsePath(""); - setDetailSearch(""); - setDetailPage(1); - } else { - setBrowsePath(""); - setLibraryPage(1); - } - } - - useEffect(() => { - if (nameModal) { - nameInputRef.current?.focus(); - nameInputRef.current?.select(); - } - }, [nameModal]); - - useEffect(() => { - if (!joinMenuOpen && !filterMenuOpen && !baseMenuId) return; - const closeOnOutsidePointer = (event: PointerEvent) => { - const target = event.target as HTMLElement | null; - if (target?.closest("[data-kb-popover-root]")) return; - setJoinMenuOpen(false); - setFilterMenuOpen(false); - setBaseMenuId(null); - }; - document.addEventListener("pointerdown", closeOnOutsidePointer); - return () => document.removeEventListener("pointerdown", closeOnOutsidePointer); - }, [baseMenuId, filterMenuOpen, joinMenuOpen]); - - function deleteLibraryFiles(ids: string[]) { - if (!ids.length) return; - const remove = new Set(ids); - setLibraryFiles((files) => files.filter((file) => !remove.has(file.id))); - setBases((current) => current.map((base) => ({ - ...base, - fileIds: base.fileIds.filter((id) => !remove.has(id)), - folders: base.folders.map((folder) => ({ - ...folder, - fileIds: folder.fileIds.filter((id) => !remove.has(id)) - })) - }))); - setSelectedFileIds((current) => current.filter((id) => !remove.has(id))); - setDeleteFileIds(null); - } - - function deleteKnowledgeFolder(deleteContainedFiles: boolean) { - if (!activeBase || !deleteFolder) return; - const deletedFileIds = new Set(deleteContainedFiles ? deleteFolder.fileIds : []); - if (deleteContainedFiles) { - setLibraryFiles((files) => files.filter((file) => !deletedFileIds.has(file.id))); - setSelectedFileIds((ids) => ids.filter((id) => !deletedFileIds.has(id))); - } - setBases((current) => current.map((base) => ({ - ...base, - fileIds: deleteContainedFiles - ? base.fileIds.filter((id) => !deletedFileIds.has(id)) - : base.fileIds, - folders: base.folders - .filter((folder) => !(base.id === activeBase.id && folder.id === deleteFolder.id)) - .map((folder) => deleteContainedFiles - ? { ...folder, fileIds: folder.fileIds.filter((id) => !deletedFileIds.has(id)) } - : folder), - updated: base.id === activeBase.id ? t("kb.updatedJustNow") : base.updated - }))); - setDetailBrowsePath(""); - setDeleteFolder(null); - } - - function renderJoinMenu(): ReactNode { - return ( -
- -
- {bases.map((base) => ( - - ))} -
- ); - } - - function renderRowOperate(target: KbFileActionTarget): ReactNode { - return target.fileIds.length || target.virtualFolderId ? ( - - ) : ; - } - - function renderFileBrowser(options: { - files: KbLibraryFile[]; - entries: KbBrowseEntry[]; - path: string; - crumbs: string[]; - onPathChange: (path: string) => void; - emptyLabel: string; - /** When set, operate column removes from the active knowledge base. */ - onRemoveFiles?: (fileIds: string[]) => void; - /** Flat search results skip folder navigation. */ - searchQuery?: string; - inlineFolderDraft?: string | null; - onInlineFolderChange?: (value: string) => void; - onInlineFolderCommit?: () => void; - onInlineFolderCancel?: () => void; - onMoveFilesToFolder?: (fileIds: string[], folderId: string | null) => void; - page: number; - onPageChange: (page: number) => void; - }): ReactNode { - if (!options.files.length && !options.entries.length && options.inlineFolderDraft == null) { - return
{options.emptyLabel}
; - } - - const query = options.searchQuery?.trim().toLowerCase() ?? ""; - const searching = query.length > 0; - const searchFiles = searching - ? options.files.filter((file) => file.path.toLowerCase().includes(query) || file.name.toLowerCase().includes(query)) - : []; - const totalItems = searching ? searchFiles.length : options.entries.length; - const totalPages = Math.max(1, Math.ceil(totalItems / FILES_PAGE_SIZE)); - const currentPage = Math.min(Math.max(options.page, 1), totalPages); - const pageStart = (currentPage - 1) * FILES_PAGE_SIZE; - const pagedSearchFiles = searchFiles.slice(pageStart, pageStart + FILES_PAGE_SIZE); - const pagedEntries = options.entries.slice(pageStart, pageStart + FILES_PAGE_SIZE); - - const renderFileRow = (file: KbLibraryFile) => { - const selected = selectedFileIds.includes(file.id); - const unsupported = file.status === "unsupported"; - const actionTarget: KbFileActionTarget = { - reference: composerReferenceForFile(file), - fileIds: [file.id], - path: file.path, - kind: "file", - context: options.onRemoveFiles ? "detail" : "library" - }; - return ( -
openFileContextMenu(event, actionTarget)} - > - - beginFileDrag( - event, - file, - options.onMoveFilesToFolder ? (selected ? selectedFileIds : [file.id]) : [] - )} - onDragEnd={() => { - draggedKbFileIdsRef.current = []; - setDropFolderId(null); - }} - > - - {file.name} - {unsupported ? ( - - - ) : null} - - {file.size} - {file.updated} - {renderRowOperate(actionTarget)} -
- ); - }; - - return ( -
- {!searching && options.path ? ( - - ) : null} -
-
- - {t("kb.files.col.name")} - {t("kb.files.col.size")} - {t("kb.files.col.updated")} - {t("kb.files.col.operate")} -
- {!searching && options.inlineFolderDraft != null ? ( -
- - - {t("kb.itemCount", { count: 0 })} - - -
- ) : null} - {searching ? ( - searchFiles.length ? pagedSearchFiles.map(renderFileRow) : ( -
{t("kb.detail.emptySearch")}
- ) - ) : options.entries.length ? pagedEntries.map((entry) => { - if (entry.kind === "folder") { - const selectableIds = entry.fileIds.filter((id) => fileById(id)?.status !== "unsupported"); - const selected = selectableIds.length > 0 && selectableIds.every((id) => selectedFileIds.includes(id)); - const actionTarget: KbFileActionTarget = { - reference: { kind: "path", id: entry.path, label: entry.name }, - fileIds: entry.fileIds, - path: entry.path, - kind: "folder", - context: options.onRemoveFiles ? "detail" : "library", - virtualFolderId: entry.moveTargetId, - virtualFolderName: entry.moveTargetId ? entry.name : undefined - }; - return ( -
{ - if (!draggedKbFileIdsRef.current.length) return; - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = "move"; - setDropFolderId(entry.moveTargetId ?? null); - } : undefined} - onDragLeave={entry.moveTargetId ? (event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropFolderId(null); - } : undefined} - onDrop={entry.moveTargetId && options.onMoveFilesToFolder ? (event) => { - event.preventDefault(); - event.stopPropagation(); - options.onMoveFilesToFolder?.(draggedKbFileIdsRef.current, entry.moveTargetId!); - draggedKbFileIdsRef.current = []; - setDropFolderId(null); - setSelectedFileIds([]); - } : undefined} - onContextMenu={(event) => openFileContextMenu(event, actionTarget)} - > - - {entry.moveTargetId && renamingFolder?.id === entry.moveTargetId ? ( - - ) : ( - - )} - {t("kb.itemCount", { count: entry.itemCount })} - - {renderRowOperate(actionTarget)} -
- ); - } - return renderFileRow(entry.file); - }) : ( -
{t("kb.files.emptyFolder")}
- )} -
- {totalItems > 0 ? ( - - ) : null} -
- ); - } - - function renderOnboarding(): ReactNode { - const selectedCount = sourceFolders.filter((folder) => folder.selected).length; - const onboardingFiles = visibleFiles - .filter((file) => file.status !== "unsupported") - .slice(0, 5); - const syncing = onboardingStep === 1; - return ( -
-
-
    -
  1. - 1 - {t("kb.onboarding.stepSync")} -
  2. -
  3. - 2 - {t("kb.onboarding.stepCreate")} -
  4. -
-
-

{t(syncing ? "kb.onboarding.title" : "kb.onboarding.createTitle")}

-

{t(syncing ? "kb.onboarding.desc" : "kb.onboarding.createDesc")}

-
- {syncing ? ( - <> -
{t("kb.onboarding.chooseFolders")}
-
- {sourceFolders.map((folder) => ( - - ))} -
- -
-
- - -
-
- - ) : ( - <> - -
{t("kb.onboarding.filesLabel")}
- {onboardingFiles.length ? ( -
- {onboardingFiles.map((file) => { - const selected = onboardingFileIds.includes(file.id); - return ( - - ); - })} -
- ) : ( -
{t("kb.onboarding.noFiles")}
- )} -

{t("kb.onboarding.filesHint")}

-
- -
- - -
-
- - )} -
-
- ); - } - - function renderDashboard(): ReactNode { - const shownBases = showAllBases ? bases : bases.slice(0, RECENT_BASE_LIMIT); - return ( -
-
-
-

{t("kb.recent.title")}

-
- {bases.length > RECENT_BASE_LIMIT ? ( - - ) : null} - -
-
- {bases.length ? ( -
- {shownBases.map((base) => ( -
- -
- - {baseMenuId === base.id ? ( -
- - -
- ) : null} -
-
- ))} -
- ) : ( -
{t("kb.recent.empty")}
- )} -
- -
-
-

{t("kb.files.title")}

-
- -
-
- -
-
- - {selectedFileIds.length ? ( - <> - -
- - {joinMenuOpen ? renderJoinMenu() : null} -
- - - ) : null} -
-
- -
- - {filterMenuOpen ? ( -
- {([ - ["all", t("kb.toolbar.filterAll")], - ["processed", t("kb.status.processed")], - ["processing", t("kb.status.processing")], - ["uploading", t("kb.status.uploading")], - ["unsupported", t("kb.status.unsupported")] - ] as const).map(([value, label]) => ( - - ))} -
- ) : null} -
-
-
- {selectedFileIds.length ? ( -
{t("kb.selection.count", { count: selectedFileIds.length })}
- ) : null} - - {renderFileBrowser({ - files: visibleFiles, - entries: browseEntries, - path: browsePath, - crumbs: browseCrumbs, - onPathChange: setBrowsePath, - emptyLabel: t("kb.files.empty"), - searchQuery: librarySearch, - page: libraryPage, - onPageChange: setLibraryPage - })} -
-
- ); - } - - function renderDetail(base: KbKnowledgeBase): ReactNode { - return ( -
-
- - -

{base.name}

-
-
-
- - - {selectedFileIds.length ? ( - <> - - - - - ) : null} -
-
- -
-
- {renderFileBrowser({ - files: activeBaseDocs, - entries: detailBrowseEntries, - path: detailBrowsePath, - crumbs: detailBrowseCrumbs, - onPathChange: setDetailBrowsePath, - emptyLabel: t("kb.detail.empty"), - searchQuery: detailSearch, - inlineFolderDraft, - onInlineFolderChange: setInlineFolderDraft, - onInlineFolderCommit: commitInlineFolder, - onInlineFolderCancel: () => setInlineFolderDraft(null), - onMoveFilesToFolder: moveFilesToFolder, - page: detailPage, - onPageChange: setDetailPage, - onRemoveFiles: (fileIds) => setRemoveFileIds([...new Set(fileIds)]) - })} -
- ); - } - - return ( - -
- {!activeBase ? ( -
- -
-

{t("kb.title")}

-

{t("kb.subtitle")}

-
-
- ) : null} - {!onboarded ? renderOnboarding() : activeBase ? renderDetail(activeBase) : renderDashboard()} -
- )} - onChange={handleSyncFolderPicked} - /> - - setNameModal(null)} - className="kb-name-modal-dialog" - initialFocusRef={nameInputRef} - footer={( - <> - - - - )} - > - - - - setSyncModalOpen(false)} - className="kb-sync-modal-dialog" - bodyClassName="kb-sync-modal-body" - > - {(() => { - const synced = sourceFolders.filter((folder) => folder.selected); - return ( -
-
-
- {t("kb.sync.syncedSection")} - {t("kb.sync.syncedSectionHint", { count: synced.length })} -
- {synced.length ? ( -
- {synced.map((folder) => ( -
- - - {folder.name} - {folder.root} - - -
- ))} -
- ) : ( -
{t("kb.sync.syncedEmpty")}
- )} -
- - -
- ); - })()} -
- - - - setRemoveFileIds(null)} - onConfirm={confirmRemoveFromActiveBase} - /> - - setDeleteFileIds(null)} - onConfirm={() => deleteLibraryFiles(deleteFileIds ?? [])} - /> - - base.id === deleteBaseId)?.name ?? "" })} - cancelLabel={t("common.cancel")} - confirmLabel={t("common.delete")} - confirmVariant="danger" - onCancel={() => setDeleteBaseId(null)} - onConfirm={deleteSelectedBase} - /> - - {fileContextMenu ? ( -
event.stopPropagation()} - > - {fileContextMenu.virtualFolderId ? ( - <> - - - - ) : ( - <> - - {fileContextMenu.context === "library" && fileContextMenu.fileIds.length ? ( -
- - {contextJoinMenuOpen ? ( -
- {bases.map((base) => ( - - ))} -
- ) : null} -
- ) : null} - - {fileContextMenu.context === "detail" ? ( - - ) : null} - - - )} -
- ) : null} -
- ); -} diff --git a/App/frontend/desktop/src/pages/literature-review-demo-data.ts b/App/frontend/desktop/src/pages/literature-review-demo-data.ts index 237722b76..253e15c04 100644 --- a/App/frontend/desktop/src/pages/literature-review-demo-data.ts +++ b/App/frontend/desktop/src/pages/literature-review-demo-data.ts @@ -12,7 +12,7 @@ export const LITREV_CONTEXT_STORAGE_KEY = "memmy.literatureReviewDemoContexts"; export const LITREV_PROJECT_CONTEXT_STORAGE_KEY = "memmy.literatureReviewDemoProjectId"; export interface LitrevLaunchContext { - kind: "kb" | "path"; + kind: "path"; id: string; label: string; } @@ -22,24 +22,25 @@ export const LITREV_DEFAULT_PROMPT = "帮我梳理近 5 年大模型长期记忆 /* ---------------------------------- 需求澄清 ---------------------------------- */ export interface LitrevSetupQuestion { - id: "focus" | "time" | "language" | "length" | "citation"; + id: "topic" | "time"; text: string; options: string[]; } -const ALL_SETUP_QUESTIONS: Array = [ - { id: "focus", text: "这篇综述更关注哪个方向?", options: ["方法与系统对比", "系统综述", "应用落地", "理论脉络"], skipPattern: /(方法|系统|应用|理论)/ }, - { id: "time", text: "希望覆盖什么时间范围?", options: ["近 3 年", "近 5 年", "近 10 年", "不限"], skipPattern: /(近\s*[0-9]|20\d{2}|时间|不限)/ }, - { id: "language", text: "正文使用什么语言?", options: ["中文", "英文", "中英双语"], skipPattern: /(中文|英文|双语)/ }, - { id: "length", text: "希望正文大约多长?", options: ["3–5 页", "8–12 页", "长文"], skipPattern: /(页|字|长文|篇幅)/ }, - { id: "citation", text: "使用哪种引用格式?", options: ["APA 7", "GB/T 7714", "IEEE"], skipPattern: /(APA|IEEE|GB\/T|国标|引用格式)/i } +const ALL_SETUP_QUESTIONS: LitrevSetupQuestion[] = [ + { id: "topic", text: "这篇文献综述的主题 / 研究领域是什么?", options: ["沿用当前描述", "方法与系统", "应用与实践", "理论与发展脉络"] }, + { id: "time", text: "希望论文覆盖什么时间范围?", options: ["近 3 年", "近 5 年", "近 10 年", "不限"] } ]; -/** Builds the clarification questions, skipping anything the prompt already answers. */ +/** Builds the two research-scope confirmations; other writing preferences stay agent-managed. */ export function buildLitrevSetupQuestions(prompt: string): LitrevSetupQuestion[] { - return ALL_SETUP_QUESTIONS - .filter((question) => !question.skipPattern.test(prompt)) - .map(({ id, text, options }) => ({ id, text, options })); + return ALL_SETUP_QUESTIONS.map((question) => { + if (question.id !== "time") return question; + const matched = question.options.find((option) => prompt.replace(/\s/g, "").includes(option.replace(/\s/g, ""))); + return matched + ? { ...question, options: [matched, ...question.options.filter((option) => option !== matched)] } + : question; + }); } export const LITREV_TOPIC_QUESTION = "文献综述的研究主题/领域是什么?"; @@ -134,17 +135,17 @@ export interface LitrevReference { id: string; title: string; meta: string; - /** Where the paper comes from: online search, knowledge base, or project files. */ - source: "web" | "knowledge" | "project"; + /** Where the paper comes from: online search or user-provided local files. */ + source: "web" | "local"; selected: boolean; } export function buildDemoReferences(): LitrevReference[] { return [ { id: "r1", title: "MemGPT: Towards LLMs as Operating Systems", meta: "Packer et al. · 2023", source: "web", selected: true }, - { id: "r2", title: "MemoryBank: Enhancing Large Language Models with Long-Term Memory", meta: "Zhong et al. · 2024", source: "knowledge", selected: true }, + { id: "r2", title: "MemoryBank: Enhancing Large Language Models with Long-Term Memory", meta: "Zhong et al. · 2024", source: "local", selected: true }, { id: "r3", title: "LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory", meta: "Wu et al. · 2024", source: "web", selected: true }, - { id: "r4", title: "Generative Agents: Interactive Simulacra of Human Behavior", meta: "Park et al. · 2023", source: "project", selected: false }, + { id: "r4", title: "Generative Agents: Interactive Simulacra of Human Behavior", meta: "Park et al. · 2023", source: "local", selected: false }, { id: "r5", title: "A Survey on the Memory Mechanism of LLM-based Agents", meta: "2024 · 仅摘要", source: "web", selected: true } ]; } @@ -176,7 +177,7 @@ export const LITREV_SEARCH_PHASE: LitrevThinkingPhase = { export const LITREV_EXECUTION_PHASE: LitrevThinkingPhase = { title: "正在生成执行计划", - stages: ["确认最终纳入文献与全文状态", "准备任务文件与知识库收录", "生成自动执行 To-do"] + stages: ["确认最终纳入文献与全文状态", "准备任务文件", "生成自动执行 To-do"] }; /** Tool-call history lines accumulated in the conversation while the wizard advances. */ @@ -281,7 +282,7 @@ export function litrevPreviewContentFor(path: string): LitrevPreviewContent { if (path.includes("正文")) return BODY_PREVIEW; return { title: path.split("/").pop() ?? path, - sections: [{ heading: "文件预览", body: "该文件的内容会在这里展示,可确认任务材料、项目文件或知识库资料。" }] + sections: [{ heading: "文件预览", body: "该文件的内容会在这里展示,可确认任务材料、项目文件或本机资料。" }] }; } @@ -307,12 +308,13 @@ export function buildHomeCapabilities(): HomeCapabilityItem[] { export interface HomeReferenceItem { path: string; - kind: "file"; + kind: "file" | "folder"; meta: string; } export function buildHomeReferenceItems(): HomeReferenceItem[] { return [ + { path: "本地文献/", kind: "folder", meta: "12 个文件" }, { path: "开题报告.docx", kind: "file", meta: "DOCX" }, { path: "研究范围.md", kind: "file", meta: "Markdown" }, { path: "文献对照表.xlsx", kind: "file", meta: "Excel" } diff --git a/App/frontend/desktop/src/pages/literature-review-page.tsx b/App/frontend/desktop/src/pages/literature-review-page.tsx index ce4d0ecbc..faa21bc13 100644 --- a/App/frontend/desktop/src/pages/literature-review-page.tsx +++ b/App/frontend/desktop/src/pages/literature-review-page.tsx @@ -17,7 +17,6 @@ import { Files, Folder, GripVertical, - LibraryBig, PanelLeftClose, PanelLeftOpen, PanelRight, @@ -26,7 +25,7 @@ import { Send, X } from "lucide-react"; -import { FileTypeIcon } from "../components/file-type-icon.js"; +import { FileTypeIcon, FolderTypeIcon } from "../components/file-type-icon.js"; import { useApiClients } from "../app/providers.js"; import { AppFrame } from "./app-frame.js"; import { AgentAttachmentCard } from "./agent-file-attachment-chip.js"; @@ -34,16 +33,15 @@ import { SidebarResizeHandle, useResizableSidebar } from "./sidebar-resize.js"; import { Button } from "../components/button.js"; import { useTranslation } from "../i18n/use-translation.js"; import { + composerFolderReferenceFromFiles, dataTransferHasComposerReference, mergeComposerContextReferences, readComposerReferenceDrag, writeComposerReferenceDrag } from "../lib/composer-file-reference.js"; -import { registerAgentManagedFiles } from "../lib/agent-managed-files.js"; import { agentActions } from "../state/app-actions.js"; import { agentChatScopeKey, type ComposerContextReference } from "../state/agent-composer-state.js"; import { useAppState } from "../state/app-state.js"; -import { buildDemoKnowledgeBases, buildDemoLibraryFiles, type KbKnowledgeBase } from "./knowledge-demo-data.js"; import { ComposerQuickActionButtons, ComposerReferencePanel, HomeContextChips } from "./home-composer-quick-actions.js"; import { mergeVoiceTranscript, useAsrRecorder } from "./asr-recorder.js"; import { Mic, Pause } from "./memory/memory-prototype-icons.js"; @@ -88,18 +86,20 @@ const LITREV_PREVIEW_WIDTH_STORAGE_KEY = "memmy.literatureReview.previewWidth"; const LITREV_FILE_BROWSER_WIDTH_STORAGE_KEY = "memmy.literatureReview.fileBrowserWidth"; type ThinkingKind = "planning" | "outline" | "search" | "execution"; -type LitrevStageKind = "questions" | "keywords" | "outline" | "references" | "tasks"; -type PreviewScope = "task" | "knowledge" | "project"; +type LitrevStageKind = "questions" | "sources" | "keywords" | "outline" | "references" | "tasks"; +type PreviewScope = "task" | "project"; type QuestionCardStatus = "preparing" | "waiting" | "cancelled"; type LitrevPhase = | { kind: "setup" } + | { kind: "sources" } | { kind: "thinking"; thinking: ThinkingKind; stage: number } | { kind: "wizard"; step: 0 | 1 | 2 } | { kind: "task" }; function stageKindForPhase(phase: LitrevPhase): LitrevStageKind { if (phase.kind === "setup") return "questions"; + if (phase.kind === "sources") return "sources"; if (phase.kind === "wizard") return phase.step === 0 ? "keywords" : phase.step === 1 ? "outline" : "references"; if (phase.kind === "task") return "tasks"; return phase.thinking === "planning" @@ -143,7 +143,7 @@ function readInitialContexts(): LitrevLaunchContext[] { if (!Array.isArray(parsed)) return []; return parsed.filter((item): item is LitrevLaunchContext => ( item != null - && (item.kind === "kb" || item.kind === "path") + && item.kind === "path" && typeof item.id === "string" && typeof item.label === "string" )); @@ -219,7 +219,7 @@ export function LiteratureReviewPage() { const [phase, setPhase] = useState(readInitialPhase); const [questionIndex, setQuestionIndex] = useState(() => { const initial = readInitialPhase(); - return initial.kind === "task" || initial.kind === "thinking" ? Number.MAX_SAFE_INTEGER : 0; + return initial.kind === "setup" ? 0 : Number.MAX_SAFE_INTEGER; }); const [todoProgress, setTodoProgress] = useState(() => ( readInitialPhase().kind === "task" ? 3 : 0 @@ -237,6 +237,7 @@ export function LiteratureReviewPage() { const current = stageKindForPhase(readInitialPhase()); return { questions: current === "questions", + sources: current === "sources", keywords: current === "keywords", outline: current === "outline", references: current === "references", @@ -244,17 +245,21 @@ export function LiteratureReviewPage() { }; }); const [answers, setAnswers] = useState>([]); + const [sourceReferences, setSourceReferences] = useState(() => ( + launchContexts.map((context) => ({ kind: "path", id: context.id, label: context.label })) + )); const [keywords, setKeywords] = useState(buildDemoKeywords); const [keywordDraft, setKeywordDraft] = useState(""); const [outline, setOutline] = useState(buildDemoOutline); const [references, setReferences] = useState(buildDemoReferences); - const [referenceTargetKbIds, setReferenceTargetKbIds] = useState([]); - const [knowledgeBases] = useState(buildDemoKnowledgeBases); const [supplements, setSupplements] = useState([]); const [composerDraft, setComposerDraft] = useState(""); const [referencePickerOpen, setReferencePickerOpen] = useState(false); const composerInputRef = useRef(null); const composerFileInputRef = useRef(null); + const composerFolderInputRef = useRef(null); + const sourceFileInputRef = useRef(null); + const sourceFolderInputRef = useRef(null); const composerScopeKey = agentChatScopeKey(appState.agent.currentChatId, appState.agent.newChatRequestId); const composerReferences = appState.agent.composerContextReferencesByScope[composerScopeKey] ?? []; const [fileContextMenu, setFileContextMenu] = useState<{ @@ -263,9 +268,6 @@ export function LiteratureReviewPage() { y: number; } | null>(null); const [previewScope, setPreviewScope] = useState("task"); - const [knowledgeScope, setKnowledgeScope] = useState(() => ( - launchContexts.find((context) => context.kind === "kb")?.id ?? "all" - )); const [previewPath, setPreviewPath] = useState(LITREV_OUTLINE_ARTIFACT); const [openPreviewTabs, setOpenPreviewTabs] = useState([ LITREV_OUTLINE_ARTIFACT, @@ -281,31 +283,12 @@ export function LiteratureReviewPage() { const previewScopeMenuRef = useRef(null); const taskFiles = useMemo(() => buildDemoTaskFiles(), []); const projectFiles = useMemo(() => ( - buildHomeReferenceItems().map((file) => ({ + buildHomeReferenceItems().filter((file) => file.kind === "file").map((file) => ({ path: `project/${file.path}`, name: file.path })) ), []); - useEffect(() => { - if (phase.kind !== "task" || todoProgress < LITREV_TODO_ITEMS.length) return; - const managedFiles = taskFiles - .filter((file) => file.folder === "references" || file.folder === "outputs") - .map((file) => { - const generated = file.folder === "outputs"; - return { - id: `agent-managed:${file.folder}:${file.name}`, - path: `${t("kb.files.agentRoot")}/${t(generated ? "kb.files.generatedFolder" : "kb.files.downloadedFolder")}/${file.name}`, - name: file.name, - size: "—", - updated: t("kb.updatedJustNow"), - source: generated ? "agent-generated" as const : "agent-downloaded" as const - }; - }); - registerAgentManagedFiles(managedFiles, window.localStorage, window); - }, [phase.kind, taskFiles, t, todoProgress]); - const libraryFiles = useMemo(() => buildDemoLibraryFiles(), []); - const setupDone = questionIndex >= questions.length; const activeStage = stageKindForPhase(phase); @@ -368,7 +351,7 @@ export function LiteratureReviewPage() { useEffect(() => { if (setupDone && phase.kind === "setup" && !workflowEnded) { - setPhase({ kind: "thinking", thinking: "planning", stage: 0 }); + setPhase({ kind: "sources" }); } }, [setupDone, phase.kind, workflowEnded]); @@ -399,6 +382,20 @@ export function LiteratureReviewPage() { setQuestionIndex(questions.length); } + function confirmSources() { + setPhase({ kind: "thinking", thinking: "planning", stage: 0 }); + } + + function addSourceReferences(references: ComposerContextReference[]) { + setSourceReferences((current) => mergeComposerContextReferences(current, references)); + } + + function removeSourceReference(reference: ComposerContextReference) { + setSourceReferences((current) => current.filter((item) => ( + item.kind !== reference.kind || item.id !== reference.id + ))); + } + function updateQuestionAnswer(index: number, answer: string) { setPendingAnswers((items) => items.map((item, itemIndex) => ( itemIndex === index ? answer : item @@ -441,6 +438,28 @@ export function LiteratureReviewPage() { return { kind: "path", id: path, label: name }; } + function referencesFromFiles(files: File[]): ComposerContextReference[] { + return files.map((file) => { + let path = file.name; + try { + path = window.memmy?.getPathForFile(file) || file.name; + } catch { + path = file.name; + } + return fileReference(path, file.name); + }); + } + + function folderReferenceFromFiles(files: File[]): ComposerContextReference | null { + return composerFolderReferenceFromFiles(files, (file) => { + try { + return window.memmy?.getPathForFile(file) || file.name; + } catch { + return file.name; + } + }); + } + function beginFileDrag(event: DragEvent, path: string, name: string) { writeComposerReferenceDrag(event.dataTransfer, fileReference(path, name)); } @@ -455,26 +474,30 @@ export function LiteratureReviewPage() { } function handleComposerFilesPicked(event: ChangeEvent) { - const references = Array.from(event.target.files ?? []).map((file) => { - let path = file.name; - try { - path = window.memmy?.getPathForFile(file) || file.name; - } catch { - path = file.name; - } - return fileReference(path, file.name); - }); + const references = referencesFromFiles(Array.from(event.target.files ?? [])); if (references.length) addComposerReferences(references); event.target.value = ""; composerInputRef.current?.focus(); } - function pickComposerKnowledgeBase(base: KbKnowledgeBase) { - addComposerReferences([{ kind: "kb", id: base.id, label: base.name }]); - setReferencePickerOpen(false); + function handleComposerFolderPicked(event: ChangeEvent) { + const reference = folderReferenceFromFiles(Array.from(event.target.files ?? [])); + if (reference) addComposerReferences([reference]); + event.target.value = ""; composerInputRef.current?.focus(); } + function handleSourceFilesPicked(event: ChangeEvent) { + addSourceReferences(referencesFromFiles(Array.from(event.target.files ?? []))); + event.target.value = ""; + } + + function handleSourceFolderPicked(event: ChangeEvent) { + const reference = folderReferenceFromFiles(Array.from(event.target.files ?? [])); + if (reference) addSourceReferences([reference]); + event.target.value = ""; + } + function pickComposerReference(item: HomeReferenceItem) { addComposerReferences([fileReference(item.path, item.path)]); setReferencePickerOpen(false); @@ -575,6 +598,7 @@ export function LiteratureReviewPage() { function completedStageLabel(stage: LitrevStageKind): string { if (stage === "questions") return t("litrev.stageActivity.questions.done"); + if (stage === "sources") return t("litrev.stageActivity.sources.done"); if (stage === "keywords") return t("litrev.stageActivity.keywords.done"); if (stage === "outline") return t("litrev.stageActivity.outline.done"); if (stage === "references") return t("litrev.stageActivity.references.done"); @@ -583,6 +607,7 @@ export function LiteratureReviewPage() { function stageOutputFor(stage: Exclude): string { if (stage === "questions") return t("litrev.stageOutput.questions"); + if (stage === "sources") return t("litrev.stageOutput.sources"); if (stage === "keywords") return t("litrev.stageOutput.keywords"); if (stage === "outline") return t("litrev.stageOutput.outline"); return t("litrev.stageOutput.references"); @@ -599,6 +624,7 @@ export function LiteratureReviewPage() { ? t("litrev.stageActivity.questions.generating") : t("litrev.stageActivity.questions.waiting"); } + if (stage === "sources") return t("litrev.stageActivity.sources.waiting"); if (stage === "tasks") { return phase.kind === "thinking" ? t("litrev.stageActivity.tasks.generating") @@ -620,6 +646,11 @@ export function LiteratureReviewPage() { function renderCompletedStageContent(stage: LitrevStageKind): ReactNode { if (stage === "questions") return renderRequirementSummary(); + if (stage === "sources") { + return sourceReferences.length + ? + :

{t("litrev.sources.none")}

; + } if (stage === "keywords") { return (
@@ -651,7 +682,7 @@ export function LiteratureReviewPage() { function renderCurrentStageContent(stage: LitrevStageKind): ReactNode { if (workflowEnded) return

{t("litrev.workflow.cancelledDetail")}

; if (phase.kind === "thinking") return renderThinkingCopy(); - if (stage === "questions") return null; + if (stage === "questions" || stage === "sources") return null; if (phase.kind === "wizard") return null; return renderTodoList(); } @@ -791,13 +822,11 @@ export function LiteratureReviewPage() { key={`${context.kind}:${context.id}`} kind="file" name={context.label} - subline={context.kind === "kb" ? t("home.quick.kind.kb") : t("home.quick.kind.file")} + subline={context.label.endsWith("/") ? t("home.quick.kind.folder") : t("home.quick.kind.file")} align="right" - leading={context.kind === "kb" ? ( - - ) : ( - - )} + leading={context.label.endsWith("/") + ? + : } /> ))}
@@ -919,6 +948,67 @@ export function LiteratureReviewPage() { ); } + function renderSourceCard(): ReactNode { + if (phase.kind !== "sources" || workflowEnded) return null; + return ( +
+
+ {t("litrev.sources.title")} +
+ {sourceReferences.length ? ( + + {t("litrev.sources.count", { count: sourceReferences.length })} + + ) : null} + +
+
+
+

+ {t(sourceReferences.length ? "litrev.sources.existingHint" : "litrev.sources.hint")} +

+ {sourceReferences.length ? ( + + ) : ( +
{t("litrev.sources.empty")}
+ )} +
+ + +
+ + { + sourceFolderInputRef.current = node; + node?.setAttribute("webkitdirectory", ""); + }} + type="file" + hidden + multiple + onChange={handleSourceFolderPicked} + /> +
+
+ + +
+
+ ); + } + /* --------------------------------- 向导卡片 --------------------------------- */ function renderKeywordStep(): ReactNode { @@ -1072,7 +1162,7 @@ export function LiteratureReviewPage() { checked={reference.selected} onChange={(event) => setReferences((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, selected: event.target.checked } : item)))} /> - {reference.selected ? : null} + {reference.selected ? : null} {reference.title} {reference.meta} @@ -1094,42 +1184,6 @@ export function LiteratureReviewPage() {
{stepTitles[phase.step]}
- {phase.step === 2 ? ( -
- - - {referenceTargetKbIds.length - ? t("litrev.refs.selectedKbCount", { count: referenceTargetKbIds.length }) - : t("litrev.refs.addToKb")} - - -
- {t("litrev.refs.targetKb")} - {knowledgeBases.map((base) => { - const selected = referenceTargetKbIds.includes(base.id); - return ( - - ); - })} -
-
- ) : null} {phase.step + 1} / 3 - {!collapsed ? ( -
- {renderKnowledgeFileTree(files, folderPath)} -
- ) : null} -
- ); - })} - {directFiles - .slice() - .sort((a, b) => a.name.localeCompare(b.name, "zh-CN")) - .map((file) => ( - - ))} - - ); - }; const crumbScope = previewScope === "task" ? t("litrev.preview.taskFiles") - : previewScope === "project" - ? t("litrev.preview.projectSpace") - : activeKb?.name ?? t("litrev.preview.allFiles"); + : t("litrev.preview.projectSpace"); return (
- {previewScope === "knowledge" ? ( -
-
- - {activeKb?.name ?? t("litrev.preview.allFiles")} - -
- {[{ id: "all", name: t("litrev.preview.allFiles") }, ...knowledgeBases].map((base) => ( - - ))} -
-
-
- ) : null}
); - }) : previewScope === "knowledge" ? ( - renderKnowledgeFileTree(kbFiles) - ) : ( + }) : ( projectFiles.map((file) => (
) : null} -
- - - - {referenceOpen ? ( -
- {props.referenceMenu} -
- ) : null} -
- ))} - {!items.length ? ( -
{t("home.quick.noMatches")}
- ) : null} -
- - ); -} - export function HomeContextChips(props: { chips: ComposerContextChip[]; onRemove?: (chip: ComposerContextChip) => void; diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index fc73797e4..f03137264 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -38,6 +38,7 @@ import { mergeComposerContextReferences, readComposerReferenceDrag } from "../lib/composer-file-reference.js"; +import { assessLiteratureSourceBatch } from "../lib/literature-source-files.js"; import { useTaskBus, type TaskBusAgentMessage } from "../lib/task-bus.js"; import type { AppAction } from "../state/app-actions.js"; import { agentActions, appActions, createAgentOperationError } from "../state/app-actions.js"; @@ -84,18 +85,14 @@ import { HistoryDagPanel, type HistoryDagPanelState } from "./history-dag-panel. import { ComposerHighlightedTextarea, ComposerQuickActionButtons, - ComposerReferencePanel, HomeContextChips, - mentionQueryFromInput, - stripMentionFromInput, type ComposerContextChip } from "./home-composer-quick-actions.js"; import { LITREV_CONTEXT_STORAGE_KEY, LITREV_PROJECT_CONTEXT_STORAGE_KEY, LITREV_PROMPT_STORAGE_KEY, - LITREV_SOURCE_INPUT_STORAGE_KEY, - type HomeReferenceItem + LITREV_SOURCE_INPUT_STORAGE_KEY } from "./literature-review-demo-data.js"; import { Mic, Pause, Plus, Send } from "./memory/memory-prototype-icons.js"; import { ArrowDown, BookOpenText, CalendarCheck2, Check, ChevronDown, Folder, History, Plus as LucidePlus, RotateCw, X } from "lucide-react"; @@ -858,8 +855,6 @@ export function HomePage() { const [historyDagPanel, setHistoryDagPanel] = useState({ open: false }); const [isCreatingChat, setIsCreatingChat] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); - const [mentionMenuDismissed, setMentionMenuDismissed] = useState(false); - const [referencePickerOpen, setReferencePickerOpen] = useState(false); const [projectPickerOperationId, setProjectPickerOperationId] = useState(null); const [firstEncounterRelayChatId, setFirstEncounterRelayChatId] = useState(() => ( readFirstEncounterRelayChat(typeof window === "undefined" ? undefined : window.sessionStorage) @@ -1480,10 +1475,6 @@ export function HomePage() { : slashPickerOpen ? "" : slashQueryFromInput(input.slice(0, Math.min(composerSelection.start, input.length))); - const mentionQuery = mentionMenuDismissed || slashPickerOpen - ? null - : mentionQueryFromInput(input); - const referenceMenuOpen = referencePickerOpen || mentionQuery != null; const localizedSlashCommands = localizeSlashCommands(slashCommands, language, t); const slashCommandsWithLocal = [ literatureReviewSlashCommand, @@ -1544,10 +1535,6 @@ export function HomePage() { composerDraftsRef.current = composerDrafts; }, [composerDrafts]); - useEffect(() => { - if (mentionQuery != null) setProjectPickerOpen(false); - }, [mentionQuery]); - useEffect(() => { if ( !state.agent.currentChatId @@ -1735,7 +1722,6 @@ export function HomePage() { rememberSlashCommand("/literature-review"); setCurrentComposerDraft(""); dispatch(agentActions.composerContextReferencesUpdated(chatScopeKey, [])); - setMentionMenuDismissed(true); dispatch(appActions.navigate("/literature-review")); return true; } @@ -1850,11 +1836,7 @@ export function HomePage() { setCurrentComposerDraft(value); setComposerSelection({ start: selectionStart, end: selectionEnd }); setSlashPickerOpen(false); - if (mentionQueryFromInput(value) != null) { - setReferencePickerOpen(false); - } setSlashMenuDismissed(false); - setMentionMenuDismissed(false); setSelectedCommandIndex(0); if ( slashQueryFromInput(value) != null && @@ -1901,8 +1883,6 @@ export function HomePage() { const nextDraft = homeSuggestionDraft(text, capability); setCurrentComposerDraft(nextDraft); setComposerSelection({ start: nextDraft.length, end: nextDraft.length }); - setReferencePickerOpen(false); - setMentionMenuDismissed(true); setSlashMenuDismissed(true); window.requestAnimationFrame(() => { if (inputRef.current) { @@ -1913,25 +1893,8 @@ export function HomePage() { }); } - function pickMentionReference(item: HomeReferenceItem) { - addComposerContextChip({ kind: "path", id: item.path, label: item.path }); - setCurrentComposerDraft((draft) => stripMentionFromInput(draft)); - setMentionMenuDismissed(true); - setReferencePickerOpen(false); - inputRef.current?.focus(); - } - - function insertComposerMentionTrigger() { - setProjectPickerOpen(false); - setSlashMenuDismissed(true); - setMentionMenuDismissed(false); - setReferencePickerOpen((open) => !open); - } - function insertComposerSlashTrigger() { setProjectPickerOpen(false); - setReferencePickerOpen(false); - setMentionMenuDismissed(true); setSlashMenuDismissed(false); setSlashPickerOpen((open) => !open); setSelectedCommandIndex(0); @@ -2335,8 +2298,9 @@ export function HomePage() { } function selectFolder(event: ChangeEvent) { + const files = Array.from(event.target.files ?? []); const reference = composerFolderReferenceFromFiles( - Array.from(event.target.files ?? []), + files, (file) => { try { return window.memmy?.getPathForFile(file) || file.name; @@ -2345,7 +2309,14 @@ export function HomePage() { } } ); - if (reference) addComposerContextChip(reference); + if (reference) { + const literatureSources = assessLiteratureSourceBatch(files).accepted; + addComposerContextChip({ + ...reference, + fileCount: literatureSources.length, + totalBytes: literatureSources.reduce((total, file) => total + file.size, 0) + }); + } event.target.value = ""; inputRef.current?.focus(); } @@ -2494,7 +2465,7 @@ export function HomePage() {
@@ -2543,32 +2514,10 @@ export function HomePage() { /> ) : null} - {referenceMenuOpen && !referencePickerOpen && !slashMenuOpen ? ( - - setMentionMenuDismissed(true)} - onPickReference={pickMentionReference} - /> - - ) : null} { - setReferencePickerOpen(false); - setMentionMenuDismissed(true); - }} - onPickReference={pickMentionReference} - /> - ) : null} slashMenu={slashMenuOpen && slashPickerOpen ? ( { - setReferencePickerOpen(false); setSlashMenuDismissed(true); setProjectPickerOpen((open) => !open); }} @@ -2796,16 +2744,6 @@ export function HomePage() { /> ) : null} - {referenceMenuOpen && !slashMenuOpen ? ( - - setMentionMenuDismissed(true)} - onPickReference={pickMentionReference} - /> - - ) : null}
>([]); - const [sourceReferences, setSourceReferences] = useState(() => ( - launchContexts.map((context) => ({ kind: "path", id: context.id, label: context.label })) + const [sourceItems, setSourceItems] = useState(() => ( + launchContexts + .filter((context) => context.label.endsWith("/") || isSupportedLiteratureSourceName(context.label)) + .map((context) => ({ + reference: { kind: "path", id: context.id, label: context.label }, + fileCount: context.fileCount ?? (context.label.endsWith("/") ? null : 1), + totalBytes: context.totalBytes ?? 0 + })) )); + const [sourceUploadNotice, setSourceUploadNotice] = useState(null); + const sourceReferences = useMemo(() => sourceItems.map((item) => item.reference), [sourceItems]); + const sourceTotals = useMemo(() => ({ + fileCount: sourceItems.reduce((count, item) => count + (item.fileCount ?? 0), 0), + complete: sourceItems.every((item) => item.fileCount != null) + }), [sourceItems]); const [keywords, setKeywords] = useState(buildDemoKeywords); const [keywordDraft, setKeywordDraft] = useState(""); + const [keywordDraftWeight, setKeywordDraftWeight] = useState(6); const [outline, setOutline] = useState(buildDemoOutline); const [references, setReferences] = useState(buildDemoReferences); const [supplements, setSupplements] = useState([]); const [composerDraft, setComposerDraft] = useState(""); - const [referencePickerOpen, setReferencePickerOpen] = useState(false); const composerInputRef = useRef(null); const composerFileInputRef = useRef(null); const composerFolderInputRef = useRef(null); @@ -268,16 +294,17 @@ export function LiteratureReviewPage() { y: number; } | null>(null); const [previewScope, setPreviewScope] = useState("task"); - const [previewPath, setPreviewPath] = useState(LITREV_OUTLINE_ARTIFACT); + const [previewPath, setPreviewPath] = useState(LITREV_LATEX_ARTIFACT); const [openPreviewTabs, setOpenPreviewTabs] = useState([ - LITREV_OUTLINE_ARTIFACT, - LITREV_BODY_ARTIFACT, - "references/MemGPT.pdf" + LITREV_LATEX_ARTIFACT, + LITREV_PDF_ARTIFACT, + "downloads/MemGPT.pdf" ]); const [fileTreeOpen, setFileTreeOpen] = useState(true); const [collapsedPreviewFolders, setCollapsedPreviewFolders] = useState>({}); const [workspaceOpen, setWorkspaceOpen] = useState(() => readInitialPhase().kind === "task"); const dragOutlineState = useRef<{ index: number; startX: number; level: 0 | 1 } | null>(null); + const dragOutlinePointerStartX = useRef(null); const [outlineDropTarget, setOutlineDropTarget] = useState<{ index: number; level: 0 | 1 } | null>(null); const scrollRef = useRef(null); const previewScopeMenuRef = useRef(null); @@ -386,14 +413,26 @@ export function LiteratureReviewPage() { setPhase({ kind: "thinking", thinking: "planning", stage: 0 }); } - function addSourceReferences(references: ComposerContextReference[]) { - setSourceReferences((current) => mergeComposerContextReferences(current, references)); + function addSourceItems(items: LiteratureSourceItem[]) { + setSourceItems((current) => { + const next = [...current]; + for (const item of items) { + if (!next.some((existing) => ( + existing.reference.kind === item.reference.kind + && existing.reference.id === item.reference.id + ))) { + next.push(item); + } + } + return next; + }); } function removeSourceReference(reference: ComposerContextReference) { - setSourceReferences((current) => current.filter((item) => ( - item.kind !== reference.kind || item.id !== reference.id + setSourceItems((current) => current.filter((item) => ( + item.reference.kind !== reference.kind || item.reference.id !== reference.id ))); + setSourceUploadNotice(null); } function updateQuestionAnswer(index: number, answer: string) { @@ -488,24 +527,49 @@ export function LiteratureReviewPage() { } function handleSourceFilesPicked(event: ChangeEvent) { - addSourceReferences(referencesFromFiles(Array.from(event.target.files ?? []))); + const existingIds = new Set(sourceReferences.map((reference) => reference.id)); + const candidates = Array.from(event.target.files ?? []).filter((file) => ( + !existingIds.has(referencesFromFiles([file])[0]?.id ?? file.name) + )); + const assessment = assessLiteratureSourceBatch(candidates); + addSourceItems(assessment.accepted.map((file) => ({ + reference: referencesFromFiles([file])[0] ?? fileReference(file.name, file.name), + fileCount: 1, + totalBytes: file.size + }))); + setSourceUploadNotice( + assessment.unsupportedCount + ? { kind: "unsupported", count: assessment.unsupportedCount } + : null + ); event.target.value = ""; } function handleSourceFolderPicked(event: ChangeEvent) { - const reference = folderReferenceFromFiles(Array.from(event.target.files ?? [])); - if (reference) addSourceReferences([reference]); + const files = Array.from(event.target.files ?? []); + const reference = folderReferenceFromFiles(files); + const alreadyAdded = reference && sourceReferences.some((item) => item.id === reference.id); + if (reference && !alreadyAdded) { + const assessment = assessLiteratureSourceBatch(files); + if (assessment.accepted.length) { + addSourceItems([{ + reference, + fileCount: assessment.accepted.length, + totalBytes: assessment.accepted.reduce((total, file) => total + file.size, 0) + }]); + setSourceUploadNotice( + assessment.unsupportedCount + ? { kind: "unsupported", count: assessment.unsupportedCount } + : null + ); + } else if (assessment.unsupportedCount) { + setSourceUploadNotice({ kind: "unsupported", count: assessment.unsupportedCount }); + } + } event.target.value = ""; } - function pickComposerReference(item: HomeReferenceItem) { - addComposerReferences([fileReference(item.path, item.path)]); - setReferencePickerOpen(false); - composerInputRef.current?.focus(); - } - function insertComposerSlash() { - setReferencePickerOpen(false); setComposerDraft((draft) => `${draft}${draft && !/\s$/.test(draft) ? " " : ""}/`); window.requestAnimationFrame(() => composerInputRef.current?.focus()); } @@ -578,7 +642,7 @@ export function LiteratureReviewPage() { if (tabs.length <= 1) return tabs; const next = tabs.filter((tab) => tab !== path); if (previewPath === path) { - setPreviewPath(next[next.length - 1] ?? LITREV_OUTLINE_ARTIFACT); + setPreviewPath(next[next.length - 1] ?? LITREV_LATEX_ARTIFACT); } return next; }); @@ -647,14 +711,14 @@ export function LiteratureReviewPage() { function renderCompletedStageContent(stage: LitrevStageKind): ReactNode { if (stage === "questions") return renderRequirementSummary(); if (stage === "sources") { - return sourceReferences.length - ? + return sourceItems.length + ? renderSourceList(false) :

{t("litrev.sources.none")}

; } if (stage === "keywords") { return (
- {keywords.map((keyword) => ( + {keywords.filter((keyword) => keyword.selected).map((keyword) => (
{keyword.text}{t("litrev.keywords.weight")} {keyword.weight}
))}
@@ -817,16 +881,16 @@ export function LiteratureReviewPage() {
{launchContexts.length ? (
- {launchContexts.map((context) => ( + {launchContexts.map((reference) => ( - : } + : } /> ))}
@@ -955,9 +1019,11 @@ export function LiteratureReviewPage() {
{t("litrev.sources.title")}
- {sourceReferences.length ? ( + {sourceItems.length && sourceTotals.complete ? ( - {t("litrev.sources.count", { count: sourceReferences.length })} + {t("litrev.sources.count", { + count: sourceTotals.fileCount + })} ) : null}
-

- {t(sourceReferences.length ? "litrev.sources.existingHint" : "litrev.sources.hint")} + {sourceItems.length ? ( +

{t("litrev.sources.existingHint")}

+ ) : null} +

+ {t("litrev.sources.policy")}

- {sourceReferences.length ? ( - + {sourceItems.length ? ( + renderSourceList(true) ) : (
{t("litrev.sources.empty")}
)} + {sourceUploadNotice ? ( +

+ {t("litrev.sources.unsupported", { count: sourceUploadNotice.count })} +

+ ) : null}
- + { sourceFolderInputRef.current = node; @@ -996,66 +1077,158 @@ export function LiteratureReviewPage() { type="file" hidden multiple + accept={LITERATURE_SOURCE_ACCEPT} onChange={handleSourceFolderPicked} />
); } + function renderSourceList(removable: boolean): ReactNode { + return ( +
+ {sourceItems.map((item) => { + const { reference } = item; + const folder = reference.label.endsWith("/"); + return ( +
+ {folder + ? + : } + {reference.label} + + {folder + ? item.fileCount == null + ? t("litrev.sources.folderSelected") + : t("litrev.sources.folderMeta", { + count: item.fileCount, + size: formatLiteratureSourceSize(item.totalBytes) + }) + : formatLiteratureSourceSize(item.totalBytes)} + + {removable ? ( + + ) : null} +
+ ); + })} +
+ ); + } + /* --------------------------------- 向导卡片 --------------------------------- */ function renderKeywordStep(): ReactNode { + const allKeywordsSelected = keywords.length > 0 && keywords.every((keyword) => keyword.selected); + const selectedKeywordCount = keywords.filter((keyword) => keyword.selected).length; return ( <>

{t("litrev.keywords.hint")}

+
{keywords.map((keyword, index) => ( -
+
+ setKeywords((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: event.target.value } : item)))} /> - - setKeywords((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, weight: Number(event.target.value) } : item)))} - /> - {keyword.weight} - + +
))}
{ event.preventDefault(); const text = keywordDraft.trim(); if (!text) return; - setKeywords((items) => [...items, { id: `k-${Date.now()}`, text, weight: 3 }]); + setKeywords((items) => [...items, { id: `k-${Date.now()}`, text, weight: keywordDraftWeight, selected: true }]); setKeywordDraft(""); + setKeywordDraftWeight(6); }} > - setKeywordDraft(event.target.value)} /> - +
+ +
+ setKeywordDraft(event.target.value)} + /> + {keywordDraft.length} / 100 +
+ + +
+
); @@ -1098,21 +1271,26 @@ export function LiteratureReviewPage() { const from = dragOutlineState.current?.index; const level = levelAtPointer(event); dragOutlineState.current = null; + dragOutlinePointerStartX.current = null; setOutlineDropTarget(null); if (from == null) return; setOutline((items) => moveOutlineItem(items, from, index, level)); }} > +
))}
@@ -1153,26 +1359,58 @@ export function LiteratureReviewPage() { } function renderReferenceStep(): ReactNode { + const selectedCount = references.filter((reference) => reference.selected).length; + const allSelected = references.length > 0 && selectedCount === references.length; return ( -
- {references.map((reference, index) => ( - - ))} -
+ <> +
+

{t("litrev.refs.note")}

+ {t("litrev.refs.selected", { selected: selectedCount, total: references.length })} +
+
+
+
+ {references.map((reference, index) => { + const abstractOnly = /仅摘要|abstract/i.test(reference.meta); + return ( + + ); + })} +
+ ); } @@ -1201,12 +1439,7 @@ export function LiteratureReviewPage() {