Q31: a Library page to read and manage documents and knowledge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c475e5ceb
commit
5c8233d258
14 files changed
+620
-1
No files matched your search
@@ -90,6 +90,14 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
}
|
||||
};
|
||||
|
||||
const query = <T>(code: string, read: () => T): DesktopResult<T> => {
|
||||
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)));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: <WorkPage {...pageProps} />,
|
||||
chat: <ChatPage {...pageProps} />,
|
||||
files: <FilesPage {...pageProps} />,
|
||||
library: <LibraryPage {...pageProps} />,
|
||||
runs: <RunsPage {...pageProps} />,
|
||||
approvals: <ApprovalsPage {...pageProps} />,
|
||||
connectors: <ConnectorsPage {...pageProps} />,
|
||||
|
||||
@@ -27,6 +27,33 @@ export const zh: Record<string, string> = {
|
||||
"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} 上下文窗口",
|
||||
|
||||
@@ -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<LibraryTab>("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 (
|
||||
<div className="split-main">
|
||||
<aside className="secondary-pane">
|
||||
<div style={{ fontSize: 14, fontWeight: 600, padding: "4px 8px 14px" }}>{t("Library")}</div>
|
||||
<div className="nav" style={{ gap: 1 }}>
|
||||
{TABS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={item.id === tab}
|
||||
className={`nav-item ${item.id === tab ? "active" : ""}`.trim()}
|
||||
onClick={() => setTab(item.id)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="main" style={{ flex: 1, minWidth: 0, overflow: "auto" }}>
|
||||
<div className="content">
|
||||
<div className="page">
|
||||
<h1 className="page-h1">{tab === "documents" ? t("Documents") : t("Knowledge")}</h1>
|
||||
{tab === "documents"
|
||||
? <DocumentsPanel orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
|
||||
: <KnowledgePanel orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inOrg<T extends { orgId: string }>(items: T[], orgId: string): T[] {
|
||||
return items.filter((item) => item.orgId === orgId);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const selected = documents.find((doc) => doc.id === selectedId) ?? null;
|
||||
const [loaded, setLoaded] = useState<{ documentId: string; versions: DocumentVersionRecord[] } | null>(null);
|
||||
const [shownVersion, setShownVersion] = useState<number | null>(null);
|
||||
const versions = loaded && loaded.documentId === selectedId ? loaded.versions : [];
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<unknown>, done: () => void) => {
|
||||
setError(null);
|
||||
try {
|
||||
await operation();
|
||||
done();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : String(failure));
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(220px, 320px) 1fr", gap: 20, minHeight: 0 }}>
|
||||
<div role="list" aria-label={t("Documents")} style={{ display: "grid", gap: 4, alignContent: "start" }}>
|
||||
{documents.length === 0 && <p style={{ color: "var(--text-2)" }}>{t("No documents yet.")}</p>}
|
||||
{documents.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
aria-current={doc.id === selectedId}
|
||||
className={`nav-item ${doc.id === selectedId ? "active" : ""}`.trim()}
|
||||
onClick={() => { setSelectedId(doc.id); setError(null); }}
|
||||
style={{ display: "grid", justifyItems: "start", gap: 2 }}
|
||||
>
|
||||
<span>{doc.title}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{doc.docType} · v{doc.version} · {formatTime(doc.updatedAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selected && (
|
||||
<article aria-label={selected.title} style={{ minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{shown?.title ?? selected.title}</h2>
|
||||
<Pill>{selected.docType}</Pill>
|
||||
<Pill tone={selected.status === "approved" ? "green" : undefined}>{shown?.status ?? selected.status}</Pill>
|
||||
</div>
|
||||
{producedBy && <p style={{ color: "var(--text-2)", margin: "0 0 8px" }}>{t("Produced by task: {title}", { title: producedBy })}</p>}
|
||||
<div className="row" style={{ gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<span>{t("Version")}</span>
|
||||
<select className="input" value={shownVersion ?? ""} onChange={(event) => setShownVersion(Number(event.target.value))}>
|
||||
{versions.map((version) => (
|
||||
<option key={version.version} value={version.version}>
|
||||
v{version.version} — {version.reason} ({formatTime(version.createdAt)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button onClick={() => setEditing(true)}>{t("Edit")}</Button>
|
||||
<Button onClick={() => setConfirmDelete(true)}>{t("Delete")}</Button>
|
||||
</div>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: "inherit", lineHeight: 1.55 }}>{shown?.content ?? selected.content}</pre>
|
||||
</article>
|
||||
)}
|
||||
{selected && (
|
||||
<Dialog title={t("Edit document")} open={editing} submitLabel={t("Save new version")} onClose={() => setEditing(false)} onSubmit={submitEdit}>
|
||||
<Field label={t("Title")}><input className="input" name="title" defaultValue={selected.title} required /></Field>
|
||||
<Field label={t("Status")}>
|
||||
<select className="input" name="status" defaultValue={selected.status}>
|
||||
{["draft", "review", "approved", "archived"].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Content")}><textarea className="input" name="content" defaultValue={selected.content} rows={14} required /></Field>
|
||||
<Field label={t("Why this change")}><input className="input" name="reason" required /></Field>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
)}
|
||||
{selected && (
|
||||
<Dialog
|
||||
title={t("Delete document")}
|
||||
open={confirmDelete}
|
||||
submitLabel={t("Delete")}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id })), () => {
|
||||
setConfirmDelete(false);
|
||||
setSelectedId(null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p>{t("Delete “{title}”? Its {count} versions stay recorded in the history.", { title: selected.title, count: versions.length })}</p>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import type { AgentRecord, GoalRecord, KnowledgeEntryRecord, ProjectRecord, TaskRecord } from "../../../../shared/domain";
|
||||
import type { KnowledgeRevisionRecord, KnowledgeScopeType } 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 { entryState, type EntryState } from "./library-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const FILTER_LABELS: Record<EntryState, string> = {
|
||||
active: t("Active"),
|
||||
superseded: t("Superseded"),
|
||||
expired: t("Expired"),
|
||||
};
|
||||
|
||||
export function KnowledgePanel({
|
||||
orgId,
|
||||
employeeId,
|
||||
entries,
|
||||
scopes,
|
||||
actions,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
entries: KnowledgeEntryRecord[];
|
||||
scopes: { projects: ProjectRecord[]; goals: GoalRecord[]; tasks: TaskRecord[]; agents: AgentRecord[] };
|
||||
actions: SnapshotActions;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
const [filter, setFilter] = useState<EntryState>("active");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState<{ entryId: string; revisions: KnowledgeRevisionRecord[] } | null>(null);
|
||||
const [dialog, setDialog] = useState<"new" | "revise" | "delete" | null>(null);
|
||||
const [scopeType, setScopeType] = useState<KnowledgeScopeType>("organization");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const visible = entries.filter((entry) => entryState(entry, now) === filter);
|
||||
const selected = entries.find((entry) => entry.id === selectedId) ?? null;
|
||||
const revisions = loaded && loaded.entryId === selectedId ? loaded.revisions : [];
|
||||
|
||||
const selectedUpdatedAt = selected?.updatedAt ?? null;
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedUpdatedAt === null) return;
|
||||
let cancelled = false;
|
||||
void window.agentCompany.listKnowledgeRevisions({ orgId, entryId: selectedId }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.ok) setLoaded({ entryId: selectedId, revisions: result.data });
|
||||
else setError(result.error.message);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [orgId, selectedId, selectedUpdatedAt]);
|
||||
|
||||
const perform = (operation: () => Promise<unknown>, done: () => void) => {
|
||||
setError(null);
|
||||
void operation().then(done, (failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)));
|
||||
};
|
||||
const text = (form: FormData, key: string) => String(form.get(key) ?? "");
|
||||
|
||||
const submitNew = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const validUntil = text(form, "validUntil");
|
||||
perform(() => actions.run(() => window.agentCompany.createKnowledgeEntry({
|
||||
orgId,
|
||||
topic: text(form, "topic"),
|
||||
category: text(form, "category"),
|
||||
content: text(form, "content"),
|
||||
importance: text(form, "importance"),
|
||||
sourceType: "employee",
|
||||
sourceId: employeeId,
|
||||
scopeType,
|
||||
scopeId: scopeType === "organization" ? null : text(form, "scopeId"),
|
||||
validUntil: validUntil ? new Date(validUntil).toISOString() : null,
|
||||
supersedesId: null,
|
||||
})), () => setDialog(null));
|
||||
};
|
||||
|
||||
const submitRevise = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
perform(() => actions.run(() => window.agentCompany.updateKnowledgeEntry({
|
||||
orgId,
|
||||
entryId: selected.id,
|
||||
topic: text(form, "topic"),
|
||||
category: text(form, "category"),
|
||||
content: text(form, "content"),
|
||||
importance: text(form, "importance"),
|
||||
reason: text(form, "reason"),
|
||||
changedByType: "employee",
|
||||
changedById: employeeId,
|
||||
})), () => setDialog(null));
|
||||
};
|
||||
|
||||
const scopeOptions: Record<Exclude<KnowledgeScopeType, "organization">, Array<{ id: string; label: string }>> = {
|
||||
project: scopes.projects.map((item) => ({ id: item.id, label: item.name })),
|
||||
goal: scopes.goals.map((item) => ({ id: item.id, label: item.title })),
|
||||
task: scopes.tasks.map((item) => ({ id: item.id, label: item.title })),
|
||||
agent: scopes.agents.map((item) => ({ id: item.id, label: item.name })),
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(220px, 340px) 1fr", gap: 20, minHeight: 0 }}>
|
||||
<div style={{ display: "grid", gap: 8, alignContent: "start" }}>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
{(["active", "superseded", "expired"] as const).map((state) => (
|
||||
<Button key={state} size="small" tone={filter === state ? "primary" : undefined} onClick={() => setFilter(state)}>
|
||||
{FILTER_LABELS[state]}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="small" onClick={() => { setScopeType("organization"); setDialog("new"); }}>{t("New")}</Button>
|
||||
</div>
|
||||
<div role="list" aria-label={t("Knowledge")} style={{ display: "grid", gap: 4 }}>
|
||||
{visible.length === 0 && <p style={{ color: "var(--text-2)" }}>{t("Nothing here.")}</p>}
|
||||
{visible.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
aria-current={entry.id === selectedId}
|
||||
className={`nav-item ${entry.id === selectedId ? "active" : ""}`.trim()}
|
||||
onClick={() => { setSelectedId(entry.id); setError(null); }}
|
||||
style={{ display: "grid", justifyItems: "start", gap: 2 }}
|
||||
>
|
||||
<span>{entry.topic}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{entry.category} · {entry.scopeType} · {entry.importance}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<article aria-label={selected.topic} style={{ minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{selected.topic}</h2>
|
||||
<Pill>{selected.category}</Pill>
|
||||
<Pill>{selected.scopeType}</Pill>
|
||||
<Pill tone={entryState(selected, now) === "active" ? "green" : "amber"}>{entryState(selected, now)}</Pill>
|
||||
</div>
|
||||
{selected.validUntil && <p style={{ color: "var(--text-2)", margin: "0 0 8px" }}>{t("Valid until {time}", { time: formatTime(selected.validUntil) })}</p>}
|
||||
<div className="row" style={{ gap: 8, marginBottom: 12 }}>
|
||||
<Button onClick={() => setDialog("revise")}>{t("Revise")}</Button>
|
||||
<Button onClick={() => setDialog("delete")}>{t("Delete")}</Button>
|
||||
</div>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
<p style={{ whiteSpace: "pre-wrap", lineHeight: 1.55 }}>{selected.content}</p>
|
||||
<div className="section-label" style={{ margin: "18px 0 8px" }}>{t("History")}</div>
|
||||
<ol style={{ display: "grid", gap: 10, paddingLeft: 18 }}>
|
||||
{revisions.map((revision) => (
|
||||
<li key={revision.revision}>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>
|
||||
r{revision.revision} · {formatTime(revision.createdAt)} · {revision.changedByType} · {revision.reason}
|
||||
</div>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{revision.content}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</article>
|
||||
)}
|
||||
<Dialog title={t("New knowledge")} open={dialog === "new"} submitLabel={t("Save")} onClose={() => setDialog(null)} onSubmit={submitNew}>
|
||||
<Field label={t("Topic")}><input className="input" name="topic" required /></Field>
|
||||
<Field label={t("Category")}><input className="input" name="category" placeholder="decision" required /></Field>
|
||||
<Field label={t("Content")}><textarea className="input" name="content" rows={6} required /></Field>
|
||||
<Field label={t("Importance")}>
|
||||
<select className="input" name="importance" defaultValue="normal">
|
||||
{["low", "normal", "high"].map((level) => <option key={level} value={level}>{level}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Applies to")}>
|
||||
<select className="input" value={scopeType} onChange={(event) => setScopeType(event.target.value as KnowledgeScopeType)}>
|
||||
{(["organization", "project", "goal", "task", "agent"] as const).map((scope) => <option key={scope} value={scope}>{scope}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{scopeType !== "organization" && (
|
||||
<Field label={t("Which one")}>
|
||||
<select className="input" name="scopeId" required>
|
||||
{scopeOptions[scopeType].map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("Valid until (optional)")}><input className="input" name="validUntil" type="datetime-local" /></Field>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
{selected && (
|
||||
<Dialog title={t("Revise knowledge")} open={dialog === "revise"} submitLabel={t("Save revision")} onClose={() => setDialog(null)} onSubmit={submitRevise}>
|
||||
<Field label={t("Topic")}><input className="input" name="topic" defaultValue={selected.topic} required /></Field>
|
||||
<Field label={t("Category")}><input className="input" name="category" defaultValue={selected.category} required /></Field>
|
||||
<Field label={t("Content")}><textarea className="input" name="content" defaultValue={selected.content} rows={6} required /></Field>
|
||||
<Field label={t("Importance")}>
|
||||
<select className="input" name="importance" defaultValue={selected.importance}>
|
||||
{["low", "normal", "high"].map((level) => <option key={level} value={level}>{level}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Why this change")}><input className="input" name="reason" required /></Field>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
)}
|
||||
{selected && (
|
||||
<Dialog
|
||||
title={t("Delete knowledge")}
|
||||
open={dialog === "delete"}
|
||||
submitLabel={t("Delete")}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id })), () => {
|
||||
setDialog(null);
|
||||
setSelectedId(null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p>{t("Delete “{title}”? Agents will stop seeing it; its revision history stays recorded.", { title: selected.topic })}</p>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { KnowledgeEntryRecord } from "../../../../shared/domain";
|
||||
|
||||
export type EntryState = "active" | "superseded" | "expired";
|
||||
|
||||
export function entryState(entry: KnowledgeEntryRecord, now: string): EntryState {
|
||||
if (entry.supersededBy) return "superseded";
|
||||
if (entry.validUntil && entry.validUntil <= now) return "expired";
|
||||
return "active";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Buildings2,
|
||||
ChatRoundDots,
|
||||
@@ -20,6 +21,7 @@ const NAV_ICONS: Record<RouteId, typeof Home> = {
|
||||
chat: ChatRoundDots,
|
||||
runs: Send,
|
||||
files: Folder,
|
||||
library: BookOpen,
|
||||
approvals: ShieldCheck,
|
||||
connectors: Plug2,
|
||||
office: Buildings2,
|
||||
|
||||
@@ -15,6 +15,7 @@ export type RouteId =
|
||||
| "work"
|
||||
| "chat"
|
||||
| "files"
|
||||
| "library"
|
||||
| "runs"
|
||||
| "approvals"
|
||||
| "connectors"
|
||||
@@ -28,7 +29,7 @@ interface NavSpec {
|
||||
|
||||
/** Routes whose pages own a full-height, multi-pane layout with their own
|
||||
* internal scroll regions, so the content area must not scroll as one block. */
|
||||
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "runs", "office", "work"]);
|
||||
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "library", "runs", "office", "work"]);
|
||||
|
||||
const allNav: NavSpec[] = [
|
||||
{ id: "home", label: t("Home") },
|
||||
@@ -38,6 +39,7 @@ const allNav: NavSpec[] = [
|
||||
{ id: "chat", label: t("Messages") },
|
||||
{ id: "runs", label: t("Runs") },
|
||||
{ id: "files", label: t("Files") },
|
||||
{ id: "library", label: t("Library") },
|
||||
{ id: "approvals", label: t("Approvals") },
|
||||
{ id: "connectors", label: t("Connectors") },
|
||||
];
|
||||
|
||||
@@ -59,6 +59,39 @@ export interface UpdateKnowledgeEntryInput {
|
||||
changedById: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentVersionRecord {
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
changedByType: string;
|
||||
changedById: string | null;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeRevisionRecord {
|
||||
revision: number;
|
||||
topic: string;
|
||||
category: string;
|
||||
content: string;
|
||||
importance: string;
|
||||
changedByType: string;
|
||||
changedById: string | null;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ListDocumentVersionsInput {
|
||||
orgId: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export interface ListKnowledgeRevisionsInput {
|
||||
orgId: string;
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
export interface DeleteKnowledgeEntryInput {
|
||||
orgId: string;
|
||||
entryId: string;
|
||||
|
||||
@@ -20,6 +20,11 @@ import type {
|
||||
DeleteMeetingInput,
|
||||
DeleteSprintInput,
|
||||
CreateKnowledgeEntryInput,
|
||||
UpdateKnowledgeEntryInput,
|
||||
DocumentVersionRecord,
|
||||
KnowledgeRevisionRecord,
|
||||
ListDocumentVersionsInput,
|
||||
ListKnowledgeRevisionsInput,
|
||||
CreateProjectInput,
|
||||
CreateWorkspaceInput,
|
||||
DeleteAutomationRuleInput,
|
||||
@@ -128,6 +133,9 @@ export interface DesktopApi {
|
||||
deleteDocument(input: DeleteDocumentInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createKnowledgeEntry(input: CreateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteKnowledgeEntry(input: DeleteKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateKnowledgeEntry(input: UpdateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
listDocumentVersions(input: ListDocumentVersionsInput): Promise<DesktopResult<DocumentVersionRecord[]>>;
|
||||
listKnowledgeRevisions(input: ListKnowledgeRevisionsInput): Promise<DesktopResult<KnowledgeRevisionRecord[]>>;
|
||||
createAutomationRule(input: CreateAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateAutomationRule(input: UpdateAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteAutomationRule(input: DeleteAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -68,6 +68,9 @@ export const IPC_CHANNELS = {
|
||||
deleteDocument: "agentcompany:delete-document",
|
||||
createKnowledgeEntry: "agentcompany:create-knowledge-entry",
|
||||
deleteKnowledgeEntry: "agentcompany:delete-knowledge-entry",
|
||||
updateKnowledgeEntry: "agentcompany:update-knowledge-entry",
|
||||
listDocumentVersions: "agentcompany:list-document-versions",
|
||||
listKnowledgeRevisions: "agentcompany:list-knowledge-revisions",
|
||||
createAutomationRule: "agentcompany:create-automation-rule",
|
||||
updateAutomationRule: "agentcompany:update-automation-rule",
|
||||
deleteAutomationRule: "agentcompany:delete-automation-rule",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { _electron as electron, expect, test } from "@playwright/test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
|
||||
|
||||
test("the library manages documents with versions and knowledge with history", async () => {
|
||||
const profileDir = join(tmpdir(), `agentcompany-new-library-${randomUUID()}`);
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
const app = await electron.launch({
|
||||
args: [resolve("out/main/index.js")],
|
||||
env: {
|
||||
...process.env,
|
||||
AGENTCOMPANY_NEW_PROFILE_DIR: profileDir,
|
||||
AGENTCOMPANY_NEW_AUTORUN: "0",
|
||||
AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: seedBuiltinSkillsFixture(join(profileDir, "builtin-src")),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByLabel("API key").fill("library-e2e-key");
|
||||
await page.getByLabel("Model").fill("gpt-5.4");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByLabel("Company name").fill("Stack Works");
|
||||
await page.getByLabel("Your name").fill("Robin Park");
|
||||
await page.getByLabel("Description").fill("Library e2e coverage.");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByLabel("Goal").fill("Keep company knowledge.");
|
||||
await page.getByLabel("Acceptance criteria").fill("Documents and knowledge are manageable.");
|
||||
await page.getByRole("button", { name: "Launch" }).click();
|
||||
await page.getByRole("button", { name: "Finish" }).click();
|
||||
await expect(page.getByRole("button", { name: /Stack Works/ })).toBeVisible();
|
||||
|
||||
const orgId = await page.evaluate(async () => {
|
||||
const snapshot = await window.agentCompany.loadSnapshot();
|
||||
if (!snapshot.ok || !snapshot.data.activeOrgId) throw new Error("no org");
|
||||
const created = await window.agentCompany.createDocument({
|
||||
orgId: snapshot.data.activeOrgId, title: "Pricing memo", docType: "memo", content: "Price is 10.",
|
||||
authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null,
|
||||
});
|
||||
if (!created.ok) throw new Error(created.error.message);
|
||||
return snapshot.data.activeOrgId;
|
||||
});
|
||||
expect(orgId).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Library", exact: true }).click();
|
||||
await page.getByRole("listitem").filter({ hasText: "Pricing memo" }).click();
|
||||
await page.getByRole("button", { name: "Edit", exact: true }).click();
|
||||
await page.getByLabel("Content").fill("Price is 12.");
|
||||
await page.getByLabel("Why this change").fill("Costs went up");
|
||||
await page.getByRole("button", { name: "Save new version" }).click();
|
||||
await expect(page.getByText("Price is 12.")).toBeVisible();
|
||||
await page.getByLabel("Version").selectOption("1");
|
||||
await expect(page.getByText("Price is 10.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Knowledge", exact: true }).click();
|
||||
await page.getByRole("button", { name: "New", exact: true }).click();
|
||||
await page.getByLabel("Topic").fill("Refund window");
|
||||
await page.getByLabel("Category").fill("policy");
|
||||
await page.getByLabel("Content").fill("Refunds within 14 days.");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("listitem").filter({ hasText: "Refund window" }).click();
|
||||
await page.getByRole("button", { name: "Revise", exact: true }).click();
|
||||
await page.getByLabel("Content").fill("Refunds within 30 days.");
|
||||
await page.getByLabel("Why this change").fill("Legal update");
|
||||
await page.getByRole("button", { name: "Save revision" }).click();
|
||||
await expect(page.getByText("Legal update")).toBeVisible();
|
||||
await expect(page.getByText("Refunds within 14 days.")).toBeVisible();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user