First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function InvalidRoutePage() {
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AgentSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ agentId?: string }> | { agentId?: string };
|
||||
}) {
|
||||
await params;
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AgentsPage() {
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import {
|
||||
restoreAgentStateOverSsh,
|
||||
trashAgentStateOverSsh,
|
||||
} from "@/lib/ssh/agent-state";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type TrashAgentStateRequest = {
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
type RestoreAgentStateRequest = {
|
||||
agentId: string;
|
||||
trashDir: string;
|
||||
};
|
||||
|
||||
const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value);
|
||||
|
||||
const resolveAgentStateSshTarget = (): string | null => {
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||
}
|
||||
const { agentId } = body as Partial<TrashAgentStateRequest>;
|
||||
const trimmed = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!trimmed) {
|
||||
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||
}
|
||||
if (!isSafeAgentId(trimmed)) {
|
||||
return NextResponse.json({ error: `Invalid agentId: ${trimmed}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const sshTarget = resolveAgentStateSshTarget();
|
||||
const result = sshTarget
|
||||
? trashAgentStateOverSsh({ sshTarget, agentId: trimmed })
|
||||
: trashAgentStateLocally({ agentId: trimmed });
|
||||
return NextResponse.json({ result });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to trash agent workspace/state.";
|
||||
console.error(message);
|
||||
const status =
|
||||
message.includes("Invalid request payload") ||
|
||||
message.includes("agentId is required") ||
|
||||
message.includes("trashDir is required") ||
|
||||
message.includes("Invalid agentId") ||
|
||||
message.includes("Gateway URL is missing") ||
|
||||
message.includes("Invalid gateway URL") ||
|
||||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||
? 400
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||
}
|
||||
const { agentId, trashDir } = body as Partial<RestoreAgentStateRequest>;
|
||||
const trimmedAgent = typeof agentId === "string" ? agentId.trim() : "";
|
||||
const trimmedTrash = typeof trashDir === "string" ? trashDir.trim() : "";
|
||||
if (!trimmedAgent) {
|
||||
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||
}
|
||||
if (!trimmedTrash) {
|
||||
return NextResponse.json({ error: "trashDir is required." }, { status: 400 });
|
||||
}
|
||||
if (!isSafeAgentId(trimmedAgent)) {
|
||||
return NextResponse.json({ error: `Invalid agentId: ${trimmedAgent}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const sshTarget = resolveAgentStateSshTarget();
|
||||
const result = sshTarget
|
||||
? restoreAgentStateOverSsh({
|
||||
sshTarget,
|
||||
agentId: trimmedAgent,
|
||||
trashDir: trimmedTrash,
|
||||
})
|
||||
: restoreAgentStateLocally({
|
||||
agentId: trimmedAgent,
|
||||
trashDir: trimmedTrash,
|
||||
});
|
||||
return NextResponse.json({ result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to restore agent state.";
|
||||
console.error(message);
|
||||
const status =
|
||||
message.includes("Invalid request payload") ||
|
||||
message.includes("agentId is required") ||
|
||||
message.includes("trashDir is required") ||
|
||||
message.includes("Invalid agentId") ||
|
||||
message.includes("Gateway URL is missing") ||
|
||||
message.includes("Invalid gateway URL") ||
|
||||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||
? 400
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
runSshJson,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
const expandTildeLocal = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const validateRawMediaPath = (raw: string): { trimmed: string; mime: string } => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) throw new Error("path is required");
|
||||
if (trimmed.length > 4096) throw new Error("path too long");
|
||||
if (/[^\S\r\n]*[\0\r\n]/.test(trimmed)) throw new Error("path contains invalid characters");
|
||||
|
||||
const ext = path.extname(trimmed).toLowerCase();
|
||||
const mime = MIME_BY_EXT[ext];
|
||||
if (!mime) throw new Error(`Unsupported media extension: ${ext || "(none)"}`);
|
||||
|
||||
return { trimmed, mime };
|
||||
};
|
||||
|
||||
const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime: string } => {
|
||||
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||
|
||||
const expanded = expandTildeLocal(trimmed);
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error("path must be absolute or start with ~/");
|
||||
}
|
||||
|
||||
const resolved = path.resolve(expanded);
|
||||
|
||||
const allowedRoot = path.join(os.homedir(), ".openclaw");
|
||||
const allowedPrefix = `${allowedRoot}${path.sep}`;
|
||||
if (!(resolved === allowedRoot || resolved.startsWith(allowedPrefix))) {
|
||||
throw new Error(`Refusing to read media outside ${allowedRoot}`);
|
||||
}
|
||||
|
||||
return { resolved, mime };
|
||||
};
|
||||
|
||||
const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => {
|
||||
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||
|
||||
if (!(trimmed.startsWith("/") || trimmed === "~" || trimmed.startsWith("~/"))) {
|
||||
throw new Error("path must be absolute or start with ~/");
|
||||
}
|
||||
|
||||
// Remote side enforces ~/.openclaw; this guard lets Studio on macOS request
|
||||
// /home/ubuntu/.openclaw/... without tripping local homedir checks.
|
||||
const normalized = trimmed.replaceAll("\\\\", "/");
|
||||
const inOpenclaw =
|
||||
normalized === "~/.openclaw" ||
|
||||
normalized.startsWith("~/.openclaw/") ||
|
||||
normalized.includes("/.openclaw/");
|
||||
if (!inOpenclaw) {
|
||||
throw new Error("Refusing to read remote media outside ~/.openclaw");
|
||||
}
|
||||
|
||||
return { remotePath: trimmed, mime };
|
||||
};
|
||||
|
||||
const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("path is not a file");
|
||||
}
|
||||
if (stat.size > MAX_MEDIA_BYTES) {
|
||||
throw new Error(`media file too large (${stat.size} bytes)`);
|
||||
}
|
||||
const buf = await fs.readFile(resolvedPath);
|
||||
return { bytes: buf, size: stat.size };
|
||||
};
|
||||
|
||||
const REMOTE_READ_SCRIPT = `
|
||||
set -euo pipefail
|
||||
|
||||
python3 - "$1" <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
raw = sys.argv[1].strip()
|
||||
if not raw:
|
||||
print(json.dumps({"error": "path is required"}))
|
||||
raise SystemExit(2)
|
||||
|
||||
p = pathlib.Path(os.path.expanduser(raw))
|
||||
try:
|
||||
resolved = p.resolve(strict=True)
|
||||
except FileNotFoundError:
|
||||
print(json.dumps({"error": f"file not found: {raw}"}))
|
||||
raise SystemExit(3)
|
||||
|
||||
home = pathlib.Path.home().resolve()
|
||||
allowed = (home / ".openclaw").resolve()
|
||||
if resolved != allowed and allowed not in resolved.parents:
|
||||
print(json.dumps({"error": f"Refusing to read media outside {allowed}"}))
|
||||
raise SystemExit(4)
|
||||
|
||||
ext = resolved.suffix.lower()
|
||||
mime = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}.get(ext) or (mimetypes.guess_type(str(resolved))[0] or "")
|
||||
|
||||
if not mime.startswith("image/"):
|
||||
print(json.dumps({"error": f"Unsupported media extension: {ext or '(none)'}"}))
|
||||
raise SystemExit(5)
|
||||
|
||||
size = resolved.stat().st_size
|
||||
max_bytes = ${MAX_MEDIA_BYTES}
|
||||
if size > max_bytes:
|
||||
print(json.dumps({"error": f"media file too large ({size} bytes)"}))
|
||||
raise SystemExit(6)
|
||||
|
||||
data = base64.b64encode(resolved.read_bytes()).decode("ascii")
|
||||
print(json.dumps({"ok": True, "mime": mime, "size": size, "data": data}))
|
||||
PY
|
||||
`;
|
||||
|
||||
const resolveSshTarget = (): string | null => {
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawPath = (searchParams.get("path") ?? "").trim();
|
||||
|
||||
const sshTarget = resolveSshTarget();
|
||||
|
||||
if (!sshTarget) {
|
||||
const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath);
|
||||
const { bytes, size } = await readLocalMedia(resolved);
|
||||
const body = new Blob([Uint8Array.from(bytes)], { type: mime });
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Content-Length": String(size),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { remotePath, mime } = validateRemoteMediaPath(rawPath);
|
||||
|
||||
const payload = runSshJson({
|
||||
sshTarget,
|
||||
argv: ["bash", "-s", "--", remotePath],
|
||||
label: "gateway media read",
|
||||
input: REMOTE_READ_SCRIPT,
|
||||
fallbackMessage: `Failed to fetch media over ssh (${sshTarget})`,
|
||||
maxBuffer: Math.ceil(MAX_MEDIA_BYTES * 1.6),
|
||||
}) as {
|
||||
ok?: boolean;
|
||||
data?: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const b64 = payload.data ?? "";
|
||||
if (!b64) {
|
||||
throw new Error("Remote media fetch returned empty data");
|
||||
}
|
||||
|
||||
const buf = Buffer.from(b64, "base64");
|
||||
const responseMime = payload.mime || mime;
|
||||
const body = new Blob([Uint8Array.from(buf)], { type: responseMime });
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": responseMime,
|
||||
"Content-Length": String(buf.length),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch media";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import { removeSkillLocally } from "@/lib/skills/remove-local";
|
||||
import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import { removeSkillOverSsh } from "@/lib/ssh/skills-remove";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([
|
||||
"openclaw-managed",
|
||||
"openclaw-workspace",
|
||||
]);
|
||||
|
||||
const normalizeRequired = (value: unknown, field: string): string => {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveSkillRemovalSshTarget = (): string | null => {
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
const normalizeRemoveRequest = (body: unknown): SkillRemoveRequest => {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new Error("Invalid request payload.");
|
||||
}
|
||||
|
||||
const record = body as Partial<Record<keyof SkillRemoveRequest, unknown>>;
|
||||
const sourceRaw = normalizeRequired(record.source, "source");
|
||||
if (!REMOVABLE_SOURCES.has(sourceRaw as RemovableSkillSource)) {
|
||||
throw new Error(`Unsupported skill source for removal: ${sourceRaw}`);
|
||||
}
|
||||
|
||||
return {
|
||||
skillKey: normalizeRequired(record.skillKey, "skillKey"),
|
||||
source: sourceRaw as RemovableSkillSource,
|
||||
baseDir: normalizeRequired(record.baseDir, "baseDir"),
|
||||
workspaceDir: normalizeRequired(record.workspaceDir, "workspaceDir"),
|
||||
managedSkillsDir: normalizeRequired(record.managedSkillsDir, "managedSkillsDir"),
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
const removeRequest = normalizeRemoveRequest(body);
|
||||
|
||||
const sshTarget = resolveSkillRemovalSshTarget();
|
||||
const result = sshTarget
|
||||
? removeSkillOverSsh({ sshTarget, request: removeRequest })
|
||||
: removeSkillLocally(removeRequest);
|
||||
|
||||
return NextResponse.json({ result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to remove skill.";
|
||||
const status =
|
||||
message.includes("required") ||
|
||||
message.includes("Invalid request payload") ||
|
||||
message.includes("Unsupported skill source") ||
|
||||
message.includes("Refusing to remove") ||
|
||||
message.includes("not a directory") ||
|
||||
message.includes("Gateway URL is missing") ||
|
||||
message.includes("Invalid gateway URL") ||
|
||||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
|
||||
? 400
|
||||
: 500;
|
||||
if (status >= 500) {
|
||||
console.error(message);
|
||||
}
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
normalizeBrowserPreviewUrl,
|
||||
resolveBrowserControlBaseUrl,
|
||||
} from "@/lib/office/browserPreview";
|
||||
import { validateBrowserPreviewTarget } from "@/lib/security/urlSafety";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type BrowserTab = {
|
||||
targetId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type BrowserTabsResponse = {
|
||||
running?: boolean;
|
||||
tabs?: BrowserTab[];
|
||||
};
|
||||
|
||||
type BrowserOpenResponse = {
|
||||
targetId?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type BrowserScreenshotResponse = {
|
||||
ok?: boolean;
|
||||
path?: string;
|
||||
targetId?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CAPTURE_WAIT_MS = 1_500;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const buildBrowserHeaders = (token: string | null): HeadersInit => {
|
||||
if (!token) return { "Content-Type": "application/json" };
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
};
|
||||
|
||||
const parseBrowserError = async (response: Response): Promise<string> => {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string };
|
||||
return payload.error?.trim() || payload.message?.trim() || response.statusText || "Browser request failed";
|
||||
} catch {
|
||||
return response.statusText || "Browser request failed";
|
||||
}
|
||||
};
|
||||
|
||||
const browserRequest = async <T>(
|
||||
baseUrl: string,
|
||||
pathname: string,
|
||||
init: RequestInit,
|
||||
token: string | null,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...buildBrowserHeaders(token),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseBrowserError(response));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
const samePreviewTarget = (left: string, right: string): boolean => {
|
||||
return normalizeBrowserPreviewUrl(left) === normalizeBrowserPreviewUrl(right);
|
||||
};
|
||||
|
||||
const ensurePreviewTab = async (
|
||||
baseUrl: string,
|
||||
token: string | null,
|
||||
browserUrl: string,
|
||||
): Promise<{ targetId: string; resolvedUrl: string }> => {
|
||||
const tabsPayload = await browserRequest<BrowserTabsResponse>(baseUrl, "/tabs", { method: "GET" }, token);
|
||||
|
||||
if (tabsPayload.running === false) {
|
||||
await browserRequest(baseUrl, "/start", { method: "POST" }, token);
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
const tabs = Array.isArray(tabsPayload.tabs) ? tabsPayload.tabs : [];
|
||||
const exactMatch = tabs.find((tab) => samePreviewTarget(tab.url, browserUrl));
|
||||
if (exactMatch?.targetId) {
|
||||
await browserRequest(baseUrl, "/tabs/focus", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId: exactMatch.targetId }),
|
||||
}, token);
|
||||
return { targetId: exactMatch.targetId, resolvedUrl: exactMatch.url || browserUrl };
|
||||
}
|
||||
|
||||
const reusableTab = tabs[0];
|
||||
if (reusableTab?.targetId) {
|
||||
await browserRequest(baseUrl, "/tabs/focus", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId: reusableTab.targetId }),
|
||||
}, token);
|
||||
await browserRequest(baseUrl, "/navigate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId: reusableTab.targetId, url: browserUrl }),
|
||||
}, token);
|
||||
return { targetId: reusableTab.targetId, resolvedUrl: browserUrl };
|
||||
}
|
||||
|
||||
const opened = await browserRequest<BrowserOpenResponse>(baseUrl, "/tabs/open", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: browserUrl }),
|
||||
}, token);
|
||||
if (!opened.targetId) {
|
||||
throw new Error("Browser preview did not return a target tab.");
|
||||
}
|
||||
return { targetId: opened.targetId, resolvedUrl: opened.url || browserUrl };
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawUrl = (searchParams.get("url") ?? "").trim();
|
||||
if (!rawUrl) {
|
||||
return NextResponse.json({ error: "url is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let browserUrl: string;
|
||||
try {
|
||||
browserUrl = validateBrowserPreviewTarget(rawUrl).toString();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "url must be an absolute public http(s) URL" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const waitMsRaw = Number(searchParams.get("waitMs") ?? "");
|
||||
const waitMs =
|
||||
Number.isFinite(waitMsRaw) && waitMsRaw >= 0
|
||||
? Math.min(Math.round(waitMsRaw), 8_000)
|
||||
: DEFAULT_CAPTURE_WAIT_MS;
|
||||
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url?.trim() ?? "";
|
||||
const controlBaseUrl = resolveBrowserControlBaseUrl(gatewayUrl);
|
||||
if (!controlBaseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "Browser screenshot preview only works when Studio is connected to a local gateway." },
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const token = settings.gateway?.token?.trim() || null;
|
||||
const { targetId, resolvedUrl } = await ensurePreviewTab(controlBaseUrl, token, browserUrl);
|
||||
|
||||
if (waitMs > 0) {
|
||||
await sleep(waitMs);
|
||||
}
|
||||
|
||||
const screenshot = await browserRequest<BrowserScreenshotResponse>(controlBaseUrl, "/screenshot", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetId, type: "png" }),
|
||||
}, token);
|
||||
|
||||
if (!screenshot.path?.trim()) {
|
||||
throw new Error("Browser screenshot did not return a media path.");
|
||||
}
|
||||
|
||||
const mediaUrl = `/api/gateway/media?path=${encodeURIComponent(screenshot.path)}`;
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
browserUrl: screenshot.url || resolvedUrl,
|
||||
imagePath: screenshot.path,
|
||||
mediaUrl,
|
||||
targetId: screenshot.targetId || targetId,
|
||||
capturedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to build browser preview";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buildMockPhoneCallScenario } from "@/lib/office/call/mock";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PhoneCallRequestBody = {
|
||||
callee?: string;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
const MAX_CALLEE_CHARS = 120;
|
||||
const MAX_MESSAGE_CHARS = 1_000;
|
||||
|
||||
const normalizeText = (value: string | null | undefined): string =>
|
||||
(value ?? "").replace(/\s+/g, " ").trim();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as PhoneCallRequestBody;
|
||||
const callee = normalizeText(body.callee);
|
||||
const message = normalizeText(body.message);
|
||||
|
||||
if (!callee) {
|
||||
return NextResponse.json({ error: "callee is required." }, { status: 400 });
|
||||
}
|
||||
if (callee.length > MAX_CALLEE_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `callee exceeds ${MAX_CALLEE_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `message exceeds ${MAX_MESSAGE_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Create Claw3D voice and text skill.
|
||||
const scenario = buildMockPhoneCallScenario({
|
||||
callee,
|
||||
message: message || null,
|
||||
voiceAvailable: Boolean(process.env.ELEVENLABS_API_KEY?.trim()),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ scenario },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to prepare the mock phone call.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
loadGitHubDashboard,
|
||||
loadGitHubPullRequestDetail,
|
||||
submitGitHubInlineComment,
|
||||
submitGitHubPullRequestReview,
|
||||
type GitHubInlineCommentSide,
|
||||
type GitHubReviewAction,
|
||||
} from "@/lib/office/github";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ReviewRequestBody = {
|
||||
repo?: string;
|
||||
number?: number;
|
||||
action?: GitHubReviewAction;
|
||||
body?: string | null;
|
||||
path?: string;
|
||||
line?: number;
|
||||
side?: GitHubInlineCommentSide;
|
||||
commitId?: string | null;
|
||||
};
|
||||
|
||||
const parsePullRequestNumber = (value: string | null): number | null => {
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return Math.round(parsed);
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const repo = (searchParams.get("repo") ?? "").trim();
|
||||
const number = parsePullRequestNumber(searchParams.get("number"));
|
||||
if (repo && number) {
|
||||
return NextResponse.json(loadGitHubPullRequestDetail({ repo, number }), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(loadGitHubDashboard(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load GitHub server room data.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as ReviewRequestBody;
|
||||
const repo = typeof body.repo === "string" ? body.repo.trim() : "";
|
||||
const number =
|
||||
typeof body.number === "number" && Number.isFinite(body.number)
|
||||
? Math.round(body.number)
|
||||
: null;
|
||||
const action = typeof body.action === "string" ? body.action : null;
|
||||
const path = typeof body.path === "string" ? body.path.trim() : "";
|
||||
const line =
|
||||
typeof body.line === "number" && Number.isFinite(body.line)
|
||||
? Math.round(body.line)
|
||||
: null;
|
||||
const side = body.side === "LEFT" || body.side === "RIGHT" ? body.side : null;
|
||||
const commentBody = typeof body.body === "string" ? body.body : null;
|
||||
|
||||
if (action) {
|
||||
if (!repo || !number) {
|
||||
return NextResponse.json(
|
||||
{ error: "repo, number, and action are required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!["APPROVE", "COMMENT", "REQUEST_CHANGES"].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unsupported review action." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = submitGitHubPullRequestReview({
|
||||
repo,
|
||||
number,
|
||||
action,
|
||||
body: commentBody,
|
||||
});
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!repo || !number || !path || !line || !side || !commentBody?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "repo, number, path, line, side, and body are required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = submitGitHubInlineComment({
|
||||
repo,
|
||||
number,
|
||||
path,
|
||||
line,
|
||||
side,
|
||||
body: commentBody,
|
||||
commitId: typeof body.commitId === "string" ? body.commitId : null,
|
||||
});
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to submit GitHub review.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadOfficePresenceSnapshot } from "@/lib/office/presence";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() || "default";
|
||||
const snapshot = loadOfficePresenceSnapshot(workspaceId);
|
||||
return NextResponse.json(snapshot);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load office presence.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listOfficeVersions, publishOfficeVersion } from "@/lib/office/store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const asString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const workspaceId = asString(body.workspaceId) || "default";
|
||||
const officeId = asString(body.officeId);
|
||||
const officeVersionId = asString(body.officeVersionId);
|
||||
const publishedBy = asString(body.publishedBy) || "studio";
|
||||
if (!workspaceId || !officeId) {
|
||||
return NextResponse.json({ error: "workspaceId and officeId are required." }, { status: 400 });
|
||||
}
|
||||
let selectedVersionId = officeVersionId;
|
||||
if (!selectedVersionId) {
|
||||
const versions = listOfficeVersions(workspaceId, officeId);
|
||||
selectedVersionId = versions[0]?.id ?? "";
|
||||
}
|
||||
if (!selectedVersionId) {
|
||||
return NextResponse.json({ error: "No office version available to publish." }, { status: 400 });
|
||||
}
|
||||
const published = publishOfficeVersion({
|
||||
workspaceId,
|
||||
officeId,
|
||||
officeVersionId: selectedVersionId,
|
||||
publishedBy,
|
||||
});
|
||||
return NextResponse.json({ published });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to publish office version.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createEmptyOfficeMap, normalizeOfficeMap, type OfficeMap } from "@/lib/office/schema";
|
||||
import {
|
||||
getPublishedOffice,
|
||||
getPublishedOfficeMap,
|
||||
listOfficesForWorkspace,
|
||||
listOfficeVersions,
|
||||
saveOfficeVersion,
|
||||
upsertOffice,
|
||||
} from "@/lib/office/store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const asString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = asString(url.searchParams.get("workspaceId")) || "default";
|
||||
const officeId = asString(url.searchParams.get("officeId"));
|
||||
const offices = listOfficesForWorkspace(workspaceId);
|
||||
const published = getPublishedOffice(workspaceId);
|
||||
const publishedMap = getPublishedOfficeMap(workspaceId);
|
||||
const versions = officeId ? listOfficeVersions(workspaceId, officeId) : [];
|
||||
return NextResponse.json({
|
||||
workspaceId,
|
||||
offices,
|
||||
versions,
|
||||
published,
|
||||
publishedMap,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load office data.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const action = asString(body.action);
|
||||
if (action === "upsertOffice") {
|
||||
const workspaceId = asString(body.workspaceId) || "default";
|
||||
const officeId = asString(body.officeId);
|
||||
const name = asString(body.name);
|
||||
if (!officeId || !name) {
|
||||
return NextResponse.json({ error: "officeId and name are required." }, { status: 400 });
|
||||
}
|
||||
const office = upsertOffice({
|
||||
workspaceId,
|
||||
officeId,
|
||||
name,
|
||||
});
|
||||
return NextResponse.json({ office });
|
||||
}
|
||||
if (action === "saveVersion") {
|
||||
const workspaceId = asString(body.workspaceId) || "default";
|
||||
const officeId = asString(body.officeId);
|
||||
const versionId = asString(body.versionId);
|
||||
const createdBy = asString(body.createdBy) || "studio";
|
||||
if (!officeId || !versionId) {
|
||||
return NextResponse.json({ error: "officeId and versionId are required." }, { status: 400 });
|
||||
}
|
||||
const incomingMap = body.map as OfficeMap | undefined;
|
||||
const fallback = createEmptyOfficeMap({
|
||||
workspaceId,
|
||||
officeVersionId: versionId,
|
||||
width: 1600,
|
||||
height: 900,
|
||||
});
|
||||
const map = normalizeOfficeMap(incomingMap, fallback);
|
||||
const record = saveOfficeVersion({
|
||||
workspaceId,
|
||||
officeId,
|
||||
versionId,
|
||||
createdBy,
|
||||
notes: asString(body.notes),
|
||||
map,
|
||||
});
|
||||
return NextResponse.json({ version: record });
|
||||
}
|
||||
return NextResponse.json({ error: "Unsupported office action." }, { status: 400 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save office data.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
applyStudioSettingsPatch,
|
||||
loadStudioSettings,
|
||||
} from "@/lib/studio/settings-store";
|
||||
import {
|
||||
resolveStandupPreference,
|
||||
sanitizeStandupPreference,
|
||||
type StudioStandupPreferencePatch,
|
||||
} from "@/lib/studio/settings";
|
||||
import { validateJiraBaseUrl } from "@/lib/security/urlSafety";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const readGatewayUrl = (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
return (url.searchParams.get("gatewayUrl") ?? "").trim();
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const gatewayUrl = readGatewayUrl(request);
|
||||
if (!gatewayUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "gatewayUrl is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const settings = loadStudioSettings();
|
||||
const config = resolveStandupPreference(settings, gatewayUrl);
|
||||
return NextResponse.json({ gatewayUrl, config: sanitizeStandupPreference(config) });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load standup config.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
gatewayUrl?: string;
|
||||
config?: StudioStandupPreferencePatch;
|
||||
};
|
||||
const gatewayUrl = typeof body.gatewayUrl === "string" ? body.gatewayUrl.trim() : "";
|
||||
if (!gatewayUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "gatewayUrl is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!body.config || typeof body.config !== "object") {
|
||||
return NextResponse.json({ error: "config is required." }, { status: 400 });
|
||||
}
|
||||
if (body.config.jira?.baseUrl?.trim()) {
|
||||
validateJiraBaseUrl(body.config.jira.baseUrl);
|
||||
}
|
||||
const settings = applyStudioSettingsPatch({
|
||||
standup: {
|
||||
[gatewayUrl]: body.config,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({
|
||||
gatewayUrl,
|
||||
config: sanitizeStandupPreference(resolveStandupPreference(settings, gatewayUrl)),
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to save standup config.";
|
||||
const status =
|
||||
message.includes("gatewayUrl is required") ||
|
||||
message.includes("config is required") ||
|
||||
message.includes("Jira base URL")
|
||||
? 400
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
advanceStandupMeeting,
|
||||
startStandupSpeaker,
|
||||
updateStandupArrivals,
|
||||
} from "@/lib/office/standup/service";
|
||||
import {
|
||||
loadActiveStandupMeeting,
|
||||
updateStandupMeeting,
|
||||
} from "@/lib/office/standup/store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(
|
||||
{ meeting: loadActiveStandupMeeting() },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load standup meeting.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
action?: "arrivals" | "start" | "advance" | "complete";
|
||||
arrivedAgentIds?: string[];
|
||||
speakerAgentId?: string | null;
|
||||
};
|
||||
const action = typeof body.action === "string" ? body.action : "";
|
||||
if (!action) {
|
||||
return NextResponse.json({ error: "action is required." }, { status: 400 });
|
||||
}
|
||||
const store = updateStandupMeeting((meeting) => {
|
||||
if (!meeting) return null;
|
||||
if (action === "arrivals") {
|
||||
return updateStandupArrivals(meeting, body.arrivedAgentIds ?? []);
|
||||
}
|
||||
if (action === "start") {
|
||||
const speakerAgentId =
|
||||
typeof body.speakerAgentId === "string" ? body.speakerAgentId.trim() : null;
|
||||
return startStandupSpeaker(meeting, speakerAgentId);
|
||||
}
|
||||
if (action === "advance") {
|
||||
return advanceStandupMeeting(meeting);
|
||||
}
|
||||
if (action === "complete") {
|
||||
return startStandupSpeaker(meeting, null);
|
||||
}
|
||||
return meeting;
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ meeting: store.activeMeeting },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to update standup meeting.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buildStandupMeeting } from "@/lib/office/standup/service";
|
||||
import { saveStandupMeeting } from "@/lib/office/standup/store";
|
||||
import type { StandupAgentSnapshot, StandupTriggerKind } from "@/lib/office/standup/types";
|
||||
import {
|
||||
applyStudioSettingsPatch,
|
||||
loadStudioSettings,
|
||||
} from "@/lib/studio/settings-store";
|
||||
import { resolveStandupPreference } from "@/lib/studio/settings";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
gatewayUrl?: string;
|
||||
agents?: StandupAgentSnapshot[];
|
||||
trigger?: StandupTriggerKind;
|
||||
scheduledFor?: string | null;
|
||||
};
|
||||
const gatewayUrl =
|
||||
typeof body.gatewayUrl === "string" ? body.gatewayUrl.trim() : "";
|
||||
if (!gatewayUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "gatewayUrl is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const settings = loadStudioSettings();
|
||||
const config = resolveStandupPreference(settings, gatewayUrl);
|
||||
const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
|
||||
const meeting = await buildStandupMeeting({
|
||||
config,
|
||||
agents: Array.isArray(body.agents) ? body.agents : [],
|
||||
trigger,
|
||||
scheduledFor:
|
||||
typeof body.scheduledFor === "string" ? body.scheduledFor : null,
|
||||
});
|
||||
saveStandupMeeting(meeting);
|
||||
if (trigger === "scheduled") {
|
||||
applyStudioSettingsPatch({
|
||||
standup: {
|
||||
[gatewayUrl]: {
|
||||
schedule: {
|
||||
lastAutoRunAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ meeting },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to start standup meeting.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { buildMockTextMessageScenario } from "@/lib/office/text/mock";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type TextMessageRequestBody = {
|
||||
recipient?: string;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
const MAX_RECIPIENT_CHARS = 120;
|
||||
const MAX_MESSAGE_CHARS = 1_000;
|
||||
|
||||
const normalizeText = (value: string | null | undefined): string =>
|
||||
(value ?? "").replace(/\s+/g, " ").trim();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as TextMessageRequestBody;
|
||||
const recipient = normalizeText(body.recipient);
|
||||
const message = normalizeText(body.message);
|
||||
|
||||
if (!recipient) {
|
||||
return NextResponse.json({ error: "recipient is required." }, { status: 400 });
|
||||
}
|
||||
if (recipient.length > MAX_RECIPIENT_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `recipient exceeds ${MAX_RECIPIENT_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `message exceeds ${MAX_MESSAGE_CHARS} characters.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Create Claw3D voice and text skill.
|
||||
const scenario = buildMockTextMessageScenario({
|
||||
recipient,
|
||||
message: message || null,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ scenario },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to prepare the mock text message.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { synthesizeVoiceReply, type VoiceReplyProvider } from "@/lib/voiceReply/provider";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type VoiceReplyRequestBody = {
|
||||
text?: string;
|
||||
provider?: VoiceReplyProvider;
|
||||
voiceId?: string | null;
|
||||
speed?: number;
|
||||
};
|
||||
|
||||
const MAX_REPLY_CHARS = 5_000;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as VoiceReplyRequestBody;
|
||||
const text = typeof body.text === "string" ? body.text.replace(/\s+/g, " ").trim() : "";
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: "Voice reply text is required." }, { status: 400 });
|
||||
}
|
||||
if (text.length > MAX_REPLY_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Voice reply text exceeds ${MAX_REPLY_CHARS} characters.` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const response = await synthesizeVoiceReply({
|
||||
text,
|
||||
provider: body.provider,
|
||||
voiceId: body.voiceId,
|
||||
speed: body.speed,
|
||||
});
|
||||
return new Response(response.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": response.headers.get("content-type") ?? "audio/mpeg",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to synthesize the voice reply.";
|
||||
const status = message.includes("Missing ELEVENLABS_API_KEY") ? 503 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { transcribeVoiceWithOpenClaw } from "@/lib/openclaw/voiceTranscription";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_VOICE_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const audio = formData.get("audio");
|
||||
if (!(audio instanceof File)) {
|
||||
return NextResponse.json({ error: "audio file is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
const byteLength = arrayBuffer.byteLength;
|
||||
if (byteLength <= 0) {
|
||||
return NextResponse.json({ error: "Audio upload is empty." }, { status: 400 });
|
||||
}
|
||||
if (byteLength > MAX_VOICE_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Audio upload exceeds the ${MAX_VOICE_UPLOAD_BYTES} byte limit.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await transcribeVoiceWithOpenClaw({
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
fileName: audio.name,
|
||||
mimeType: audio.type,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
transcript: result.transcript,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
decision: result.decision,
|
||||
ignored: result.ignored,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to transcribe audio.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveUserPath } from "@/lib/clawdbot/paths";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PathAutocompleteEntry = {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
displayPath: string;
|
||||
isDirectory: boolean;
|
||||
};
|
||||
|
||||
type PathAutocompleteResult = {
|
||||
query: string;
|
||||
directory: string;
|
||||
entries: PathAutocompleteEntry[];
|
||||
};
|
||||
|
||||
type PathAutocompleteOptions = {
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
homedir?: () => string;
|
||||
};
|
||||
|
||||
const normalizeQuery = (query: string): string => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Query is required.");
|
||||
}
|
||||
if (trimmed === "~") {
|
||||
return "~/";
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
return trimmed;
|
||||
}
|
||||
const withoutLeading = trimmed.replace(/^[\\/]+/, "");
|
||||
return `~/${withoutLeading}`;
|
||||
};
|
||||
|
||||
const isWithinHome = (target: string, home: string): boolean => {
|
||||
const relative = path.relative(home, target);
|
||||
if (!relative) return true;
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
};
|
||||
|
||||
const listPathAutocompleteEntries = ({
|
||||
query,
|
||||
maxResults = 10,
|
||||
homedir = os.homedir,
|
||||
}: PathAutocompleteOptions): PathAutocompleteResult => {
|
||||
const normalized = normalizeQuery(query);
|
||||
const resolvedHome = path.resolve(homedir());
|
||||
const resolvedQuery = resolveUserPath(normalized, homedir);
|
||||
if (!isWithinHome(resolvedQuery, resolvedHome)) {
|
||||
throw new Error("Path must stay within the home directory.");
|
||||
}
|
||||
|
||||
const endsWithSlash = normalized.endsWith("/") || normalized.endsWith(path.sep);
|
||||
const directoryPath = endsWithSlash ? resolvedQuery : path.dirname(resolvedQuery);
|
||||
const prefix = endsWithSlash ? "" : path.basename(resolvedQuery);
|
||||
|
||||
if (!isWithinHome(directoryPath, resolvedHome)) {
|
||||
throw new Error("Path must stay within the home directory.");
|
||||
}
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
throw new Error(`Directory does not exist: ${directoryPath}`);
|
||||
}
|
||||
const stat = fs.statSync(directoryPath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${directoryPath}`);
|
||||
}
|
||||
|
||||
const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : 10;
|
||||
|
||||
const entries = fs
|
||||
.readdirSync(directoryPath, { withFileTypes: true })
|
||||
.filter((entry) => !entry.name.startsWith("."))
|
||||
.filter((entry) => entry.name.startsWith(prefix))
|
||||
.map((entry) => {
|
||||
const fullPath = path.join(directoryPath, entry.name);
|
||||
const relative = path.relative(resolvedHome, fullPath);
|
||||
const normalizedRelative = relative.split(path.sep).join("/");
|
||||
const displayBase = `~/${normalizedRelative}`;
|
||||
return {
|
||||
name: entry.name,
|
||||
fullPath,
|
||||
displayPath: entry.isDirectory() ? `${displayBase}/` : displayBase,
|
||||
isDirectory: entry.isDirectory(),
|
||||
} satisfies PathAutocompleteEntry;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return { query: normalized, directory: directoryPath, entries };
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawQuery = searchParams.get("q");
|
||||
const query = rawQuery && rawQuery.trim() ? rawQuery.trim() : "~/";
|
||||
const result = listPathAutocompleteEntries({ query, maxResults: 10 });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to list path suggestions.";
|
||||
console.error(message);
|
||||
const status = message.includes("does not exist") ? 404 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
sanitizeStudioGatewaySettings,
|
||||
sanitizeStudioSettings,
|
||||
type StudioSettingsPatch,
|
||||
} from "@/lib/studio/settings";
|
||||
import {
|
||||
applyStudioSettingsPatch,
|
||||
loadLocalGatewayDefaults,
|
||||
loadStudioSettings,
|
||||
} from "@/lib/studio/settings-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const isPatch = (value: unknown): value is StudioSettingsPatch =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = loadStudioSettings();
|
||||
const localGatewayDefaults = loadLocalGatewayDefaults();
|
||||
return NextResponse.json(
|
||||
{
|
||||
settings: sanitizeStudioSettings(settings),
|
||||
localGatewayDefaults: sanitizeStudioGatewaySettings(localGatewayDefaults),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load studio settings.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (!isPatch(body)) {
|
||||
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||
}
|
||||
const settings = applyStudioSettingsPatch(body);
|
||||
return NextResponse.json(
|
||||
{ settings: sanitizeStudioSettings(settings) },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to save studio settings.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+1355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Bebas_Neue, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Claw3D",
|
||||
description: "Focused operator studio for the OpenClaw gateway.",
|
||||
};
|
||||
|
||||
const display = Bebas_Neue({
|
||||
variable: "--font-display",
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const sans = IBM_Plex_Sans({
|
||||
variable: "--font-sans",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const mono = IBM_Plex_Mono({
|
||||
variable: "--font-mono",
|
||||
weight: ["400", "500", "600"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var t=localStorage.getItem('theme');var m=window.matchMedia('(prefers-color-scheme: dark)').matches;var d=t?t==='dark':m;document.documentElement.classList.toggle('dark',d);}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${display.variable} ${sans.variable} ${mono.variable} antialiased`}>
|
||||
<main className="h-screen w-screen overflow-hidden bg-background">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { OfficeBuilderPanel } from "@/features/office/components/OfficeBuilderPanel";
|
||||
import { createStarterOfficeMap, normalizeOfficeMap } from "@/lib/office/schema";
|
||||
import { getPublishedOfficeMap } from "@/lib/office/store";
|
||||
|
||||
const WORKSPACE_ID = "default";
|
||||
const OFFICE_ID = "hq";
|
||||
|
||||
export default function OfficeBuilderPage() {
|
||||
const fallback = createStarterOfficeMap({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
officeVersionId: "builder-draft",
|
||||
width: 1600,
|
||||
height: 900,
|
||||
});
|
||||
const map = normalizeOfficeMap(getPublishedOfficeMap(WORKSPACE_ID), fallback);
|
||||
return (
|
||||
<main className="relative h-screen w-screen overflow-hidden bg-background p-3">
|
||||
<OfficeBuilderPanel initialMap={map} workspaceId={WORKSPACE_ID} officeId={OFFICE_ID} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
import { AgentStoreProvider } from "@/features/agents/state/store";
|
||||
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
|
||||
|
||||
export default function OfficePage() {
|
||||
return (
|
||||
<AgentStoreProvider>
|
||||
<Suspense fallback={null}>
|
||||
<OfficeScreen />
|
||||
</Suspense>
|
||||
</AgentStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/office");
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
.agent-markdown {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.agent-markdown p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-markdown p + p,
|
||||
.agent-markdown ul,
|
||||
.agent-markdown ol,
|
||||
.agent-markdown pre,
|
||||
.agent-markdown blockquote,
|
||||
.agent-markdown table {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.agent-markdown h1,
|
||||
.agent-markdown h2,
|
||||
.agent-markdown h3,
|
||||
.agent-markdown h4,
|
||||
.agent-markdown h5,
|
||||
.agent-markdown h6 {
|
||||
margin: 1.1rem 0 0.5rem 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.agent-markdown :is(h1, h2, h3, h4, h5, h6):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-markdown h1 + p,
|
||||
.agent-markdown h2 + p,
|
||||
.agent-markdown h3 + p,
|
||||
.agent-markdown h4 + p,
|
||||
.agent-markdown h5 + p,
|
||||
.agent-markdown h6 + p,
|
||||
.agent-markdown h1 + ul,
|
||||
.agent-markdown h2 + ul,
|
||||
.agent-markdown h3 + ul,
|
||||
.agent-markdown h4 + ul,
|
||||
.agent-markdown h5 + ul,
|
||||
.agent-markdown h6 + ul,
|
||||
.agent-markdown h1 + ol,
|
||||
.agent-markdown h2 + ol,
|
||||
.agent-markdown h3 + ol,
|
||||
.agent-markdown h4 + ol,
|
||||
.agent-markdown h5 + ol,
|
||||
.agent-markdown h6 + ol {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-markdown ul,
|
||||
.agent-markdown ol {
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.agent-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.agent-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.agent-markdown ul ul {
|
||||
list-style-type: circle;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.agent-markdown ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.agent-markdown ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.agent-markdown ul ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.agent-markdown li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-markdown li > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-markdown code {
|
||||
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.82em;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
background: color-mix(in oklch, var(--surface-3) 92%, transparent);
|
||||
padding: 0.14rem 0.28rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1px solid color-mix(in oklch, var(--surface-selected-border) 88%, transparent);
|
||||
}
|
||||
|
||||
.agent-markdown pre {
|
||||
background: color-mix(in oklch, var(--surface-2) 94%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid color-mix(in oklch, var(--surface-selected-border) 82%, transparent);
|
||||
overflow-x: auto;
|
||||
line-height: 1.64;
|
||||
}
|
||||
|
||||
.agent-markdown pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.agent-tool-markdown pre {
|
||||
max-height: 70px;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.agent-markdown blockquote {
|
||||
border-left: 3px solid var(--surface-selected-border);
|
||||
padding-left: 0.7rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.agent-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.agent-markdown th,
|
||||
.agent-markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-markdown a {
|
||||
color: color-mix(in oklch, var(--action-bg) 68%, var(--foreground));
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.agent-markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dark .agent-markdown code {
|
||||
background: color-mix(in oklch, var(--surface-1) 80%, var(--surface-0));
|
||||
border-color: color-mix(in oklch, var(--chat-assistant-border) 88%, var(--surface-selected-border));
|
||||
}
|
||||
|
||||
.dark .agent-markdown pre {
|
||||
background: color-mix(in oklch, var(--surface-0) 82%, var(--surface-1));
|
||||
border-color: color-mix(in oklch, var(--chat-assistant-border) 86%, transparent);
|
||||
box-shadow: inset 0 1px 0 var(--elev-overlay-1);
|
||||
}
|
||||
|
||||
.dark .ui-chat-assistant-card .agent-markdown :is(p + p, ul, ol, pre, blockquote, table) {
|
||||
margin-top: 1.35rem;
|
||||
}
|
||||
|
||||
.dark .ui-chat-assistant-card .agent-markdown :is(h1, h2, h3, h4, h5, h6) {
|
||||
margin: 1.35rem 0 0.68rem 0;
|
||||
}
|
||||
|
||||
.dark .ui-chat-assistant-card .agent-markdown li + li {
|
||||
margin-top: 0.42rem;
|
||||
}
|
||||
Reference in New Issue
Block a user