chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
@@ -0,0 +1,326 @@
/**
* DocumentsSpatialView — the document store browser authored once with the
* spatial vocabulary, so it renders correctly wherever it is displayed:
*
* - GUI today through `<SpatialSurface>` (DOM).
* - Future adapters can reuse the same snapshot contract behind the retained modality types.
*
* It is purely presentational (a snapshot + an action callback in, primitives
* out) and imports only the cross-modality primitives, so it is safe to render
* without pulling browser-only runtime imports into the presentational layer.
*
* The list, stats, and search results are fetched/mapped in the data wrapper
* ({@link ./DocumentsView.tsx}) and handed in already projected to display
* shape; this component never fetches or computes business values — it displays
* the snapshot and dispatches actions. The search query is local interactive
* state held with `useSpatialState` so it works on every surface.
*/
import {
Button,
Card,
Field,
HStack,
List,
Text,
VStack,
} from "@elizaos/ui/spatial";
/** Which render state the document browser is in. */
export type DocumentsViewState = "loading" | "error" | "empty" | "ready";
/** A single document row, already projected to display shape by the wrapper. */
export interface DocumentCard {
id: string;
/** Display title (the presented `filename`). */
title: string;
/** Pre-formatted meta line (e.g. "markdown · 4 KB · Jun 16"), or empty. */
meta: string;
}
/** A single search result row, already projected by the wrapper. */
export interface DocumentSearchHit {
id: string;
/** Display title of the source document. */
title: string;
/** Pre-trimmed snippet of the matching fragment, or empty. */
snippet: string;
}
/** Search sub-state, mirroring the wrapper's search state machine. */
export type DocumentsSearchState =
| { kind: "idle" }
| { kind: "searching"; query: string }
| { kind: "results"; query: string; hits: DocumentSearchHit[] }
| { kind: "error"; query: string; message: string };
export interface DocumentsSnapshot {
/** The browser state machine. */
state: DocumentsViewState;
/** Documents to list (only meaningful when state === "ready"). */
documents: DocumentCard[];
/** Total stored document count (from /stats). */
documentCount: number;
/** Total stored fragment count (from /stats). */
fragmentCount: number;
/** Active search text (controlled by the wrapper). */
query: string;
/** Search sub-state (only surfaced when state === "ready"). */
search: DocumentsSearchState;
/** Error message when state === "error". */
error?: string;
}
export const EMPTY_DOCUMENTS_SNAPSHOT: DocumentsSnapshot = {
state: "loading",
documents: [],
documentCount: 0,
fragmentCount: 0,
query: "",
search: { kind: "idle" },
};
export interface DocumentsSpatialViewProps {
snapshot: DocumentsSnapshot;
/**
* Dispatch by action id:
* `retry` — reload after an error,
* `search:<text>` — set the search text and run a document search,
* `clear-search` — drop the active search and show the full list,
* `open:<id>` — open/inspect the document `<id>`.
*/
onAction?: (action: string) => void;
}
export function DocumentsSpatialView({
snapshot,
onAction,
}: DocumentsSpatialViewProps) {
return (
<Card gap={1} padding={1}>
{snapshot.state === "loading" ? (
<Text tone="muted" align="center" style="caption">
Loading
</Text>
) : snapshot.state === "error" ? (
<DocumentsErrorBody snapshot={snapshot} onAction={onAction} />
) : snapshot.state === "empty" ? (
<DocumentsEmptyBody />
) : (
<DocumentsReadyBody snapshot={snapshot} onAction={onAction} />
)}
</Card>
);
}
function DocumentsErrorBody({
snapshot,
onAction,
}: {
snapshot: DocumentsSnapshot;
onAction?: (action: string) => void;
}) {
return (
<>
<Text bold>Could not load documents</Text>
<Text tone="danger" style="caption">
{snapshot.error ?? "Could not load documents."}
</Text>
<HStack gap={1}>
<Button agent="retry" onPress={() => onAction?.("retry")}>
Retry
</Button>
</HStack>
</>
);
}
function DocumentsEmptyBody() {
return <Text bold>None</Text>;
}
function DocumentsReadyBody({
snapshot,
onAction,
}: {
snapshot: DocumentsSnapshot;
onAction?: (action: string) => void;
}) {
return (
<>
<Text tone="muted" style="caption">
{countLabel(snapshot.documentCount, "document")} ·{" "}
{countLabel(snapshot.fragmentCount, "fragment")}
</Text>
<Field
kind="text"
label="Search"
value={snapshot.query}
placeholder="keyword or meaning"
agent="documents-search"
onChange={(value) => onAction?.(`search:${value}`)}
/>
<DocumentsSearchBody search={snapshot.search} onAction={onAction} />
<Text style="caption" tone="muted">
Documents ({snapshot.documents.length})
</Text>
{snapshot.documents.length === 0 ? (
<Text tone="muted" style="caption">
None
</Text>
) : (
<List gap={0}>
{snapshot.documents.map((doc) => (
<DocumentRow key={doc.id} doc={doc} onAction={onAction} />
))}
</List>
)}
</>
);
}
function DocumentRow({
doc,
onAction,
}: {
doc: DocumentCard;
onAction?: (action: string) => void;
}) {
// Title on its own full-width line, the meta caption below it, and the Open
// control alone on the last line — so a long filename never collides with the
// button and the affordance frames cleanly at every width.
return (
<VStack gap={0} agent={`doc-${doc.id}`}>
<HStack gap={1} align="center">
<Text tone="muted" wrap={false}>
</Text>
<Text bold grow={1}>
{doc.title}
</Text>
</HStack>
{doc.meta ? (
<Text style="caption" tone="muted">
{doc.meta}
</Text>
) : null}
<HStack gap={1} justify="end">
<Button
variant="outline"
tone="default"
agent={`open:${doc.id}`}
onPress={() => onAction?.(`open:${doc.id}`)}
>
</Button>
</HStack>
</VStack>
);
}
function DocumentsSearchBody({
search,
onAction,
}: {
search: DocumentsSearchState;
onAction?: (action: string) => void;
}) {
if (search.kind === "idle") return null;
if (search.kind === "searching") {
return (
<Text tone="muted" style="caption" wrap={false}>
Searching for {search.query}
</Text>
);
}
if (search.kind === "error") {
return (
<>
<Text bold>Search failed</Text>
<Text tone="danger" style="caption">
{search.message}
</Text>
<HStack gap={1}>
<Button
agent="clear-search"
onPress={() => onAction?.("clear-search")}
>
Clear
</Button>
</HStack>
</>
);
}
return (
<>
<Text style="caption" tone="muted">
Results ({search.hits.length})
</Text>
{search.hits.length === 0 ? (
<Text tone="muted" style="caption" wrap={false}>
None
</Text>
) : (
<List gap={0}>
{search.hits.map((hit) => (
<SearchResultRow key={hit.id} hit={hit} onAction={onAction} />
))}
</List>
)}
<HStack gap={1}>
<Button agent="clear-search" onPress={() => onAction?.("clear-search")}>
Clear
</Button>
</HStack>
</>
);
}
function SearchResultRow({
hit,
onAction,
}: {
hit: DocumentSearchHit;
onAction?: (action: string) => void;
}) {
// A hit carries a long free-text snippet, so it gets its own full-width
// wrapping line above the Open control (which sits alone on the last line) —
// nothing competes with the button for horizontal space at any width.
return (
<VStack gap={0} agent={`hit-${hit.id}`}>
<HStack gap={1} align="center">
<Text tone="muted" wrap={false}>
</Text>
<Text bold grow={1}>
{hit.title}
</Text>
</HStack>
{hit.snippet ? (
<Text style="caption" tone="muted">
{hit.snippet}
</Text>
) : null}
<HStack gap={1} justify="end">
<Button
variant="outline"
tone="default"
agent={`open:${hit.id}`}
onPress={() => onAction?.(`open:${hit.id}`)}
>
</Button>
</HStack>
</VStack>
);
}
function countLabel(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
}
@@ -0,0 +1,260 @@
// @vitest-environment jsdom
/**
* Drives the DocumentsView GUI data wrapper through the rendered spatial DOM.
* It is a read-only document browser over the read-only
* endpoints this plugin serves:
* GET {base}/api/documents -> { documents, total, ... }
* GET {base}/api/documents/stats -> { documentCount, fragmentCount }
* GET {base}/api/documents/search -> { results, count, ... }
*
* The default fetchers hit those URLs via `client.getBaseUrl()`; every test here
* injects the `fetchers` seam so the suite stays offline. We assert the rendered
* spatial DOM across the four load states (loading / error / empty / populated)
* plus the search round-trip and the open-document affordance.
*/
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
// `@elizaos/ui` is the giant renderer barrel; DocumentsView only touches
// `client.getBaseUrl()` (default fetcher seam, overridden in every test) and
// `client.sendChatMessage()` (open-document affordance). The spatial primitives
// come from the separate `@elizaos/ui/spatial` subpath, which is not mocked.
const { sendChatMessage } = vi.hoisted(() => ({ sendChatMessage: vi.fn() }));
vi.mock("@elizaos/ui", () => ({
client: {
getBaseUrl: () => "http://test.local",
sendChatMessage,
},
}));
import { type DocumentsFetchers, DocumentsView } from "./DocumentsView.js";
// ---------------------------------------------------------------------------
// Wire fixtures — match the real route response shapes (routes.ts).
// ---------------------------------------------------------------------------
function presentedDocument(overrides: Record<string, unknown> = {}) {
return {
id: "doc-1",
filename: "Quarterly Plan.md",
contentType: "text/markdown",
fileSize: 4096,
createdAt: Date.parse("2026-06-16T09:00:00.000Z"),
fragmentCount: 7,
source: "upload",
scope: "global",
provenance: { kind: "upload", label: "Manual upload" },
canEditText: true,
canDelete: true,
...overrides,
};
}
function documentsList(documents = [presentedDocument()]) {
return {
ok: true,
available: true,
agentId: "agent-1",
documents,
total: documents.length,
limit: 100,
offset: 0,
};
}
function documentsStats(documentCount = 1, fragmentCount = 7) {
return { documentCount, fragmentCount, agentId: "agent-1" };
}
function searchResponse(query: string) {
return {
query,
threshold: 0.3,
results: [
{
id: "frag-1",
text: "The quarterly plan covers hiring and runway.",
similarity: 0.81,
documentId: "doc-1",
documentTitle: "Quarterly Plan.md",
position: 0,
},
],
count: 1,
};
}
function makeFetchers(
overrides: Partial<DocumentsFetchers> = {},
): DocumentsFetchers {
return {
fetchDocuments: async () => documentsList(),
fetchStats: async () => documentsStats(),
fetchSearch: async (query: string) => searchResponse(query),
...overrides,
};
}
function agent(agentId: string): HTMLElement {
const el = document.querySelector(`[data-agent-id="${agentId}"]`);
if (!el) throw new Error(`no element with data-agent-id="${agentId}"`);
return el as HTMLElement;
}
afterEach(() => {
cleanup();
sendChatMessage.mockClear();
});
describe("DocumentsView — states", () => {
it("shows the loading state while the first fetch is in flight", () => {
const never = new Promise<never>(() => {});
render(
React.createElement(DocumentsView, {
fetchers: makeFetchers({ fetchDocuments: () => never }),
}),
);
expect(screen.getByText("Loading")).toBeTruthy();
});
it("renders the populated list with titles and the stats line", async () => {
render(React.createElement(DocumentsView, { fetchers: makeFetchers() }));
await screen.findByText("Quarterly Plan.md");
expect(screen.getByText("Documents (1)")).toBeTruthy();
// Stats line reflects the /stats counts.
expect(screen.getByText("1 document · 7 fragments")).toBeTruthy();
// Row meta renders the real presented fields (short content type + size).
expect(screen.getByText(/markdown/)).toBeTruthy();
});
it("shows the empty state (no fabricated rows) when zero documents are stored", async () => {
render(
React.createElement(DocumentsView, {
fetchers: makeFetchers({
fetchDocuments: async () => documentsList([]),
fetchStats: async () => documentsStats(0, 0),
}),
}),
);
await screen.findByText("None");
expect(screen.queryByText("Quarterly Plan.md")).toBeNull();
});
it("shows the error state with a Retry that refetches into populated", async () => {
let attempt = 0;
const fetchDocuments = async () => {
attempt += 1;
if (attempt === 1) throw new Error("boom");
return documentsList();
};
render(
React.createElement(DocumentsView, {
fetchers: makeFetchers({ fetchDocuments }),
}),
);
await screen.findByText("boom");
fireEvent.click(agent("retry"));
await screen.findByText("Quarterly Plan.md");
});
it("refetches on the background poll (no manual Refresh button)", async () => {
vi.useFakeTimers();
try {
let calls = 0;
const fetchDocuments = async () => {
calls += 1;
return documentsList();
};
render(
React.createElement(DocumentsView, {
fetchers: makeFetchers({ fetchDocuments }),
}),
);
// Flush the initial mount load without firing the poll timer.
await vi.advanceTimersByTimeAsync(0);
expect(calls).toBe(1);
expect(document.querySelector('[data-agent-id="refresh"]')).toBeNull();
// Advancing past the poll interval triggers a quiet refetch.
await vi.advanceTimersByTimeAsync(20_000);
expect(calls).toBe(2);
} finally {
vi.useRealTimers();
}
});
});
describe("DocumentsView — search", () => {
it("runs a search on input and renders results from /api/documents/search", async () => {
let searched: string | null = null;
const fetchSearch = async (query: string) => {
searched = query;
return searchResponse(query);
};
render(
React.createElement(DocumentsView, {
fetchers: makeFetchers({ fetchSearch }),
}),
);
await screen.findByText("Quarterly Plan.md");
// Typing in the agent-addressable search field runs the search (no button).
const input = agent("documents-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "quarterly" } });
await screen.findByText(/quarterly plan covers hiring/i);
expect(searched).toBe("quarterly");
expect(screen.getByText("Results (1)")).toBeTruthy();
});
it("surfaces a search failure without dropping the document list", async () => {
const fetchSearch = async () => {
throw new Error("search exploded");
};
render(
React.createElement(DocumentsView, {
fetchers: makeFetchers({ fetchSearch }),
}),
);
await screen.findByText("Quarterly Plan.md");
const input = agent("documents-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "anything" } });
await screen.findByText("Search failed");
// The document list is still present underneath the failed search.
expect(screen.getByText("Documents (1)")).toBeTruthy();
});
it("clears an active search back to the full list", async () => {
render(React.createElement(DocumentsView, { fetchers: makeFetchers() }));
await screen.findByText("Quarterly Plan.md");
const input = agent("documents-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "quarterly" } });
await screen.findByText("Results (1)");
fireEvent.click(agent("clear-search"));
await waitFor(() => {
expect(screen.queryByText("Results (1)")).toBeNull();
});
expect(screen.getByText("Documents (1)")).toBeTruthy();
});
});
describe("DocumentsView — open affordance", () => {
it("routes open-document through the assistant chat (no fabricated nav)", async () => {
render(React.createElement(DocumentsView, { fetchers: makeFetchers() }));
await screen.findByText("Quarterly Plan.md");
fireEvent.click(agent("open:doc-1"));
expect(sendChatMessage).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,389 @@
/**
* DocumentsView — the GUI data wrapper for the document store browser.
*
* It owns the live document data (the fetcher seam over the read-only endpoints
* this plugin serves, the quiet background poll, the search round-trip, and the
* wire->display mapping) and renders the one presentational
* {@link DocumentsSpatialView} inside a {@link SpatialSurface}. The browser DOM
* surface ships today, while the retained modality contract stays available for
* future adapters.
*
* Data source (read-only document routes this plugin serves; see routes.ts):
* GET {base}/api/documents?limit=&offset= -> { documents, total, ... }
* GET {base}/api/documents/stats -> { documentCount, fragmentCount }
* GET {base}/api/documents/search?q= -> { results, count, ... }
*
* The browser is read-only here: the owner actions are `retry` (reload after an
* error), `search:<q>` / `clear-search` (drive the document search), and
* `open:<id>` (ask the assistant to open a document — no fabricated navigation).
* The default fetchers build URLs from `client.getBaseUrl()`; tests inject the
* fetcher seam so they stay offline. The view renders the real `PresentedDocument`
* fields the route emits — no fabricated rows.
*/
import { client } from "@elizaos/ui";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PresentedDocument } from "../../document-presenter.js";
import {
type DocumentCard,
type DocumentSearchHit,
type DocumentsSearchState,
type DocumentsSnapshot,
DocumentsSpatialView,
} from "./DocumentsSpatialView.tsx";
// ---------------------------------------------------------------------------
// Wire shapes — local mirror of the JSON the document routes serve.
// ---------------------------------------------------------------------------
/** Response of `GET /api/documents` (see routes.ts handler). */
interface DocumentsListWire {
ok: boolean;
available: boolean;
agentId: string;
documents: PresentedDocument[];
total: number;
limit: number;
offset: number;
}
/** Response of `GET /api/documents/stats`. */
interface DocumentsStatsWire {
documentCount: number;
fragmentCount: number;
agentId: string;
}
/** One row of the `results` array from `GET /api/documents/search`. */
interface DocumentSearchResultWire {
id: string;
text: string;
similarity?: number;
documentId?: string;
documentTitle: string;
position?: unknown;
}
/** Response of `GET /api/documents/search`. */
interface DocumentsSearchWire {
query: string;
threshold: number;
results: DocumentSearchResultWire[];
count: number;
}
// ---------------------------------------------------------------------------
// Fetcher seams — default to real GETs; tests inject offline fakes.
// ---------------------------------------------------------------------------
export interface DocumentsFetchers {
fetchDocuments: () => Promise<DocumentsListWire>;
fetchStats: () => Promise<DocumentsStatsWire>;
fetchSearch: (query: string) => Promise<DocumentsSearchWire>;
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${client.getBaseUrl()}${path}`);
if (!response.ok) {
throw new Error(`Documents request failed (${response.status}): ${path}`);
}
return (await response.json()) as T;
}
const DEFAULT_LIST_LIMIT = 100;
/** Background-poll cadence that keeps the list fresh without a Refresh button. */
const DOCUMENTS_POLL_MS = 20_000;
const defaultFetchers: DocumentsFetchers = {
fetchDocuments: () =>
getJson<DocumentsListWire>(
`/api/documents?limit=${DEFAULT_LIST_LIMIT}&offset=0`,
),
fetchStats: () => getJson<DocumentsStatsWire>("/api/documents/stats"),
fetchSearch: (query) =>
getJson<DocumentsSearchWire>(
`/api/documents/search?q=${encodeURIComponent(query)}`,
),
};
export interface DocumentsViewProps {
/** Owner display name. Accepted for host compatibility; not rendered. */
ownerName?: string;
/** Test/host injection seam. Defaults to real `/api/documents*` GETs. */
fetchers?: DocumentsFetchers;
}
// ---------------------------------------------------------------------------
// Display helpers (format-only; no business math).
// ---------------------------------------------------------------------------
const BYTE_UNITS = ["B", "KB", "MB", "GB"] as const;
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "";
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
value /= 1024;
unit += 1;
}
const rounded = unit === 0 ? value : Math.round(value * 10) / 10;
return `${rounded} ${BYTE_UNITS[unit]}`;
}
function formatDate(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "";
const date = new Date(value);
return Number.isNaN(date.getTime())
? ""
: date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function shortContentType(contentType: string): string {
if (!contentType || contentType === "unknown") return "";
const slash = contentType.lastIndexOf("/");
return slash >= 0 ? contentType.slice(slash + 1) : contentType;
}
function documentMeta(document: PresentedDocument): string {
return [
shortContentType(document.contentType),
formatFileSize(document.fileSize),
formatDate(document.createdAt),
]
.filter((part) => part.length > 0)
.join(" · ");
}
function toCard(document: PresentedDocument): DocumentCard {
return {
id: document.id,
title: document.filename,
meta: documentMeta(document),
};
}
const SNIPPET_MAX = 100;
function toHit(result: DocumentSearchResultWire): DocumentSearchHit {
const trimmed = result.text.trim();
const snippet =
trimmed.length > SNIPPET_MAX
? `${trimmed.slice(0, SNIPPET_MAX - 1)}`
: trimmed;
return {
id: result.id,
title: result.documentTitle,
snippet,
};
}
// ---------------------------------------------------------------------------
// Fetch-driven state machines.
// ---------------------------------------------------------------------------
interface DocumentsData {
documents: PresentedDocument[];
documentCount: number;
fragmentCount: number;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: DocumentsData };
type SearchState =
| { kind: "idle" }
| { kind: "searching"; query: string }
| { kind: "results"; query: string; results: DocumentSearchResultWire[] }
| { kind: "error"; query: string; message: string };
/** Route an open-document request through the assistant chat (no fabricated nav). */
function requestOpenDocument(id: string): void {
const send = (client as { sendChatMessage?: (text: string) => void })
.sendChatMessage;
send?.(`Open the document ${id}.`);
}
export function DocumentsView(props: DocumentsViewProps = {}): ReactNode {
const fetchers = props.fetchers ?? defaultFetchers;
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [query, setQuery] = useState("");
const [search, setSearch] = useState<SearchState>({ kind: "idle" });
const fetchersRef = useRef(fetchers);
fetchersRef.current = fetchers;
// `silent` is the background-poll path: refresh the data in place without
// flashing the loading state, clearing the user's search, or surfacing a
// transient poll failure over an already-populated list.
const load = useCallback((options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
let cancelled = false;
if (!silent) {
setState({ kind: "loading" });
setSearch({ kind: "idle" });
setQuery("");
}
Promise.all([
fetchersRef.current.fetchDocuments(),
fetchersRef.current.fetchStats(),
])
.then(([list, stats]) => {
if (cancelled) return;
setState({
kind: "ready",
data: {
documents: list.documents,
documentCount: stats.documentCount,
fragmentCount: stats.fragmentCount,
},
});
})
.catch((error: unknown) => {
if (cancelled || silent) return;
setState({
kind: "error",
message:
error instanceof Error
? error.message
: "Could not load documents.",
});
});
return () => {
cancelled = true;
};
}, []);
// Load on mount, then keep the list fresh with a quiet 20s poll (no manual
// Refresh button). The poll reuses the existing load fn; cleared on unmount.
useEffect(() => {
const cancelInitial = load();
const timer = setInterval(() => load({ silent: true }), DOCUMENTS_POLL_MS);
return () => {
cancelInitial();
clearInterval(timer);
};
}, [load]);
// Update the controlled query and run/clear the search. An empty query drops
// back to the full list (no spurious empty-query request to the route).
const updateSearch = useCallback((rawQuery: string) => {
setQuery(rawQuery);
const trimmed = rawQuery.trim();
if (!trimmed) {
setSearch({ kind: "idle" });
return;
}
setSearch({ kind: "searching", query: trimmed });
fetchersRef.current
.fetchSearch(trimmed)
.then((response) => {
setSearch({
kind: "results",
query: trimmed,
results: response.results,
});
})
.catch((error: unknown) => {
setSearch({
kind: "error",
query: trimmed,
message: error instanceof Error ? error.message : "Search failed.",
});
});
}, []);
const searchSnapshot = useMemo<DocumentsSearchState>(() => {
switch (search.kind) {
case "idle":
return { kind: "idle" };
case "searching":
return { kind: "searching", query: search.query };
case "error":
return { kind: "error", query: search.query, message: search.message };
case "results":
return {
kind: "results",
query: search.query,
hits: search.results.map(toHit),
};
}
}, [search]);
const snapshot = useMemo<DocumentsSnapshot>(() => {
if (state.kind === "loading") {
return EMPTY_SNAPSHOT;
}
if (state.kind === "error") {
return {
state: "error",
documents: [],
documentCount: 0,
fragmentCount: 0,
query: "",
search: { kind: "idle" },
error: state.message,
};
}
const { documents, documentCount, fragmentCount } = state.data;
if (documents.length === 0) {
return {
state: "empty",
documents: [],
documentCount,
fragmentCount,
query: "",
search: { kind: "idle" },
};
}
return {
state: "ready",
documents: documents.map(toCard),
documentCount,
fragmentCount,
query,
search: searchSnapshot,
};
}, [state, query, searchSnapshot]);
const onAction = useCallback(
(action: string) => {
if (action === "retry") {
load();
return;
}
if (action === "clear-search") {
setQuery("");
setSearch({ kind: "idle" });
return;
}
if (action.startsWith("search:")) {
updateSearch(action.slice("search:".length));
return;
}
if (action.startsWith("open:")) {
requestOpenDocument(action.slice("open:".length));
return;
}
},
[load, updateSearch],
);
return <DocumentsSpatialView snapshot={snapshot} onAction={onAction} />;
}
const EMPTY_SNAPSHOT: DocumentsSnapshot = {
state: "loading",
documents: [],
documentCount: 0,
fragmentCount: 0,
query: "",
search: { kind: "idle" },
};
export default DocumentsView;
@@ -0,0 +1,5 @@
// Vite view-bundle entry. Re-exports DocumentsView so the built bundle
// (dist/views/bundle.js) exposes the named export the view loader reads.
// Kept separate from DocumentsView.tsx so that file exports only React
// components and stays Fast-Refresh-compatible in dev.
export { DocumentsView } from "./DocumentsView.tsx";
@@ -0,0 +1,86 @@
/** Unit tests for presentDocument: deterministic mapping of document Memory metadata to display cards (no runtime). */
import type { Memory, UUID } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import {
getDocumentTitleFromMetadata,
presentDocument,
} from "./document-presenter";
function docMemory(metadata: Record<string, unknown>): Memory {
return {
id: "00000000-0000-0000-0000-0000000000d1" as UUID,
entityId: "00000000-0000-0000-0000-0000000000e1" as UUID,
roomId: "00000000-0000-0000-0000-0000000000r1" as UUID,
agentId: "00000000-0000-0000-0000-0000000000a1" as UUID,
createdAt: 1000,
content: { text: "hello from the transcript" },
metadata: metadata as Memory["metadata"],
};
}
describe("presentDocument — transcript link passthrough", () => {
it("surfaces transcriptId + audio URL when the doc mirrors a Transcript", () => {
const dto = presentDocument(
docMemory({
type: "document",
source: "transcript",
transcriptId: "00000000-0000-0000-0000-000000000abc",
audioUrl: "/api/media/abc123.wav",
filename: "standup.txt",
}),
1,
);
expect(dto.transcriptId).toBe("00000000-0000-0000-0000-000000000abc");
expect(dto.transcriptAudioUrl).toBe("/api/media/abc123.wav");
});
it("omits the transcript fields for a plain (non-transcript) document", () => {
const dto = presentDocument(
docMemory({ type: "document", source: "user", filename: "notes.txt" }),
1,
);
expect(dto.transcriptId).toBeUndefined();
expect(dto.transcriptAudioUrl).toBeUndefined();
});
});
describe("presentDocument — import provenance", () => {
it("labels conversation imports distinctly from manual uploads", () => {
const dto = presentDocument(
docMemory({
type: "document",
source: "import",
addedFrom: "import",
filename: "claude/conv-1.md",
}),
1,
);
expect(dto.source).toBe("import");
expect(dto.addedFrom).toBe("import");
expect(dto.provenance).toEqual({
kind: "import",
label: "Conversation import",
});
});
});
describe("getDocumentTitleFromMetadata — derived-title truncation", () => {
it("never splits a surrogate pair at the truncation cut", () => {
// The 😀 spans code units 78..79 of the first line, exactly where the
// 80-char label cut lands; a blind slice would leave a lone high surrogate
// that renders as U+FFFD in the documents view.
const line = `${"x".repeat(78)}😀${"y".repeat(20)}`;
const title = getDocumentTitleFromMetadata(undefined, line);
expect(title.isWellFormed()).toBe(true);
expect(title).toBe(`${"x".repeat(78)}...`);
});
it("keeps an astral char that falls fully inside the kept prefix", () => {
const line = `😀${"x".repeat(100)}`;
const title = getDocumentTitleFromMetadata(undefined, line);
expect(title.isWellFormed()).toBe(true);
expect(title.startsWith("😀")).toBe(true);
expect(title.endsWith("...")).toBe(true);
});
});
@@ -0,0 +1,437 @@
/** Maps a stored document `Memory` into the display-card shape the documents view renders — provenance kind, title, and derived metadata. */
import type { Memory } from "@elizaos/core";
import type { DocumentVisibilityScope } from "./service-loader.js";
export type DocumentProvenanceKind =
| "import"
| "upload"
| "learned"
| "character"
| "url"
| "youtube"
| "bundled"
| "unknown";
export interface DocumentProvenance {
kind: DocumentProvenanceKind;
label: string;
detail?: string;
}
export interface PresentedDocument {
id: string;
filename: string;
contentType: string;
fileSize: number;
createdAt: number;
fragmentCount: number;
source: DocumentProvenanceKind;
scope: DocumentVisibilityScope;
scopedToEntityId?: string;
addedBy?: string;
addedByRole?: string;
addedFrom?: string;
url?: string;
/** Served URL of the original-bytes file linked to this document, so the
* detail view can offer "download original" (PR6). */
mediaUrl?: string;
/** Stored filename of the linked original-bytes file (PR6). */
mediaFileName?: string;
provenance: DocumentProvenance;
canEditText: boolean;
editabilityReason?: string;
canDelete: boolean;
deleteabilityReason?: string;
content?: { text?: string };
/** When this document is the searchable mirror of a voice Transcript, the
* original transcript record id (so the Knowledge view can link back to it)
* and its audio URL. Populated from the mirror metadata (#8789). */
transcriptId?: string;
transcriptAudioUrl?: string;
}
const BINARY_CONTENT_TYPE_PREFIXES = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/octet-stream",
"image/",
"audio/",
"video/",
];
const BINARY_FILE_EXTENSIONS = new Set([
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"zip",
"rar",
"7z",
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"mp3",
"wav",
"mp4",
"mov",
"avi",
]);
const DOCUMENT_SCOPE_VALUES = new Set<DocumentVisibilityScope>([
"global",
"owner-private",
"user-private",
"agent-private",
]);
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function asNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function truncateLabel(value: string, maxLength = 80): string {
if (value.length <= maxLength) return value;
// Back off one code unit when the cut would land between the halves of a
// surrogate pair — otherwise the label ends in a lone high surrogate that
// renders as U+FFFD in the documents view.
let end = maxLength - 1;
const lastKept = value.charCodeAt(end - 1);
if (lastKept >= 0xd800 && lastKept <= 0xdbff) end -= 1;
return `${value.slice(0, end).trimEnd()}...`;
}
function stripMarkdownPrefix(line: string): string {
return line
.replace(/^#+\s*/, "")
.replace(/^[-*]\s+/, "")
.replace(/^\d+[.)]\s+/, "")
.trim();
}
function deriveTitleFromText(
text: string | undefined,
fallback = "Untitled",
): string {
if (!text) return fallback;
const lines = text
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (const line of lines) {
if (/^path:\s+/i.test(line)) continue;
const candidate = stripMarkdownPrefix(line);
if (candidate.length > 0) {
return truncateLabel(candidate);
}
}
return fallback;
}
function looksLikeBase64(text: string): boolean {
const clean = text.replace(/\s/g, "");
if (clean.length < 16 || clean.length % 4 !== 0) return false;
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean)) return false;
return /[a-z]/.test(clean) && (/[A-Z]/.test(clean) || /\d/.test(clean));
}
function getFilenameExtension(
filename: string | undefined,
): string | undefined {
if (!filename) return undefined;
const dotIndex = filename.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === filename.length - 1) return undefined;
return filename.slice(dotIndex + 1).toLowerCase();
}
function isBinaryLike(
contentType: string | undefined,
filename: string | undefined,
) {
const normalizedContentType = contentType?.toLowerCase();
if (
normalizedContentType &&
BINARY_CONTENT_TYPE_PREFIXES.some((prefix) =>
normalizedContentType.startsWith(prefix),
)
) {
return true;
}
const extension = getFilenameExtension(filename);
return extension ? BINARY_FILE_EXTENSIONS.has(extension) : false;
}
function isGenericFilename(
filename: string | undefined,
provenanceKind: DocumentProvenanceKind,
): boolean {
if (!filename) return true;
const normalized = filename.toLowerCase();
if (normalized === "document-note.txt") return true;
if (provenanceKind === "learned" && normalized === "user-document.txt") {
return true;
}
return false;
}
export function normalizeDocumentSource(
source: unknown,
): DocumentProvenanceKind {
switch (source) {
case "import":
return "import";
case "upload":
case "rag-service-main-upload":
return "upload";
case "learned":
return "learned";
case "character":
return "character";
case "url":
return "url";
case "youtube":
return "youtube";
case "eliza-default-documents":
return "bundled";
default:
return "unknown";
}
}
export function getDocumentProvenance(
metadata: Record<string, unknown> | undefined,
): DocumentProvenance {
const kind = normalizeDocumentSource(metadata?.source);
switch (kind) {
case "import":
return { kind, label: "Conversation import" };
case "upload":
return { kind, label: "Manual upload" };
case "learned":
return { kind, label: "Learned document" };
case "character":
return {
kind,
label: "Character document",
detail: asString(metadata?.path),
};
case "url":
return {
kind,
label: "Imported URL",
detail: asString(metadata?.url),
};
case "youtube":
return {
kind,
label: "YouTube transcript",
detail: asString(metadata?.url),
};
case "bundled":
return { kind, label: "Bundled document" };
default:
return { kind, label: "Document" };
}
}
export function getDocumentContentType(
metadata: Record<string, unknown> | undefined,
): string {
return (
asString(metadata?.fileType) ?? asString(metadata?.contentType) ?? "unknown"
);
}
export function getDocumentTitleFromMetadata(
metadata: Record<string, unknown> | undefined,
contentText?: string,
): string {
const provenance = getDocumentProvenance(metadata);
const filename = asString(metadata?.filename);
const title = asString(metadata?.title);
const originalFilename = asString(metadata?.originalFilename);
const url = asString(metadata?.url);
if (filename && !isGenericFilename(filename, provenance.kind)) {
return filename;
}
if (title) return title;
if (
originalFilename &&
!isGenericFilename(originalFilename, provenance.kind)
) {
return originalFilename;
}
if (url) return url;
return deriveTitleFromText(contentText, "Untitled");
}
export function getDocumentVisibilityScope(
metadata: Record<string, unknown> | undefined,
): DocumentVisibilityScope {
return DOCUMENT_SCOPE_VALUES.has(metadata?.scope as DocumentVisibilityScope)
? (metadata?.scope as DocumentVisibilityScope)
: "global";
}
export function isDocumentTextBacked(memory: Memory): boolean {
const metadata = asRecord(memory.metadata);
if (typeof metadata?.textBacked === "boolean") {
return metadata.textBacked;
}
const filename = getDocumentTitleFromMetadata(metadata, memory.content.text);
const contentType = getDocumentContentType(metadata);
if (isBinaryLike(contentType, filename)) {
return false;
}
const contentText = memory.content.text;
if (typeof contentText !== "string" || contentText.trim().length === 0) {
return false;
}
return !looksLikeBase64(contentText);
}
export function getDocumentPreviewText(memory: Memory): string | undefined {
const contentText = memory.content.text;
if (typeof contentText !== "string") return undefined;
const trimmed = contentText.trim();
if (trimmed.length === 0 || looksLikeBase64(trimmed)) {
return undefined;
}
return trimmed;
}
export function getDocumentEditability(memory: Memory): {
canEditText: boolean;
reason?: string;
} {
const metadata = asRecord(memory.metadata);
const provenance = getDocumentProvenance(metadata);
if (provenance.kind === "bundled") {
return {
canEditText: false,
reason: "Bundled documents are seeded by the runtime.",
};
}
if (provenance.kind === "character") {
return {
canEditText: false,
reason: "Character documents come from source files or character config.",
};
}
if (!isDocumentTextBacked(memory)) {
return {
canEditText: false,
reason: "Only text-backed documents can be edited here.",
};
}
return { canEditText: true };
}
export function getDocumentDeleteability(memory: Memory): {
canDelete: boolean;
reason?: string;
} {
const provenance = getDocumentProvenance(asRecord(memory.metadata));
if (provenance.kind === "bundled") {
return {
canDelete: false,
reason: "Bundled documents are recreated by the runtime.",
};
}
if (provenance.kind === "character") {
return {
canDelete: false,
reason: "Character documents are backed by the character source.",
};
}
return { canDelete: true };
}
export function presentDocument(
memory: Memory,
fragmentCount: number,
options?: { includeContent?: boolean },
): PresentedDocument {
const metadata = asRecord(memory.metadata);
const provenance = getDocumentProvenance(metadata);
const contentType = getDocumentContentType(metadata);
const previewText = options?.includeContent
? getDocumentPreviewText(memory)
: undefined;
const editability = getDocumentEditability(memory);
const deleteability = getDocumentDeleteability(memory);
return {
id: String(memory.id ?? ""),
filename: getDocumentTitleFromMetadata(metadata, memory.content.text),
contentType,
fileSize: asNumber(metadata?.fileSize) ?? 0,
createdAt: asNumber(memory.createdAt) ?? 0,
fragmentCount,
source: provenance.kind,
scope: getDocumentVisibilityScope(metadata),
scopedToEntityId: asString(metadata?.scopedToEntityId),
addedBy: asString(metadata?.addedBy),
addedByRole: asString(metadata?.addedByRole),
addedFrom: asString(metadata?.addedFrom),
url: asString(metadata?.url),
...(asString(metadata?.mediaUrl)
? { mediaUrl: asString(metadata?.mediaUrl) }
: {}),
...(asString(metadata?.mediaFileName)
? { mediaFileName: asString(metadata?.mediaFileName) }
: {}),
provenance,
canEditText: editability.canEditText,
editabilityReason: editability.reason,
canDelete: deleteability.canDelete,
deleteabilityReason: deleteability.reason,
...(previewText ? { content: { text: previewText } } : {}),
...(asString(metadata?.transcriptId)
? { transcriptId: asString(metadata?.transcriptId) }
: {}),
...(asString(metadata?.audioUrl)
? { transcriptAudioUrl: asString(metadata?.audioUrl) }
: {}),
};
}
+18
View File
@@ -0,0 +1,18 @@
/** Public entry for @elizaos/plugin-documents: the HTTP route plugin plus its documents view, presenter, and type re-exports. */
export {
type DocumentCard,
type DocumentSearchHit,
type DocumentsSearchState,
type DocumentsSnapshot,
DocumentsSpatialView,
type DocumentsViewState,
EMPTY_DOCUMENTS_SNAPSHOT,
} from "./components/documents/DocumentsSpatialView.js";
export {
type DocumentsFetchers,
DocumentsView,
type DocumentsViewProps,
} from "./components/documents/DocumentsView.js";
export * from "./plugin.js";
export * from "./routes.js";
export * from "./service-loader.js";
+131
View File
@@ -0,0 +1,131 @@
/**
* Document HTTP routes for the app-documents plugin.
*
* These routes are registered through the plugin route registry with
* `rawPath: true` so the agent server dispatches them via runtime routes.
*/
import type http from "node:http";
import { TLSSocket } from "node:tls";
import type {
AgentRuntime,
LegacyRouteHandler,
Plugin,
Route,
} from "@elizaos/core";
import {
sendJson as httpSendJson,
sendJsonError as httpSendJsonError,
} from "@elizaos/core";
import { readJsonBody as httpReadJsonBody } from "@elizaos/shared";
import { handleDocumentsRoutes } from "./routes.js";
function json(res: http.ServerResponse, data: unknown, status = 200): void {
httpSendJson(res, data, status);
}
function error(res: http.ServerResponse, message: string, status = 400): void {
httpSendJsonError(res, message, status);
}
function firstHeaderValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return firstHeaderValue(value[0]);
}
if (typeof value !== "string") {
return null;
}
const normalized = value.split(",")[0]?.trim();
return normalized ? normalized : null;
}
function requestBaseUrl(req: http.IncomingMessage): string {
const headers = req.headers;
const protocol =
firstHeaderValue(headers["x-forwarded-proto"]) ??
(req.socket instanceof TLSSocket && req.socket.encrypted
? "https"
: "http");
const host =
firstHeaderValue(headers["x-forwarded-host"]) ??
firstHeaderValue(headers.host) ??
"localhost";
return `${protocol}://${host}`;
}
function documentRouteHandler(): LegacyRouteHandler {
return async (
req: unknown,
res: unknown,
runtime: unknown,
): Promise<void> => {
const httpReq = req as http.IncomingMessage;
const httpRes = res as http.ServerResponse;
const agentRuntime = (runtime as AgentRuntime) ?? null;
const method = (httpReq.method ?? "GET").toUpperCase();
const url = new URL(httpReq.url ?? "/", requestBaseUrl(httpReq));
await handleDocumentsRoutes({
req: httpReq,
res: httpRes,
method,
pathname: url.pathname,
url,
runtime: agentRuntime,
json,
error,
readJsonBody: httpReadJsonBody,
});
};
}
const DOCUMENT_ROUTES: Array<{ type: string; path: string }> = [
{ type: "GET", path: "/api/documents" },
{ type: "GET", path: "/api/documents/stats" },
{ type: "POST", path: "/api/documents" },
{ type: "POST", path: "/api/documents/bulk" },
{ type: "POST", path: "/api/documents/url" },
{ type: "GET", path: "/api/documents/search" },
{ type: "GET", path: "/api/documents/:id" },
{ type: "PATCH", path: "/api/documents/:id" },
{ type: "DELETE", path: "/api/documents/:id" },
{ type: "GET", path: "/api/documents/:id/fragments" },
];
export const documentsRoutes: Route[] = DOCUMENT_ROUTES.map(
(route) =>
({
type: route.type as Route["type"],
path: route.path,
rawPath: true as const,
handler: documentRouteHandler(),
}) as Route,
);
export const documentsPlugin: Plugin = {
name: "@elizaos/plugin-documents-routes",
description: "Document management, fragment listing, and search routes",
routes: documentsRoutes,
// OWNER_DOCUMENTS is still host-adapted by plugin-personal-assistant.
// Do not register the scaffold action from this route/view plugin.
actions: [],
views: [
{
id: "documents",
label: "Documents",
description: "Browse and search the document store.",
icon: "FileText",
path: "/documents",
modalities: ["gui"],
bundlePath: "dist/views/bundle.js",
// First-party instrumented view (data-agent-id controls): grant the
// agent-surface capability so the view broker admits agent-driven
// fills/clicks (#13452 manifest gate).
surface: { capabilities: ["agent-surface"] },
componentExport: "DocumentsView",
tags: ["documents", "files", "signatures"],
relatedActions: ["OWNER_DOCUMENTS"],
visibleInManager: true,
desktopTabEnabled: true,
},
],
};
@@ -0,0 +1,442 @@
/**
* Route-level tests for the first-class `roomId` filter and `mediaFormat` facet
* added in #13593 (knowledge slice 1). Exercises `GET /api/documents` through
* `handleDocumentsRoutes` with a mock documents service, asserting the new
* filters compose with tags[] and that access control still applies after
* filtering (owner-private items don't leak to a non-owner actor).
*/
import type { Memory, UUID } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { type DocumentRouteContext, handleDocumentsRoutes } from "./routes.ts";
const AGENT_ID = "00000000-0000-0000-0000-0000000000aa" as UUID;
const OWNER_ENTITY = "00000000-0000-0000-0000-0000000000b1" as UUID;
const USER_ENTITY = "00000000-0000-0000-0000-0000000000c2" as UUID;
const ROOM_A = "00000000-0000-0000-0000-0000000000d1" as UUID;
const ROOM_B = "00000000-0000-0000-0000-0000000000d2" as UUID;
function docMemory(id: string, metadata: Record<string, unknown>): Memory {
return {
id: id as UUID,
entityId: (metadata.addedBy as UUID) ?? OWNER_ENTITY,
agentId: AGENT_ID,
roomId: (metadata.roomId as UUID) ?? ROOM_A,
createdAt: 1000,
content: { text: `body-${id}` },
metadata: { type: "document", ...metadata } as Memory["metadata"],
};
}
/** Documents spanning two rooms and multiple formats + scopes. */
function seedDocuments(): Memory[] {
return [
docMemory("img-a", {
documentId: "img-a",
tags: ["attachment", "media-format:image"],
mediaFormat: "image",
roomId: ROOM_A,
scope: "user-private",
addedBy: USER_ENTITY,
scopedToEntityId: USER_ENTITY,
}),
docMemory("pdf-a", {
documentId: "pdf-a",
tags: ["attachment", "media-format:pdf"],
mediaFormat: "pdf",
roomId: ROOM_A,
scope: "user-private",
addedBy: USER_ENTITY,
scopedToEntityId: USER_ENTITY,
}),
docMemory("img-b", {
documentId: "img-b",
tags: ["attachment", "media-format:image"],
mediaFormat: "image",
roomId: ROOM_B,
scope: "user-private",
addedBy: USER_ENTITY,
scopedToEntityId: USER_ENTITY,
}),
docMemory("owner-secret", {
documentId: "owner-secret",
tags: ["attachment", "media-format:pdf"],
mediaFormat: "pdf",
roomId: ROOM_A,
scope: "owner-private",
addedBy: OWNER_ENTITY,
}),
];
}
function makeRuntimeAndService(docs: Memory[]) {
let scanned = false;
const documentsService = {
getMemories: vi.fn(
async (params: { tableName: string; offset?: number }) => {
if (params.tableName !== "documents") return [];
// Single batch: return all on first call, empty after.
if (scanned) return [];
scanned = true;
return docs;
},
),
countMemories: vi.fn(async () => docs.length),
addDocument: vi.fn(),
searchDocuments: vi.fn(async () => []),
updateDocument: vi.fn(),
deleteMemory: vi.fn(),
};
const runtime = {
agentId: AGENT_ID,
getService: vi.fn((name: string) =>
name === "documents" ? documentsService : null,
),
getServiceLoadPromise: vi.fn(),
getSetting: vi.fn((key: string) =>
key === "ELIZA_ADMIN_ENTITY_ID" ? OWNER_ENTITY : undefined,
),
getMemoryById: vi.fn(async () => null),
};
return { runtime, documentsService };
}
async function listDocuments(params: {
query: string;
actorEntityId?: UUID;
}): Promise<{ status: number; body: Record<string, unknown> }> {
const { runtime } = makeRuntimeAndService(seedDocuments());
const url = new URL(`http://local/api/documents?${params.query}`);
let captured: { status: number; body: unknown } = { status: 0, body: null };
const res = {
setHeader: vi.fn(),
} as unknown as DocumentRouteContext["res"];
const headers: Record<string, string> = {};
if (params.actorEntityId) headers["x-eliza-entity-id"] = params.actorEntityId;
const ctx = {
req: { headers } as DocumentRouteContext["req"],
res,
method: "GET",
pathname: "/api/documents",
url,
runtime: runtime as never,
json: (_res: unknown, data: unknown, status = 200) => {
captured = { status, body: data };
},
error: (_res: unknown, message: string, status = 400) => {
captured = { status, body: { error: message } };
},
readJsonBody: async () => null,
} as unknown as DocumentRouteContext;
const handled = await handleDocumentsRoutes(ctx);
expect(handled).toBe(true);
return {
status: captured.status,
body: captured.body as Record<string, unknown>,
};
}
function docIds(body: Record<string, unknown>): string[] {
const documents = (body.documents as Array<{ id?: string }>) ?? [];
return documents.map((d) => d.id ?? "").sort();
}
describe("GET /api/documents — roomId + mediaFormat filters (#13593)", () => {
it("filters by roomId (composes with post-filter access control)", async () => {
// The USER owns img-a/pdf-a/img-b (user-private scoped to them). Filtering
// ROOM_A as that user yields their two ROOM_A items; owner-secret is
// owner-private and excluded.
const { body } = await listDocuments({
query: `roomId=${ROOM_A}`,
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["img-a", "pdf-a"]);
expect(docIds(body)).not.toContain("img-b"); // ROOM_B excluded by roomId
expect(docIds(body)).not.toContain("owner-secret"); // owner-private excluded
});
it("filters by mediaFormat facet", async () => {
const { body } = await listDocuments({
query: "mediaFormat=image",
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["img-a", "img-b"]);
});
it("composes roomId + mediaFormat", async () => {
const { body } = await listDocuments({
query: `roomId=${ROOM_A}&mediaFormat=pdf`,
actorEntityId: USER_ENTITY,
});
// ROOM_A pdfs the USER can read: pdf-a only (owner-secret is owner-private).
expect(docIds(body)).toEqual(["pdf-a"]);
});
it("composes mediaFormat with a tags[] filter", async () => {
const { body } = await listDocuments({
query: "mediaFormat=pdf&tags=attachment",
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["pdf-a"]);
});
it("owner can read owner-private items matching the facet", async () => {
const { body } = await listDocuments({
query: `roomId=${ROOM_A}&mediaFormat=pdf`,
actorEntityId: OWNER_ENTITY,
});
// Owner reads owner-secret (owner-private); pdf-a belongs to USER, so it is
// not returned to the owner by default (no scopedToEntityId filter).
expect(docIds(body)).toEqual(["owner-secret"]);
});
it("applies access control AFTER filtering — non-owner never sees owner-private", async () => {
// USER actor filters ROOM_A pdfs: pdf-a is theirs (user-private scoped to
// them), owner-secret is owner-private and must be excluded.
const { body } = await listDocuments({
query: `roomId=${ROOM_A}&mediaFormat=pdf`,
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["pdf-a"]);
expect(docIds(body)).not.toContain("owner-secret");
});
it("supports the `format` alias for mediaFormat", async () => {
const { body } = await listDocuments({
query: "format=image",
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["img-a", "img-b"]);
});
});
async function requestDocuments(params: {
pathname: string;
query: string;
actorEntityId?: UUID;
}): Promise<{ status: number; body: Record<string, unknown> }> {
const { runtime } = makeRuntimeAndService(seedDocuments());
const url = new URL(`http://local${params.pathname}?${params.query}`);
let captured: { status: number; body: unknown } = { status: 0, body: null };
const headers: Record<string, string> = {};
if (params.actorEntityId) headers["x-eliza-entity-id"] = params.actorEntityId;
const ctx = {
req: { headers } as DocumentRouteContext["req"],
res: { setHeader: vi.fn() } as unknown as DocumentRouteContext["res"],
method: "GET",
pathname: params.pathname,
url,
runtime: runtime as never,
json: (_res: unknown, data: unknown, status = 200) => {
captured = { status, body: data };
},
error: (_res: unknown, message: string, status = 400) => {
captured = { status, body: { error: message } };
},
readJsonBody: async () => null,
} as unknown as DocumentRouteContext;
const handled = await handleDocumentsRoutes(ctx);
expect(handled).toBe(true);
return {
status: captured.status,
body: captured.body as Record<string, unknown>,
};
}
describe("GET /api/documents — knowledgeFacet hub grouping (#13594)", () => {
it("groups pdf/text/file into the `doc` facet", async () => {
// The USER's readable pdf is pdf-a; the `doc` facet must catch it via the
// pdf->doc grouping (owner-secret is owner-private, excluded).
const { body } = await requestDocuments({
pathname: "/api/documents",
query: "knowledgeFacet=doc",
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["pdf-a"]);
});
it("passes image/audio/video/transcript through unchanged", async () => {
const { body } = await requestDocuments({
pathname: "/api/documents",
query: "knowledgeFacet=image",
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["img-a", "img-b"]);
});
it("treats `all` as a no-op (whole readable store)", async () => {
const { body } = await requestDocuments({
pathname: "/api/documents",
query: "knowledgeFacet=all",
actorEntityId: USER_ENTITY,
});
expect(docIds(body)).toEqual(["img-a", "img-b", "pdf-a"]);
});
});
describe("GET /api/documents/facets — whole-store hub counts (#13594)", () => {
it("returns coarse facet counts over the whole readable store", async () => {
// As the USER: img-a, img-b (image) + pdf-a (doc) are readable; owner-secret
// is owner-private and excluded. Counts must describe the whole store, not a
// page slice — the review blocker.
const { body } = await requestDocuments({
pathname: "/api/documents/facets",
query: "",
actorEntityId: USER_ENTITY,
});
const counts = body.counts as Record<string, number>;
expect(counts).toMatchObject({
all: 3,
doc: 1,
image: 2,
audio: 0,
video: 0,
transcript: 0,
});
});
it("honors the scope/room narrowing but ignores the facet itself", async () => {
// Narrow to ROOM_A: img-a (image) + pdf-a (doc) for the USER. The facet
// param is dropped inside the count so every bucket is still counted.
const { body } = await requestDocuments({
pathname: "/api/documents/facets",
query: `roomId=${ROOM_A}&knowledgeFacet=image`,
actorEntityId: USER_ENTITY,
});
const counts = body.counts as Record<string, number>;
expect(counts).toMatchObject({ all: 2, doc: 1, image: 1 });
});
it("lets the owner count owner-private items", async () => {
// Owner reads global + owner-private; owner-secret (owner-private pdf) is
// counted as a `doc`. The USER's user-private items are scoped to the USER,
// so the owner does not see them by default.
const { body } = await requestDocuments({
pathname: "/api/documents/facets",
query: "",
actorEntityId: OWNER_ENTITY,
});
const counts = body.counts as Record<string, number>;
expect(counts.doc).toBeGreaterThanOrEqual(1);
});
});
describe("GET /api/documents/search — roomId pushed into service scope (#13593)", () => {
it("passes roomId to searchDocuments BEFORE ranking/capping", async () => {
const searchDocuments = vi.fn(async () => []);
const documentsService = {
getMemories: vi.fn(async () => []),
countMemories: vi.fn(async () => 0),
addDocument: vi.fn(),
searchDocuments,
updateDocument: vi.fn(),
deleteMemory: vi.fn(),
};
const runtime = {
agentId: AGENT_ID,
getService: vi.fn((name: string) =>
name === "documents" ? documentsService : null,
),
getServiceLoadPromise: vi.fn(),
getSetting: vi.fn((key: string) =>
key === "ELIZA_ADMIN_ENTITY_ID" ? OWNER_ENTITY : undefined,
),
getMemoryById: vi.fn(async () => null),
};
const url = new URL(
`http://local/api/documents/search?q=chart&roomId=${ROOM_A}&mediaFormat=image`,
);
let captured: unknown = null;
const ctx = {
req: { headers: { "x-eliza-entity-id": OWNER_ENTITY } },
res: { setHeader: vi.fn() },
method: "GET",
pathname: "/api/documents/search",
url,
runtime: runtime as never,
json: (_res: unknown, data: unknown) => {
captured = data;
},
error: vi.fn(),
readJsonBody: async () => null,
} as unknown as DocumentRouteContext;
const handled = await handleDocumentsRoutes(ctx);
expect(handled).toBe(true);
expect(captured).not.toBeNull();
// roomId is threaded into the service search scope (2nd arg) so the DB
// pre-filters by room before the top-N cap.
expect(searchDocuments).toHaveBeenCalledTimes(1);
const scopeArg = searchDocuments.mock.calls[0][1] as
| { roomId?: string }
| undefined;
expect(scopeArg?.roomId).toBe(ROOM_A);
});
it("keeps help-tagged fragment results when free-text search also has tag=help", async () => {
const helpFragment = docMemory("help-fragment", {
documentId: "help-doc",
title: "Getting started",
tags: ["help"],
scope: "global",
addedBy: AGENT_ID,
});
helpFragment.content.text = "The chat pill opens Eliza from every view.";
helpFragment.similarity = 0.92;
const searchDocuments = vi.fn(async () => [helpFragment]);
const documentsService = {
getMemories: vi.fn(async () => []),
countMemories: vi.fn(async () => 0),
addDocument: vi.fn(),
searchDocuments,
updateDocument: vi.fn(),
deleteMemory: vi.fn(),
};
const runtime = {
agentId: AGENT_ID,
getService: vi.fn((name: string) =>
name === "documents" ? documentsService : null,
),
getServiceLoadPromise: vi.fn(),
getSetting: vi.fn((key: string) =>
key === "ELIZA_ADMIN_ENTITY_ID" ? OWNER_ENTITY : undefined,
),
getMemoryById: vi.fn(async () => null),
};
const url = new URL(
"http://local/api/documents/search?q=chat%20pill&tag=help",
);
let captured: unknown = null;
const ctx = {
req: { headers: { "x-eliza-entity-id": OWNER_ENTITY } },
res: { setHeader: vi.fn() },
method: "GET",
pathname: "/api/documents/search",
url,
runtime: runtime as never,
json: (_res: unknown, data: unknown) => {
captured = data;
},
error: vi.fn(),
readJsonBody: async () => null,
} as unknown as DocumentRouteContext;
const handled = await handleDocumentsRoutes(ctx);
expect(handled).toBe(true);
expect(captured).toMatchObject({
count: 1,
results: [
{
documentId: "help-doc",
documentTitle: "Getting started",
},
],
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
// Canonical types and the runtime service loader live in @elizaos/agent.
// Re-export them from one place so route handlers, presenters, and any other
// consumers in this plugin don't drift from the agent-side definitions.
export {
type DocumentAddedByRole,
type DocumentAddedFrom,
type DocumentSearchMode,
type DocumentsLoadFailReason,
type DocumentsServiceLike,
type DocumentsServiceResult,
type DocumentVisibilityScope,
getDocumentsService,
getDocumentsServiceTimeoutMs,
} from "@elizaos/agent/api/documents-service-loader";