diff --git a/apps/api/src/ai-consent.test.ts b/apps/api/src/ai-consent.test.ts index caf5be5..38f0a02 100644 --- a/apps/api/src/ai-consent.test.ts +++ b/apps/api/src/ai-consent.test.ts @@ -29,6 +29,7 @@ function setup() { }, spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) }, deploymentSettings: { findUnique: vi.fn(async () => null) }, + integrationProviderConfig: { findUnique: vi.fn(async () => null) }, aiDataConsent: { findMany: vi.fn(async () => []), upsert }, $transaction: vi.fn(async (queries) => Promise.all(queries)), }, @@ -134,6 +135,28 @@ describe("consent grants", () => { expect(JSON.stringify(status.recipients)).not.toContain("supermemory"); expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled(); }); + it("discloses the deployment's media servers before model use, keyed by server", async () => { + const { deps, actor } = setup(); + const settings = { + comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" }, + transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" }, + }; + vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({ + id: "media", + ciphertext: "fake-media-ciphertext", + } as never); + deps.secrets = { + load: vi.fn(() => JSON.stringify(settings)), + } as unknown as RouterDeps["secrets"]; + const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] }); + expect(status.recipients.map((recipient) => recipient.name)).toEqual([ + "ComfyUI (http://127.0.0.1:8188)", + "Transcription (http://127.0.0.1:8080)", + ]); + expect(status.recipients[0]?.detail).toBe("Model: sdxl."); + expect(JSON.stringify(status)).not.toContain("comfy-key"); + expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1); + }); it("returns an operator policy without requiring a hosted provider", async () => { const { deps, actor } = setup(); deps.env.privacyPolicyUrl = "https://example.com/privacy"; diff --git a/apps/api/src/ai-consent.ts b/apps/api/src/ai-consent.ts index 8c595b5..076eff7 100644 --- a/apps/api/src/ai-consent.ts +++ b/apps/api/src/ai-consent.ts @@ -3,6 +3,9 @@ import { ORPCError } from "@orpc/server"; import { aiRecipient, cloudAgentsEnabled, + loadMediaSettings, + MEDIA_COMFYUI_PROVIDER_ID, + MEDIA_TRANSCRIPTION_PROVIDER_ID, parseModelSecret, selectConfiguredModel, toStringRecord, @@ -210,6 +213,30 @@ export async function aiConsentStatus( ) { add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" })); } + if (uses.includes("model")) { + // Media tools and card actions send prompts, images, video and audio to the owner's servers. + const media = await loadMediaSettings(deps); + if (media.comfyui) { + add( + aiRecipient({ + provider: MEDIA_COMFYUI_PROVIDER_ID, + use: "model", + baseUrl: media.comfyui.baseUrl, + modelId: media.comfyui.imageModel, + }), + ); + } + if (media.transcription) { + add( + aiRecipient({ + provider: MEDIA_TRANSCRIPTION_PROVIDER_ID, + use: "model", + baseUrl: media.transcription.baseUrl, + modelId: media.transcription.model, + }), + ); + } + } return { scope: createHash("sha256") .update(JSON.stringify([actor.userId, actor.spaceId])) diff --git a/apps/api/src/media-jobs.ts b/apps/api/src/media-jobs.ts index 45dd6f3..a6d3a98 100644 --- a/apps/api/src/media-jobs.ts +++ b/apps/api/src/media-jobs.ts @@ -1,7 +1,7 @@ import { ORPCError } from "@orpc/server"; import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit"; +import type { EncryptedSecretStore } from "@rakazo/adapters"; import { - type EncryptedSecretStore, loadMediaSettings, MediaJobRejectedError, MediaSettingsInvalidError, diff --git a/apps/web/src/components/MediaSettings.tsx b/apps/web/src/components/MediaSettings.tsx new file mode 100644 index 0000000..ad93abb --- /dev/null +++ b/apps/web/src/components/MediaSettings.tsx @@ -0,0 +1,278 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { MediaSettingsUpdate, MediaSettingsView } from "@rakazo/contracts"; +import { Button, Field, FieldLabel, Input, Textarea } from "@rakazo/ui-web"; +import { useEffect, useId, useState } from "react"; +import { rpc } from "../lib/rpc"; +import { SuccessPop } from "./ai/primitives"; + +type KeyDraft = { value: string; remove: boolean }; + +const emptyKey: KeyDraft = { value: "", remove: false }; + +/** Omitted keeps the saved key, empty removes it, anything else replaces it. */ +function keyUpdate(draft: KeyDraft): string | undefined { + if (draft.value.trim()) return draft.value.trim(); + return draft.remove ? "" : undefined; +} + +function ApiKeyField({ + saved, + draft, + disabled, + onChange, +}: { + saved: boolean; + draft: KeyDraft; + disabled: boolean; + onChange: (draft: KeyDraft) => void; +}) { + const { t } = useLingui(); + const id = useId(); + const showSaved = saved && !draft.remove; + return ( + +
+ + API key + + {showSaved && !draft.value ? ( + + ) : null} +
+ onChange({ ...draft, value: event.target.value })} + /> +
+ ); +} + +/** The deployment owner's media servers: ComfyUI for generation, a transcription server. */ +export function MediaSettings() { + const { t } = useLingui(); + const ids = useId(); + const [view, setView] = useState(null); + const [comfyUrl, setComfyUrl] = useState(""); + const [comfyKey, setComfyKey] = useState(emptyKey); + const [imageModel, setImageModel] = useState(""); + const [imageWorkflow, setImageWorkflow] = useState(""); + const [videoWorkflow, setVideoWorkflow] = useState(""); + const [transcriptionUrl, setTranscriptionUrl] = useState(""); + const [transcriptionModel, setTranscriptionModel] = useState(""); + const [transcriptionKey, setTranscriptionKey] = useState(emptyKey); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + function apply(next: MediaSettingsView) { + setView(next); + setComfyUrl(next.comfyui?.baseUrl ?? ""); + setImageModel(next.comfyui?.imageModel ?? ""); + setImageWorkflow(next.comfyui?.imageWorkflow ?? ""); + setVideoWorkflow(next.comfyui?.videoWorkflow ?? ""); + setTranscriptionUrl(next.transcription?.baseUrl ?? ""); + setTranscriptionModel(next.transcription?.model ?? ""); + setComfyKey(emptyKey); + setTranscriptionKey(emptyKey); + } + + useEffect(() => { + let cancelled = false; + rpc.media.settings + .get() + .then((next) => { + if (!cancelled) apply(next); + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`); + }); + return () => { + cancelled = true; + }; + }, [t]); + + async function save() { + if (busy) return; + setBusy(true); + setSaved(false); + setError(null); + const update: MediaSettingsUpdate = { + comfyui: comfyUrl.trim() + ? { + baseUrl: comfyUrl.trim(), + apiKey: keyUpdate(comfyKey), + imageModel, + imageWorkflow, + videoWorkflow, + } + : null, + transcription: transcriptionUrl.trim() + ? { + baseUrl: transcriptionUrl.trim(), + model: transcriptionModel, + apiKey: keyUpdate(transcriptionKey), + } + : null, + }; + try { + apply(await rpc.media.settings.update(update)); + setSaved(true); + } catch (err) { + setError(err instanceof Error ? err.message : t`Couldn't save`); + } finally { + setBusy(false); + } + } + + const disabled = busy || view === null; + const advancedSaved = Boolean( + view?.comfyui?.imageModel || view?.comfyui?.imageWorkflow || view?.comfyui?.videoWorkflow, + ); + const workflowClass = "max-h-48 overflow-auto font-mono text-xs"; + + return ( +
+
+

ComfyUI

+ + + Server URL + + setComfyUrl(event.target.value)} + /> + + +
+ + + Advanced + + + + + + Image model + + setImageModel(event.target.value)} + /> + + + + Image workflow + +