Merge claude/media-smart-cuts into claude/media-brand-overlay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
1bcd746881
82
apps/api/src/media-library.ts
Normal file
82
apps/api/src/media-library.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -205,6 +205,12 @@ import {
|
|||||||
startMediaJob,
|
startMediaJob,
|
||||||
updateMediaSettings,
|
updateMediaSettings,
|
||||||
} from "./media-jobs.js";
|
} from "./media-jobs.js";
|
||||||
|
import {
|
||||||
|
addMediaLibrarySource,
|
||||||
|
listMediaLibrarySources,
|
||||||
|
removeMediaLibrarySource,
|
||||||
|
syncMediaLibrarySourceNow,
|
||||||
|
} from "./media-library.js";
|
||||||
import {
|
import {
|
||||||
disconnectMemoryProvider,
|
disconnectMemoryProvider,
|
||||||
getMemoryProviderConfig,
|
getMemoryProviderConfig,
|
||||||
@ -3797,6 +3803,20 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
cancelMediaJob(deps, context.actor, input.jobId),
|
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: {
|
connections: {
|
||||||
catalog: authed.connections.catalog.handler(async ({ context, input }) => {
|
catalog: authed.connections.catalog.handler(async ({ context, input }) => {
|
||||||
|
|||||||
@ -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) {
|
async function asDeploymentOwner(page: Page, isDeploymentOwner: boolean) {
|
||||||
await page.route("**/rpc/bootstrap", async (route) => {
|
await page.route("**/rpc/bootstrap", async (route) => {
|
||||||
const response = await route.fetch();
|
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) => {
|
test("the deployment owner points media at their own servers", async ({ page }, testInfo) => {
|
||||||
await asDeploymentOwner(page, true);
|
await asDeploymentOwner(page, true);
|
||||||
|
await fakeLibrary(page);
|
||||||
let current: ReturnType<typeof view> = { comfyui: null, transcription: null };
|
let current: ReturnType<typeof view> = { comfyui: null, transcription: null };
|
||||||
const saved: Draft[] = [];
|
const saved: Draft[] = [];
|
||||||
await page.route("**/rpc/media/settings/get", (route) =>
|
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");
|
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 }) => {
|
test("members who do not own the deployment have no media settings", async ({ page }) => {
|
||||||
await asDeploymentOwner(page, false);
|
await asDeploymentOwner(page, false);
|
||||||
await signup(page, `media-member-${Date.now()}@rakazo.test`, "password12", "Media Member");
|
await signup(page, `media-member-${Date.now()}@rakazo.test`, "password12", "Media Member");
|
||||||
|
|||||||
41
apps/web/e2e/mobile-font-size.spec.ts
Normal file
41
apps/web/e2e/mobile-font-size.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
162
apps/web/src/components/MediaLibraryFolders.tsx
Normal file
162
apps/web/src/components/MediaLibraryFolders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { Button, Field, FieldLabel, Input, Textarea } from "@rakazo/ui-web";
|
|||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { SuccessPop } from "./ai/primitives";
|
import { SuccessPop } from "./ai/primitives";
|
||||||
|
import { MediaLibraryFolders } from "./MediaLibraryFolders";
|
||||||
|
|
||||||
type KeyDraft = { value: string; remove: boolean };
|
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() {
|
export function MediaSettings() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const ids = useId();
|
const ids = useId();
|
||||||
@ -152,6 +155,7 @@ export function MediaSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="media-settings" className="space-y-5">
|
<div data-testid="media-settings" className="space-y-5">
|
||||||
|
<MediaLibraryFolders />
|
||||||
<section className="rounded-xl border border-border px-4 py-4">
|
<section className="rounded-xl border border-border px-4 py-4">
|
||||||
<h3 className="text-ui-body font-medium text-foreground">ComfyUI</h3>
|
<h3 className="text-ui-body font-medium text-foreground">ComfyUI</h3>
|
||||||
<Field className="mt-3">
|
<Field className="mt-3">
|
||||||
|
|||||||
@ -251,7 +251,7 @@ export function OoStage({
|
|||||||
) : null}
|
) : 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}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -548,7 +548,7 @@ export function OoGroupStage({
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</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}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr "(optional)"
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# Tag} other {# Tage}}"
|
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
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "Aktives Modell"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "Aktivität"
|
msgstr "Aktivität"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr "Stimme konnte nicht aktualisiert werden"
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr "{name} konnte nicht hochgeladen werden. Versuche es erneut."
|
msgstr "{name} konnte nicht hochgeladen werden. Versuche es erneut."
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr "Konnte nicht geladen werden"
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr "Messaging-Einstellungen konnten nicht geladen werden"
|
msgstr "Messaging-Einstellungen konnten nicht geladen werden"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr "Schwung"
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr "Blume"
|
msgstr "Blume"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr "Ordner"
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr "Schriften lokal geladen"
|
msgstr "Schriften lokal geladen"
|
||||||
@ -2821,6 +2833,14 @@ msgstr "In Arbeit"
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr "Posteingang & E-Mail"
|
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
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "Standard übernehmen"
|
msgstr "Standard übernehmen"
|
||||||
@ -2927,6 +2947,10 @@ msgstr "Später"
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr "Verlassen"
|
msgstr "Verlassen"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr "Mediathek"
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr "Privatkanal — ich bin da."
|
msgstr "Privatkanal — ich bin da."
|
||||||
@ -4157,6 +4181,10 @@ msgstr "Entfernen"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "{0} entfernen"
|
msgstr "{0} entfernen"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr "Ordner entfernen"
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr "Git-Ereignis entfernen"
|
msgstr "Git-Ereignis entfernen"
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr "(optional)"
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{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
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "Active model"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "Activity"
|
msgstr "Activity"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr "Could not update voice"
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr "Could not upload {name}. Try again."
|
msgstr "Could not upload {name}. Try again."
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr "Couldn't load"
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr "Couldn't load messaging settings"
|
msgstr "Couldn't load messaging settings"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr "Flick"
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr "Flower"
|
msgstr "Flower"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr "Folder"
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr "Fonts loaded locally"
|
msgstr "Fonts loaded locally"
|
||||||
@ -2821,6 +2833,14 @@ msgstr "In progress"
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr "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
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "Inherit default"
|
msgstr "Inherit default"
|
||||||
@ -2927,6 +2947,10 @@ msgstr "Later"
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr "Leave"
|
msgstr "Leave"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr "Library"
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr "Life channel — I'm here."
|
msgstr "Life channel — I'm here."
|
||||||
@ -4157,6 +4181,10 @@ msgstr "Remove"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "Remove {0}"
|
msgstr "Remove {0}"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr "Remove folder"
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr "Remove Git event"
|
msgstr "Remove Git event"
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. placeholder {0}: source.assetCount
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "{0, plural, one {# file} other {# files}}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: group.entries.length
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "Modelo activo"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "Actividad"
|
msgstr "Actividad"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr ""
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr ""
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr "No se pudo cargar la configuración de mensajería"
|
msgstr "No se pudo cargar la configuración de mensajería"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr ""
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2821,6 +2833,14 @@ msgstr ""
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Index again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Indexing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/bot-panel.tsx
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "Heredar predeterminado"
|
msgstr "Heredar predeterminado"
|
||||||
@ -2927,6 +2947,10 @@ msgstr ""
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr "Salir"
|
msgstr "Salir"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4157,6 +4181,10 @@ msgstr "Quitar"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "Quitar {0}"
|
msgstr "Quitar {0}"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. placeholder {0}: source.assetCount
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "{0, plural, one {# file} other {# files}}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: group.entries.length
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "सक्रिय मॉडल"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "गतिविधि"
|
msgstr "गतिविधि"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr ""
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr ""
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr ""
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2821,6 +2833,14 @@ msgstr ""
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Index again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Indexing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/bot-panel.tsx
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "डिफ़ॉल्ट का पालन करें"
|
msgstr "डिफ़ॉल्ट का पालन करें"
|
||||||
@ -2927,6 +2947,10 @@ msgstr ""
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4157,6 +4181,10 @@ msgstr "हटाएं"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "{0} हटाएं"
|
msgstr "{0} हटाएं"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. placeholder {0}: source.assetCount
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "{0, plural, one {# file} other {# files}}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: group.entries.length
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "현재 모델"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "활동"
|
msgstr "활동"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr ""
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr ""
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr "메시징 설정을 불러올 수 없습니다"
|
msgstr "메시징 설정을 불러올 수 없습니다"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr ""
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2821,6 +2833,14 @@ msgstr ""
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Index again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Indexing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/bot-panel.tsx
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "기본 설정 사용"
|
msgstr "기본 설정 사용"
|
||||||
@ -2927,6 +2947,10 @@ msgstr ""
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr "나가기"
|
msgstr "나가기"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4157,6 +4181,10 @@ msgstr "삭제"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "{0} 삭제"
|
msgstr "{0} 삭제"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. placeholder {0}: source.assetCount
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "{0, plural, one {# file} other {# files}}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: group.entries.length
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "Modelo ativo"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "Atividade"
|
msgstr "Atividade"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr ""
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr ""
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr ""
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2821,6 +2833,14 @@ msgstr ""
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Index again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Indexing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/bot-panel.tsx
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "Herdar (padrão)"
|
msgstr "Herdar (padrão)"
|
||||||
@ -2927,6 +2947,10 @@ msgstr ""
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4157,6 +4181,10 @@ msgstr "Remover"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "Remover {0}"
|
msgstr "Remover {0}"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. placeholder {0}: source.assetCount
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "{0, plural, one {# file} other {# files}}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: group.entries.length
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "Активная модель"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "Активность"
|
msgstr "Активность"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr ""
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr ""
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr "Не удалось загрузить настройки обмена сообщениями."
|
msgstr "Не удалось загрузить настройки обмена сообщениями."
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr ""
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2821,6 +2833,14 @@ msgstr ""
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Index again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Indexing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/bot-panel.tsx
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "Наследовать значение по умолчанию"
|
msgstr "Наследовать значение по умолчанию"
|
||||||
@ -2927,6 +2947,10 @@ msgstr "Позже"
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr "Выйти"
|
msgstr "Выйти"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4157,6 +4181,10 @@ msgstr "Удалить"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "Удалить {0}"
|
msgstr "Удалить {0}"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr "Удалить событие Git"
|
msgstr "Удалить событие Git"
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. placeholder {0}: source.assetCount
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "{0, plural, one {# file} other {# files}}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: group.entries.length
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "Etkin model"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "Etkinlik"
|
msgstr "Etkinlik"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr ""
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr ""
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr ""
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2821,6 +2833,14 @@ msgstr ""
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Index again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Indexing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/bot-panel.tsx
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "Varsayılanı devral"
|
msgstr "Varsayılanı devral"
|
||||||
@ -2927,6 +2947,10 @@ msgstr ""
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4157,6 +4181,10 @@ msgstr "Kaldır"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "{0} kaldır"
|
msgstr "{0} kaldır"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -27,6 +27,11 @@ msgstr ""
|
|||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#. placeholder {0}: source.assetCount
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "{0, plural, one {# file} other {# files}}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#. placeholder {0}: group.entries.length
|
#. placeholder {0}: group.entries.length
|
||||||
#: src/pages/ModelSettingsOverlay.tsx
|
#: src/pages/ModelSettingsOverlay.tsx
|
||||||
msgid "{0, plural, one {# model} other {# models}}"
|
msgid "{0, plural, one {# model} other {# models}}"
|
||||||
@ -368,6 +373,7 @@ msgstr "当前模型"
|
|||||||
msgid "Activity"
|
msgid "Activity"
|
||||||
msgstr "动态"
|
msgstr "动态"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
#: src/pages/ScratchpadSection.tsx
|
#: src/pages/ScratchpadSection.tsx
|
||||||
@ -1829,6 +1835,7 @@ msgstr ""
|
|||||||
msgid "Could not upload {name}. Try again."
|
msgid "Could not upload {name}. Try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -1839,6 +1846,7 @@ msgstr ""
|
|||||||
msgid "Couldn't load messaging settings"
|
msgid "Couldn't load messaging settings"
|
||||||
msgstr "无法加载消息设置"
|
msgstr "无法加载消息设置"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
#: src/components/MediaSettings.tsx
|
#: src/components/MediaSettings.tsx
|
||||||
#: src/components/NotificationSettingsPanel.tsx
|
#: src/components/NotificationSettingsPanel.tsx
|
||||||
#: src/components/UserProfileSettings.tsx
|
#: src/components/UserProfileSettings.tsx
|
||||||
@ -2519,6 +2527,10 @@ msgstr ""
|
|||||||
msgid "Flower"
|
msgid "Flower"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PrivacySettingsOverlay.tsx
|
#: src/pages/PrivacySettingsOverlay.tsx
|
||||||
msgid "Fonts loaded locally"
|
msgid "Fonts loaded locally"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -2821,6 +2833,14 @@ msgstr ""
|
|||||||
msgid "Inbox & email"
|
msgid "Inbox & email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Index again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Indexing…"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/shell/bot-panel.tsx
|
#: src/pages/shell/bot-panel.tsx
|
||||||
msgid "Inherit default"
|
msgid "Inherit default"
|
||||||
msgstr "沿用默认"
|
msgstr "沿用默认"
|
||||||
@ -2927,6 +2947,10 @@ msgstr ""
|
|||||||
msgid "Leave"
|
msgid "Leave"
|
||||||
msgstr "退出"
|
msgstr "退出"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Library"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/voice-persona-copy.ts
|
#: src/lib/voice-persona-copy.ts
|
||||||
msgid "Life channel — I'm here."
|
msgid "Life channel — I'm here."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -4157,6 +4181,10 @@ msgstr "移除"
|
|||||||
msgid "Remove {0}"
|
msgid "Remove {0}"
|
||||||
msgstr "移除 {0}"
|
msgstr "移除 {0}"
|
||||||
|
|
||||||
|
#: src/components/MediaLibraryFolders.tsx
|
||||||
|
msgid "Remove folder"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/RoutineEditor.tsx
|
#: src/pages/RoutineEditor.tsx
|
||||||
msgid "Remove Git event"
|
msgid "Remove Git event"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@ -80,7 +80,10 @@ export function ExternalConversationSettings({
|
|||||||
return (
|
return (
|
||||||
<div data-testid="external-conversation-settings">
|
<div data-testid="external-conversation-settings">
|
||||||
<div className="mb-6">
|
<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`}
|
{conversation.displayName || t`External conversation`}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 truncate text-ui-body-sm text-muted-foreground">
|
<p className="mt-1 truncate text-ui-body-sm text-muted-foreground">
|
||||||
|
|||||||
@ -134,7 +134,7 @@ export function PeerMessagesOverlay({
|
|||||||
<div className="mb-1 text-ui-body-sm text-muted-foreground/70" dir="auto">
|
<div className="mb-1 text-ui-body-sm text-muted-foreground/70" dir="auto">
|
||||||
{sent ? botName : peerBotName}
|
{sent ? botName : peerBotName}
|
||||||
</div>
|
</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>
|
<ChatMarkdown>{peerMessage.text}</ChatMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -558,7 +558,9 @@ export function PluginsOverlay({
|
|||||||
{item.name[0]}
|
{item.name[0]}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -156,7 +156,7 @@ export function RoutineListRow({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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)}
|
{routineName(routine)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
|
<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"
|
className="text-muted-foreground size-icon-md"
|
||||||
aria-hidden
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
@ -587,7 +587,7 @@ function InboundTriggerCard({
|
|||||||
) : (
|
) : (
|
||||||
<Globe strokeWidth={1.6} className="text-muted-foreground size-icon-md" aria-hidden />
|
<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>}
|
{kind === "github" ? <Trans>Git event</Trans> : <Trans>When a webhook fires</Trans>}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -229,9 +229,9 @@ export function RoutineSchedule({
|
|||||||
<div className="mt-2 rounded-xl border border-border p-3">
|
<div className="mt-2 rounded-xl border border-border p-3">
|
||||||
<div className="flex items-center gap-2.5 px-0.5">
|
<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 />
|
<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 ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="mt-2.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export function ScratchpadSection({ botId }: { botId: string }) {
|
|||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<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"
|
dir="auto"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function WelcomePage() {
|
|||||||
<p className="text-[27px] leading-[1.35] text-foreground/90 font-medium">
|
<p className="text-[27px] leading-[1.35] text-foreground/90 font-medium">
|
||||||
<Trans>Your specialists. One calm place to talk.</Trans>
|
<Trans>Your specialists. One calm place to talk.</Trans>
|
||||||
</p>
|
</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>
|
<Trans>Simple to start. Expert when you need it.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -383,7 +383,7 @@ function ChartCanvas({
|
|||||||
return (
|
return (
|
||||||
<div className="text-foreground/75">
|
<div className="text-foreground/75">
|
||||||
{meta.title ? (
|
{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}
|
) : null}
|
||||||
{meta.swatches.length > 0 ? (
|
{meta.swatches.length > 0 ? (
|
||||||
<div className="mb-2 flex flex-wrap gap-x-3 gap-y-1">
|
<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">
|
<span className="grid size-control-sm place-items-center rounded-lg bg-muted text-ui-label text-foreground">
|
||||||
M
|
M
|
||||||
</span>
|
</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>
|
<Trans>Connect MCP server “{name}”</Trans>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -811,7 +811,7 @@ const MessageView = memo(function MessageView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="text-[14.5px] font-semibold text-foreground tracking-tight"
|
className="text-ui-body-sm font-semibold text-foreground tracking-tight"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
>
|
>
|
||||||
{effectiveSpeakerName}
|
{effectiveSpeakerName}
|
||||||
@ -1048,7 +1048,7 @@ const MessageView = memo(function MessageView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
|
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
|
||||||
{block.progress || block.result ? (
|
{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}>
|
<ChatMarkdown streaming={running}>
|
||||||
{block.result || block.progress || ""}
|
{block.result || block.progress || ""}
|
||||||
</ChatMarkdown>
|
</ChatMarkdown>
|
||||||
@ -1095,7 +1095,7 @@ const MessageView = memo(function MessageView({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
{removed
|
||||||
? block.status === "archived"
|
? block.status === "archived"
|
||||||
? t`Archived. Chat, memory, and files kept.`
|
? t`Archived. Chat, memory, and files kept.`
|
||||||
@ -1201,7 +1201,7 @@ const MessageView = memo(function MessageView({
|
|||||||
className={
|
className={
|
||||||
encounterMode
|
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"
|
? "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"
|
dir="auto"
|
||||||
>
|
>
|
||||||
@ -1218,7 +1218,7 @@ const MessageView = memo(function MessageView({
|
|||||||
className={
|
className={
|
||||||
encounterMode
|
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"
|
? "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"
|
dir="auto"
|
||||||
>
|
>
|
||||||
@ -1330,7 +1330,7 @@ const MessageView = memo(function MessageView({
|
|||||||
{block.state}
|
{block.state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<ChatMarkdown>{block.text}</ChatMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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">
|
<span className="grid size-control-lg place-items-center rounded-full bg-accent text-ui-body-sm text-foreground/75">
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14.5px] text-foreground/90">{userName}</span>
|
<span className="text-ui-body-sm text-foreground/90">{userName}</span>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
{open ? (
|
{open ? (
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
|
|||||||
@ -98,7 +98,7 @@ body,
|
|||||||
/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable).
|
/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable).
|
||||||
Heading, code and quote colors come from the shared @rakazo/chat-ui markdown styles. */
|
Heading, code and quote colors come from the shared @rakazo/chat-ui markdown styles. */
|
||||||
.rk-chat-markdown {
|
.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);
|
line-height: var(--line-height-reading);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
letter-spacing: -0.011em;
|
letter-spacing: -0.011em;
|
||||||
@ -116,7 +116,7 @@ body,
|
|||||||
inset 0 1px 0 rgba(255, 255, 255, 0.09),
|
inset 0 1px 0 rgba(255, 255, 255, 0.09),
|
||||||
0 12px 36px rgba(0, 0, 0, 0.55);
|
0 12px 36px rgba(0, 0, 0, 0.55);
|
||||||
padding: 1.25em 1.5em;
|
padding: 1.25em 1.5em;
|
||||||
font-size: 14.5px;
|
font-size: calc(14.5px * var(--font-scale));
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
}
|
}
|
||||||
@ -130,7 +130,7 @@ body,
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.15em 0.4em;
|
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;
|
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
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.
|
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)
|
## 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.
|
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.
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import {
|
|||||||
historyCompactJob,
|
historyCompactJob,
|
||||||
historyCompactJobKey,
|
historyCompactJobKey,
|
||||||
MEDIA_JOB_QUEUE,
|
MEDIA_JOB_QUEUE,
|
||||||
|
MEDIA_LIBRARY_JOB_QUEUE,
|
||||||
|
mediaLibrarySyncJob,
|
||||||
|
mediaLibrarySyncJobKey,
|
||||||
mediaRunJob,
|
mediaRunJob,
|
||||||
mediaRunJobKey,
|
mediaRunJobKey,
|
||||||
messagingDeliverJob,
|
messagingDeliverJob,
|
||||||
@ -26,6 +29,7 @@ function handlers(): BackgroundJobHandlers {
|
|||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
"media.run": 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"));
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const payloadSchemas = {
|
|||||||
"cloud_agent.poll": z.object({ agentId: z.string().min(1) }),
|
"cloud_agent.poll": z.object({ agentId: z.string().min(1) }),
|
||||||
"profile.consolidate": z.object({ userId: z.string().min(1) }),
|
"profile.consolidate": z.object({ userId: z.string().min(1) }),
|
||||||
"media.run": z.object({ jobId: 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]> };
|
} satisfies { [Name in BackgroundJobName]: z.ZodType<BackgroundJobPayloads[Name]> };
|
||||||
|
|
||||||
export function parseBackgroundJob(name: string, payload: unknown): BackgroundJob {
|
export function parseBackgroundJob(name: string, payload: unknown): BackgroundJob {
|
||||||
@ -178,3 +179,20 @@ export function mediaRunJob(jobId: string, availableAt?: Date): BackgroundJob {
|
|||||||
...(availableAt ? { availableAt } : {}),
|
...(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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -535,6 +535,8 @@ export interface BackgroundJobPayloads {
|
|||||||
"profile.consolidate": { userId: string };
|
"profile.consolidate": { userId: string };
|
||||||
/** Run one media job; scope, input and lease live on the job row. */
|
/** Run one media job; scope, input and lease live on the job row. */
|
||||||
"media.run": { jobId: string };
|
"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;
|
export type BackgroundJobName = keyof BackgroundJobPayloads;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { scheduleComputerSleep, sleepComputerIfIdle } from "./computer-idle.js";
|
|||||||
import { performComputerUpdate } from "./computer-update.js";
|
import { performComputerUpdate } from "./computer-update.js";
|
||||||
import type { createRunExecutor } from "./executor.js";
|
import type { createRunExecutor } from "./executor.js";
|
||||||
import { compactHistory } from "./history-compaction.js";
|
import { compactHistory } from "./history-compaction.js";
|
||||||
|
import { syncMediaLibrarySource } from "./media-library.js";
|
||||||
import type { MediaRuntimeHost } from "./media-runtime.js";
|
import type { MediaRuntimeHost } from "./media-runtime.js";
|
||||||
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
||||||
import { deliverMessagingOutbound, mirrorMessagingOutbound } from "./messaging-delivery.js";
|
import { deliverMessagingOutbound, mirrorMessagingOutbound } from "./messaging-delivery.js";
|
||||||
@ -117,6 +118,9 @@ export function createBackgroundJobHandlers(deps: {
|
|||||||
"media.run": async (payload) => {
|
"media.run": async (payload) => {
|
||||||
await deps.media?.runJob(payload.jobId);
|
await deps.media?.runJob(payload.jobId);
|
||||||
},
|
},
|
||||||
|
"media.library.sync": async (payload) => {
|
||||||
|
await syncMediaLibrarySource(deps, payload.sourceId);
|
||||||
|
},
|
||||||
"profile.consolidate": async (payload) => {
|
"profile.consolidate": async (payload) => {
|
||||||
await consolidateProfile(
|
await consolidateProfile(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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.",
|
"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 }),
|
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",
|
name: "add_mcp_server",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -13,15 +13,23 @@ const unrestricted = { kind: "specialist", restrictBuiltinTools: false, allowedB
|
|||||||
|
|
||||||
function select(
|
function select(
|
||||||
bot: { kind: string; restrictBuiltinTools: boolean; allowedBuiltinTools: string[] },
|
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({
|
return selectBuiltinToolsForRun({
|
||||||
graphicalToolsAllowed: options.graphicalToolsAllowed ?? true,
|
graphicalToolsAllowed: options.graphicalToolsAllowed ?? true,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
trigger: "user",
|
trigger: "user",
|
||||||
semanticMemoryEnabled: false,
|
semanticMemoryEnabled: false,
|
||||||
messagingChannelRun: false,
|
messagingChannelRun: options.messagingChannelRun ?? false,
|
||||||
agentConnectionsEnabled: options.agentConnectionsEnabled,
|
agentConnectionsEnabled: options.agentConnectionsEnabled,
|
||||||
|
mediaLibraryEnabled: options.mediaLibraryEnabled,
|
||||||
|
foreignAgentRun: options.foreignAgentRun,
|
||||||
bot,
|
bot,
|
||||||
}).map((tool) => tool.name);
|
}).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", () => {
|
it("applies the allowlist to agent connection tools too", () => {
|
||||||
const bot = {
|
const bot = {
|
||||||
kind: "specialist",
|
kind: "specialist",
|
||||||
|
|||||||
@ -34,6 +34,9 @@ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set<string>([
|
|||||||
...PERSONAL_AGENT_TOOLS,
|
...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. */
|
/** 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>([
|
const USER_MODEL_TOOLS: ReadonlySet<string> = new Set<string>([
|
||||||
"update_user_profile",
|
"update_user_profile",
|
||||||
@ -110,6 +113,8 @@ export function selectBuiltinToolsForRun(options: {
|
|||||||
cloudAgentEnabled?: boolean;
|
cloudAgentEnabled?: boolean;
|
||||||
/** Media tools appear only for operations the space can run. */
|
/** Media tools appear only for operations the space can run. */
|
||||||
mediaCapabilities?: MediaCapabilities;
|
mediaCapabilities?: MediaCapabilities;
|
||||||
|
/** The run's user has a media library folder. */
|
||||||
|
mediaLibraryEnabled?: boolean;
|
||||||
messagingChannelRun: boolean;
|
messagingChannelRun: boolean;
|
||||||
/** Cross-owner agent connections only exist for chat-linked bots. */
|
/** Cross-owner agent connections only exist for chat-linked bots. */
|
||||||
agentConnectionsEnabled?: boolean;
|
agentConnectionsEnabled?: boolean;
|
||||||
@ -138,6 +143,11 @@ export function selectBuiltinToolsForRun(options: {
|
|||||||
).filter(
|
).filter(
|
||||||
(tool) =>
|
(tool) =>
|
||||||
!(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) &&
|
!(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 ||
|
(!options.messagingChannelRun ||
|
||||||
(![
|
(![
|
||||||
"remember",
|
"remember",
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type {
|
|||||||
WebProvider,
|
WebProvider,
|
||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
import {
|
import {
|
||||||
|
ArtifactTooLargeError,
|
||||||
historyCompactJob,
|
historyCompactJob,
|
||||||
routineJobKey,
|
routineJobKey,
|
||||||
routineWakeupJob,
|
routineWakeupJob,
|
||||||
@ -34,6 +35,8 @@ import {
|
|||||||
BotSecretName,
|
BotSecretName,
|
||||||
BotSecretSubmission,
|
BotSecretSubmission,
|
||||||
isAttachmentImageMimeType,
|
isAttachmentImageMimeType,
|
||||||
|
MediaLibraryImportInputSchema,
|
||||||
|
MediaLibrarySearchInputSchema,
|
||||||
type MessageBlock,
|
type MessageBlock,
|
||||||
MessageBlock as MessageBlockSchema,
|
MessageBlock as MessageBlockSchema,
|
||||||
OPENAI_COMPATIBLE_PROVIDER_ID,
|
OPENAI_COMPATIBLE_PROVIDER_ID,
|
||||||
@ -61,6 +64,7 @@ import {
|
|||||||
isMessagingChannelRun,
|
isMessagingChannelRun,
|
||||||
isOneShotRoutineCrons,
|
isOneShotRoutineCrons,
|
||||||
isTerminal,
|
isTerminal,
|
||||||
|
messageBlockForArtifact,
|
||||||
messagingChannelId,
|
messagingChannelId,
|
||||||
messagingChannelPrivacyBlock,
|
messagingChannelPrivacyBlock,
|
||||||
messagingDmSurfaceNote,
|
messagingDmSurfaceNote,
|
||||||
@ -304,6 +308,12 @@ import {
|
|||||||
needsOAuthProbe,
|
needsOAuthProbe,
|
||||||
parseMcpServerToolArgs,
|
parseMcpServerToolArgs,
|
||||||
} from "./mcp-server-tool.js";
|
} from "./mcp-server-tool.js";
|
||||||
|
import {
|
||||||
|
importMediaLibraryAsset,
|
||||||
|
MediaLibraryError,
|
||||||
|
searchMediaLibrary,
|
||||||
|
userHasMediaLibrary,
|
||||||
|
} from "./media-library.js";
|
||||||
import { executeMediaTool, isMediaToolName } from "./media-tools.js";
|
import { executeMediaTool, isMediaToolName } from "./media-tools.js";
|
||||||
import { loadAgentMemoryContext } from "./memory-context.js";
|
import { loadAgentMemoryContext } from "./memory-context.js";
|
||||||
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
||||||
@ -1559,6 +1569,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
semanticMemoryEnabled,
|
semanticMemoryEnabled,
|
||||||
cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId),
|
cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId),
|
||||||
mediaCapabilities: await deps.media?.capabilities(run.spaceId).catch(() => undefined),
|
mediaCapabilities: await deps.media?.capabilities(run.spaceId).catch(() => undefined),
|
||||||
|
mediaLibraryEnabled: await userHasMediaLibrary(deps.prisma, run.userId).catch(
|
||||||
|
() => false,
|
||||||
|
),
|
||||||
messagingChannelRun,
|
messagingChannelRun,
|
||||||
agentConnectionsEnabled: hasMessagingIdentity,
|
agentConnectionsEnabled: hasMessagingIdentity,
|
||||||
foreignAgentRun,
|
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") {
|
if (name === "attach_file") {
|
||||||
const filePath = String(args.path ?? "");
|
const filePath = String(args.path ?? "");
|
||||||
if (!deps.artifacts) {
|
if (!deps.artifacts) {
|
||||||
|
|||||||
@ -91,6 +91,7 @@ export {
|
|||||||
runFfmpeg,
|
runFfmpeg,
|
||||||
} from "./media/ffmpeg.js";
|
} from "./media/ffmpeg.js";
|
||||||
export { createVideoOperations } from "./media/video-operations.js";
|
export { createVideoOperations } from "./media/video-operations.js";
|
||||||
|
export * from "./media-library.js";
|
||||||
export * from "./media-operations.js";
|
export * from "./media-operations.js";
|
||||||
export * from "./media-runtime.js";
|
export * from "./media-runtime.js";
|
||||||
export * from "./media-settings.js";
|
export * from "./media-settings.js";
|
||||||
|
|||||||
63
packages/adapters/src/media-library.test.ts
Normal file
63
packages/adapters/src/media-library.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
343
packages/adapters/src/media-library.ts
Normal file
343
packages/adapters/src/media-library.ts
Normal 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 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -32,6 +32,8 @@ export interface MediaProbe {
|
|||||||
};
|
};
|
||||||
audio?: { codec: string; sampleRate: number; channels: number };
|
audio?: { codec: string; sampleRate: number; channels: number };
|
||||||
hasAudio: boolean;
|
hasAudio: boolean;
|
||||||
|
/** When the recording was made, from the container's `creation_time` tag. */
|
||||||
|
createdAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FfmpegProgress {
|
export interface FfmpegProgress {
|
||||||
@ -224,7 +226,7 @@ interface ProbeStream {
|
|||||||
/** Parses `ffprobe -print_format json -show_format -show_streams` output. */
|
/** Parses `ffprobe -print_format json -show_format -show_streams` output. */
|
||||||
export function parseProbeOutput(json: string): MediaProbe {
|
export function parseProbeOutput(json: string): MediaProbe {
|
||||||
const data = JSON.parse(json) as {
|
const data = JSON.parse(json) as {
|
||||||
format?: { duration?: string };
|
format?: { duration?: string; tags?: { creation_time?: string } };
|
||||||
streams?: ProbeStream[];
|
streams?: ProbeStream[];
|
||||||
};
|
};
|
||||||
const streams = data.streams ?? [];
|
const streams = data.streams ?? [];
|
||||||
@ -251,9 +253,16 @@ export function parseProbeOutput(json: string): MediaProbe {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
hasAudio: Boolean(audio),
|
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) {
|
function probeVideo(stream: ProbeStream, width: number, height: number) {
|
||||||
const rotation = normalizeRotation(
|
const rotation = normalizeRotation(
|
||||||
stream.side_data_list?.find((entry) => typeof entry.rotation === "number")?.rotation ??
|
stream.side_data_list?.find((entry) => typeof entry.rotation === "number")?.rotation ??
|
||||||
|
|||||||
@ -472,6 +472,8 @@ export function describeToolActivity(toolName: string, args: unknown): string {
|
|||||||
if (toolName === "write_file") return `Writing ${detail(record.path)}`;
|
if (toolName === "write_file") return `Writing ${detail(record.path)}`;
|
||||||
if (toolName === "list_files") return `Listing ${detail(record.path ?? ".")}`;
|
if (toolName === "list_files") return `Listing ${detail(record.path ?? ".")}`;
|
||||||
if (toolName === "attach_file") return `Attaching ${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 === "open_path") return `Opening ${detail(record.path)}`;
|
||||||
if (toolName === "render_plot") return "Rendering a chart";
|
if (toolName === "render_plot") return "Rendering a chart";
|
||||||
if (toolName === "add_mcp_server") return `Connecting MCP server: ${detail(record.name)}`;
|
if (toolName === "add_mcp_server") return `Connecting MCP server: ${detail(record.name)}`;
|
||||||
|
|||||||
@ -25,6 +25,7 @@ function handlers(): BackgroundJobHandlers {
|
|||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
"media.run": vi.fn(async () => undefined),
|
"media.run": vi.fn(async () => undefined),
|
||||||
|
"media.library.sync": vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ function handlers(overrides: Partial<BackgroundJobHandlers> = {}): BackgroundJob
|
|||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
"media.run": vi.fn(async () => undefined),
|
"media.run": vi.fn(async () => undefined),
|
||||||
|
"media.library.sync": vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ function handlers(): BackgroundJobHandlers {
|
|||||||
"cloud_agent.poll": vi.fn(async () => undefined),
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
||||||
"profile.consolidate": vi.fn(async () => undefined),
|
"profile.consolidate": vi.fn(async () => undefined),
|
||||||
"media.run": vi.fn(async () => undefined),
|
"media.run": vi.fn(async () => undefined),
|
||||||
|
"media.library.sync": vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export * from "./media.js";
|
|||||||
export * from "./media-analysis.js";
|
export * from "./media-analysis.js";
|
||||||
export * from "./media-generation.js";
|
export * from "./media-generation.js";
|
||||||
export * from "./media-jobs.js";
|
export * from "./media-jobs.js";
|
||||||
|
export * from "./media-library.js";
|
||||||
export * from "./media-platforms.js";
|
export * from "./media-platforms.js";
|
||||||
export * from "./media-presets.js";
|
export * from "./media-presets.js";
|
||||||
export * from "./media-settings.js";
|
export * from "./media-settings.js";
|
||||||
|
|||||||
39
packages/contracts/src/media-library.ts
Normal file
39
packages/contracts/src/media-library.ts
Normal 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>;
|
||||||
@ -108,6 +108,7 @@ import {
|
|||||||
MediaJobStartInputSchema,
|
MediaJobStartInputSchema,
|
||||||
MediaJobStartOutputSchema,
|
MediaJobStartOutputSchema,
|
||||||
} from "./media-jobs.js";
|
} from "./media-jobs.js";
|
||||||
|
import { MediaLibrarySourceAddInputSchema, MediaLibrarySourceSchema } from "./media-library.js";
|
||||||
import { MediaSettingsUpdateSchema, MediaSettingsViewSchema } from "./media-settings.js";
|
import { MediaSettingsUpdateSchema, MediaSettingsViewSchema } from "./media-settings.js";
|
||||||
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
|
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
|
||||||
import {
|
import {
|
||||||
@ -799,6 +800,13 @@ export const appContract = {
|
|||||||
get: oc.input(z.object({ jobId: Id })).output(MediaJobSnapshotSchema),
|
get: oc.input(z.object({ jobId: Id })).output(MediaJobSnapshotSchema),
|
||||||
cancel: 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: {
|
connections: {
|
||||||
catalog: oc
|
catalog: oc
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
@ -54,6 +54,7 @@ model User {
|
|||||||
checkIns CheckIn[]
|
checkIns CheckIn[]
|
||||||
dailyBriefs DailyBrief[]
|
dailyBriefs DailyBrief[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
|
mediaLibrarySources MediaLibrarySource[]
|
||||||
notificationSettings UserNotificationSettings?
|
notificationSettings UserNotificationSettings?
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@ -1189,6 +1190,53 @@ model MediaJob {
|
|||||||
@@map("media_jobs")
|
@@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 {
|
model UsageRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
spaceId String
|
spaceId String
|
||||||
|
|||||||
@ -109,6 +109,7 @@ async function main() {
|
|||||||
"packages/testkit/src/bot-secrets.test.ts",
|
"packages/testkit/src/bot-secrets.test.ts",
|
||||||
"packages/testkit/src/delegation.postgres.test.ts",
|
"packages/testkit/src/delegation.postgres.test.ts",
|
||||||
"packages/testkit/src/media.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/user-profile.postgres.test.ts",
|
||||||
"packages/testkit/src/onboarding-intake.postgres.test.ts",
|
"packages/testkit/src/onboarding-intake.postgres.test.ts",
|
||||||
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
|
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
|
||||||
|
|||||||
204
packages/testkit/src/media-library.postgres.test.ts
Normal file
204
packages/testkit/src/media-library.postgres.test.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -45,20 +45,26 @@
|
|||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--radius-2xl: calc(var(--radius) + 8px);
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
|
||||||
/* Typography scale */
|
/* Typography scale. Every text size follows --font-scale, which grows on phones. */
|
||||||
--text-ui-caption: var(--font-size-caption);
|
--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-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-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-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-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-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-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);
|
--text-ui-title--line-height: var(--line-height-tight);
|
||||||
|
|
||||||
/* Control + icon sizes (also drive h-*, size-*, gap-*, p-* utilities) */
|
/* Control + icon sizes (also drive h-*, size-*, gap-*, p-* utilities) */
|
||||||
@ -74,6 +80,18 @@
|
|||||||
--spacing-rk-sidebar-y: var(--spacing-sidebar-row-y);
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user