Merge claude/media-platform-export into claude/media-smart-cuts

Brings in main's media library; keeps analyze_video next to search_media and import_media.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-05 11:35:16 +00:00
commit 4e2cc71e0a
52 changed files with 1672 additions and 36 deletions

View File

@ -0,0 +1,82 @@
import path from "node:path";
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { mediaLibrarySyncJob } from "@rakazo/adapter-kit";
import { checkMediaLibraryFolder, mediaLibrarySourceView } from "@rakazo/adapters";
import type { Actor, MediaLibrarySourceAddInput } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
export interface MediaLibraryRouteDeps {
prisma: PrismaClient;
jobs: JobPublisher;
}
/** Library folders are paths on the server, so only its owner adds them, like host folders. */
function requireDeploymentOwner(actor: Actor) {
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
}
async function ownSource(deps: MediaLibraryRouteDeps, actor: Actor, sourceId: string) {
const source = await deps.prisma.mediaLibrarySource.findFirst({
where: { id: sourceId, userId: actor.userId },
});
if (!source) throw new ORPCError("NOT_FOUND");
return source;
}
export async function listMediaLibrarySources(deps: MediaLibraryRouteDeps, actor: Actor) {
requireDeploymentOwner(actor);
const rows = await deps.prisma.mediaLibrarySource.findMany({
where: { userId: actor.userId },
orderBy: { createdAt: "asc" },
});
return rows.map(mediaLibrarySourceView);
}
export async function addMediaLibrarySource(
deps: MediaLibraryRouteDeps,
actor: Actor,
input: MediaLibrarySourceAddInput,
) {
requireDeploymentOwner(actor);
const problem = await checkMediaLibraryFolder(input.path);
if (problem) throw new ORPCError("BAD_REQUEST", { message: problem });
const folder = path.resolve(input.path);
const existing = await deps.prisma.mediaLibrarySource.findUnique({
where: { userId_path: { userId: actor.userId, path: folder } },
});
if (existing) return mediaLibrarySourceView(existing);
const source = await deps.prisma.mediaLibrarySource.create({
data: { userId: actor.userId, path: folder, status: "syncing" },
});
await deps.jobs.enqueue(mediaLibrarySyncJob(source.id));
return mediaLibrarySourceView(source);
}
export async function removeMediaLibrarySource(
deps: MediaLibraryRouteDeps,
actor: Actor,
sourceId: string,
) {
requireDeploymentOwner(actor);
await ownSource(deps, actor, sourceId);
await deps.prisma.mediaLibrarySource.deleteMany({
where: { id: sourceId, userId: actor.userId },
});
return { ok: true as const };
}
export async function syncMediaLibrarySourceNow(
deps: MediaLibraryRouteDeps,
actor: Actor,
sourceId: string,
) {
requireDeploymentOwner(actor);
await ownSource(deps, actor, sourceId);
const source = await deps.prisma.mediaLibrarySource.update({
where: { id: sourceId },
data: { status: "syncing", error: null },
});
await deps.jobs.enqueue(mediaLibrarySyncJob(sourceId));
return mediaLibrarySourceView(source);
}

View File

@ -205,6 +205,12 @@ import {
startMediaJob,
updateMediaSettings,
} from "./media-jobs.js";
import {
addMediaLibrarySource,
listMediaLibrarySources,
removeMediaLibrarySource,
syncMediaLibrarySourceNow,
} from "./media-library.js";
import {
disconnectMemoryProvider,
getMemoryProviderConfig,
@ -3797,6 +3803,20 @@ export function createRouter(deps: RouterDeps) {
cancelMediaJob(deps, context.actor, input.jobId),
),
},
library: {
list: authed.media.library.list.handler(({ context }) =>
listMediaLibrarySources(deps, context.actor),
),
add: authed.media.library.add.handler(({ context, input }) =>
addMediaLibrarySource(deps, context.actor, input),
),
remove: authed.media.library.remove.handler(({ context, input }) =>
removeMediaLibrarySource(deps, context.actor, input.sourceId),
),
sync: authed.media.library.sync.handler(({ context, input }) =>
syncMediaLibrarySourceNow(deps, context.actor, input.sourceId),
),
},
},
connections: {
catalog: authed.connections.catalog.handler(async ({ context, input }) => {

View File

@ -37,6 +37,68 @@ function view(draft: Draft, previous: { comfyui: boolean; transcription: boolean
};
}
type Source = {
id: string;
path: string;
status: "idle" | "syncing" | "failed";
error: string | null;
assetCount: number;
lastSyncedAt: string | null;
};
/** Library folders on the fake server: a new folder indexes on its next list. */
async function fakeLibrary(page: Page, initial: Source[] = []) {
let sources = initial;
const calls: { proc: string; input: unknown }[] = [];
await page.route("**/rpc/media/library/**", (route) => {
const proc = new URL(route.request().url()).pathname.split("/").at(-1) ?? "";
const input = (route.request().postDataJSON() as { json?: unknown } | null)?.json;
calls.push({ proc, input });
if (proc === "list") {
const listed = sources;
sources = sources.map((source) =>
source.status === "syncing"
? { ...source, status: "idle", assetCount: 1284, lastSyncedAt: new Date().toISOString() }
: source,
);
return route.fulfill({ json: { json: listed } });
}
if (proc === "add") {
const { path } = input as { path: string };
if (!path.startsWith("/")) {
return route.fulfill({
status: 400,
json: {
json: {
defined: false,
code: "BAD_REQUEST",
status: 400,
message: "Use an absolute folder path.",
},
},
});
}
const source: Source = {
id: `source-${sources.length + 1}`,
path,
status: "syncing",
error: null,
assetCount: 0,
lastSyncedAt: null,
};
sources = [...sources, source];
return route.fulfill({ json: { json: source } });
}
if (proc === "remove") {
const { sourceId } = input as { sourceId: string };
sources = sources.filter((source) => source.id !== sourceId);
return route.fulfill({ json: { json: { ok: true } } });
}
return route.fallback();
});
return calls;
}
async function asDeploymentOwner(page: Page, isDeploymentOwner: boolean) {
await page.route("**/rpc/bootstrap", async (route) => {
const response = await route.fetch();
@ -50,6 +112,7 @@ async function asDeploymentOwner(page: Page, isDeploymentOwner: boolean) {
test("the deployment owner points media at their own servers", async ({ page }, testInfo) => {
await asDeploymentOwner(page, true);
await fakeLibrary(page);
let current: ReturnType<typeof view> = { comfyui: null, transcription: null };
const saved: Draft[] = [];
await page.route("**/rpc/media/settings/get", (route) =>
@ -113,6 +176,55 @@ test("the deployment owner points media at their own servers", async ({ page },
await expect(comfyKey).toHaveAttribute("placeholder", "Optional");
});
test("the deployment owner adds a photo folder to the library", async ({ page }, testInfo) => {
await asDeploymentOwner(page, true);
const calls = await fakeLibrary(page, [
{
id: "source-0",
path: "/srv/media/Videos",
status: "failed",
error: "Folder not found.",
assetCount: 0,
lastSyncedAt: null,
},
]);
await page.route("**/rpc/media/settings/get", (route) =>
route.fulfill({ json: { json: { comfyui: null, transcription: null } } }),
);
await signup(page, `media-library-${Date.now()}@rakazo.test`, "password12", "Library Owner");
await completeOnboarding(page);
const settings = await openUserSettings(page);
await settings.getByTestId("settings-nav-media").click();
const library = settings.getByTestId("media-library");
await expect(library.getByText("Folder not found.")).toBeVisible();
const folder = library.getByLabel("Folder", { exact: true });
await folder.fill("photos");
await library.getByRole("button", { name: "Add", exact: true }).click();
await expect(library.getByRole("alert")).toHaveText("Use an absolute folder path.");
await folder.fill("/srv/media/Photos");
await library.getByRole("button", { name: "Add", exact: true }).click();
await expect(folder).toHaveValue("");
await expect(library.getByText("/srv/media/Photos")).toBeVisible();
// The new folder indexes in the background; the list catches up on its own.
await expect(library.getByText("1,284 files")).toBeVisible({ timeout: 10_000 });
await captureScreenshot(page, testInfo, "media-library");
await library
.getByRole("listitem")
.filter({ hasText: "/srv/media/Videos" })
.getByRole("button", { name: "Remove folder" })
.click();
await expect(library.getByText("/srv/media/Videos")).toHaveCount(0);
expect(calls.filter((call) => call.proc !== "list")).toEqual([
{ proc: "add", input: { path: "photos" } },
{ proc: "add", input: { path: "/srv/media/Photos" } },
{ proc: "remove", input: { sourceId: "source-0" } },
]);
});
test("members who do not own the deployment have no media settings", async ({ page }) => {
await asDeploymentOwner(page, false);
await signup(page, `media-member-${Date.now()}@rakazo.test`, "password12", "Media Member");

View File

@ -0,0 +1,41 @@
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
async function fontSize(locator: Locator) {
return locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
}
test("phone widths read text at 16px and up while desktop keeps its sizes", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `mobile-font-${stamp}@rakazo.test`, "password12", "Font QA");
await completeOnboarding(page);
const composer = page.getByRole("combobox", { name: /^Message/ });
await composer.fill("Please review `shared/PROJECT_CHECKPOINT_WRAPUP.md`.");
await composer.press("Enter");
const reply = page
.getByTestId("transcript")
.locator("[data-message-id]")
.filter({ hasText: "done. i handled:" });
await expect(reply).toBeVisible({ timeout: 30_000 });
const smallText = page.locator(".text-ui-body-sm").first();
const desktopSmall = await fontSize(smallText);
expect(desktopSmall).toBe(14);
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => fontSize(smallText)).toBeCloseTo(16, 1);
expect(await fontSize(composer)).toBeGreaterThanOrEqual(16);
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await captureScreenshot(page, testInfo, "mobile-font-size-dark");
await page.emulateMedia({ colorScheme: "light" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await captureScreenshot(page, testInfo, "mobile-font-size-light");
await page.setViewportSize({ width: 1280, height: 720 });
await expect.poll(() => fontSize(smallText)).toBe(desktopSmall);
});

View File

@ -0,0 +1,162 @@
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import type { MediaLibrarySource } from "@rakazo/contracts";
import { Button, Input } from "@rakazo/ui-web";
import { RefreshCw, X } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { rpc } from "../lib/rpc";
import { Shimmer } from "./ai/primitives";
const POLL_MS = 2000;
/** Folders on the server whose photos and videos the owner's bots can search and use. */
export function MediaLibraryFolders() {
const { t } = useLingui();
const id = useId();
const [sources, setSources] = useState<MediaLibrarySource[] | null>(null);
const [folder, setFolder] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
rpc.media.library
.list()
.then((next) => {
if (!cancelled) setSources(next);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`);
});
return () => {
cancelled = true;
};
}, [t]);
const syncing = sources?.some((source) => source.status === "syncing") ?? false;
useEffect(() => {
if (!syncing) return;
const timer = setInterval(() => {
rpc.media.library
.list()
.then(setSources)
.catch(() => undefined);
}, POLL_MS);
return () => clearInterval(timer);
}, [syncing]);
async function act(action: () => Promise<void>) {
if (busy) return;
setBusy(true);
setError(null);
try {
await action();
} catch (err) {
setError(err instanceof Error ? err.message : t`Couldn't save`);
} finally {
setBusy(false);
}
}
function replace(next: MediaLibrarySource) {
setSources((current) => [...(current ?? []).filter((source) => source.id !== next.id), next]);
}
const disabled = busy || sources === null;
return (
<section
data-testid="media-library"
className="rounded-xl border border-border px-4 py-4"
aria-labelledby={`${id}-title`}
>
<h3 id={`${id}-title`} className="text-ui-body font-medium text-foreground">
<Trans>Library</Trans>
</h3>
{sources?.length ? (
<ul className="mt-3 divide-y divide-border">
{sources.map((source) => (
<li key={source.id} className="flex items-center gap-2 py-2">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-ui-body-sm text-foreground">{source.path}</p>
{source.status === "syncing" ? (
<p className="text-ui-caption">
<Shimmer>
<Trans>Indexing…</Trans>
</Shimmer>
</p>
) : source.status === "failed" ? (
<p className="text-ui-caption text-destructive">{source.error}</p>
) : (
<p className="text-ui-caption text-muted-foreground">
<Plural value={source.assetCount} one="# file" other="# files" />
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Index again`}
disabled={disabled || source.status === "syncing"}
onClick={() =>
void act(async () => {
replace(await rpc.media.library.sync({ sourceId: source.id }));
})
}
>
<RefreshCw />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t`Remove folder`}
disabled={disabled}
onClick={() =>
void act(async () => {
await rpc.media.library.remove({ sourceId: source.id });
setSources((current) =>
(current ?? []).filter((entry) => entry.id !== source.id),
);
})
}
>
<X />
</Button>
</li>
))}
</ul>
) : null}
<form
className="mt-3 flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
const path = folder.trim();
if (!path) return;
void act(async () => {
replace(await rpc.media.library.add({ path }));
setFolder("");
});
}}
>
<Input
aria-label={t`Folder`}
value={folder}
disabled={disabled}
placeholder="/srv/photos"
autoComplete="off"
spellCheck={false}
onChange={(event) => setFolder(event.target.value)}
/>
<Button type="submit" variant="secondary" disabled={disabled || !folder.trim()}>
<Trans>Add</Trans>
</Button>
</form>
{error ? (
<p role="alert" className="mt-2 text-ui-body-sm text-destructive">
{error}
</p>
) : null}
</section>
);
}

View File

@ -4,6 +4,7 @@ import { Button, Field, FieldLabel, Input, Textarea } from "@rakazo/ui-web";
import { useEffect, useId, useRef, useState } from "react";
import { rpc } from "../lib/rpc";
import { SuccessPop } from "./ai/primitives";
import { MediaLibraryFolders } from "./MediaLibraryFolders";
type KeyDraft = { value: string; remove: boolean };
@ -67,7 +68,9 @@ function ApiKeyField({
);
}
/** The deployment owner's media servers: ComfyUI for generation, a transcription server. */
/**
* The deployment owner's media: library folders, ComfyUI for generation, a transcription server.
*/
export function MediaSettings() {
const { t } = useLingui();
const ids = useId();
@ -152,6 +155,7 @@ export function MediaSettings() {
return (
<div data-testid="media-settings" className="space-y-5">
<MediaLibraryFolders />
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">ComfyUI</h3>
<Field className="mt-3">

View File

@ -251,7 +251,7 @@ export function OoStage({
) : null}
</>
) : (
<p className="mt-1 max-w-xl truncate text-center text-ui-caption text-muted-foreground">
<p className="mt-1 w-full max-w-xl truncate text-center text-ui-caption text-muted-foreground">
{subtitle}
</p>
)}
@ -548,7 +548,7 @@ export function OoGroupStage({
{subtitle}
</p>
) : (
<p className="mt-1 max-w-xl truncate text-center text-ui-caption text-muted-foreground">
<p className="mt-1 w-full max-w-xl truncate text-center text-ui-caption text-muted-foreground">
{subtitle}
</p>
)}

View File

@ -27,6 +27,11 @@ msgstr "(optional)"
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# Tag} other {# Tage}}"
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr "{0, plural, one {# Datei} other {# Dateien}}"
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "Aktives Modell"
msgid "Activity"
msgstr "Aktivität"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr "Stimme konnte nicht aktualisiert werden"
msgid "Could not upload {name}. Try again."
msgstr "{name} konnte nicht hochgeladen werden. Versuche es erneut."
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr "Konnte nicht geladen werden"
msgid "Couldn't load messaging settings"
msgstr "Messaging-Einstellungen konnten nicht geladen werden"
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr "Schwung"
msgid "Flower"
msgstr "Blume"
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr "Ordner"
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr "Schriften lokal geladen"
@ -2821,6 +2833,14 @@ msgstr "In Arbeit"
msgid "Inbox & email"
msgstr "Posteingang & E-Mail"
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr "Neu einlesen"
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr "Wird eingelesen…"
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Standard übernehmen"
@ -2927,6 +2947,10 @@ msgstr "Später"
msgid "Leave"
msgstr "Verlassen"
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr "Mediathek"
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr "Privatkanal — ich bin da."
@ -4157,6 +4181,10 @@ msgstr "Entfernen"
msgid "Remove {0}"
msgstr "{0} entfernen"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr "Ordner entfernen"
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr "Git-Ereignis entfernen"

View File

@ -27,6 +27,11 @@ msgstr "(optional)"
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# day} other {# days}}"
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr "{0, plural, one {# file} other {# files}}"
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "Active model"
msgid "Activity"
msgstr "Activity"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr "Could not update voice"
msgid "Could not upload {name}. Try again."
msgstr "Could not upload {name}. Try again."
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr "Couldn't load"
msgid "Couldn't load messaging settings"
msgstr "Couldn't load messaging settings"
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr "Flick"
msgid "Flower"
msgstr "Flower"
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr "Folder"
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr "Fonts loaded locally"
@ -2821,6 +2833,14 @@ msgstr "In progress"
msgid "Inbox & email"
msgstr "Inbox & email"
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr "Index again"
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr "Indexing…"
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Inherit default"
@ -2927,6 +2947,10 @@ msgstr "Later"
msgid "Leave"
msgstr "Leave"
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr "Library"
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr "Life channel — I'm here."
@ -4157,6 +4181,10 @@ msgstr "Remove"
msgid "Remove {0}"
msgstr "Remove {0}"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr "Remove folder"
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr "Remove Git event"

View File

@ -27,6 +27,11 @@ msgstr ""
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "Modelo activo"
msgid "Activity"
msgstr "Actividad"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr ""
msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "No se pudo cargar la configuración de mensajería"
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr ""
msgid "Flower"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2821,6 +2833,14 @@ msgstr ""
msgid "Inbox & email"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Heredar predeterminado"
@ -2927,6 +2947,10 @@ msgstr ""
msgid "Leave"
msgstr "Salir"
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr ""
@ -4157,6 +4181,10 @@ msgstr "Quitar"
msgid "Remove {0}"
msgstr "Quitar {0}"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""

View File

@ -27,6 +27,11 @@ msgstr ""
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "सक्रिय मॉडल"
msgid "Activity"
msgstr "गतिविधि"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr ""
msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr ""
msgid "Flower"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2821,6 +2833,14 @@ msgstr ""
msgid "Inbox & email"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "डिफ़ॉल्ट का पालन करें"
@ -2927,6 +2947,10 @@ msgstr ""
msgid "Leave"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr ""
@ -4157,6 +4181,10 @@ msgstr "हटाएं"
msgid "Remove {0}"
msgstr "{0} हटाएं"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""

View File

@ -27,6 +27,11 @@ msgstr ""
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "현재 모델"
msgid "Activity"
msgstr "활동"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr ""
msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "메시징 설정을 불러올 수 없습니다"
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr ""
msgid "Flower"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2821,6 +2833,14 @@ msgstr ""
msgid "Inbox & email"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "기본 설정 사용"
@ -2927,6 +2947,10 @@ msgstr ""
msgid "Leave"
msgstr "나가기"
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr ""
@ -4157,6 +4181,10 @@ msgstr "삭제"
msgid "Remove {0}"
msgstr "{0} 삭제"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""

View File

@ -27,6 +27,11 @@ msgstr ""
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "Modelo ativo"
msgid "Activity"
msgstr "Atividade"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr ""
msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr ""
msgid "Flower"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2821,6 +2833,14 @@ msgstr ""
msgid "Inbox & email"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Herdar (padrão)"
@ -2927,6 +2947,10 @@ msgstr ""
msgid "Leave"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr ""
@ -4157,6 +4181,10 @@ msgstr "Remover"
msgid "Remove {0}"
msgstr "Remover {0}"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""

View File

@ -27,6 +27,11 @@ msgstr ""
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "Активная модель"
msgid "Activity"
msgstr "Активность"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr ""
msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "Не удалось загрузить настройки обмена сообщениями."
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr ""
msgid "Flower"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2821,6 +2833,14 @@ msgstr ""
msgid "Inbox & email"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Наследовать значение по умолчанию"
@ -2927,6 +2947,10 @@ msgstr "Позже"
msgid "Leave"
msgstr "Выйти"
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr ""
@ -4157,6 +4181,10 @@ msgstr "Удалить"
msgid "Remove {0}"
msgstr "Удалить {0}"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr "Удалить событие Git"

View File

@ -27,6 +27,11 @@ msgstr ""
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "Etkin model"
msgid "Activity"
msgstr "Etkinlik"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr ""
msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr ""
msgid "Flower"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2821,6 +2833,14 @@ msgstr ""
msgid "Inbox & email"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Varsayılanı devral"
@ -2927,6 +2947,10 @@ msgstr ""
msgid "Leave"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr ""
@ -4157,6 +4181,10 @@ msgstr "Kaldır"
msgid "Remove {0}"
msgstr "{0} kaldır"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""

View File

@ -27,6 +27,11 @@ msgstr ""
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: source.assetCount
#: src/components/MediaLibraryFolders.tsx
msgid "{0, plural, one {# file} other {# files}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -368,6 +373,7 @@ msgstr "当前模型"
msgid "Activity"
msgstr "动态"
#: src/components/MediaLibraryFolders.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
@ -1829,6 +1835,7 @@ msgstr ""
msgid "Could not upload {name}. Try again."
msgstr ""
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -1839,6 +1846,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "无法加载消息设置"
#: src/components/MediaLibraryFolders.tsx
#: src/components/MediaSettings.tsx
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
@ -2519,6 +2527,10 @@ msgstr ""
msgid "Flower"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Folder"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2821,6 +2833,14 @@ msgstr ""
msgid "Inbox & email"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Index again"
msgstr ""
#: src/components/MediaLibraryFolders.tsx
msgid "Indexing…"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "沿用默认"
@ -2927,6 +2947,10 @@ msgstr ""
msgid "Leave"
msgstr "退出"
#: src/components/MediaLibraryFolders.tsx
msgid "Library"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Life channel — I'm here."
msgstr ""
@ -4157,6 +4181,10 @@ msgstr "移除"
msgid "Remove {0}"
msgstr "移除 {0}"
#: src/components/MediaLibraryFolders.tsx
msgid "Remove folder"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Remove Git event"
msgstr ""

View File

@ -80,7 +80,10 @@ export function ExternalConversationSettings({
return (
<div data-testid="external-conversation-settings">
<div className="mb-6">
<h2 className="truncate text-[17px] font-medium text-foreground" dir="auto">
<h2
className="truncate text-ui-reading leading-normal font-medium text-foreground"
dir="auto"
>
{conversation.displayName || t`External conversation`}
</h2>
<p className="mt-1 truncate text-ui-body-sm text-muted-foreground">

View File

@ -134,7 +134,7 @@ export function PeerMessagesOverlay({
<div className="mb-1 text-ui-body-sm text-muted-foreground/70" dir="auto">
{sent ? botName : peerBotName}
</div>
<div className="text-[14.5px] leading-[1.5] text-foreground/90" dir="auto">
<div className="text-ui-body-sm leading-[1.5] text-foreground/90" dir="auto">
<ChatMarkdown>{peerMessage.text}</ChatMarkdown>
</div>
</div>

View File

@ -558,7 +558,9 @@ export function PluginsOverlay({
{item.name[0]}
</div>
)}
<div className="truncate text-[17px] font-medium text-foreground">{item.name}</div>
<div className="truncate text-ui-reading leading-normal font-medium text-foreground">
{item.name}
</div>
</div>
<Button
type="button"

View File

@ -156,7 +156,7 @@ export function RoutineListRow({
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14.5px] font-medium text-foreground" dir="auto">
<span className="block truncate text-ui-body-sm font-medium text-foreground" dir="auto">
{routineName(routine)}
</span>
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
@ -522,7 +522,7 @@ function MessageTriggerCard({ provider, onRemove }: { provider: string; onRemove
className="text-muted-foreground size-icon-md"
aria-hidden
/>
<span className="flex-1 text-[14.5px] text-foreground">{label}</span>
<span className="flex-1 text-ui-body-sm text-foreground">{label}</span>
<Button
variant="ghost"
size="icon-xs"
@ -587,7 +587,7 @@ function InboundTriggerCard({
) : (
<Globe strokeWidth={1.6} className="text-muted-foreground size-icon-md" aria-hidden />
)}
<span className="flex-1 text-[14.5px] text-foreground">
<span className="flex-1 text-ui-body-sm text-foreground">
{kind === "github" ? <Trans>Git event</Trans> : <Trans>When a webhook fires</Trans>}
</span>
<Button

View File

@ -229,9 +229,9 @@ export function RoutineSchedule({
<div className="mt-2 rounded-xl border border-border p-3">
<div className="flex items-center gap-2.5 px-0.5">
<Clock size={17} strokeWidth={1.6} className="shrink-0 text-muted-foreground" aria-hidden />
<span className="text-[14.5px] text-foreground">{lead}</span>
<span className="text-ui-body-sm text-foreground">{lead}</span>
{detail ? (
<span className="flex-1 text-[14.5px] text-muted-foreground">{detail}</span>
<span className="flex-1 text-ui-body-sm text-muted-foreground">{detail}</span>
) : null}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">

View File

@ -124,7 +124,7 @@ export function ScratchpadSection({ botId }: { botId: string }) {
/>
<div className="min-w-0 flex-1">
<div
className={`text-start text-[14.5px] ${item.status === "done" ? "text-muted-foreground/80 line-through" : "text-foreground"}`}
className={`text-start text-ui-body-sm ${item.status === "done" ? "text-muted-foreground/80 line-through" : "text-foreground"}`}
dir="auto"
>
{item.title}

View File

@ -21,7 +21,7 @@ export function WelcomePage() {
<p className="text-[27px] leading-[1.35] text-foreground/90 font-medium">
<Trans>Your specialists. One calm place to talk.</Trans>
</p>
<p className="text-[18px] leading-[1.5] text-foreground/60">
<p className="text-ui-title-sm leading-[1.5] text-foreground/60">
<Trans>Simple to start. Expert when you need it.</Trans>
</p>
</div>

View File

@ -383,7 +383,7 @@ function ChartCanvas({
return (
<div className="text-foreground/75">
{meta.title ? (
<div className="mb-1 text-[14.5px] font-semibold text-foreground">{meta.title}</div>
<div className="mb-1 text-ui-body-sm font-semibold text-foreground">{meta.title}</div>
) : null}
{meta.swatches.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-x-3 gap-y-1">
@ -460,7 +460,7 @@ export function McpApprovalCard({
<span className="grid size-control-sm place-items-center rounded-lg bg-muted text-ui-label text-foreground">
M
</span>
<span className="text-[14.5px] font-medium text-foreground">
<span className="text-ui-body-sm font-medium text-foreground">
<Trans>Connect MCP server “{name}”</Trans>
</span>
</div>

View File

@ -811,7 +811,7 @@ const MessageView = memo(function MessageView({
</div>
<div className="flex items-center gap-2">
<span
className="text-[14.5px] font-semibold text-foreground tracking-tight"
className="text-ui-body-sm font-semibold text-foreground tracking-tight"
dir="auto"
>
{effectiveSpeakerName}
@ -1048,7 +1048,7 @@ const MessageView = memo(function MessageView({
</div>
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
{block.progress || block.result ? (
<div className="mt-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
<div className="mt-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown streaming={running}>
{block.result || block.progress || ""}
</ChatMarkdown>
@ -1095,7 +1095,7 @@ const MessageView = memo(function MessageView({
)}
</span>
</div>
<div className="mt-2 text-[14.5px] leading-[1.5] text-foreground/75" dir="auto">
<div className="mt-2 text-ui-body-sm leading-[1.5] text-foreground/75" dir="auto">
{removed
? block.status === "archived"
? t`Archived. Chat, memory, and files kept.`
@ -1201,7 +1201,7 @@ const MessageView = memo(function MessageView({
className={
encounterMode
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-chat-user-foreground shadow-md"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-ui-reading leading-[1.62] text-chat-user-foreground shadow-md"
}
dir="auto"
>
@ -1218,7 +1218,7 @@ const MessageView = memo(function MessageView({
className={
encounterMode
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-[17.5px] leading-[1.85] text-foreground/95"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-ui-reading leading-[1.85] text-foreground/95"
}
dir="auto"
>
@ -1330,7 +1330,7 @@ const MessageView = memo(function MessageView({
{block.state}
</span>
</div>
<div className="my-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
<div className="my-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown>{block.text}</ChatMarkdown>
</div>
</div>

View File

@ -30,7 +30,7 @@ export function UserMenu({
<span className="grid size-control-lg place-items-center rounded-full bg-accent text-ui-body-sm text-foreground/75">
{initials}
</span>
<span className="text-[14.5px] text-foreground/90">{userName}</span>
<span className="text-ui-body-sm text-foreground/90">{userName}</span>
</PopoverTrigger>
{open ? (
<PopoverContent

View File

@ -98,7 +98,7 @@ body,
/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable).
Heading, code and quote colors come from the shared @rakazo/chat-ui markdown styles. */
.rk-chat-markdown {
font-size: var(--font-size-reading);
font-size: calc(var(--font-size-reading) * var(--font-scale));
line-height: var(--line-height-reading);
color: var(--foreground);
letter-spacing: -0.011em;
@ -116,7 +116,7 @@ body,
inset 0 1px 0 rgba(255, 255, 255, 0.09),
0 12px 36px rgba(0, 0, 0, 0.55);
padding: 1.25em 1.5em;
font-size: 14.5px;
font-size: calc(14.5px * var(--font-scale));
line-height: 1.65;
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
@ -130,7 +130,7 @@ body,
font-weight: 500;
border-radius: 6px;
padding: 0.15em 0.4em;
font-size: 14.5px;
font-size: calc(14.5px * var(--font-scale));
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}

View File

@ -80,6 +80,26 @@ Docker computer topology:
Set any of them to `0`, `none` or `unlimited` to remove that ceiling. A malformed value fails the
supervisor at startup naming the variable, rather than surfacing later as a failed bot.
### Media library
The deployment owner can add folders of photos and videos under Settings, Media. The worker
indexes them (names, dates, sizes; never moving or changing a file), re-indexes every six hours,
and the owner's bots can then search the library and copy a file into a conversation. Paths are
read on the server, so under Compose mount each folder read-only into both `api` and `worker` at
the same path, for example in an overlay:
```yaml
services:
api:
volumes: ["/srv/media/Photos:/media/Photos:ro"]
worker:
volumes: ["/srv/media/Photos:/media/Photos:ro"]
```
and add `/media/Photos` in the settings. Indexed are the photo and video types a conversation can
hold; capture dates come from video metadata when ffmpeg is installed, otherwise from the file's
modification time.
## Docker Compose (single machine)
1. Copy `.env.example` to `.env` and set `POSTGRES_PASSWORD` (`openssl rand -hex 16`), plus `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, and `SCREEN_PROXY_SECRET` to independent long random strings (32+ characters; 64 hex for `ENCRYPTION_KEY`). Docker sandboxes also need a dedicated `SANDBOX_SUPERVISOR_TOKEN`. Keep existing `ENCRYPTION_KEY` values so stored credentials stay decryptable.

View File

@ -4,6 +4,9 @@ import {
historyCompactJob,
historyCompactJobKey,
MEDIA_JOB_QUEUE,
MEDIA_LIBRARY_JOB_QUEUE,
mediaLibrarySyncJob,
mediaLibrarySyncJobKey,
mediaRunJob,
mediaRunJobKey,
messagingDeliverJob,
@ -26,6 +29,7 @@ function handlers(): BackgroundJobHandlers {
"cloud_agent.poll": vi.fn(async () => undefined),
"profile.consolidate": vi.fn(async () => undefined),
"media.run": vi.fn(async () => undefined),
"media.library.sync": vi.fn(async () => undefined),
};
}
@ -134,3 +138,21 @@ describe("mediaRunJob", () => {
expect(mediaRunJobKey("job-1")).not.toBe(mediaRunJobKey("job-2"));
});
});
describe("mediaLibrarySyncJob", () => {
it("validates, dispatches, and keeps one pending sync per folder on the library queue", async () => {
const target = handlers();
await dispatchBackgroundJob(target, "media.library.sync", { sourceId: "source-1" });
expect(target["media.library.sync"]).toHaveBeenCalledWith({ sourceId: "source-1" });
expect(() => parseBackgroundJob("media.library.sync", { sourceId: "" })).toThrow();
const at = new Date("2026-10-05T12:00:00.000Z");
expect(mediaLibrarySyncJob("source-1", at)).toEqual({
name: "media.library.sync",
payload: { sourceId: "source-1" },
replaceKey: mediaLibrarySyncJobKey("source-1"),
queue: MEDIA_LIBRARY_JOB_QUEUE,
availableAt: at,
});
expect(mediaLibrarySyncJob("source-1").availableAt).toBeUndefined();
});
});

View File

@ -24,6 +24,7 @@ const payloadSchemas = {
"cloud_agent.poll": z.object({ agentId: z.string().min(1) }),
"profile.consolidate": z.object({ userId: z.string().min(1) }),
"media.run": z.object({ jobId: z.string().min(1) }),
"media.library.sync": z.object({ sourceId: z.string().min(1) }),
} satisfies { [Name in BackgroundJobName]: z.ZodType<BackgroundJobPayloads[Name]> };
export function parseBackgroundJob(name: string, payload: unknown): BackgroundJob {
@ -178,3 +179,20 @@ export function mediaRunJob(jobId: string, availableAt?: Date): BackgroundJob {
...(availableAt ? { availableAt } : {}),
};
}
/** Library syncs walk disks; one at a time keeps them from competing for the same drive. */
export const MEDIA_LIBRARY_JOB_QUEUE = "media-library";
export function mediaLibrarySyncJobKey(sourceId: string): string {
return `media.library.sync:${sourceId}`;
}
export function mediaLibrarySyncJob(sourceId: string, availableAt?: Date): BackgroundJob {
return {
name: "media.library.sync",
payload: { sourceId },
replaceKey: mediaLibrarySyncJobKey(sourceId),
queue: MEDIA_LIBRARY_JOB_QUEUE,
...(availableAt ? { availableAt } : {}),
};
}

View File

@ -535,6 +535,8 @@ export interface BackgroundJobPayloads {
"profile.consolidate": { userId: string };
/** Run one media job; scope, input and lease live on the job row. */
"media.run": { jobId: string };
/** Re-index one media library folder; the source row holds the path and owner. */
"media.library.sync": { sourceId: string };
}
export type BackgroundJobName = keyof BackgroundJobPayloads;

View File

@ -16,6 +16,7 @@ import { scheduleComputerSleep, sleepComputerIfIdle } from "./computer-idle.js";
import { performComputerUpdate } from "./computer-update.js";
import type { createRunExecutor } from "./executor.js";
import { compactHistory } from "./history-compaction.js";
import { syncMediaLibrarySource } from "./media-library.js";
import type { MediaRuntimeHost } from "./media-runtime.js";
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
import { deliverMessagingOutbound, mirrorMessagingOutbound } from "./messaging-delivery.js";
@ -117,6 +118,9 @@ export function createBackgroundJobHandlers(deps: {
"media.run": async (payload) => {
await deps.media?.runJob(payload.jobId);
},
"media.library.sync": async (payload) => {
await syncMediaLibrarySource(deps, payload.sourceId);
},
"profile.consolidate": async (payload) => {
await consolidateProfile(
{

View File

@ -589,6 +589,31 @@ export const builtinAgentTools: ConnectorTool[] = [
"Find cut points in a video artifact: scene changes, pauses, loud moments and, if it was transcribed, transcript timing. Read-only; you are woken with the result as JSON (times in seconds) to plan edit_video steps. title: a few words in the user's language for the card.",
inputSchema: mediaParameters(AnalyzeVideoInputSchema, { continueAfter: false }),
},
{
name: "search_media",
description:
"Search the user's photo and video library, newest first. query matches words in folder and file names (trips, events, dates people put in names). Returns asset ids; import_media brings one into the conversation.",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
kind: { type: "string", enum: ["photo", "video"] },
taken_after: { type: "string", description: "ISO 8601 date-time." },
taken_before: { type: "string", description: "ISO 8601 date-time." },
limit: { type: "integer", minimum: 1, maximum: 50, description: "Default 20." },
},
},
},
{
name: "import_media",
description:
"Copy a photo or video from the user's library into this conversation as an artifact, so it can be shown or edited with the media tools. The original file is not changed.",
inputSchema: {
type: "object",
properties: { asset_id: { type: "string" } },
required: ["asset_id"],
},
},
{
name: "add_mcp_server",
description:

View File

@ -13,15 +13,23 @@ const unrestricted = { kind: "specialist", restrictBuiltinTools: false, allowedB
function select(
bot: { kind: string; restrictBuiltinTools: boolean; allowedBuiltinTools: string[] },
options: { graphicalToolsAllowed?: boolean; agentConnectionsEnabled?: boolean } = {},
options: {
graphicalToolsAllowed?: boolean;
agentConnectionsEnabled?: boolean;
mediaLibraryEnabled?: boolean;
foreignAgentRun?: boolean;
messagingChannelRun?: boolean;
} = {},
) {
return selectBuiltinToolsForRun({
graphicalToolsAllowed: options.graphicalToolsAllowed ?? true,
groupId: null,
trigger: "user",
semanticMemoryEnabled: false,
messagingChannelRun: false,
messagingChannelRun: options.messagingChannelRun ?? false,
agentConnectionsEnabled: options.agentConnectionsEnabled,
mediaLibraryEnabled: options.mediaLibraryEnabled,
foreignAgentRun: options.foreignAgentRun,
bot,
}).map((tool) => tool.name);
}
@ -39,6 +47,18 @@ describe("builtin tools per bot", () => {
);
});
it("offers the media library tools only to the user's own runs once a folder exists", () => {
const library = ["search_media", "import_media"];
expect(select(unrestricted)).not.toEqual(expect.arrayContaining(["search_media"]));
expect(select(unrestricted, { mediaLibraryEnabled: true })).toEqual(
expect.arrayContaining(library),
);
for (const run of [{ foreignAgentRun: true }, { messagingChannelRun: true }]) {
const names = select(unrestricted, { mediaLibraryEnabled: true, ...run });
expect(names.filter((name) => library.includes(name))).toEqual([]);
}
});
it("applies the allowlist to agent connection tools too", () => {
const bot = {
kind: "specialist",

View File

@ -34,6 +34,9 @@ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set<string>([
...PERSONAL_AGENT_TOOLS,
]);
/** Tools over the user's own photo and video library. */
export const MEDIA_LIBRARY_TOOLS: ReadonlySet<string> = new Set(["search_media", "import_media"]);
/** Tools that write what the user's bots know about the user, so only the user may drive them. */
const USER_MODEL_TOOLS: ReadonlySet<string> = new Set<string>([
"update_user_profile",
@ -110,6 +113,8 @@ export function selectBuiltinToolsForRun(options: {
cloudAgentEnabled?: boolean;
/** Media tools appear only for operations the space can run. */
mediaCapabilities?: MediaCapabilities;
/** The run's user has a media library folder. */
mediaLibraryEnabled?: boolean;
messagingChannelRun: boolean;
/** Cross-owner agent connections only exist for chat-linked bots. */
agentConnectionsEnabled?: boolean;
@ -138,6 +143,11 @@ export function selectBuiltinToolsForRun(options: {
).filter(
(tool) =>
!(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) &&
// The library is the user's private media: not for other owners' agents or shared chats.
(!MEDIA_LIBRARY_TOOLS.has(tool.name) ||
(options.mediaLibraryEnabled === true &&
!options.foreignAgentRun &&
!options.messagingChannelRun)) &&
(!options.messagingChannelRun ||
(![
"remember",

View File

@ -20,6 +20,7 @@ import type {
WebProvider,
} from "@rakazo/adapter-kit";
import {
ArtifactTooLargeError,
historyCompactJob,
routineJobKey,
routineWakeupJob,
@ -34,6 +35,8 @@ import {
BotSecretName,
BotSecretSubmission,
isAttachmentImageMimeType,
MediaLibraryImportInputSchema,
MediaLibrarySearchInputSchema,
type MessageBlock,
MessageBlock as MessageBlockSchema,
OPENAI_COMPATIBLE_PROVIDER_ID,
@ -61,6 +64,7 @@ import {
isMessagingChannelRun,
isOneShotRoutineCrons,
isTerminal,
messageBlockForArtifact,
messagingChannelId,
messagingChannelPrivacyBlock,
messagingDmSurfaceNote,
@ -304,6 +308,12 @@ import {
needsOAuthProbe,
parseMcpServerToolArgs,
} from "./mcp-server-tool.js";
import {
importMediaLibraryAsset,
MediaLibraryError,
searchMediaLibrary,
userHasMediaLibrary,
} from "./media-library.js";
import { executeMediaTool, isMediaToolName } from "./media-tools.js";
import { loadAgentMemoryContext } from "./memory-context.js";
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
@ -1559,6 +1569,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
semanticMemoryEnabled,
cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId),
mediaCapabilities: await deps.media?.capabilities(run.spaceId).catch(() => undefined),
mediaLibraryEnabled: await userHasMediaLibrary(deps.prisma, run.userId).catch(
() => false,
),
messagingChannelRun,
agentConnectionsEnabled: hasMessagingIdentity,
foreignAgentRun,
@ -2533,6 +2546,38 @@ export function createRunExecutor(deps: ExecutorDeps) {
});
}
}
if (name === "search_media") {
const parsed = MediaLibrarySearchInputSchema.safeParse(args);
if (!parsed.success) return finish({ error: parsed.error.issues[0]?.message });
return finish(await searchMediaLibrary(deps.prisma, run.userId, parsed.data));
}
if (name === "import_media") {
const parsed = MediaLibraryImportInputSchema.safeParse(args);
if (!parsed.success) return finish({ error: "asset_id is required" });
if (!deps.artifacts) return finish({ error: "artifact storage unavailable" });
try {
const row = await importMediaLibraryAsset(
{ prisma: deps.prisma, artifacts: deps.artifacts },
{
spaceId: run.spaceId,
userId: run.userId,
botId: bot.id,
groupId: thread.groupId,
runId: run.id,
signal: context.signal,
},
parsed.data.asset_id,
);
await publishMessage(deps, run, "bot", [messageBlockForArtifact(row)]);
return finish({ ok: true, artifactId: row.id, name: row.name });
} catch (error) {
if (error instanceof ArtifactTooLargeError) {
return finish({ error: `file exceeds the ${formatMiB(error.maxBytes)} limit` });
}
if (error instanceof MediaLibraryError) return finish({ error: error.message });
throw error;
}
}
if (name === "attach_file") {
const filePath = String(args.path ?? "");
if (!deps.artifacts) {

View File

@ -91,6 +91,7 @@ export {
runFfmpeg,
} from "./media/ffmpeg.js";
export { createVideoOperations } from "./media/video-operations.js";
export * from "./media-library.js";
export * from "./media-operations.js";
export * from "./media-runtime.js";
export * from "./media-settings.js";

View File

@ -0,0 +1,63 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
checkMediaLibraryFolder,
mediaLibraryFileType,
walkMediaLibrary,
} from "./media-library.js";
const dirs: string[] = [];
function tempDir() {
const dir = mkdtempSync(path.join(tmpdir(), "rakazo-library-unit-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
async function walk(root: string) {
const out: string[] = [];
for await (const file of walkMediaLibrary(root)) out.push(file);
return out;
}
describe("media library", () => {
it("indexes photos and videos only", () => {
expect(mediaLibraryFileType("IMG_0001.JPG")).toEqual({ kind: "photo", mimeType: "image/jpeg" });
expect(mediaLibraryFileType("clip.mp4")).toEqual({ kind: "video", mimeType: "video/mp4" });
expect(mediaLibraryFileType("song.mp3")).toBeNull();
expect(mediaLibraryFileType("notes.txt")).toBeNull();
});
it("walks below the folder without hidden entries or symlinks", async () => {
const root = tempDir();
const outside = tempDir();
mkdirSync(path.join(root, "2024", "Summer"), { recursive: true });
mkdirSync(path.join(root, ".cache"));
writeFileSync(path.join(root, "2024", "Summer", "b.jpg"), "x");
writeFileSync(path.join(root, "2024", "a.png"), "x");
writeFileSync(path.join(root, "2024", "readme.txt"), "x");
writeFileSync(path.join(root, ".cache", "c.jpg"), "x");
writeFileSync(path.join(root, ".hidden.jpg"), "x");
writeFileSync(path.join(outside, "d.jpg"), "x");
symlinkSync(path.join(outside, "d.jpg"), path.join(root, "link.jpg"));
symlinkSync(outside, path.join(root, "linked-folder"));
expect(await walk(root)).toEqual(["2024/Summer/b.jpg", "2024/a.png"]);
});
it("accepts only an absolute path to an existing folder", async () => {
const root = tempDir();
writeFileSync(path.join(root, "file.jpg"), "x");
expect(await checkMediaLibraryFolder(root)).toBeNull();
expect(await checkMediaLibraryFolder("photos")).toBe("Use an absolute folder path.");
expect(await checkMediaLibraryFolder(path.join(root, "file.jpg"))).toBe(
"This is not a folder.",
);
expect(await checkMediaLibraryFolder(path.join(root, "missing"))).toBe("Folder not found.");
});
});

View File

@ -0,0 +1,343 @@
import type { Dirent } from "node:fs";
import { lstat, readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
import type { ArtifactStore, JobPublisher } from "@rakazo/adapter-kit";
import { mediaLibrarySyncJob } from "@rakazo/adapter-kit";
import type {
MediaLibraryKind,
MediaLibrarySearchInput,
MediaLibrarySource,
MediaLibrarySourceStatus,
} from "@rakazo/contracts";
import { attachmentKindOf } from "@rakazo/contracts";
import { inferAttachmentMimeType } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { storeArtifactFromFile } from "./artifact-files.js";
import { detectFfmpeg, probeMedia } from "./media/ffmpeg.js";
/*
* Media library folders: the deployment owner points evOo at folders on the server, a sync job
* indexes the photos and videos below them, and the user's bots search the index and import a
* file into the conversation when they need its bytes. Files are never moved or changed.
*/
/** A folder is re-indexed this long after its last sync, so new files show up on their own. */
export const MEDIA_LIBRARY_RESYNC_MS = 6 * 60 * 60 * 1000;
const DELETE_BATCH = 500;
const DEFAULT_SEARCH_LIMIT = 20;
export class MediaLibraryError extends Error {
constructor(message: string) {
super(message);
this.name = "MediaLibraryError";
}
}
/** Photo or video for files a conversation can hold; null for everything else. */
export function mediaLibraryFileType(
name: string,
): { kind: MediaLibraryKind; mimeType: string } | null {
const mimeType = inferAttachmentMimeType(name);
if (!mimeType) return null;
const kind = attachmentKindOf(mimeType);
if (kind === "image") return { kind: "photo", mimeType };
if (kind === "video") return { kind: "video", mimeType };
return null;
}
function visible(entry: Dirent): boolean {
return !entry.name.startsWith(".") && !entry.isSymbolicLink();
}
/**
* Relative POSIX paths of the media files below `root`. Hidden entries and symlinks are skipped
* and never followed, so the walk cannot leave the folder; unreadable subfolders are skipped.
*/
export async function* walkMediaLibrary(
root: string,
signal?: AbortSignal,
relativeDir = "",
): AsyncGenerator<string> {
signal?.throwIfAborted();
let entries: Dirent[];
try {
entries = await readdir(path.join(root, relativeDir), { withFileTypes: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (relativeDir && (code === "ENOENT" || code === "EACCES" || code === "ENOTDIR")) return;
throw error;
}
for (const entry of entries.filter(visible).sort((a, b) => (a.name < b.name ? -1 : 1))) {
const relative = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) yield* walkMediaLibrary(root, signal, relative);
else if (entry.isFile() && mediaLibraryFileType(entry.name)) yield relative;
}
}
export function mediaLibrarySourceView(row: {
id: string;
path: string;
status: string;
error: string | null;
assetCount: number;
lastSyncedAt: Date | null;
}): MediaLibrarySource {
return {
id: row.id,
path: row.path,
status: row.status as MediaLibrarySourceStatus,
error: row.error,
assetCount: row.assetCount,
lastSyncedAt: row.lastSyncedAt?.toISOString() ?? null,
};
}
/** An absolute path to an existing folder, or a message saying what is wrong with it. */
export async function checkMediaLibraryFolder(folder: string): Promise<string | null> {
if (!path.isAbsolute(folder)) return "Use an absolute folder path.";
try {
if (!(await stat(folder)).isDirectory()) return "This is not a folder.";
} catch {
return "Folder not found.";
}
return null;
}
type IndexedFile = {
relativePath: string;
name: string;
kind: MediaLibraryKind;
mimeType: string;
size: bigint;
modifiedAt: Date;
takenAt: Date;
width: number | null;
height: number | null;
durationMs: number | null;
};
async function indexFile(
root: string,
relativePath: string,
canProbe: boolean,
signal?: AbortSignal,
): Promise<IndexedFile | null> {
const name = path.posix.basename(relativePath);
const type = mediaLibraryFileType(name);
if (!type) return null;
const full = path.join(root, ...relativePath.split("/"));
const info = await lstat(full).catch(() => null);
if (!info?.isFile()) return null;
const probe = canProbe
? await probeMedia(full, { signal, mimeType: type.mimeType }).catch(() => null)
: null;
return {
relativePath,
name,
...type,
size: BigInt(info.size),
modifiedAt: info.mtime,
takenAt: probe?.createdAt ?? info.mtime,
width: probe?.video?.width ?? null,
height: probe?.video?.height ?? null,
durationMs: type.kind === "video" && probe?.durationMs ? probe.durationMs : null,
};
}
/**
* Re-indexes one folder: new and changed files are probed and stored, files that are gone are
* dropped, unchanged files cost one stat. A failure is recorded on the source instead of
* retried; the next sync is scheduled either way.
*/
export async function syncMediaLibrarySource(
deps: { prisma: PrismaClient; jobs: JobPublisher },
sourceId: string,
options: { signal?: AbortSignal; now?: () => Date } = {},
): Promise<void> {
const now = options.now ?? (() => new Date());
const source = await deps.prisma.mediaLibrarySource.findUnique({ where: { id: sourceId } });
if (!source) return;
await deps.prisma.mediaLibrarySource.update({
where: { id: sourceId },
data: { status: "syncing", error: null },
});
let result: { status: MediaLibrarySourceStatus; error: string | null; synced: boolean };
try {
const problem = await checkMediaLibraryFolder(source.path);
if (problem) throw new MediaLibraryError(problem);
await indexFolder(deps.prisma, source, options.signal);
result = { status: "idle", error: null, synced: true };
} catch (error) {
const message =
error instanceof MediaLibraryError ? error.message : "Could not read this folder.";
if (!(error instanceof MediaLibraryError)) {
getLogger().error(`media library sync failed for source ${sourceId}`, error);
}
result = { status: "failed", error: message, synced: false };
}
const assetCount = await deps.prisma.mediaLibraryAsset.count({ where: { sourceId } });
const updated = await deps.prisma.mediaLibrarySource.updateMany({
where: { id: sourceId },
data: {
status: result.status,
error: result.error,
assetCount,
...(result.synced ? { lastSyncedAt: now() } : {}),
},
});
// Removed while syncing: nothing left to schedule.
if (updated.count === 0) return;
await deps.jobs.enqueue(
mediaLibrarySyncJob(sourceId, new Date(now().getTime() + MEDIA_LIBRARY_RESYNC_MS)),
);
}
async function indexFolder(
prisma: PrismaClient,
source: { id: string; userId: string; path: string },
signal?: AbortSignal,
) {
const canProbe = (await detectFfmpeg()) !== null;
const known = new Map(
(
await prisma.mediaLibraryAsset.findMany({
where: { sourceId: source.id },
select: { id: true, relativePath: true, size: true, modifiedAt: true },
})
).map((row) => [row.relativePath, row]),
);
const seen = new Set<string>();
for await (const relativePath of walkMediaLibrary(source.path, signal)) {
seen.add(relativePath);
const row = known.get(relativePath);
if (row) {
const info = await lstat(path.join(source.path, ...relativePath.split("/"))).catch(
() => null,
);
if (
info &&
BigInt(info.size) === row.size &&
info.mtime.getTime() === row.modifiedAt.getTime()
) {
continue;
}
}
const file = await indexFile(source.path, relativePath, canProbe, signal);
if (!file) continue;
await prisma.mediaLibraryAsset.upsert({
where: { sourceId_relativePath: { sourceId: source.id, relativePath } },
create: { ...file, sourceId: source.id, userId: source.userId },
update: file,
});
}
const gone = [...known.values()].filter((row) => !seen.has(row.relativePath));
for (let index = 0; index < gone.length; index += DELETE_BATCH) {
await prisma.mediaLibraryAsset.deleteMany({
where: { id: { in: gone.slice(index, index + DELETE_BATCH).map((row) => row.id) } },
});
}
}
export async function userHasMediaLibrary(prisma: PrismaClient, userId: string) {
return (await prisma.mediaLibrarySource.count({ where: { userId } })) > 0;
}
/** The `search_media` tool: the user's library, newest capture first. */
export async function searchMediaLibrary(
prisma: PrismaClient,
userId: string,
input: MediaLibrarySearchInput,
) {
const words = input.query?.split(/\s+/).filter(Boolean) ?? [];
const where = {
userId,
...(input.kind ? { kind: input.kind } : {}),
...(input.taken_after || input.taken_before
? {
takenAt: {
...(input.taken_after ? { gte: new Date(input.taken_after) } : {}),
...(input.taken_before ? { lt: new Date(input.taken_before) } : {}),
},
}
: {}),
// Folder and file names carry most of what people know about their media.
AND: words.map((word) => ({
relativePath: { contains: word, mode: "insensitive" as const },
})),
};
const [total, rows] = await Promise.all([
prisma.mediaLibraryAsset.count({ where }),
prisma.mediaLibraryAsset.findMany({
where,
orderBy: [{ takenAt: "desc" }, { id: "asc" }],
take: input.limit ?? DEFAULT_SEARCH_LIMIT,
}),
]);
return {
total,
items: rows.map((row) => ({
asset_id: row.id,
path: row.relativePath,
kind: row.kind,
taken_at: row.takenAt.toISOString(),
size_mb: Math.round((Number(row.size) / (1024 * 1024)) * 10) / 10,
...(row.width && row.height ? { width: row.width, height: row.height } : {}),
...(row.durationMs ? { duration_s: Math.round(row.durationMs / 100) / 10 } : {}),
})),
};
}
/**
* The asset's file, or a MediaLibraryError. The resolved path must still lie inside the folder:
* a folder swapped for a symlink after indexing does not lead anywhere else.
*/
export async function resolveMediaLibraryFile(
prisma: PrismaClient,
userId: string,
assetId: string,
) {
const asset = await prisma.mediaLibraryAsset.findFirst({
where: { id: assetId, userId },
include: { source: { select: { path: true } } },
});
if (!asset) throw new MediaLibraryError("No media with this id in the library.");
const root = await realpath(asset.source.path).catch(() => null);
const file = await realpath(path.join(asset.source.path, ...asset.relativePath.split("/"))).catch(
() => null,
);
if (!root || !file?.startsWith(`${root}${path.sep}`)) {
throw new MediaLibraryError("This file is no longer in the library folder.");
}
return { asset, file };
}
/** The `import_media` tool: copies a library file into the conversation as an artifact. */
export async function importMediaLibraryAsset(
deps: { prisma: PrismaClient; artifacts: ArtifactStore },
scope: {
spaceId: string;
userId: string;
botId: string | null;
groupId: string | null;
runId: string | null;
signal?: AbortSignal;
},
assetId: string,
) {
const { asset, file } = await resolveMediaLibraryFile(deps.prisma, scope.userId, assetId);
return storeArtifactFromFile(deps, {
spaceId: scope.spaceId,
userId: scope.userId,
botId: scope.botId,
groupId: scope.groupId,
runId: scope.runId,
name: asset.name,
mimeType: asset.mimeType,
path: file,
signal: scope.signal,
...(asset.width ? { width: asset.width } : {}),
...(asset.height ? { height: asset.height } : {}),
...(asset.durationMs ? { durationMs: asset.durationMs } : {}),
});
}

View File

@ -32,6 +32,8 @@ export interface MediaProbe {
};
audio?: { codec: string; sampleRate: number; channels: number };
hasAudio: boolean;
/** When the recording was made, from the container's `creation_time` tag. */
createdAt?: Date;
}
export interface FfmpegProgress {
@ -221,7 +223,7 @@ interface ProbeStream {
/** Parses `ffprobe -print_format json -show_format -show_streams` output. */
export function parseProbeOutput(json: string): MediaProbe {
const data = JSON.parse(json) as {
format?: { duration?: string };
format?: { duration?: string; tags?: { creation_time?: string } };
streams?: ProbeStream[];
};
const streams = data.streams ?? [];
@ -248,9 +250,16 @@ export function parseProbeOutput(json: string): MediaProbe {
}
: {}),
hasAudio: Boolean(audio),
...creationTime(data.format?.tags?.creation_time),
};
}
/** Cameras without a clock write 1970 or 1904; those are no capture time. */
function creationTime(value: string | undefined): { createdAt?: Date } {
const time = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(time) && time > Date.UTC(1990, 0, 1) ? { createdAt: new Date(time) } : {};
}
function probeVideo(stream: ProbeStream, width: number, height: number) {
const rotation = normalizeRotation(
stream.side_data_list?.find((entry) => typeof entry.rotation === "number")?.rotation ??

View File

@ -472,6 +472,8 @@ export function describeToolActivity(toolName: string, args: unknown): string {
if (toolName === "write_file") return `Writing ${detail(record.path)}`;
if (toolName === "list_files") return `Listing ${detail(record.path ?? ".")}`;
if (toolName === "attach_file") return `Attaching ${detail(record.path)}`;
if (toolName === "search_media") return "Searching your photos and videos";
if (toolName === "import_media") return "Bringing in a photo or video";
if (toolName === "open_path") return `Opening ${detail(record.path)}`;
if (toolName === "render_plot") return "Rendering a chart";
if (toolName === "add_mcp_server") return `Connecting MCP server: ${detail(record.name)}`;

View File

@ -25,6 +25,7 @@ function handlers(): BackgroundJobHandlers {
"cloud_agent.poll": vi.fn(async () => undefined),
"profile.consolidate": vi.fn(async () => undefined),
"media.run": vi.fn(async () => undefined),
"media.library.sync": vi.fn(async () => undefined),
};
}

View File

@ -30,6 +30,7 @@ function handlers(overrides: Partial<BackgroundJobHandlers> = {}): BackgroundJob
"cloud_agent.poll": vi.fn(async () => undefined),
"profile.consolidate": vi.fn(async () => undefined),
"media.run": vi.fn(async () => undefined),
"media.library.sync": vi.fn(async () => undefined),
...overrides,
};
}

View File

@ -15,6 +15,7 @@ function handlers(): BackgroundJobHandlers {
"cloud_agent.poll": vi.fn(async () => undefined),
"profile.consolidate": vi.fn(async () => undefined),
"media.run": vi.fn(async () => undefined),
"media.library.sync": vi.fn(async () => undefined),
};
}

View File

@ -18,6 +18,7 @@ export * from "./media.js";
export * from "./media-analysis.js";
export * from "./media-generation.js";
export * from "./media-jobs.js";
export * from "./media-library.js";
export * from "./media-platforms.js";
export * from "./media-presets.js";
export * from "./media-settings.js";

View File

@ -0,0 +1,39 @@
import { z } from "zod";
/** Kinds a library folder indexes; everything else in the folder is ignored. */
export const MediaLibraryKindSchema = z.enum(["photo", "video"]);
export type MediaLibraryKind = z.infer<typeof MediaLibraryKindSchema>;
export const MediaLibrarySourceStatusSchema = z.enum(["idle", "syncing", "failed"]);
export type MediaLibrarySourceStatus = z.infer<typeof MediaLibrarySourceStatusSchema>;
export const MediaLibrarySourceSchema = z.object({
id: z.string(),
path: z.string(),
status: MediaLibrarySourceStatusSchema,
error: z.string().nullable(),
assetCount: z.number().int().nonnegative(),
lastSyncedAt: z.string().nullable(),
});
export type MediaLibrarySource = z.infer<typeof MediaLibrarySourceSchema>;
export const MediaLibrarySourceAddInputSchema = z.object({
path: z.string().trim().min(1).max(4096),
});
export type MediaLibrarySourceAddInput = z.infer<typeof MediaLibrarySourceAddInputSchema>;
/** Arguments of the `search_media` agent tool. */
export const MediaLibrarySearchInputSchema = z.object({
query: z.string().trim().max(200).optional(),
kind: MediaLibraryKindSchema.optional(),
taken_after: z.string().datetime({ offset: true }).optional(),
taken_before: z.string().datetime({ offset: true }).optional(),
limit: z.number().int().min(1).max(50).optional(),
});
export type MediaLibrarySearchInput = z.infer<typeof MediaLibrarySearchInputSchema>;
/** Arguments of the `import_media` agent tool. */
export const MediaLibraryImportInputSchema = z.object({
asset_id: z.string().min(1).max(64),
});
export type MediaLibraryImportInput = z.infer<typeof MediaLibraryImportInputSchema>;

View File

@ -108,6 +108,7 @@ import {
MediaJobStartInputSchema,
MediaJobStartOutputSchema,
} from "./media-jobs.js";
import { MediaLibrarySourceAddInputSchema, MediaLibrarySourceSchema } from "./media-library.js";
import { MediaSettingsUpdateSchema, MediaSettingsViewSchema } from "./media-settings.js";
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
import {
@ -799,6 +800,13 @@ export const appContract = {
get: oc.input(z.object({ jobId: Id })).output(MediaJobSnapshotSchema),
cancel: oc.input(z.object({ jobId: Id })).output(MediaJobSnapshotSchema),
},
/** Server folders the user's bots can search and use; deployment owner only. */
library: {
list: oc.output(z.array(MediaLibrarySourceSchema)),
add: oc.input(MediaLibrarySourceAddInputSchema).output(MediaLibrarySourceSchema),
remove: oc.input(z.object({ sourceId: Id })).output(z.object({ ok: z.literal(true) })),
sync: oc.input(z.object({ sourceId: Id })).output(MediaLibrarySourceSchema),
},
},
connections: {
catalog: oc

View File

@ -0,0 +1,52 @@
-- Folders on the server whose photos and videos a user's bots can search and use, and their index.
-- CreateTable
CREATE TABLE "media_library_sources" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'idle',
"error" TEXT,
"assetCount" INTEGER NOT NULL DEFAULT 0,
"lastSyncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "media_library_sources_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "media_library_assets" (
"id" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"relativePath" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" BIGINT NOT NULL,
"modifiedAt" TIMESTAMP(3) NOT NULL,
"takenAt" TIMESTAMP(3) NOT NULL,
"width" INTEGER,
"height" INTEGER,
"durationMs" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "media_library_assets_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "media_library_sources_userId_path_key" ON "media_library_sources"("userId", "path");
-- CreateIndex
CREATE INDEX "media_library_assets_userId_takenAt_idx" ON "media_library_assets"("userId", "takenAt");
-- CreateIndex
CREATE UNIQUE INDEX "media_library_assets_sourceId_relativePath_key" ON "media_library_assets"("sourceId", "relativePath");
-- AddForeignKey
ALTER TABLE "media_library_sources" ADD CONSTRAINT "media_library_sources_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "media_library_assets" ADD CONSTRAINT "media_library_assets_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "media_library_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -54,6 +54,7 @@ model User {
checkIns CheckIn[]
dailyBriefs DailyBrief[]
pushSubscriptions PushSubscription[]
mediaLibrarySources MediaLibrarySource[]
notificationSettings UserNotificationSettings?
@@unique([email])
@ -1189,6 +1190,53 @@ model MediaJob {
@@map("media_jobs")
}
/// A folder on the server whose photos and videos the user's bots can search and use.
model MediaLibrarySource {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Absolute folder path on the server the API and worker run on.
path String
/// idle, syncing or failed.
status String @default("idle")
error String?
assetCount Int @default(0)
lastSyncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assets MediaLibraryAsset[]
@@unique([userId, path])
@@map("media_library_sources")
}
/// One photo or video in a library folder. Files stay where they are; this is the index.
model MediaLibraryAsset {
id String @id @default(cuid())
sourceId String
source MediaLibrarySource @relation(fields: [sourceId], references: [id], onDelete: Cascade)
userId String
/// POSIX path below the source folder.
relativePath String
name String
/// photo or video.
kind String
mimeType String
size BigInt
modifiedAt DateTime
/// Capture time from the file's metadata, else its modification time.
takenAt DateTime
width Int?
height Int?
durationMs Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sourceId, relativePath])
@@index([userId, takenAt])
@@map("media_library_assets")
}
model UsageRecord {
id String @id @default(cuid())
spaceId String

View File

@ -109,6 +109,7 @@ async function main() {
"packages/testkit/src/bot-secrets.test.ts",
"packages/testkit/src/delegation.postgres.test.ts",
"packages/testkit/src/media.postgres.test.ts",
"packages/testkit/src/media-library.postgres.test.ts",
"packages/testkit/src/user-profile.postgres.test.ts",
"packages/testkit/src/onboarding-intake.postgres.test.ts",
"packages/testkit/src/onboarding-discovery.postgres.test.ts",

View File

@ -0,0 +1,204 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ComposioEmulator,
importMediaLibraryAsset,
LocalArtifactStore,
MediaLibraryError,
searchMediaLibrary,
} from "@rakazo/adapters";
import type { MediaLibrarySource } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createApp } from "../../../apps/api/src/app.ts";
import { sessionCookieHeader } from "./index.js";
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeWithDatabase = hasDb ? describe.sequential : describe.skip;
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
describeWithDatabase("media library (PostgreSQL)", () => {
let handles: AppHandles;
let app: App;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-library-data-"));
const library = mkdtempSync(path.join(tmpdir(), "rakazo-library-"));
const outside = mkdtempSync(path.join(tmpdir(), "rakazo-library-outside-"));
beforeAll(async () => {
const { createApp } = await import("../../../apps/api/src/app.ts");
handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
wakeupDriver: "memory",
signupsEnabled: "true",
composio: new ComposioEmulator(),
});
app = handles.app;
mkdirSync(path.join(library, "Italy 2024"));
mkdirSync(path.join(library, ".thumbnails"));
writeFileSync(path.join(library, "Italy 2024", "beach.png"), PNG);
writeFileSync(path.join(library, "Italy 2024", "notes.txt"), "not media");
writeFileSync(path.join(library, "birthday.png"), PNG);
writeFileSync(path.join(library, ".thumbnails", "hidden.png"), PNG);
writeFileSync(path.join(outside, "secret.png"), PNG);
symlinkSync(path.join(outside, "secret.png"), path.join(library, "linked.png"));
utimesSync(
path.join(library, "Italy 2024", "beach.png"),
new Date("2024-07-01"),
new Date("2024-07-01"),
);
utimesSync(path.join(library, "birthday.png"), new Date("2025-03-01"), new Date("2025-03-01"));
});
afterAll(async () => {
await handles?.stop();
for (const dir of [dataDir, library, outside]) rmSync(dir, { recursive: true, force: true });
});
async function user(label: string) {
const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label);
const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me");
return { cookie, me };
}
async function waitForSync(cookie: string, sourceId: string) {
const start = Date.now();
while (Date.now() - start < 20_000) {
const sources = await rpc<MediaLibrarySource[]>(app, cookie, "media/library/list");
const source = sources.find((entry) => entry.id === sourceId);
if (source && source.status !== "syncing") return source;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`library source ${sourceId} never finished syncing`);
}
it("indexes, searches and imports only the owner's own library files", async () => {
const owner = await user("library-owner");
const other = await user("library-other");
await handles.prisma.deploymentSettings.upsert({
where: { id: "default" },
create: { id: "default", ownerUserId: owner.me.userId },
update: { ownerUserId: owner.me.userId },
});
expect((await raw(app, other.cookie, "media/library/list")).status).toBe(403);
expect((await raw(app, other.cookie, "media/library/add", { path: library })).status).toBe(403);
expect(
(await raw(app, owner.cookie, "media/library/add", { path: "relative/folder" })).status,
).toBe(400);
expect(
(await raw(app, owner.cookie, "media/library/add", { path: path.join(library, "nope") }))
.status,
).toBe(400);
const added = await rpc<MediaLibrarySource>(app, owner.cookie, "media/library/add", {
path: library,
});
expect(added).toMatchObject({ path: library, status: "syncing" });
// Adding the same folder again returns it instead of a duplicate.
expect(
(await rpc<MediaLibrarySource>(app, owner.cookie, "media/library/add", { path: library })).id,
).toBe(added.id);
const synced = await waitForSync(owner.cookie, added.id);
expect(synced).toMatchObject({ status: "idle", error: null, assetCount: 2 });
expect(synced.lastSyncedAt).not.toBeNull();
const all = await searchMediaLibrary(handles.prisma, owner.me.userId, {});
expect(all.total).toBe(2);
expect(all.items.map((item) => item.path)).toEqual(["birthday.png", "Italy 2024/beach.png"]);
const italy = await searchMediaLibrary(handles.prisma, owner.me.userId, {
query: "italy BEACH",
});
expect(italy.items.map((item) => item.path)).toEqual(["Italy 2024/beach.png"]);
const before = await searchMediaLibrary(handles.prisma, owner.me.userId, {
taken_before: "2025-01-01T00:00:00Z",
});
expect(before.items.map((item) => item.path)).toEqual(["Italy 2024/beach.png"]);
expect((await searchMediaLibrary(handles.prisma, other.me.userId, {})).total).toBe(0);
const artifacts = new LocalArtifactStore(dataDir);
const assetId = italy.items[0]!.asset_id;
const imported = await importMediaLibraryAsset(
{ prisma: handles.prisma, artifacts },
{
spaceId: owner.me.spaceId,
userId: owner.me.userId,
botId: null,
groupId: null,
runId: null,
},
assetId,
);
expect(imported).toMatchObject({ name: "beach.png", mimeType: "image/png", size: PNG.length });
await expect(
importMediaLibraryAsset(
{ prisma: handles.prisma, artifacts },
{
spaceId: other.me.spaceId,
userId: other.me.userId,
botId: null,
groupId: null,
runId: null,
},
assetId,
),
).rejects.toBeInstanceOf(MediaLibraryError);
// Another user can neither resync nor remove the owner's folder.
expect(
(await raw(app, other.cookie, "media/library/sync", { sourceId: added.id })).status,
).toBe(403);
// A deleted file leaves the index on the next sync.
rmSync(path.join(library, "birthday.png"));
await rpc(app, owner.cookie, "media/library/sync", { sourceId: added.id });
expect(await waitForSync(owner.cookie, added.id)).toMatchObject({ assetCount: 1 });
await rpc(app, owner.cookie, "media/library/remove", { sourceId: added.id });
expect(await rpc(app, owner.cookie, "media/library/list")).toEqual([]);
expect(await handles.prisma.mediaLibraryAsset.count({ where: { sourceId: added.id } })).toBe(0);
expect(
(await raw(app, owner.cookie, "media/library/sync", { sourceId: added.id })).status,
).toBe(404);
});
});
async function signup(app: App, email: string, name: string) {
const res = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ email, password: "password12", name }),
});
if (res.status >= 400) throw new Error(`signup failed ${res.status}: ${await res.text()}`);
return sessionCookieHeader(res);
}
function raw(app: App, cookie: string, proc: string, body: unknown = {}) {
return app.request(`/rpc/${proc}`, {
method: "POST",
headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" },
body: JSON.stringify({ json: body }),
});
}
async function rpc<T>(app: App, cookie: string, proc: string, body: unknown = {}): Promise<T> {
const res = await raw(app, cookie, proc, body);
const text = await res.text();
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
if (res.status >= 400 || parsed.error) {
throw new Error(`${proc} ${res.status}: ${parsed.error?.message ?? text}`);
}
return parsed.json as T;
}

View File

@ -45,20 +45,26 @@
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
/* Typography scale */
--text-ui-caption: var(--font-size-caption);
/* Typography scale. Every text size follows --font-scale, which grows on phones. */
--text-xs: calc(0.75rem * var(--font-scale));
--text-sm: calc(0.875rem * var(--font-scale));
--text-base: calc(1rem * var(--font-scale));
--text-lg: calc(1.125rem * var(--font-scale));
--text-xl: calc(1.25rem * var(--font-scale));
--text-2xl: calc(1.5rem * var(--font-scale));
--text-ui-caption: calc(var(--font-size-caption) * var(--font-scale));
--text-ui-caption--line-height: var(--line-height-tight);
--text-ui-label: var(--font-size-label);
--text-ui-label: calc(var(--font-size-label) * var(--font-scale));
--text-ui-label--line-height: var(--line-height-tight);
--text-ui-body-sm: var(--font-size-body-sm);
--text-ui-body-sm: calc(var(--font-size-body-sm) * var(--font-scale));
--text-ui-body-sm--line-height: var(--line-height-body);
--text-ui-body: var(--font-size-body);
--text-ui-body: calc(var(--font-size-body) * var(--font-scale));
--text-ui-body--line-height: var(--line-height-body);
--text-ui-reading: var(--font-size-reading);
--text-ui-reading: calc(var(--font-size-reading) * var(--font-scale));
--text-ui-reading--line-height: var(--line-height-reading);
--text-ui-title-sm: var(--font-size-title-sm);
--text-ui-title-sm: calc(var(--font-size-title-sm) * var(--font-scale));
--text-ui-title-sm--line-height: var(--line-height-tight);
--text-ui-title: var(--font-size-title);
--text-ui-title: calc(var(--font-size-title) * var(--font-scale));
--text-ui-title--line-height: var(--line-height-tight);
/* Control + icon sizes (also drive h-*, size-*, gap-*, p-* utilities) */
@ -74,6 +80,18 @@
--spacing-rk-sidebar-y: var(--spacing-sidebar-row-y);
}
/* Phones read the 14px base size at 16px (Safari also stops zooming into fields at 16px);
all other sizes grow by the same ratio. */
:root {
--font-scale: 1;
}
@media (max-width: 767.98px) {
:root {
--font-scale: 1.142857;
}
}
@layer base {
* {
@apply border-border outline-ring/50;