First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
+5
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AgentsPage() {
redirect("/office");
}
+121
View File
@@ -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 });
}
}
+216
View File
@@ -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 });
}
}
+194
View File
@@ -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 });
}
}
+56
View File
@@ -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 });
}
}
+120
View File
@@ -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 });
}
}
+17
View File
@@ -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 });
}
}
+38
View File
@@ -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 });
}
}
+88
View File
@@ -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 });
}
}
+61
View File
@@ -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 });
}
}
+55
View File
@@ -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 });
}
}
+47
View File
@@ -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 });
}
}
+121
View File
@@ -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 });
}
}
+53
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/office");
}
+196
View File
@@ -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;
}