feat: initial commit

This commit is contained in:
Jeffrey Wu
2026-09-07 09:57:33 +08:00
commit f39f6ac881
66 changed files with 9859 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import { useState } from "react";
import { Bot, RotateCcw, Settings } from "lucide-react";
import { ApprovalDialog } from "./components/ApprovalDialog";
import { ChatPanel } from "./components/ChatPanel";
import { SettingsDialog } from "./components/SettingsDialog";
import { SkillsPanel } from "./components/SkillsPanel";
import { TimelinePanel } from "./components/TimelinePanel";
import { HarnessToolsProvider } from "./components/ToolUIs";
import { useHarnessState, useHarnessStore } from "./assistant/HarnessContext";
type MobileTab = "skills" | "chat" | "events";
export function App() { const store = useHarnessStore(); const state = useHarnessState(); const [settings, setSettings] = useState(false); const [tab, setTab] = useState<MobileTab>("chat"); return <HarnessToolsProvider><div className="app-shell"><header className="topbar"><div className="brand"><span className="brand-mark"><Bot/></span><div><strong>Agent Studio Mini</strong><small>Pi Coding Agent Harness</small></div></div><div className="session-summary"><span className={`run-dot ${state.isRunning ? "running" : ""}`}/><span>{state.sessionId?.slice(0, 16) ?? "creating session…"}</span><span className="model-badge">{state.model.modelId ?? "Model missing"}</span>{state.model.supportsImages && <span className="vision-badge">Vision</span>}<button className="icon-button" title="Reset Session" aria-label="重置 Session" disabled={state.isRunning} onClick={() => void store.reset()}><RotateCcw/></button><button className="settings-button" onClick={() => setSettings(true)}><Settings/>Settings</button></div></header><nav className="mobile-tabs" aria-label="移动端面板切换">{(["skills", "chat", "events"] as MobileTab[]).map((item) => <button key={item} className={tab === item ? "active" : ""} onClick={() => setTab(item)}>{item}</button>)}</nav><div className="workspace-grid"><div className={`mobile-panel ${tab === "skills" ? "shown" : ""}`}><SkillsPanel/></div><div className={`mobile-panel center ${tab === "chat" ? "shown" : ""}`}><ChatPanel openSettings={() => setSettings(true)}/></div><div className={`mobile-panel ${tab === "events" ? "shown" : ""}`}><TimelinePanel/></div></div>{settings && <SettingsDialog open onOpenChange={setSettings}/>}<ApprovalDialog/></div></HarnessToolsProvider>; }
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext, useSyncExternalStore } from "react";
import { HarnessClientStore, type ClientState } from "./store";
export const StoreContext = createContext<HarnessClientStore | null>(null);
export function useHarnessStore(): HarnessClientStore {
const store = useContext(StoreContext);
if (!store) throw new Error("Harness store is missing");
return store;
}
export function useHarnessState(): ClientState {
const store = useHarnessStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
+20
View File
@@ -0,0 +1,20 @@
import { useMemo, useSyncExternalStore, type PropsWithChildren } from "react";
import { AssistantRuntimeProvider, useExternalStoreRuntime } from "@assistant-ui/react";
import { StoreContext } from "./HarnessContext";
import { HarnessClientStore } from "./store";
export function HarnessRuntime({ store, children }: PropsWithChildren<{ store: HarnessClientStore }>) {
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
const runtime = useExternalStoreRuntime({
messages: state.messages,
isRunning: state.isRunning,
isSendDisabled: !state.model.configured || !state.model.keyConfigured || state.connection !== "online",
setMessages: store.setMessages,
onNew: store.onNew,
onCancel: store.onCancel,
adapters: { attachments: store.attachmentAdapter },
convertMessage: (message) => message,
});
const value = useMemo(() => store, [store]);
return <StoreContext.Provider value={value}><AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider></StoreContext.Provider>;
}
+199
View File
@@ -0,0 +1,199 @@
import type { AppendMessage, Attachment, AttachmentAdapter, CompleteAttachment, PendingAttachment, ThreadMessageLike } from "@assistant-ui/react";
import { modelConnectionInputSchema, persistedModelConnectionSchema, type HarnessEvent, type ImageAttachment, type PersistedModelConnection, type RedactedModelConnection, type SkillCatalogItem } from "@agent-studio/shared";
export type ConnectionState = "connecting" | "online" | "reconnecting" | "offline";
export interface ClientState {
sessionId?: string;
messages: ThreadMessageLike[];
events: HarnessEvent[];
skills: SkillCatalogItem[];
model: RedactedModelConnection;
isRunning: boolean;
connection: ConnectionState;
error?: string | undefined;
}
interface LocalAttachment extends ImageAttachment { objectUrl: string }
type Listener = () => void;
const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"];
const modelStorageKey = "agent-studio-mini:model-connection:v1";
export class HarnessClientStore {
private state: ClientState = { messages: [], events: [], skills: [], model: { configured: false, source: "missing", keyConfigured: false }, isRunning: false, connection: "connecting" };
private readonly listeners = new Set<Listener>();
private readonly seen = new Set<string>();
private readonly attachments = new Map<string, LocalAttachment>();
private source?: EventSource;
private lastSequence = 0;
private initializing: Promise<void> | undefined;
getSnapshot = (): ClientState => this.state;
subscribe = (listener: Listener): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
private update(patch: Partial<ClientState>): void { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
initialize(): Promise<void> {
if (this.state.sessionId) return Promise.resolve();
if (!this.initializing) {
this.initializing = this.initializeOnce().finally(() => { this.initializing = undefined; });
}
return this.initializing;
}
private async initializeOnce(): Promise<void> {
try {
// Session readiness must not depend on the optional Skills catalog request.
const created = await api<{ sessionId: string }>("/api/sessions", { method: "POST" });
this.update({ sessionId: created.sessionId, connection: "connecting", error: undefined });
this.connect();
await this.refreshModel();
await this.restorePersistedModel();
void this.refreshSkills();
} catch (error) {
this.update({ connection: "offline", error: `Session 初始化失败:${messageOf(error)}` });
}
}
private async refreshSkills(): Promise<void> {
try {
const result = await api<{ skills: SkillCatalogItem[] }>("/api/skills");
this.update({ skills: result.skills });
} catch (error) {
this.update({ error: `Skills Catalog 加载失败:${messageOf(error)}` });
}
}
private async requireSession(): Promise<string> {
if (!this.state.sessionId) await this.initialize();
if (!this.state.sessionId) {
throw new Error(this.state.error ?? "Session 初始化失败,请确认 Fastify 服务已启动后重试。");
}
return this.state.sessionId;
}
private connect(): void {
if (!this.state.sessionId) return;
this.source?.close();
const cursor = this.lastSequence ? `?afterSequence=${this.lastSequence}` : "";
const source = new EventSource(`/api/sessions/${this.state.sessionId}/events${cursor}`);
this.source = source;
source.onopen = () => this.update({ connection: "online", error: undefined });
source.onerror = () => this.update({ connection: source.readyState === EventSource.CLOSED ? "offline" : "reconnecting" });
for (const type of eventTypes) source.addEventListener(type, (raw) => this.reduce(JSON.parse((raw as MessageEvent<string>).data) as HarnessEvent));
source.addEventListener("stream.gap", () => { this.update({ error: "事件历史窗口已发生缺口;当前视图已从最早可用事件恢复。" }); });
}
reduce(event: HarnessEvent): void {
if (this.seen.has(event.eventId) || event.sequence <= this.lastSequence) return;
this.seen.add(event.eventId);
this.lastSequence = event.sequence;
const events = [...this.state.events, event].slice(-1_000);
let messages = this.state.messages;
let isRunning = this.state.isRunning;
if (event.type === "user.message") {
const attachmentItems = event.payload.attachmentIds.map((id) => this.attachments.get(id)).filter((item): item is LocalAttachment => Boolean(item));
messages = [...messages, { id: event.payload.messageId, role: "user", createdAt: new Date(event.timestamp), content: [{ type: "text", text: event.payload.text }, ...attachmentItems.map((item) => ({ type: "image" as const, image: item.objectUrl, filename: item.name }))], attachments: attachmentItems.map((item) => ({ id: item.attachmentId, type: "image" as const, name: item.name, contentType: item.mediaType, status: { type: "complete" as const }, content: [{ type: "image" as const, image: item.objectUrl, filename: item.name }] })) }];
} else if (event.type === "assistant.delta") {
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => appendText(parts, event.payload.delta), { type: "running" }, event.runId);
} else if (event.type === "assistant.completed") {
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" });
} else if (event.type === "tool.requested") {
const id = assistantIdForRun(messages, event.runId);
messages = updateAssistant(messages, id, event.timestamp, (parts) => upsertToolCall(parts, event.payload.toolCallId, event.payload.toolName, asArgs(event.payload.arguments)), { type: "running" }, event.runId);
} else if (event.type === "tool.completed" || event.type === "tool.failed") {
messages = updateTool(messages, event.payload.toolCallId, { result: event.type === "tool.completed" ? event.payload.result : { error: event.payload.error }, isError: event.type === "tool.failed" });
} else if (event.type === "approval.required") {
messages = updateTool(messages, event.payload.toolCallId, { approval: { id: event.payload.approvalId } });
} else if (event.type === "approval.resolved") {
const approval = event.payload.status === "approved" ? { id: event.payload.approvalId, approved: true } : event.payload.status === "denied" ? { id: event.payload.approvalId, approved: false, reason: "denied" } : { id: event.payload.approvalId, resolution: event.payload.status };
messages = updateTool(messages, event.payload.toolCallId, { approval });
} else if (event.type === "run.started") isRunning = true;
else if (event.type === "run.completed") isRunning = false;
else if (event.type === "run.cancelled") { isRunning = false; messages = finishLast(messages, { type: "incomplete", reason: "cancelled" }); }
else if (event.type === "run.failed") { isRunning = false; messages = finishLast(messages, { type: "incomplete", reason: "error", error: event.payload.message }); }
this.update({ events, messages, isRunning });
}
onNew = async (message: AppendMessage): Promise<void> => {
const sessionId = await this.requireSession();
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
const attachmentIds = (message.attachments ?? []).map((item) => item.id).filter((id) => this.attachments.has(id));
if (attachmentIds.length > 0 && this.state.model.supportsImages !== true) throw new Error("当前模型不支持图片输入,请移除图片或切换 Vision 模型。");
await api(`/api/sessions/${sessionId}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, attachmentIds }) });
};
onCancel = async (): Promise<void> => { if (this.state.sessionId) await api(`/api/sessions/${this.state.sessionId}/cancel`, { method: "POST" }); };
setMessages = (messages: readonly ThreadMessageLike[]): void => this.update({ messages: [...messages] });
async reset(): Promise<void> {
const old = this.state.sessionId;
this.source?.close();
if (old) await api(`/api/sessions/${old}`, { method: "DELETE" });
for (const item of this.attachments.values()) URL.revokeObjectURL(item.objectUrl);
this.attachments.clear(); this.seen.clear(); this.lastSequence = 0;
const created = await api<{ sessionId: string }>("/api/sessions", { method: "POST" });
this.state = { ...this.state, sessionId: created.sessionId, messages: [], events: [], model: { configured: false, source: "missing", keyConfigured: false }, isRunning: false, connection: "connecting", error: undefined };
for (const listener of this.listeners) listener();
await this.refreshModel(); await this.restorePersistedModel(); this.connect();
}
async refreshModel(): Promise<void> { if (!this.state.sessionId) return; const model = await api<RedactedModelConnection>(`/api/sessions/${this.state.sessionId}/model-config`); this.update({ model }); }
async saveModel(input: unknown): Promise<void> { const sessionId = await this.requireSession(); const parsed = modelConnectionInputSchema.parse(input); const model = await api<RedactedModelConnection>(`/api/sessions/${sessionId}/model-config`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(parsed) }); persistModel(withoutCredential(parsed)); this.update({ model }); }
async clearCredentials(): Promise<void> { const sessionId = await this.requireSession(); await api(`/api/sessions/${sessionId}/model-config/credentials`, { method: "DELETE" }); await this.refreshModel(); }
forgetPersistedModel(): void { removePersistedModel(); }
async testModel(): Promise<string> { const sessionId = await this.requireSession(); const result = await api<{ message: string }>(`/api/sessions/${sessionId}/model-config/test`, { method: "POST" }); return result.message; }
async resolveApproval(id: string, decision: "approve" | "deny"): Promise<void> { await api(`/api/approvals/${id}/${decision}`, { method: "POST" }); }
readonly attachmentAdapter: AttachmentAdapter = {
accept: "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp",
add: async ({ file }): Promise<PendingAttachment> => {
const sessionId = await this.requireSession();
const form = new FormData(); form.append("file", file, file.name);
const item = await api<ImageAttachment>(`/api/sessions/${sessionId}/attachments`, { method: "POST", body: form });
const objectUrl = URL.createObjectURL(file);
this.attachments.set(item.attachmentId, { ...item, objectUrl });
return { id: item.attachmentId, type: "image", name: item.name, contentType: item.mediaType, file, status: { type: "requires-action", reason: "composer-send" }, content: [{ type: "image", image: objectUrl, filename: item.name }] };
},
remove: async (attachment: Attachment): Promise<void> => {
const item = this.attachments.get(attachment.id); if (!item || !this.state.sessionId) return;
await api(`/api/sessions/${this.state.sessionId}/attachments/${attachment.id}`, { method: "DELETE" }); URL.revokeObjectURL(item.objectUrl); this.attachments.delete(attachment.id);
},
send: async (attachment: PendingAttachment): Promise<CompleteAttachment> => {
const item = this.attachments.get(attachment.id); if (!item) throw new Error("Uploaded attachment is missing");
return { id: item.attachmentId, type: "image", name: item.name, contentType: item.mediaType, status: { type: "complete" }, content: [{ type: "image", image: item.objectUrl, filename: item.name }] };
},
};
private async restorePersistedModel(): Promise<void> {
if (!this.state.sessionId || this.state.model.configured) return;
const persisted = readPersistedModel();
if (!persisted) return;
try {
const model = await api<RedactedModelConnection>(`/api/sessions/${this.state.sessionId}/model-config`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(persisted) });
this.update({ model });
} catch {
removePersistedModel();
this.update({ error: "本地保存的模型配置已失效,请在 Settings 中重新配置。" });
}
}
}
type ContentParts = Exclude<ThreadMessageLike["content"], string>;
function updateAssistant(messages: ThreadMessageLike[], id: string, timestamp: string, change: (parts: ContentParts) => ContentParts, status: ThreadMessageLike["status"], runId?: string): ThreadMessageLike[] {
const index = messages.findIndex((item) => item.id === id);
if (index < 0) return [...messages, { id, role: "assistant", createdAt: new Date(timestamp), content: change([]), status, metadata: { custom: runId ? { runId } : {} } }];
return messages.map((item, current) => current === index ? { ...item, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item);
}
function appendText(parts: ContentParts, delta: string): ContentParts { const last = parts.at(-1); return last?.type === "text" ? [...parts.slice(0, -1), { ...last, text: last.text + delta }] : [...parts, { type: "text", text: delta }]; }
function upsertToolCall(parts: ContentParts, toolCallId: string, toolName: string, args: Record<string, string | number | boolean | null>): ContentParts {
const index = parts.findIndex((part) => part.type === "tool-call" && part.toolCallId === toolCallId);
if (index < 0) return [...parts, { type: "tool-call", toolCallId, toolName, args }];
return parts.map((part, current) => current === index && part.type === "tool-call" ? { ...part, toolName, args } : part);
}
function updateTool(messages: ThreadMessageLike[], toolCallId: string, patch: Record<string, unknown>): ThreadMessageLike[] { return messages.map((message) => ({ ...message, content: typeof message.content === "string" ? message.content : message.content.map((part) => part.type === "tool-call" && part.toolCallId === toolCallId ? { ...part, ...patch } : part) })); }
function assistantIdForRun(messages: ThreadMessageLike[], runId?: string): string { const existing = [...messages].reverse().find((item) => item.role === "assistant" && item.metadata?.custom?.runId === runId); return existing?.id ?? `assistant_${runId ?? crypto.randomUUID()}`; }
function finishLast(messages: ThreadMessageLike[], status: ThreadMessageLike["status"]): ThreadMessageLike[] { const index = messages.findLastIndex((item) => item.role === "assistant"); return index < 0 ? messages : messages.map((item, current) => current === index ? { ...item, status } : item); }
function asArgs(value: unknown): Record<string, string | number | boolean | null> { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string | number | boolean | null] => entry[1] === null || ["string", "number", "boolean"].includes(typeof entry[1]))); }
async function api<T = Record<string, unknown>>(url: string, init?: RequestInit): Promise<T> { const response = await fetch(url, { ...init, headers: { accept: "application/json", ...init?.headers } }); const data = await response.json().catch(() => ({})) as { error?: { message?: string; code?: string } }; if (!response.ok) throw new Error(data.error?.message ?? `Request failed (${response.status})`); return data as T; }
function messageOf(error: unknown): string { return error instanceof Error ? error.message : "Unexpected error"; }
function withoutCredential(input: unknown): PersistedModelConnection { const value = modelConnectionInputSchema.parse(input); return persistedModelConnectionSchema.parse(Object.fromEntries(Object.entries(value).filter(([key]) => key !== "apiKey"))); }
function readPersistedModel(): PersistedModelConnection | undefined { if (typeof window === "undefined") return undefined; try { const raw = window.localStorage.getItem(modelStorageKey); if (!raw) return undefined; if (raw.length > 16_384) { removePersistedModel(); return undefined; } const parsed = persistedModelConnectionSchema.safeParse(JSON.parse(raw)); if (!parsed.success) { removePersistedModel(); return undefined; } return parsed.data; } catch { removePersistedModel(); return undefined; } }
function persistModel(input: PersistedModelConnection): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(modelStorageKey, JSON.stringify(input)); } catch { /* Storage may be disabled or full; the active Session remains configured. */ } }
function removePersistedModel(): void { if (typeof window === "undefined") return; try { window.localStorage.removeItem(modelStorageKey); } catch { /* Storage may be unavailable. */ } }
@@ -0,0 +1,24 @@
import { Component, type ReactNode } from "react";
interface Props { children: ReactNode }
interface State { failed: boolean }
export class AppErrorBoundary extends Component<Props, State> {
override state: State = { failed: false };
static getDerivedStateFromError(): State {
return { failed: true };
}
override render(): ReactNode {
if (!this.state.failed) return this.props.children;
return <main className="fatal-fallback" role="alert">
<div>
<span className="eyebrow risk-text">UI RECOVERY</span>
<h1></h1>
<p>Agent Run Web Session</p>
<button className="accent" onClick={() => window.location.reload()}></button>
</div>
</main>;
}
}
@@ -0,0 +1,11 @@
import { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { useHarnessState, useHarnessStore } from "../assistant/HarnessContext";
export function ApprovalDialog() {
const state = useHarnessState(); const store = useHarnessStore(); const [submitting, setSubmitting] = useState(false);
const pending = [...state.events].reverse().find((event) => event.type === "approval.required" && !state.events.some((candidate) => candidate.type === "approval.resolved" && candidate.payload.approvalId === event.payload.approvalId));
if (!pending || pending.type !== "approval.required") return null;
const act = async (decision: "approve" | "deny") => { setSubmitting(true); try { await store.resolveApproval(pending.payload.approvalId, decision); } finally { setSubmitting(false); } };
return <Dialog.Root open><Dialog.Portal><Dialog.Overlay className="dialog-overlay approval-overlay"/><Dialog.Content className="approval-dialog" onEscapeKeyDown={(event) => event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} aria-describedby="approval-risk"><span className="eyebrow risk-text">HUMAN APPROVAL REQUIRED</span><Dialog.Title>稿</Dialog.Title><Dialog.Description id="approval-risk">Tool <code>{pending.payload.toolName}</code> Session <code>report.md</code> </Dialog.Description><pre>{JSON.stringify(pending.payload.arguments, null, 2).slice(0, 1500)}</pre><div className="dialog-actions"><button className="danger" disabled={submitting} onClick={() => void act("deny")}></button><button className="accent" disabled={submitting} onClick={() => void act("approve")}></button></div></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
+48
View File
@@ -0,0 +1,48 @@
import { useState } from "react";
import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import { ImagePlus, Send, Square, X } from "lucide-react";
import * as Dialog from "@radix-ui/react-dialog";
import { ToolFallback } from "./ToolUIs";
import { useHarnessState } from "../assistant/HarnessContext";
const demoPrompt = "请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。";
function TextPart({ text }: { text: string }) { return <div className="message-text">{text}</div>; }
function ImagePart({ image, filename }: { image: string; filename?: string }) { return <ImagePreview src={image} name={filename ?? "图片"}/>; }
const partComponents = { Text: TextPart, Image: ImagePart, tools: { Fallback: ToolFallback } };
function UserMessage() { return <MessagePrimitive.Root className="message user"><div className="role">You</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Attachments>{() => null}</MessagePrimitive.Attachments></MessagePrimitive.Root>; }
function AssistantMessage() { return <MessagePrimitive.Root className="message assistant"><div className="role">Agent</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Error/></MessagePrimitive.Root>; }
function ImagePreview({ src, name }: { src: string; name: string }) {
return <Dialog.Root><Dialog.Trigger asChild><button className="image-preview" aria-label={`预览 ${name}`}><img src={src} alt={name}/></button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className="image-dialog" aria-describedby={undefined}><Dialog.Title>{name}</Dialog.Title><img src={src} alt={name}/><Dialog.Close className="icon-button" aria-label="关闭预览"><X/></Dialog.Close></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
function ComposerAttachment() {
const preview = useAuiState((state) => {
const part = state.attachment.content?.find((item) => item.type === "image");
return part?.type === "image" ? part.image : undefined;
});
return <AttachmentPrimitive.Root className="composer-attachment">{preview ? <img src={preview} alt=""/> : <span aria-hidden="true">IMG</span>}<AttachmentPrimitive.Name/><AttachmentPrimitive.Remove className="remove-attachment" aria-label="移除图片"><X size={14}/></AttachmentPrimitive.Remove></AttachmentPrimitive.Root>;
}
export function ChatPanel({ openSettings }: { openSettings: () => void }) {
const state = useHarnessState();
const aui = useAui();
const [dragging, setDragging] = useState(false);
const modelReady = state.model.configured && state.model.keyConfigured;
const fillDemo = () => aui.composer().setText(demoPrompt);
return <main className="chat-panel" aria-label="Streaming Chat">
<div className="chat-toolbar"><div><strong>{state.isRunning ? "Agent 正在运行" : "准备就绪"}</strong><span className={`connection ${state.connection}`}>{state.connection}</span></div><button className="text-button" onClick={fillDemo}> Demo Prompt</button></div>
{!modelReady && <button className="config-banner" onClick={openSettings}>{state.model.configured ? "模型配置已恢复,请在 Settings 重新输入 API Key。" : "尚未配置模型。打开 Settings 后才能发送消息。"}</button>}
{state.error && <div className="error-banner" role="alert">{state.error}</div>}
<ThreadPrimitive.Root className="thread-root"><ThreadPrimitive.Viewport className="thread-viewport"><ThreadPrimitive.Empty><div className="empty-state"><div className="orb">π</div><h2>Agent Loop </h2><p> Demo Prompt Skill Tool </p></div></ThreadPrimitive.Empty><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }}/><ThreadPrimitive.ViewportFooter className="composer-footer">
<ComposerPrimitive.Root className={`composer ${dragging ? "dragging" : ""}`}>
<ComposerPrimitive.AttachmentDropzone className="dropzone" onDragEnter={() => setDragging(true)} onDragLeave={() => setDragging(false)} onDrop={() => setDragging(false)}>
<ComposerPrimitive.Attachments components={{ Attachment: ComposerAttachment }}/>
<ComposerPrimitive.Input className="composer-input" placeholder={modelReady ? "给 Agent Studio Mini 发送任务…" : state.model.configured ? "请重新输入 API Key" : "请先配置模型连接"} aria-label="消息输入"/>
<div className="composer-actions"><ComposerPrimitive.AddAttachment className="icon-button" aria-label="添加图片" title="选择、拖拽或粘贴 JPEG/PNG/WebP"><ImagePlus/></ComposerPrimitive.AddAttachment><span className="composer-hint"> 4 · 5 MiB/</span>{state.isRunning ? <ComposerPrimitive.Cancel className="send-button stop" aria-label="停止运行"><Square size={16}/></ComposerPrimitive.Cancel> : <ComposerPrimitive.Send className="send-button" aria-label="发送消息"><Send size={17}/></ComposerPrimitive.Send>}</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
</ThreadPrimitive.ViewportFooter></ThreadPrimitive.Viewport></ThreadPrimitive.Root>
<div className="sr-live" aria-live="polite">{state.isRunning ? "" : state.messages.at(-1)?.role === "assistant" ? "Assistant 回复已完成" : ""}</div>
</main>;
}
@@ -0,0 +1,21 @@
import { useState, type FormEvent } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { Eye, EyeOff, X } from "lucide-react";
import { useHarnessState, useHarnessStore } from "../assistant/HarnessContext";
const presets = {
openai: { providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", modelId: "gpt-5.4", supportsImages: true },
anthropic: { providerId: "anthropic", api: "anthropic-messages", baseUrl: "https://api.anthropic.com", modelId: "claude-sonnet-4-6", supportsImages: true },
compatible: { providerId: "openai-compatible", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "your-model-id", supportsImages: false },
} as const;
type ApiType = "openai-responses" | "openai-completions" | "anthropic-messages";
interface FormState { providerId: string; api: ApiType; baseUrl: string; apiKey: string; modelId: string; displayName: string; supportsImages: boolean }
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const store = useHarnessStore(); const state = useHarnessState(); const [showKey, setShowKey] = useState(false); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState("");
const [form, setForm] = useState<FormState>(() => ({ providerId: state.model.providerId ?? "openai", api: state.model.api ?? "openai-responses", baseUrl: state.model.baseUrl ?? "https://api.openai.com/v1", apiKey: "", modelId: state.model.modelId ?? "gpt-5.4", displayName: state.model.displayName ?? "", supportsImages: state.model.supportsImages ?? true }));
const applyPreset = (name: keyof typeof presets) => setForm((value) => ({ ...value, ...presets[name], apiKey: "", displayName: "" }));
const submit = async (event: FormEvent) => { event.preventDefault(); setBusy(true); setNotice(""); try { await store.saveModel({ providerId: form.providerId, api: form.api, modelId: form.modelId, supportsImages: form.supportsImages, ...(form.apiKey ? { apiKey: form.apiKey } : {}), ...(form.baseUrl ? { baseUrl: form.baseUrl } : {}), ...(form.displayName ? { displayName: form.displayName } : {}) }); setForm((value) => ({ ...value, apiKey: "" })); setNotice(store.getSnapshot().model.credentialStorage === "encrypted" ? "模型配置已保存;API Key 已由服务器加密持久化。" : "模型配置已保存;当前服务器未启用加密凭证持久化,API Key 仅保存在 Session 内存。"); } catch (error) { setNotice(error instanceof Error ? error.message : "保存失败"); } finally { setBusy(false); } };
const storageLabel = state.model.credentialStorage === "encrypted" ? "服务端加密" : state.model.credentialStorage === "environment" ? "环境变量" : state.model.credentialStorage === "memory" ? "Session 内存" : "无";
return <Dialog.Root open={open} onOpenChange={(next) => { if (!busy) onOpenChange(next); }}><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className="settings-dialog" aria-describedby="settings-description"><div className="dialog-header"><div><span className="eyebrow">SESSION RUNTIME</span><Dialog.Title></Dialog.Title><Dialog.Description id="settings-description"> .env API Key Web Storage使 AES-256-GCM </Dialog.Description></div><Dialog.Close className="icon-button" aria-label="关闭设置"><X/></Dialog.Close></div><div className="presets"><button onClick={() => applyPreset("openai")}>OpenAI</button><button onClick={() => applyPreset("anthropic")}>Anthropic</button><button onClick={() => applyPreset("compatible")}>OpenAI-compatible</button></div><form onSubmit={(event) => void submit(event)}><div className="form-grid"><label>Provider ID<input value={form.providerId} onChange={(e) => setForm({ ...form, providerId: e.target.value })} required/></label><label>API<select value={form.api} onChange={(e) => setForm({ ...form, api: e.target.value as ApiType })}><option value="openai-responses">openai-responses</option><option value="openai-completions">openai-completions</option><option value="anthropic-messages">anthropic-messages</option></select></label><label className="span-2">Base URL<input value={form.baseUrl} onChange={(e) => setForm({ ...form, baseUrl: e.target.value })} placeholder="https://api.example.com/v1"/></label><label>Model ID<input value={form.modelId} onChange={(e) => setForm({ ...form, modelId: e.target.value })} required/></label><label>Display Name<input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })}/></label><label className="span-2">API Key<div className="password-field"><input type={showKey ? "text" : "password"} autoComplete="off" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} placeholder={state.model.keyConfigured ? state.model.keyHint ?? "已配置" : "输入 API Key"}/><button type="button" className="icon-button" onClick={() => setShowKey(!showKey)} aria-label={showKey ? "隐藏 API Key" : "显示 API Key"}>{showKey ? <EyeOff/> : <Eye/>}</button></div></label><label className="check span-2"><input type="checkbox" checked={form.supportsImages} onChange={(e) => setForm({ ...form, supportsImages: e.target.checked })}/> Pi Model </label></div>{notice && <p className="notice" role="status">{notice}</p>}<div className="config-status"><span>{state.model.source}</span><span>{state.model.keyConfigured ? state.model.keyHint ?? "已配置" : "未配置"}</span><span>{storageLabel}</span></div><div className="dialog-actions"><button type="button" className="danger" disabled={busy || state.isRunning} onClick={() => void store.clearCredentials()}></button><button type="button" disabled={busy} onClick={() => { store.forgetPersistedModel(); setNotice("已清除浏览器本地保存的模型配置;当前 Session 不受影响。"); }}></button><button type="button" disabled={busy} onClick={() => { setBusy(true); void store.testModel().then(setNotice).catch((error: unknown) => setNotice(error instanceof Error ? error.message : "测试失败")).finally(() => setBusy(false)); }}></button><button type="submit" className="accent" disabled={busy || state.isRunning}></button></div></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
+7
View File
@@ -0,0 +1,7 @@
import { useHarnessState } from "../assistant/HarnessContext";
export function SkillsPanel() {
const { skills, events, connection } = useHarnessState();
const active = new Set(events.filter((event) => event.type === "skill.requested" || event.type === "skill.loaded").map((event) => event.payload.name));
return <aside className="side-panel skills-panel" aria-label="Skills Catalog"><div className="panel-heading"><span className="eyebrow">RESOURCES</span><h2>Skills Catalog</h2><p> Pi read </p></div>{connection === "connecting" && <div className="panel-state"> Skills</div>}{skills.length === 0 && connection !== "connecting" && <div className="panel-state"> Skill</div>}<div className="skill-list">{skills.map((skill) => { const level = skill.diagnostics.some((item) => item.severity === "error") ? "error" : skill.diagnostics.length ? "warning" : "valid"; return <article key={`${skill.name}:${skill.filePath}`} className={`skill-item ${active.has(skill.name) ? "active" : ""}`}><div className="skill-title"><strong>{skill.name}</strong><span className={`diagnostic ${level}`}>{level}</span></div><p>{skill.description}</p><small>{skill.source} · {skill.filePath}</small>{skill.diagnostics.map((item, index) => <div className="diagnostic-message" key={index}>{item.message}</div>)}</article>; })}</div></aside>;
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { useHarnessState } from "../assistant/HarnessContext";
import type { HarnessEvent } from "@agent-studio/shared";
type Filter = "all" | "run" | "skill" | "tool" | "approval" | "model";
export function TimelinePanel() {
const { events } = useHarnessState(); const [filter, setFilter] = useState<Filter>("all"); const viewport = useRef<HTMLDivElement>(null); const atBottom = useRef(true);
const visible = events.filter((event) => filter === "all" || event.type.startsWith(`${filter}.`) || (filter === "tool" && event.type.startsWith("attachment.")));
useEffect(() => { if (atBottom.current) viewport.current?.scrollTo({ top: viewport.current.scrollHeight }); }, [visible.length]);
return <aside className="side-panel timeline-panel" aria-label="Event Timeline"><div className="panel-heading"><span className="eyebrow">HARNESS FACTS</span><h2>Event Timeline</h2><p>Pi Skill Session sequence </p></div><div className="filters" role="group" aria-label="事件筛选">{(["all", "run", "skill", "tool", "approval", "model"] as Filter[]).map((item) => <button key={item} className={filter === item ? "selected" : ""} onClick={() => setFilter(item)}>{item}</button>)}</div><div className="timeline" ref={viewport} onScroll={(event) => { const node = event.currentTarget; atBottom.current = node.scrollHeight - node.scrollTop - node.clientHeight < 48; }}>{visible.length === 0 && <div className="panel-state"> Harness </div>}{visible.map((event) => <EventRow key={event.eventId} event={event}/>)}</div></aside>;
}
function EventRow({ event }: { event: HarnessEvent }) { const [open, setOpen] = useState(false); return <article className={`event-row ${event.type.startsWith("approval.") ? "risk" : ""}`}><button onClick={() => setOpen(!open)} aria-expanded={open}><span className="sequence">#{event.sequence}</span><span><strong>{event.type}</strong><small>{new Date(event.timestamp).toLocaleTimeString()}</small></span></button>{open && <pre>{JSON.stringify(event.payload, null, 2)}</pre>}</article>; }
+48
View File
@@ -0,0 +1,48 @@
import { useState } from "react";
import { AuiConfig, AuiProvider, defineToolkit, Tools, useAui, type Toolkit, type ToolCallMessagePartProps } from "@assistant-ui/react";
import type { PropsWithChildren } from "react";
import { z } from "zod";
import { useHarnessStore } from "../assistant/HarnessContext";
type JsonObject = Record<string, unknown>;
function stateLabel(props: ToolCallMessagePartProps<JsonObject, unknown>): string {
if (props.isError) return "执行失败";
if (props.approval?.resolution) return props.approval.resolution === "expired" ? "审批超时" : "已取消";
if (props.approval?.approved === false) return "已拒绝";
if (props.approval?.approved === true && props.result === undefined) return "已批准 · 执行中";
if (props.approval && props.approval.approved === undefined) return "等待审批";
if (props.result !== undefined) return "完成";
return "执行中";
}
function ApprovalActions({ approval }: Pick<ToolCallMessagePartProps<JsonObject, unknown>, "approval">) {
const store = useHarnessStore();
const [submitting, setSubmitting] = useState(false);
if (!approval || approval.approved !== undefined || approval.resolution) return null;
const act = async (decision: "approve" | "deny") => { setSubmitting(true); try { await store.resolveApproval(approval.id, decision); } finally { setSubmitting(false); } };
return <div className="approval-actions"><button disabled={submitting} className="danger" onClick={() => void act("deny")}></button><button disabled={submitting} className="accent" onClick={() => void act("approve")}></button></div>;
}
export function MockAdsMetricsUI(props: ToolCallMessagePartProps<JsonObject, unknown>) {
const result = asRecord(asRecord(props.result).details ?? props.result);
return <section className="tool-card" data-tool="mock_ads_metrics"><header><span>mock_ads_metrics</span><span className="status">{stateLabel(props)}</span></header><p className="muted"> {String(props.args?.accountId ?? "—")} · {String(props.args?.days ?? "—")} </p>{Object.keys(result).length > 0 && <div className="metrics"><Metric label="Spend" value={result.spend}/><Metric label="CTR" value={result.ctr} suffix="%"/><Metric label="CPA" value={result.cpa}/><Metric label="ROAS" value={result.roas}/></div>}</section>;
}
export function SaveReportDraftUI(props: ToolCallMessagePartProps<JsonObject, unknown>) {
const content = typeof props.args?.content === "string" ? props.args.content : "";
return <section className="tool-card risk" data-tool="save_report_draft"><header><span>save_report_draft</span><span className="status">{stateLabel(props)}</span></header><p> Session <code>report.md</code></p><p className="summary">{content.slice(0, 180)}{content.length > 180 ? "…" : ""}</p>{props.approval && <ApprovalActions approval={props.approval}/>}</section>;
}
export function ToolFallback(props: ToolCallMessagePartProps) { return <section className="tool-card"><header><span>{props.toolName}</span><span className="status">{stateLabel(props)}</span></header><pre>{JSON.stringify(props.args ?? {}, null, 2)}</pre></section>; }
function Metric({ label, value, suffix = "" }: { label: string; value: unknown; suffix?: string }) { return <div><span>{label}</span><strong>{typeof value === "number" ? value.toLocaleString() : "—"}{suffix}</strong></div>; }
function asRecord(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; }
const authoredToolkit = defineToolkit({
mock_ads_metrics: { description: "显示服务端广告指标工具结果", parameters: z.object({ accountId: z.string(), days: z.number() }), render: MockAdsMetricsUI },
save_report_draft: { description: "显示服务端报告保存审批与结果", parameters: z.object({ content: z.string(), title: z.string().optional() }), render: SaveReportDraftUI },
});
const harnessToolkit = authoredToolkit as Toolkit;
export function HarnessToolsProvider({ children }: PropsWithChildren) {
const parent = useAui();
const config = AuiConfig({ tools: Tools({ toolkit: harnessToolkit }) });
return <AuiProvider extends={parent} config={config}>{children}</AuiProvider>;
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { HarnessRuntime } from "./assistant/HarnessRuntime";
import { HarnessClientStore } from "./assistant/store";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import "./styles/app.css";
const store = new HarnessClientStore();
void store.initialize();
createRoot(document.getElementById("root")!).render(<StrictMode><AppErrorBoundary><HarnessRuntime store={store}><App/></HarnessRuntime></AppErrorBoundary></StrictMode>);
+106
View File
@@ -0,0 +1,106 @@
:root {
color-scheme: dark;
--black: #111111; --cyan: #25f4ee; --pink: #fe2c55;
--surface-0: #0c0d0f; --surface-1: #131519; --surface-2: #1a1d22; --surface-3: #23272e;
--border: #2c3139; --border-strong: #3d444f; --text: #f3f5f7; --muted: #929aa6;
--accent: var(--cyan); --danger: var(--pink); --focus: #a8fffc;
--radius: 10px; --shadow: 0 18px 50px rgb(0 0 0 / .34);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--surface-0); color: var(--text); font-synthesis: none;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; min-width: 320px; height: 100%; margin: 0; overflow: hidden; }
body { background: radial-gradient(circle at 50% -20%, #183136 0, var(--surface-0) 34%); }
button, input, select, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
button:disabled { opacity: .46; cursor: not-allowed; }
.app-shell { display: grid; grid-template-rows: 64px 1fr; height: 100%; }
.topbar { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 16px; border-bottom: 1px solid var(--border); background: rgb(12 13 15 / .9); backdrop-filter: blur(14px); z-index: 10; }
.brand, .session-summary, .brand > div, .chat-toolbar > div { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand > div { align-items: flex-start; flex-direction: column; gap: 1px; }
.brand strong { letter-spacing: -.02em; }
.brand small { color: var(--muted); font-size: 11px; }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid #267a7a; border-radius: 10px; color: var(--cyan); background: #132426; box-shadow: inset 0 0 16px rgb(37 244 238 / .08); }
.session-summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
.run-dot { width: 8px; height: 8px; border-radius: 50%; background: #59616d; }
.run-dot.running { background: var(--cyan); box-shadow: 0 0 12px var(--cyan); animation: pulse 1.4s ease-in-out infinite; }
.model-badge, .vision-badge { border: 1px solid var(--border-strong); border-radius: 999px; padding: 5px 9px; color: var(--text); background: var(--surface-2); }
.vision-badge { color: var(--cyan); border-color: #267a7a; }
.settings-button, .send-button, .text-button, .accent, .danger, .dialog-actions button, .presets button { min-height: 36px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-2); padding: 0 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.settings-button svg, .icon-button svg { width: 17px; height: 17px; }
.settings-button:hover, .text-button:hover, .dialog-actions button:hover, .presets button:hover { background: var(--surface-3); border-color: #59616d; }
.icon-button { width: 40px; height: 40px; display: inline-grid; place-items: center; border: 0; border-radius: 8px; background: transparent; cursor: pointer; }
.icon-button:hover { background: var(--surface-3); }
.workspace-grid { min-height: 0; display: grid; grid-template-columns: minmax(220px, 280px) minmax(420px, 1fr) minmax(260px, 340px); }
.mobile-panel { min-height: 0; min-width: 0; }
.side-panel { height: 100%; min-height: 0; display: flex; flex-direction: column; background: rgb(19 21 25 / .88); }
.skills-panel { border-right: 1px solid var(--border); }
.timeline-panel { border-left: 1px solid var(--border); }
.panel-heading { padding: 20px 16px 14px; border-bottom: 1px solid var(--border); }
.panel-heading h2 { margin: 4px 0 7px; font-size: 16px; }
.panel-heading p { margin: 0; color: var(--muted); line-height: 1.45; font-size: 12px; }
.eyebrow { font-size: 10px; font-weight: 800; letter-spacing: .13em; color: var(--cyan); }
.skill-list, .timeline { min-height: 0; overflow: auto; padding: 8px; }
.skill-item { padding: 13px 10px; border-left: 2px solid transparent; border-bottom: 1px solid var(--border); }
.skill-item.active { border-left-color: var(--cyan); background: linear-gradient(90deg, rgb(37 244 238 / .08), transparent); }
.skill-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.skill-title strong { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; }
.skill-item p { margin: 7px 0; color: #cbd0d6; line-height: 1.45; font-size: 12px; }
.skill-item small { color: var(--muted); word-break: break-word; }
.diagnostic { border-radius: 999px; padding: 2px 6px; font-size: 9px; text-transform: uppercase; border: 1px solid; }
.diagnostic.valid { color: var(--cyan); border-color: #267a7a; }.diagnostic.warning { color: #ffd166; border-color: #806c2d; }.diagnostic.error { color: var(--pink); border-color: #8d2940; }
.diagnostic-message { color: #ffb4c4; font-size: 11px; margin-top: 8px; }
.panel-state { padding: 20px; color: var(--muted); font-size: 13px; text-align: center; }
.chat-panel { height: 100%; min-height: 0; display: grid; grid-template-rows: auto auto auto 1fr; background: var(--surface-0); }
.chat-toolbar { min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid var(--border); font-size: 12px; }
.connection { text-transform: capitalize; color: var(--muted); }.connection.online { color: var(--cyan); }.connection.reconnecting { color: #ffd166; }.connection.offline { color: var(--pink); }
.text-button { min-height: 32px; color: var(--cyan); }
.config-banner, .error-banner { margin: 10px 16px 0; border-radius: 8px; padding: 10px 12px; text-align: left; }
.config-banner { border: 1px solid #756326; background: #28230f; color: #ffe395; cursor: pointer; }.error-banner { border: 1px solid #7e293b; background: #2b1118; color: #ffbdca; }
.thread-root, .thread-viewport { min-height: 0; height: 100%; }
.thread-viewport { overflow-y: auto; padding: 16px 0 0; scroll-padding-bottom: 170px; }
.empty-state { min-height: 52vh; display: grid; place-items: center; align-content: center; text-align: center; padding: 32px; color: var(--muted); }
.empty-state h2 { margin: 16px 0 6px; color: var(--text); font-size: 22px; }.empty-state p { max-width: 460px; line-height: 1.55; }
.orb { width: 70px; height: 70px; display: grid; place-items: center; border-radius: 50%; color: var(--cyan); font: 700 28px Georgia, serif; background: radial-gradient(circle at 35% 30%, #27535a, #121c20 62%); border: 1px solid #2e6f74; box-shadow: 0 0 50px rgb(37 244 238 / .12); }
.message { width: min(760px, calc(100% - 32px)); margin: 0 auto 22px; }
.message .role { margin-bottom: 7px; color: var(--muted); font: 700 10px ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
.message.user { display: grid; justify-items: end; }.message.user .role { text-align: right; }
.message-text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.62; }
.user .message-text { max-width: min(86%, 620px); padding: 11px 14px; border-radius: 14px 14px 3px 14px; background: #20252b; border: 1px solid #333a44; }
.assistant .message-text { color: #e7eaee; }
.image-preview { border: 1px solid var(--border-strong); padding: 0; border-radius: 9px; background: var(--surface-2); overflow: hidden; cursor: zoom-in; margin: 8px 5px 0 0; }
.image-preview img { display: block; width: 160px; height: 110px; object-fit: cover; }
.tool-card { margin: 12px 0; border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; }
.tool-card.risk { border-color: #713044; box-shadow: inset 3px 0 0 var(--pink); }
.tool-card header { padding: 10px 12px; display: flex; justify-content: space-between; background: var(--surface-2); font: 600 12px ui-monospace, monospace; }
.tool-card .status { color: var(--cyan); }.tool-card.risk .status { color: #ff91a8; }.tool-card p, .tool-card pre { margin: 0; padding: 10px 12px; font-size: 12px; line-height: 1.5; }.tool-card pre { overflow: auto; color: #cbd0d6; }.tool-card .summary { max-height: 95px; overflow: hidden; color: var(--muted); }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--border); }.metrics div { padding: 10px; border-right: 1px solid var(--border); }.metrics span, .metrics strong { display: block; }.metrics span { color: var(--muted); font-size: 10px; }.metrics strong { margin-top: 4px; font-size: 14px; }
.approval-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); }
.accent { color: #071011; background: var(--cyan); border-color: var(--cyan); font-weight: 750; }.accent:hover { background: #66fffa !important; }.danger { color: #ffb3c3; border-color: #7e2d40; background: #291319; }.danger:hover { border-color: var(--pink) !important; background: #36151e !important; }
.composer-footer { position: sticky; bottom: 0; padding: 16px; background: linear-gradient(transparent, var(--surface-0) 18%); }
.composer { width: min(780px, 100%); margin: 0 auto; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.composer.dragging { border-color: var(--cyan); box-shadow: 0 0 0 3px rgb(37 244 238 / .12); }
.dropzone { padding: 8px; }
.composer-input { display: block; width: 100%; min-height: 62px; max-height: 160px; resize: none; border: 0; padding: 10px; background: transparent; color: var(--text); outline: none !important; }
.composer-actions { display: flex; align-items: center; gap: 5px; }.composer-hint { flex: 1; color: var(--muted); font-size: 10px; }.send-button { background: var(--cyan); color: #061112; border-color: var(--cyan); font-weight: 750; }.send-button.stop { background: var(--surface-2); color: #ff9caf; border-color: #7e2d40; }
.composer-attachment { width: 92px; min-height: 76px; position: relative; display: inline-grid; place-items: center; margin: 4px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface-2); font-size: 10px; }
.composer-attachment img { width: 100%; height: 54px; object-fit: cover; }.remove-attachment { position: absolute; top: 3px; right: 3px; width: 25px; height: 25px; border: 0; border-radius: 50%; display: grid; place-items: center; background: rgb(0 0 0 / .72); cursor: pointer; }
.filters { padding: 8px; display: flex; gap: 5px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }.filters button { min-height: 29px; padding: 0 8px; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; }.filters button.selected { color: var(--cyan); border-color: #267a7a; background: #122326; }
.event-row { position: relative; border-left: 1px solid var(--border-strong); padding: 0 0 8px 8px; }.event-row.risk { border-left-color: var(--pink); }.event-row > button { width: 100%; min-height: 44px; display: grid; grid-template-columns: 35px 1fr; gap: 6px; align-items: center; text-align: left; border: 0; background: transparent; border-radius: 7px; cursor: pointer; }.event-row > button:hover { background: var(--surface-2); }.event-row .sequence { color: var(--muted); font: 10px ui-monospace, monospace; }.event-row strong, .event-row small { display: block; }.event-row strong { font: 600 11px ui-monospace, monospace; }.event-row small { margin-top: 3px; color: var(--muted); font-size: 9px; }.event-row pre { margin: 3px 0 8px 36px; max-height: 200px; overflow: auto; padding: 8px; border-radius: 6px; background: #0b0c0e; color: #bac1ca; font-size: 9px; white-space: pre-wrap; }
.dialog-overlay { position: fixed; inset: 0; z-index: 30; background: rgb(2 3 4 / .72); backdrop-filter: blur(5px); animation: fade .15s ease; }
.settings-dialog, .approval-dialog, .image-dialog { position: fixed; z-index: 31; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(620px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--border-strong); border-radius: 14px; background: #15171b; box-shadow: 0 30px 100px rgb(0 0 0 / .65); padding: 20px; }
.dialog-header { display: flex; justify-content: space-between; gap: 16px; }.dialog-header h2, .approval-dialog h2 { margin: 5px 0 7px; }.dialog-header p, .approval-dialog > p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.presets { display: flex; gap: 8px; margin: 18px 0 14px; }.presets button { flex: 1; font-size: 12px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }.form-grid label { display: grid; gap: 6px; color: #c2c7ce; font-size: 11px; }.form-grid input, .form-grid select { width: 100%; min-height: 42px; border: 1px solid var(--border-strong); border-radius: 7px; padding: 0 10px; color: var(--text); background: #101216; }.span-2 { grid-column: span 2 !important; }.password-field { position: relative; }.password-field input { padding-right: 48px; }.password-field .icon-button { position: absolute; right: 1px; top: 1px; }.check { grid-template-columns: auto 1fr !important; align-items: center; min-height: 40px; }.check input { width: 18px; min-height: 18px; accent-color: var(--cyan); }
.notice { padding: 10px; border-radius: 7px; background: var(--surface-2); font-size: 12px; }.config-status { display: flex; gap: 16px; color: var(--muted); font-size: 11px; margin: 12px 0; }.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; padding-top: 14px; border-top: 1px solid var(--border); }.dialog-actions .danger { margin-right: auto; }
.approval-dialog { width: min(500px, calc(100vw - 32px)); border-color: #8b3045; }.approval-dialog pre { max-height: 200px; overflow: auto; margin: 16px 0; padding: 10px; border-radius: 8px; background: #0c0d0f; color: #cbd0d6; font-size: 10px; white-space: pre-wrap; }.risk-text { color: var(--pink); }
.image-dialog { width: min(900px, calc(100vw - 32px)); padding: 12px; }.image-dialog h2 { padding: 0 36px 8px 5px; margin: 0; font-size: 14px; }.image-dialog img { display: block; max-width: 100%; max-height: calc(100vh - 100px); margin: auto; }.image-dialog .icon-button { position: absolute; top: 4px; right: 4px; }
.sr-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }.mobile-tabs { display: none; }
.fatal-fallback { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--surface-0); color: var(--text); }.fatal-fallback > div { width: min(520px, 100%); border: 1px solid #8b3045; border-radius: 14px; padding: 24px; background: var(--surface-1); box-shadow: var(--shadow); }.fatal-fallback h1 { margin: 8px 0; font-size: 22px; }.fatal-fallback p { margin: 0 0 18px; color: var(--muted); line-height: 1.6; }
@keyframes pulse { 50% { opacity: .45; transform: scale(.8); } } @keyframes fade { from { opacity: 0; } }
@media (max-width: 1050px) { .workspace-grid { grid-template-columns: 230px minmax(380px, 1fr); }.workspace-grid > .mobile-panel:last-child { display: none; }.session-summary > span:nth-child(2) { display: none; } }
@media (max-width: 720px) {
html, body, #root { overflow: hidden; }.app-shell { grid-template-rows: 58px 44px 1fr; }.brand small, .model-badge, .vision-badge, .session-summary > .run-dot { display: none; }.topbar { padding: 0 10px; }.settings-button { width: 42px; padding: 0; font-size: 0; }.settings-button svg { width: 18px; }.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-1); }.mobile-tabs button { border: 0; background: transparent; color: var(--muted); text-transform: capitalize; }.mobile-tabs button.active { color: var(--cyan); box-shadow: inset 0 -2px var(--cyan); }.workspace-grid { display: block; min-height: 0; }.mobile-panel { display: none !important; height: 100%; }.mobile-panel.shown { display: block !important; }.side-panel { border: 0; }.chat-toolbar { padding: 6px 10px; }.text-button { padding: 0 8px; }.composer-footer { padding: 10px; }.message { width: calc(100% - 20px); }.metrics { grid-template-columns: repeat(2, 1fr); }.form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: span 1 !important; }.presets { overflow-x: auto; }.presets button { flex: 0 0 auto; }.dialog-actions { flex-wrap: wrap; }.dialog-actions button { flex: 1; }.dialog-actions .danger { margin: 0; }.composer-hint { display: none; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }