From 5c8233d25864b398b60ce3e1402a289fd9610873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 06:26:15 -0400 Subject: [PATCH] Q31: a Library page to read and manage documents and knowledge Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-handlers.ts | 19 ++ src/preload/index.ts | 3 + src/renderer/src/app/App.tsx | 2 + src/renderer/src/i18n/zh.ts | 27 +++ src/renderer/src/pages/LibraryPage.tsx | 63 +++++ .../src/pages/library/DocumentsPanel.tsx | 155 +++++++++++++ .../src/pages/library/KnowledgePanel.tsx | 217 ++++++++++++++++++ .../src/pages/library/library-helpers.ts | 9 + src/renderer/src/shared/NavIcon.tsx | 2 + src/renderer/src/shared/Shell.tsx | 4 +- src/shared/commands/collaboration.ts | 33 +++ src/shared/desktop-api.ts | 8 + src/shared/ipc.ts | 3 + tests/e2e/library.spec.ts | 76 ++++++ 14 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/pages/LibraryPage.tsx create mode 100644 src/renderer/src/pages/library/DocumentsPanel.tsx create mode 100644 src/renderer/src/pages/library/KnowledgePanel.tsx create mode 100644 src/renderer/src/pages/library/library-helpers.ts create mode 100644 tests/e2e/library.spec.ts diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 79716aa4..04e5acf8 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -90,6 +90,14 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }; + const query = (code: string, read: () => T): DesktopResult => { + try { + return ok(read()); + } catch (error) { + return fail(code, error instanceof Error ? error.message : String(error)); + } + }; + ipcMain.handle(IPC_CHANNELS.loadSnapshot, () => { try { return ok(snapshot.load()); @@ -163,6 +171,17 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.deleteDocument, (_event, input) => mutate(() => documents.delete(input))); ipcMain.handle(IPC_CHANNELS.createKnowledgeEntry, (_event, input) => mutate(() => knowledge.create(input))); ipcMain.handle(IPC_CHANNELS.deleteKnowledgeEntry, (_event, input) => mutate(() => knowledge.delete(input))); + ipcMain.handle(IPC_CHANNELS.updateKnowledgeEntry, (_event, input) => mutate(() => knowledge.update(input))); + ipcMain.handle(IPC_CHANNELS.listDocumentVersions, (_event, input) => query("document_versions_failed", () => + documents.versions(input.orgId, input.documentId).map((v) => ({ + version: v.version, title: v.title, content: v.content, status: v.status, + changedByType: v.changed_by_type, changedById: v.changed_by_id, reason: v.reason, createdAt: v.created_at, + })))); + ipcMain.handle(IPC_CHANNELS.listKnowledgeRevisions, (_event, input) => query("knowledge_revisions_failed", () => + knowledge.revisions(input.orgId, input.entryId).map((r) => ({ + revision: r.revision, topic: r.topic, category: r.category, content: r.content, importance: r.importance, + changedByType: r.changed_by_type, changedById: r.changed_by_id, reason: r.reason, createdAt: r.created_at, + })))); ipcMain.handle(IPC_CHANNELS.createAutomationRule, (_event, input) => mutate(() => automation.create(input))); ipcMain.handle(IPC_CHANNELS.updateAutomationRule, (_event, input) => mutate(() => automation.update(input))); ipcMain.handle(IPC_CHANNELS.deleteAutomationRule, (_event, input) => mutate(() => automation.delete(input))); diff --git a/src/preload/index.ts b/src/preload/index.ts index fbea2950..eb308abd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,6 +32,9 @@ const api: DesktopApi = { deleteDocument: (input) => invoke(IPC_CHANNELS.deleteDocument, input), createKnowledgeEntry: (input) => invoke(IPC_CHANNELS.createKnowledgeEntry, input), deleteKnowledgeEntry: (input) => invoke(IPC_CHANNELS.deleteKnowledgeEntry, input), + updateKnowledgeEntry: (input) => invoke(IPC_CHANNELS.updateKnowledgeEntry, input), + listDocumentVersions: (input) => invoke(IPC_CHANNELS.listDocumentVersions, input), + listKnowledgeRevisions: (input) => invoke(IPC_CHANNELS.listKnowledgeRevisions, input), createAutomationRule: (input) => invoke(IPC_CHANNELS.createAutomationRule, input), updateAutomationRule: (input) => invoke(IPC_CHANNELS.updateAutomationRule, input), deleteAutomationRule: (input) => invoke(IPC_CHANNELS.deleteAutomationRule, input), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 0e3876c5..a8af9592 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -8,6 +8,7 @@ import { TeamPage } from "../pages/TeamPage"; import { WorkPage } from "../pages/WorkPage"; import { ChatPage } from "../pages/ChatPage"; import { FilesPage } from "../pages/FilesPage"; +import { LibraryPage } from "../pages/LibraryPage"; import { ApprovalsPage } from "../pages/ApprovalsPage"; import { ConnectorsPage } from "../pages/ConnectorsPage"; import { RunsPage } from "../pages/RunsPage"; @@ -81,6 +82,7 @@ export function App() { work: , chat: , files: , + library: , runs: , approvals: , connectors: , diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 0761c558..2f941d81 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -27,6 +27,33 @@ export const zh: Record = { "Advance": "推进", "Advanced": "高级", "Integrations": "集成密钥", + "Applies to": "适用范围", + "Category": "类别", + "Content": "内容", + "Delete “{title}”? Agents will stop seeing it; its revision history stays recorded.": "删除“{title}”?Agent 将不再看到它,修订历史仍会保留。", + "Delete “{title}”? Its {count} versions stay recorded in the history.": "删除“{title}”?它的 {count} 个版本仍会保留在历史中。", + "Delete document": "删除文档", + "Delete knowledge": "删除知识", + "Documents": "文档", + "Edit document": "编辑文档", + "Expired": "已过期", + "History": "历史", + "Importance": "重要度", + "Knowledge": "知识", + "New": "新建", + "New knowledge": "新建知识", + "No documents yet.": "还没有文档。", + "Nothing here.": "这里还没有内容。", + "Produced by task: {title}": "产出任务:{title}", + "Revise": "修订", + "Revise knowledge": "修订知识", + "Save new version": "保存为新版本", + "Save revision": "保存修订", + "Superseded": "已被取代", + "Valid until (optional)": "有效期至(可选)", + "Valid until {time}": "有效期至 {time}", + "Which one": "具体对象", + "Why this change": "修改原因", "Read by {names}": "已读:{names}", "Context window (tokens)": "上下文窗口(token)", "{label} context window": "{label} 上下文窗口", diff --git a/src/renderer/src/pages/LibraryPage.tsx b/src/renderer/src/pages/LibraryPage.tsx new file mode 100644 index 00000000..e8f2d883 --- /dev/null +++ b/src/renderer/src/pages/LibraryPage.tsx @@ -0,0 +1,63 @@ +import { useMemo, useState } from "react"; +import { DocumentsPanel } from "./library/DocumentsPanel"; +import { KnowledgePanel } from "./library/KnowledgePanel"; +import type { PageProps } from "./types"; +import { activeOrgId, requireActiveEmployee } from "./types"; +import { t } from "../i18n/i18n"; + +type LibraryTab = "documents" | "knowledge"; + +const TABS: Array<{ id: LibraryTab; label: string }> = [ + { id: "documents", label: t("Documents") }, + { id: "knowledge", label: t("Knowledge") }, +]; + +export function LibraryPage({ snapshot, actions }: PageProps) { + const orgId = activeOrgId(snapshot); + const employee = requireActiveEmployee(snapshot); + const [tab, setTab] = useState("documents"); + const documents = useMemo(() => inOrg(snapshot.documents, orgId), [snapshot.documents, orgId]); + const entries = useMemo(() => inOrg(snapshot.knowledgeEntries, orgId), [snapshot.knowledgeEntries, orgId]); + const tasks = useMemo(() => inOrg(snapshot.tasks, orgId), [snapshot.tasks, orgId]); + const scopes = useMemo(() => ({ + projects: inOrg(snapshot.projects, orgId), + goals: inOrg(snapshot.goals, orgId), + tasks, + agents: inOrg(snapshot.agents, orgId), + }), [snapshot.projects, snapshot.goals, snapshot.agents, tasks, orgId]); + + return ( +
+ +
+
+
+

{tab === "documents" ? t("Documents") : t("Knowledge")}

+ {tab === "documents" + ? + : } +
+
+
+
+ ); +} + +function inOrg(items: T[], orgId: string): T[] { + return items.filter((item) => item.orgId === orgId); +} diff --git a/src/renderer/src/pages/library/DocumentsPanel.tsx b/src/renderer/src/pages/library/DocumentsPanel.tsx new file mode 100644 index 00000000..8c58c9b4 --- /dev/null +++ b/src/renderer/src/pages/library/DocumentsPanel.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState, type FormEvent } from "react"; +import type { DocumentRecord, TaskRecord } from "../../../../shared/domain"; +import type { DocumentVersionRecord } from "../../../../shared/commands"; +import type { SnapshotActions } from "../../app/useSnapshot"; +import { Dialog } from "../../shared/Dialog"; +import { Button, Field, Pill } from "../../shared/ui"; +import { formatTime } from "../types"; +import { t } from "../../i18n/i18n"; + +export function DocumentsPanel({ + orgId, + employeeId, + documents, + tasks, + actions, +}: { + orgId: string; + employeeId: string; + documents: DocumentRecord[]; + tasks: TaskRecord[]; + actions: SnapshotActions; +}) { + const [selectedId, setSelectedId] = useState(null); + const selected = documents.find((doc) => doc.id === selectedId) ?? null; + const [loaded, setLoaded] = useState<{ documentId: string; versions: DocumentVersionRecord[] } | null>(null); + const [shownVersion, setShownVersion] = useState(null); + const versions = loaded && loaded.documentId === selectedId ? loaded.versions : []; + const [editing, setEditing] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [error, setError] = useState(null); + + const currentVersion = selected?.version ?? null; + useEffect(() => { + if (!selectedId || currentVersion === null) return; + let cancelled = false; + void window.agentCompany.listDocumentVersions({ orgId, documentId: selectedId }).then((result) => { + if (cancelled) return; + if (!result.ok) { + setError(result.error.message); + return; + } + setLoaded({ documentId: selectedId, versions: result.data }); + setShownVersion(currentVersion); + }); + return () => { cancelled = true; }; + }, [orgId, selectedId, currentVersion]); + + const perform = async (operation: () => Promise, done: () => void) => { + setError(null); + try { + await operation(); + done(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } + }; + + const submitEdit = (event: FormEvent) => { + event.preventDefault(); + if (!selected) return; + const form = new FormData(event.currentTarget); + void perform(() => actions.run(() => window.agentCompany.updateDocument({ + orgId, + documentId: selected.id, + title: String(form.get("title") ?? ""), + content: String(form.get("content") ?? ""), + status: String(form.get("status") ?? selected.status), + reason: String(form.get("reason") ?? ""), + changedByType: "employee", + changedById: employeeId, + })), () => setEditing(false)); + }; + + const shown = versions.find((version) => version.version === shownVersion) ?? null; + const producedBy = selected?.taskId ? tasks.find((task) => task.id === selected.taskId)?.title : null; + + return ( +
+
+ {documents.length === 0 &&

{t("No documents yet.")}

} + {documents.map((doc) => ( + + ))} +
+ {selected && ( +
+
+

{shown?.title ?? selected.title}

+ {selected.docType} + {shown?.status ?? selected.status} +
+ {producedBy &&

{t("Produced by task: {title}", { title: producedBy })}

} +
+ + + +
+ {error &&
{error}
} +
{shown?.content ?? selected.content}
+
+ )} + {selected && ( + setEditing(false)} onSubmit={submitEdit}> + + + + +