feat: initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="theme-color" content="#111111" /><title>Agent Studio Mini</title></head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@agent-studio/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "dev": "vite --host 127.0.0.1", "typecheck": "tsc --noEmit", "build": "vite build" },
|
||||
"dependencies": {
|
||||
"@agent-studio/shared": "workspace:*",
|
||||
"@assistant-ui/react": "0.15.17",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "typescript": "^5.9.3", "vite": "^7.2.6" }
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>);
|
||||
@@ -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; } }
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
import { HarnessClientStore } from "../src/assistant/store";
|
||||
|
||||
function event<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["payload"], runId = "run_1"): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId, timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; }
|
||||
|
||||
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||
|
||||
describe("HarnessClientStore reducer", () => {
|
||||
it("merges streaming deltas into one assistant text part", () => { const store = new HarnessClientStore(); store.reduce(event(1, "assistant.delta", { messageId: "msg_a", delta: "你" })); store.reduce(event(2, "assistant.delta", { messageId: "msg_a", delta: "好" })); expect(store.getSnapshot().messages[0]?.content).toEqual([{ type: "text", text: "你好" }]); });
|
||||
it("de-duplicates replayed events by id and sequence", () => { const store = new HarnessClientStore(); const value = event(1, "run.started", { modelId: "fake" }); store.reduce(value); store.reduce(value); expect(store.getSnapshot().events).toHaveLength(1); });
|
||||
it("maps tool and approval state by toolCallId", () => { const store = new HarnessClientStore(); store.reduce(event(1, "tool.requested", { toolCallId: "t1", toolName: "save_report_draft", arguments: { content: "x" } })); store.reduce(event(2, "approval.required", { approvalId: "a1", sessionId: "ses_1", runId: "run_1", toolCallId: "t1", toolName: "save_report_draft", arguments: {}, riskLevel: "medium", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 1000).toISOString(), status: "pending" })); store.reduce(event(3, "approval.resolved", { approvalId: "a1", toolCallId: "t1", status: "approved", resolvedAt: new Date().toISOString() })); const content = store.getSnapshot().messages[0]?.content; expect(typeof content === "string" ? undefined : content?.[0]).toMatchObject({ type: "tool-call", toolCallId: "t1", approval: { approved: true } }); });
|
||||
it("upserts duplicate tool requests by toolCallId", () => { const store = new HarnessClientStore(); store.reduce(event(1, "tool.requested", { toolCallId: "t1", toolName: "read", arguments: { path: "<protected-path>" } })); store.reduce(event(2, "tool.requested", { toolCallId: "t1", toolName: "read", arguments: { path: ".agents/skills/ads-analysis/SKILL.md" } })); const content = store.getSnapshot().messages[0]?.content; expect(typeof content === "string" ? [] : content?.filter((part) => part.type === "tool-call")).toHaveLength(1); expect(typeof content === "string" ? undefined : content?.[0]).toMatchObject({ toolCallId: "t1", args: { path: ".agents/skills/ads-analysis/SKILL.md" } }); });
|
||||
it("maps cancelled and failed runs to incomplete status", () => { const store = new HarnessClientStore(); store.reduce(event(1, "assistant.delta", { messageId: "m", delta: "x" })); store.reduce(event(2, "run.cancelled", { reason: "user" })); expect(store.getSnapshot().messages[0]?.status).toMatchObject({ type: "incomplete", reason: "cancelled" }); });
|
||||
it("blocks image messages locally for text-only models", async () => { const store = new HarnessClientStore(); const snapshot = store.getSnapshot(); snapshot.sessionId = "ses_1"; snapshot.model = { configured: true, source: "session", providerId: "x", modelId: "x", supportsImages: false, keyConfigured: true }; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ attachmentId: "att_uploaded", sessionId: "ses_1", name: "x.png", mediaType: "image/png", byteSize: 3, width: 1, height: 1, status: "ready" }) })); vi.stubGlobal("URL", { ...URL, createObjectURL: () => "blob:x", revokeObjectURL: vi.fn() }); const pending = await store.attachmentAdapter.add({ file: new File(["png"], "x.png", { type: "image/png" }) }); if (!("id" in pending)) throw new Error("unexpected generator"); await expect(store.onNew({ id: "ignored", role: "user", parentId: null, sourceId: null, createdAt: new Date(), content: [{ type: "text", text: "x" }], attachments: [{ id: pending.id, type: "image", name: "x", status: { type: "complete" }, content: [{ type: "image", image: "blob:x" }] }], metadata: { custom: {} }, runConfig: undefined })).rejects.toThrow("不支持图片"); expect(fetch).toHaveBeenCalledTimes(1); vi.unstubAllGlobals(); });
|
||||
it("persists model metadata without the API key", async () => { const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_1"; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ configured: true, source: "session", providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true, keyConfigured: true, keyHint: "••••cret" }) })); await store.saveModel({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "top-secret", modelId: "gpt", supportsImages: true }); const raw = localStorage.getItem("agent-studio-mini:model-connection:v1"); expect(raw).not.toContain("top-secret"); expect(JSON.parse(raw!)).toEqual({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", modelId: "gpt", supportsImages: true }); });
|
||||
it("restores saved metadata into a new Session without credentials", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify({ providerId: "saved", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "saved-model", supportsImages: false })); const requests: Array<{ url: string; init?: RequestInit }> = []; vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); requests.push({ url, init }); const body = url === "/api/skills" ? { skills: [] } : url === "/api/sessions" ? { sessionId: "ses_new" } : init?.method === "PUT" ? { configured: true, source: "session", providerId: "saved", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "saved-model", supportsImages: false, keyConfigured: false } : { configured: false, source: "missing", keyConfigured: false }; return { ok: true, json: async () => body }; })); class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} } vi.stubGlobal("EventSource", FakeEventSource); const store = new HarnessClientStore(); await store.initialize(); expect(store.getSnapshot().model).toMatchObject({ modelId: "saved-model", keyConfigured: false }); const restored = requests.find((item) => item.init?.method === "PUT"); expect(restored?.init?.body).not.toContain("apiKey"); });
|
||||
it("drops corrupted persisted configuration without breaking initialization", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", "not-json"); vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { const url = String(input); const body = url === "/api/skills" ? { skills: [] } : url === "/api/sessions" ? { sessionId: "ses_new" } : { configured: false, source: "missing", keyConfigured: false }; return { ok: true, json: async () => body }; })); class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} } vi.stubGlobal("EventSource", FakeEventSource); const store = new HarnessClientStore(); await store.initialize(); expect(localStorage.getItem("agent-studio-mini:model-connection:v1")).toBeNull(); expect(store.getSnapshot().model.configured).toBe(false); });
|
||||
it("waits for an in-flight Session before saving the model", async () => {
|
||||
let releaseSession!: () => void;
|
||||
const sessionGate = new Promise<void>((resolve) => { releaseSession = resolve; });
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input); requests.push({ url, init });
|
||||
if (url === "/api/sessions" && init?.method === "POST") { await sessionGate; return { ok: true, json: async () => ({ sessionId: "ses_delayed" }) }; }
|
||||
if (url === "/api/skills") return { ok: true, json: async () => ({ skills: [] }) };
|
||||
if (init?.method === "PUT") return { ok: true, json: async () => ({ configured: true, source: "session", providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true, keyConfigured: true, keyHint: "••••cret" }) };
|
||||
return { ok: true, json: async () => ({ configured: false, source: "missing", keyConfigured: false }) };
|
||||
}));
|
||||
class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} }
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
const store = new HarnessClientStore();
|
||||
const initializing = store.initialize();
|
||||
const saving = store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true });
|
||||
expect(requests.filter((item) => item.init?.method === "POST")).toHaveLength(1);
|
||||
expect(requests.some((item) => item.init?.method === "PUT")).toBe(false);
|
||||
releaseSession();
|
||||
await Promise.all([initializing, saving]);
|
||||
expect(requests.find((item) => item.init?.method === "PUT")?.url).toBe("/api/sessions/ses_delayed/model-config");
|
||||
expect(store.getSnapshot().model).toMatchObject({ configured: true, keyConfigured: true });
|
||||
});
|
||||
it("reports the Session initialization failure instead of a false save success", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
const store = new HarnessClientStore();
|
||||
await expect(store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true })).rejects.toThrow("Session 初始化失败:Failed to fetch");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
import { App } from "../src/App";
|
||||
import { HarnessRuntime } from "../src/assistant/HarnessRuntime";
|
||||
import { HarnessClientStore } from "../src/assistant/store";
|
||||
import { MockAdsMetricsUI, SaveReportDraftUI } from "../src/components/ToolUIs";
|
||||
import { AppErrorBoundary } from "../src/components/AppErrorBoundary";
|
||||
|
||||
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); });
|
||||
describe("web UI", () => {
|
||||
it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /尚未配置模型/ })).toBeInTheDocument(); });
|
||||
it("renders the mock metrics Tool UI", () => { render(<MockAdsMetricsUI {...toolProps("mock_ads_metrics", { accountId: "demo-account", days: 7 }, { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } })}/>); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); });
|
||||
it("renders approval actions inside save_report_draft", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><SaveReportDraftUI {...toolProps("save_report_draft", { content: "报告正文" }, undefined, { id: "apr_1" })}/></HarnessRuntime>); expect(screen.getByRole("button", { name: "批准一次" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument(); });
|
||||
it("opens settings with password input and does not use Web Storage", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password"); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); });
|
||||
it("prompts for a new key when local model metadata was restored", () => { const store = new HarnessClientStore(); store.getSnapshot().model = { configured: true, source: "session", providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false, keyConfigured: false }; render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByRole("button", { name: /模型配置已恢复/ })).toBeInTheDocument(); expect(screen.getByLabelText("消息输入")).toHaveAttribute("placeholder", "请重新输入 API Key"); });
|
||||
it("omits empty optional model fields when saving settings", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); const save = vi.spyOn(store, "saveModel").mockResolvedValue(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); await user.type(screen.getByLabelText("API Key"), "sk-fake"); await user.click(screen.getByRole("button", { name: "保存配置" })); expect(save).toHaveBeenCalledWith({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fake", modelId: "gpt-5.4", supportsImages: true }); });
|
||||
it("fills the classroom demo prompt without sending it", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: "填入 Demo Prompt" })); expect((screen.getByLabelText("消息输入") as HTMLTextAreaElement).value).toContain("ads-analysis"); expect(store.getSnapshot().events).toHaveLength(0); });
|
||||
it("shows a recoverable fallback instead of a black screen for render failures", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const Broken = () => { throw new Error("tool renderer failed"); }; render(<AppErrorBoundary><Broken/></AppErrorBoundary>); expect(screen.getByRole("alert")).toHaveTextContent("界面渲染出现异常"); expect(screen.getByRole("button", { name: "重新加载页面" })).toBeInTheDocument(); });
|
||||
it("renders a duplicated Pi Tool sequence and approval without crashing", () => {
|
||||
const store = new HarnessClientStore();
|
||||
store.reduce(harnessEvent(1, "user.message", { messageId: "user_1", text: "demo", attachmentIds: [] }));
|
||||
store.reduce(harnessEvent(2, "run.started", { modelId: "fake" }));
|
||||
store.reduce(harnessEvent(3, "assistant.completed", { messageId: "assistant_1" }));
|
||||
store.reduce(harnessEvent(4, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: "<protected-path>" } }));
|
||||
store.reduce(harnessEvent(5, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: ".agents/skills/ads-analysis/SKILL.md" } }));
|
||||
store.reduce(harnessEvent(6, "tool.completed", { toolCallId: "read_1", toolName: "read", result: { status: "success" } }));
|
||||
store.reduce(harnessEvent(7, "tool.requested", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", arguments: { accountId: "demo-account", days: 7 } }));
|
||||
store.reduce(harnessEvent(8, "tool.completed", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", result: { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } } }));
|
||||
store.reduce(harnessEvent(9, "tool.requested", { toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" } }));
|
||||
store.reduce(harnessEvent(10, "approval.required", { approvalId: "apr_1", sessionId: "ses_1", runId: "run_1", toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" }, riskLevel: "medium", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), status: "pending" }));
|
||||
render(<AppErrorBoundary><HarnessRuntime store={store}><App/></HarnessRuntime></AppErrorBoundary>);
|
||||
expect(screen.queryByText("界面渲染出现异常")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("ROAS")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "允许保存报告草稿?" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function toolProps(toolName: string, args: Record<string, unknown>, result?: unknown, approval?: { id: string }) { return { type: "tool-call" as const, toolCallId: "t1", toolName, args, argsText: JSON.stringify(args), result, isError: false, status: { type: "complete" as const }, addResult: vi.fn(), resume: vi.fn(), respondToApproval: vi.fn(), ...(approval ? { approval } : {}) }; }
|
||||
function harnessEvent<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["payload"]): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; }
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "noEmit": true, "types": ["vite/client"] },
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173, proxy: { "/api": "http://127.0.0.1:3001", "/health": "http://127.0.0.1:3001" } },
|
||||
build: { outDir: "dist" },
|
||||
});
|
||||
Reference in New Issue
Block a user