feat(web): add Agent Studio workspace UI
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
import { useHarnessRuntime, type HarnessRuntimeStore } from "./assistant/use-harness-runtime.js";
|
||||||
|
import { Thread } from "./components/chat/Thread.js";
|
||||||
|
import { AppShell } from "./components/layout/AppShell.js";
|
||||||
|
import { SkillsPanel } from "./components/skills/SkillsPanel.js";
|
||||||
|
import { EventTimeline } from "./components/timeline/EventTimeline.js";
|
||||||
|
import { HarnessClientStore } from "./store/harness-client-store.js";
|
||||||
|
|
||||||
|
type AppStore = HarnessRuntimeStore & {
|
||||||
|
start(): Promise<void>;
|
||||||
|
getModelConfig(): Promise<{ modelConfig: { configured: boolean; modelId?: string; supportsImages?: boolean } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppProps = {
|
||||||
|
store?: AppStore;
|
||||||
|
autoStart?: boolean;
|
||||||
|
modelConfigured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function App({ store: suppliedStore, autoStart = true, modelConfigured: suppliedModelConfigured }: AppProps) {
|
||||||
|
const [ownedStore] = useState(() => new HarnessClientStore());
|
||||||
|
const store = suppliedStore ?? ownedStore;
|
||||||
|
const snapshot = useSyncExternalStore(
|
||||||
|
(subscriber) => store.subscribe(subscriber),
|
||||||
|
() => store.getSnapshot(),
|
||||||
|
() => store.getSnapshot(),
|
||||||
|
);
|
||||||
|
const [model, setModel] = useState({
|
||||||
|
configured: suppliedModelConfigured ?? false,
|
||||||
|
modelId: suppliedModelConfigured === true ? "Configured model" : "No model",
|
||||||
|
supportsImages: false,
|
||||||
|
});
|
||||||
|
const runtime = useHarnessRuntime(store, { isSendDisabled: !model.configured });
|
||||||
|
const busy = Object.values(snapshot.runs).some((run) => run.status === "running");
|
||||||
|
const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoStart) return undefined;
|
||||||
|
let active = true;
|
||||||
|
void store.start().then(async () => {
|
||||||
|
const response = await store.getModelConfig();
|
||||||
|
if (active) setModel({
|
||||||
|
configured: response.modelConfig.configured,
|
||||||
|
modelId: response.modelConfig.modelId ?? "No model",
|
||||||
|
supportsImages: response.modelConfig.supportsImages ?? false,
|
||||||
|
});
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [autoStart, store]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<AppShell
|
||||||
|
topBar={{
|
||||||
|
...(snapshot.sessionId === undefined ? {} : { sessionId: snapshot.sessionId }),
|
||||||
|
connection: snapshot.connection,
|
||||||
|
modelLabel: model.modelId,
|
||||||
|
supportsImages: model.supportsImages,
|
||||||
|
onOpenSettings: () => { window.dispatchEvent(new CustomEvent("agent-studio:open-settings")); },
|
||||||
|
onReset: () => window.location.reload(),
|
||||||
|
}}
|
||||||
|
skills={<SkillsPanel skills={snapshot.skills} />}
|
||||||
|
chat={<Thread
|
||||||
|
runtime={runtime}
|
||||||
|
busy={busy}
|
||||||
|
modelConfigured={model.configured}
|
||||||
|
connection={snapshot.connection}
|
||||||
|
error={snapshot.error}
|
||||||
|
approvalPending={approvalPending}
|
||||||
|
/>}
|
||||||
|
timeline={<EventTimeline events={snapshot.timeline} />}
|
||||||
|
/>
|
||||||
|
</AssistantRuntimeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export type HarnessRuntimeStore = {
|
|||||||
cancel(): Promise<unknown>;
|
cancel(): Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useHarnessRuntime(store: HarnessRuntimeStore) {
|
export function useHarnessRuntime(store: HarnessRuntimeStore, options: { isSendDisabled?: boolean } = {}) {
|
||||||
const snapshot = useSyncExternalStore(
|
const snapshot = useSyncExternalStore(
|
||||||
(subscriber) => store.subscribe(subscriber),
|
(subscriber) => store.subscribe(subscriber),
|
||||||
() => store.getSnapshot(),
|
() => store.getSnapshot(),
|
||||||
@@ -32,6 +32,7 @@ export function useHarnessRuntime(store: HarnessRuntimeStore) {
|
|||||||
messages,
|
messages,
|
||||||
convertMessage: (message) => message,
|
convertMessage: (message) => message,
|
||||||
isRunning,
|
isRunning,
|
||||||
|
isSendDisabled: options.isSendDisabled,
|
||||||
onNew: async (message) => {
|
onNew: async (message) => {
|
||||||
await store.sendMessage(extractMessageInput(message));
|
await store.sendMessage(extractMessageInput(message));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ComposerPrimitive } from "@assistant-ui/react";
|
||||||
|
import { ArrowUp, Paperclip, Square } from "lucide-react";
|
||||||
|
|
||||||
|
export type ComposerProps = {
|
||||||
|
busy: boolean;
|
||||||
|
modelConfigured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Composer({ busy, modelConfigured }: ComposerProps) {
|
||||||
|
return (
|
||||||
|
<div className="composer-area">
|
||||||
|
{!modelConfigured ? <p className="composer-hint">请先配置模型连接</p> : null}
|
||||||
|
<ComposerPrimitive.Root className="composer">
|
||||||
|
<ComposerPrimitive.Input
|
||||||
|
className="composer__input"
|
||||||
|
placeholder={modelConfigured ? "向 Agent 描述任务,Enter 发送" : "配置模型后即可开始"}
|
||||||
|
aria-label="消息"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="composer__actions">
|
||||||
|
<ComposerPrimitive.AddAttachment className="icon-button" aria-label="添加图片" title="添加图片">
|
||||||
|
<Paperclip size={18} aria-hidden="true" />
|
||||||
|
</ComposerPrimitive.AddAttachment>
|
||||||
|
{busy ? (
|
||||||
|
<ComposerPrimitive.Cancel className="send-button send-button--stop" aria-label="停止当前运行">
|
||||||
|
<Square size={15} aria-hidden="true" />停止
|
||||||
|
</ComposerPrimitive.Cancel>
|
||||||
|
) : (
|
||||||
|
<ComposerPrimitive.Send className="send-button" aria-label="发送消息" disabled={!modelConfigured}>
|
||||||
|
<ArrowUp size={17} aria-hidden="true" />发送
|
||||||
|
</ComposerPrimitive.Send>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComposerPrimitive.Root>
|
||||||
|
<p className="composer-footnote">工具执行与审批状态以服务器事件为准</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { MessagePrimitive, ThreadPrimitive, type AssistantRuntime } from "@assistant-ui/react";
|
||||||
|
import { Bot, ChevronDown, Sparkles, User } from "lucide-react";
|
||||||
|
import { Composer } from "./Composer.js";
|
||||||
|
import { StateView } from "../common/StateView.js";
|
||||||
|
import type { ConnectionStatus } from "../../store/reducer.js";
|
||||||
|
|
||||||
|
const demoPrompt = "请分析 demo-account 最近 7 天广告数据,并生成一份简短报告草稿。";
|
||||||
|
|
||||||
|
export function Thread({ runtime, busy, modelConfigured, connection, error, approvalPending }: {
|
||||||
|
runtime: AssistantRuntime;
|
||||||
|
busy: boolean;
|
||||||
|
modelConfigured: boolean;
|
||||||
|
connection: ConnectionStatus;
|
||||||
|
error: string | undefined;
|
||||||
|
approvalPending: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="chat-panel" aria-label="Streaming Chat">
|
||||||
|
<ThreadPrimitive.Root className="thread-root">
|
||||||
|
<ThreadPrimitive.Viewport className="thread-viewport">
|
||||||
|
<div className="thread-states">
|
||||||
|
{connection === "connecting" ? <StateView kind="loading" title="正在建立训练会话" /> : null}
|
||||||
|
{connection === "reconnecting" ? <StateView kind="reconnecting" title="事件流正在重连" detail="运行不会因页面连接中断而取消。" /> : null}
|
||||||
|
{connection === "error" ? <StateView kind="error" title="会话启动失败" {...(error === undefined ? {} : { detail: error })} /> : null}
|
||||||
|
{busy ? <StateView kind="busy" title="Agent 正在运行" /> : null}
|
||||||
|
{approvalPending ? <StateView kind="approval" title="等待工具审批" /> : null}
|
||||||
|
</div>
|
||||||
|
<ThreadPrimitive.Empty>
|
||||||
|
<div className="thread-empty">
|
||||||
|
<span className="thread-empty__icon"><Sparkles size={22} aria-hidden="true" /></span>
|
||||||
|
<span className="eyebrow">TRAINING WORKSPACE</span>
|
||||||
|
<h1>从一次可观察的 Agent 任务开始</h1>
|
||||||
|
<p>查看 Skill 如何渐进加载、工具如何执行,以及写入动作如何等待你的审批。</p>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => runtime.thread.composer.setText(demoPrompt)}>
|
||||||
|
填入演示任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ThreadPrimitive.Empty>
|
||||||
|
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||||
|
<ThreadPrimitive.ScrollToBottom className="scroll-button" aria-label="滚动到底部">
|
||||||
|
<ChevronDown size={18} aria-hidden="true" />
|
||||||
|
</ThreadPrimitive.ScrollToBottom>
|
||||||
|
</ThreadPrimitive.Viewport>
|
||||||
|
<Composer busy={busy} modelConfigured={modelConfigured} />
|
||||||
|
</ThreadPrimitive.Root>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMessage() {
|
||||||
|
return (
|
||||||
|
<MessagePrimitive.Root className="message message--user">
|
||||||
|
<span className="message__avatar"><User size={16} aria-hidden="true" /></span>
|
||||||
|
<div className="message__body"><span className="message__label">你</span><MessagePrimitive.Parts /></div>
|
||||||
|
</MessagePrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantMessage() {
|
||||||
|
return (
|
||||||
|
<MessagePrimitive.Root className="message message--assistant">
|
||||||
|
<span className="message__avatar"><Bot size={16} aria-hidden="true" /></span>
|
||||||
|
<div className="message__body"><span className="message__label">Agent</span><MessagePrimitive.Parts /></div>
|
||||||
|
</MessagePrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { AlertTriangle, CheckCircle2, LoaderCircle, Radio } from "lucide-react";
|
||||||
|
|
||||||
|
export type StateViewProps = {
|
||||||
|
kind: "loading" | "empty" | "error" | "reconnecting" | "busy" | "approval";
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StateView({ kind, title, detail }: StateViewProps) {
|
||||||
|
const Icon = kind === "error" ? AlertTriangle : kind === "empty" ? CheckCircle2 : kind === "reconnecting" ? Radio : LoaderCircle;
|
||||||
|
return (
|
||||||
|
<div className={`state-view state-view--${kind}`} role={kind === "error" ? "alert" : "status"}>
|
||||||
|
<Icon aria-hidden="true" size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
{detail === undefined ? null : <p>{detail}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react";
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { TopBar, type TopBarProps } from "../topbar/TopBar.js";
|
||||||
|
|
||||||
|
type WorkspaceTab = "chat" | "skills" | "events";
|
||||||
|
|
||||||
|
export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarProps; skills: ReactNode; chat: ReactNode; timeline: ReactNode }) {
|
||||||
|
const [activeTab, setActiveTab] = useState<WorkspaceTab>("chat");
|
||||||
|
const [mobile, setMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") return undefined;
|
||||||
|
const media = window.matchMedia("(max-width: 720px)");
|
||||||
|
const update = () => setMobile(media.matches);
|
||||||
|
update();
|
||||||
|
media.addEventListener("change", update);
|
||||||
|
return () => media.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<TopBar {...topBar} />
|
||||||
|
<nav className="mobile-tabs" aria-label="工作区导航" role="tablist">
|
||||||
|
<TabButton id="chat" active={activeTab === "chat"} onSelect={setActiveTab}><MessageSquare size={17} />Chat</TabButton>
|
||||||
|
<TabButton id="skills" active={activeTab === "skills"} onSelect={setActiveTab}><PanelsTopLeft size={17} />Skills</TabButton>
|
||||||
|
<TabButton id="events" active={activeTab === "events"} onSelect={setActiveTab}><Activity size={17} />Events</TabButton>
|
||||||
|
</nav>
|
||||||
|
<div className="workspace-grid">
|
||||||
|
<div id="skills-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "skills"}>{skills}</div>
|
||||||
|
<div id="chat-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "chat"}>{chat}</div>
|
||||||
|
<div id="events-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "events"}>{timeline}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ id, active, onSelect, children }: { id: WorkspaceTab; active: boolean; onSelect: (tab: WorkspaceTab) => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button type="button" role="tab" aria-selected={active} aria-controls={`${id}-panel`} tabIndex={active ? 0 : -1} onClick={() => onSelect(id)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { AlertCircle, BookOpen, Check, LoaderCircle } from "lucide-react";
|
||||||
|
import type { ClientSkill } from "../../store/reducer.js";
|
||||||
|
|
||||||
|
export function SkillsPanel({ skills }: { skills: ClientSkill[] }) {
|
||||||
|
return (
|
||||||
|
<section className="side-panel skills-panel" aria-label="Skills Catalog">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">DISCOVERY</span>
|
||||||
|
<h2>Skills</h2>
|
||||||
|
</div>
|
||||||
|
<span className="count-badge" aria-label={`${String(skills.length)} 个 Skill`}>{skills.length}</span>
|
||||||
|
</div>
|
||||||
|
<p className="panel-intro">只展示目录元信息。完整指令由 Agent 在需要时读取。</p>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<div className="panel-empty"><BookOpen size={18} aria-hidden="true" />尚未发现 Skill</div>
|
||||||
|
) : (
|
||||||
|
<ul className="skill-list">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<li className="skill-item" key={`${skill.source}:${skill.name}`}>
|
||||||
|
<div className="skill-item__title">
|
||||||
|
<strong>{skill.name}</strong>
|
||||||
|
{skill.activity === "requested" ? <LoaderCircle className="spin" size={15} aria-label="正在请求" /> : null}
|
||||||
|
{skill.activity === "loaded" ? <Check size={15} aria-label="已加载" /> : null}
|
||||||
|
</div>
|
||||||
|
<p>{skill.description}</p>
|
||||||
|
<div className="skill-meta"><span>{skill.source}</span><code>{skill.filePath}</code></div>
|
||||||
|
{skill.diagnostics.map((diagnostic, index) => (
|
||||||
|
<div className="diagnostic" key={`${diagnostic.message}:${String(index)}`}>
|
||||||
|
<AlertCircle size={14} aria-hidden="true" />{diagnostic.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { HarnessEvent } from "@agent-studio/shared";
|
||||||
|
import { Activity, CircleDot } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type EventCategory = "all" | "agent" | "tools" | "system";
|
||||||
|
|
||||||
|
export function EventTimeline({ events }: { events: HarnessEvent[] }) {
|
||||||
|
const [category, setCategory] = useState<EventCategory>("all");
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearBottomRef = useRef(true);
|
||||||
|
const visible = useMemo(
|
||||||
|
() => [...events].sort((left, right) => left.sequence - right.sequence).filter((event) => matchesCategory(event, category)),
|
||||||
|
[category, events],
|
||||||
|
);
|
||||||
|
const recent = visible.slice(-100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (viewport !== null && nearBottomRef.current) viewport.scrollTop = viewport.scrollHeight;
|
||||||
|
}, [recent.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="side-panel timeline-panel" aria-label="Event Timeline">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div><span className="eyebrow">EVENT STORE</span><h2>Timeline</h2></div>
|
||||||
|
<Activity size={18} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<label className="filter-label">
|
||||||
|
<span>筛选事件</span>
|
||||||
|
<select value={category} onChange={(event) => setCategory(event.target.value as EventCategory)}>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="agent">Agent</option>
|
||||||
|
<option value="tools">工具与审批</option>
|
||||||
|
<option value="system">系统</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="timeline"
|
||||||
|
ref={viewportRef}
|
||||||
|
onScroll={(event) => {
|
||||||
|
const element = event.currentTarget;
|
||||||
|
nearBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<div className="panel-empty"><Activity size={18} aria-hidden="true" />等待事件</div>
|
||||||
|
) : recent.map((event) => (
|
||||||
|
<article className="event-row" key={event.eventId}>
|
||||||
|
<CircleDot size={13} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<div className="event-row__head"><code>#{event.sequence}</code><strong>{event.type}</strong></div>
|
||||||
|
<time dateTime={event.timestamp}>{formatTime(event.timestamp)}</time>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{visible.length > recent.length ? <p className="timeline-note">仅显示最近 100 条匹配事件</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCategory(event: HarnessEvent, category: EventCategory): boolean {
|
||||||
|
if (category === "all") return true;
|
||||||
|
if (category === "tools") return event.type.startsWith("tool.") || event.type.startsWith("approval.");
|
||||||
|
if (category === "agent") return event.type.startsWith("assistant.") || event.type.startsWith("run.") || event.type.startsWith("skill.");
|
||||||
|
return event.type.startsWith("session.") || event.type.startsWith("model.") || event.type.startsWith("attachment.") || event.type === "user.message";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: string): string {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(new Date(timestamp));
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Bot, Eye, EyeOff, RotateCcw, Settings2 } from "lucide-react";
|
||||||
|
import type { ConnectionStatus } from "../../store/reducer.js";
|
||||||
|
|
||||||
|
export type TopBarProps = {
|
||||||
|
sessionId?: string;
|
||||||
|
connection: ConnectionStatus;
|
||||||
|
modelLabel: string;
|
||||||
|
supportsImages: boolean;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopBar({ sessionId, connection, modelLabel, supportsImages, onOpenSettings, onReset }: TopBarProps) {
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand-lockup">
|
||||||
|
<span className="brand-mark"><Bot size={18} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<strong>Agent Studio</strong>
|
||||||
|
<span>MINI HARNESS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topbar__status" aria-label="会话状态">
|
||||||
|
<span className={`connection-dot connection-dot--${connection}`} aria-hidden="true" />
|
||||||
|
<span>{connectionLabel(connection)}</span>
|
||||||
|
<span className="topbar__divider" aria-hidden="true" />
|
||||||
|
<span className="session-code">{shortSession(sessionId)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="topbar__actions">
|
||||||
|
<span className="status-chip">{modelLabel}</span>
|
||||||
|
<span className="status-chip">
|
||||||
|
{supportsImages ? <Eye size={14} aria-hidden="true" /> : <EyeOff size={14} aria-hidden="true" />}
|
||||||
|
{supportsImages ? "Vision" : "Text"}
|
||||||
|
</span>
|
||||||
|
<button className="icon-button" type="button" aria-label="重置训练会话" title="重置训练会话" onClick={onReset}>
|
||||||
|
<RotateCcw size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" type="button" aria-label="打开模型设置" title="打开模型设置" onClick={onOpenSettings}>
|
||||||
|
<Settings2 size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionLabel(status: ConnectionStatus): string {
|
||||||
|
if (status === "open") return "已连接";
|
||||||
|
if (status === "connecting") return "连接中";
|
||||||
|
if (status === "reconnecting") return "重连中";
|
||||||
|
if (status === "error") return "连接失败";
|
||||||
|
return "未连接";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSession(sessionId?: string): string {
|
||||||
|
if (sessionId === undefined) return "SESSION PENDING";
|
||||||
|
return sessionId.length > 18 ? `${sessionId.slice(0, 10)}...${sessionId.slice(-4)}` : sessionId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App.js";
|
||||||
|
import "./styles/tokens.css";
|
||||||
|
import "./styles/base.css";
|
||||||
|
import "./styles/layout.css";
|
||||||
|
import "./styles/components.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (root === null) throw new Error("Root element not found");
|
||||||
|
|
||||||
|
createRoot(root).render(<StrictMode><App /></StrictMode>);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; }
|
||||||
|
button, textarea, select, input { font: inherit; }
|
||||||
|
button, select { color: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||||
|
code { font-family: "SFMono-Regular", Consolas, monospace; }
|
||||||
|
h1, h2, p { margin-top: 0; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.topbar { display: grid; grid-template-columns: minmax(190px, 1fr) auto minmax(190px, 1fr); align-items: center; gap: 16px; padding: 0 20px; border-bottom: 1px solid var(--border); background: #141515; }
|
||||||
|
.brand-lockup, .topbar__status, .topbar__actions, .panel-heading, .skill-item__title, .event-row__head, .composer__actions, .state-view { display: flex; align-items: center; }
|
||||||
|
.brand-lockup { gap: 10px; }
|
||||||
|
.brand-lockup > div { display: grid; gap: 1px; }
|
||||||
|
.brand-lockup span { color: var(--muted); font-size: 9px; letter-spacing: .16em; }
|
||||||
|
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #397c79; border-radius: 10px; color: var(--accent) !important; background: #132321; }
|
||||||
|
.topbar__status { gap: 8px; color: var(--muted); font-size: 12px; }
|
||||||
|
.connection-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.connection-dot--open { background: var(--accent); box-shadow: 0 0 0 3px rgb(37 244 238 / 10%); }
|
||||||
|
.connection-dot--error { background: var(--danger); }
|
||||||
|
.connection-dot--connecting, .connection-dot--reconnecting { background: var(--warning); }
|
||||||
|
.topbar__divider { width: 1px; height: 14px; background: var(--border-strong); }
|
||||||
|
.session-code { font-family: "SFMono-Regular", Consolas, monospace; }
|
||||||
|
.topbar__actions { justify-content: flex-end; gap: 8px; }
|
||||||
|
.status-chip, .count-badge { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; color: #c8d1cf; background: var(--surface-raised); }
|
||||||
|
.status-chip { height: 30px; padding: 0 10px; font-size: 11px; }
|
||||||
|
.count-badge { min-width: 26px; height: 24px; justify-content: center; font-size: 11px; }
|
||||||
|
.icon-button { width: 44px; height: 44px; display: inline-grid; place-items: center; border: 0; border-radius: var(--radius-sm); color: var(--muted); background: transparent; }
|
||||||
|
.icon-button:hover { color: var(--text); background: var(--surface-raised); }
|
||||||
|
.panel-heading { justify-content: space-between; min-height: 78px; padding: 16px 18px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.panel-heading h2 { margin: 3px 0 0; font-size: 16px; }
|
||||||
|
.eyebrow { color: var(--accent); font-size: 9px; font-weight: 700; letter-spacing: .15em; }
|
||||||
|
.panel-intro { margin: 0; padding: 14px 18px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
.skill-list { overflow: auto; list-style: none; margin: 0; padding: 6px 0 24px; }
|
||||||
|
.skill-item { padding: 16px 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.skill-item__title { justify-content: space-between; gap: 8px; }
|
||||||
|
.skill-item__title svg { color: var(--accent); }
|
||||||
|
.skill-item p { margin: 7px 0 10px; color: #c4ccca; font-size: 12px; line-height: 1.5; }
|
||||||
|
.skill-meta { display: grid; gap: 5px; color: var(--muted); font-size: 10px; }
|
||||||
|
.skill-meta code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.diagnostic { display: flex; gap: 6px; margin-top: 9px; color: var(--warning); font-size: 11px; }
|
||||||
|
.panel-empty { margin: auto; display: grid; place-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||||
|
.thread-root { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; }
|
||||||
|
.thread-viewport { position: relative; overflow-y: auto; padding: 28px clamp(20px, 5vw, 72px); }
|
||||||
|
.thread-states { position: sticky; z-index: 2; top: 0; max-width: 760px; margin: 0 auto 16px; display: grid; gap: 8px; }
|
||||||
|
.thread-empty { min-height: 100%; max-width: 580px; margin: auto; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; }
|
||||||
|
.thread-empty__icon { width: 48px; height: 48px; display: grid; place-items: center; margin-bottom: 24px; border: 1px solid #367875; border-radius: 14px; color: var(--accent); background: #142523; }
|
||||||
|
.thread-empty h1 { max-width: 520px; margin: 9px 0 12px; font-size: clamp(25px, 4vw, 38px); line-height: 1.1; letter-spacing: -.03em; }
|
||||||
|
.thread-empty p { max-width: 500px; color: var(--muted); line-height: 1.7; }
|
||||||
|
.secondary-button { min-height: 44px; margin-top: 8px; padding: 0 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text); background: var(--surface-raised); }
|
||||||
|
.secondary-button:hover { border-color: #4d7472; }
|
||||||
|
.message { max-width: 760px; margin: 0 auto 28px; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; }
|
||||||
|
.message--user .message__body { width: fit-content; max-width: 88%; padding: 12px 15px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-raised); }
|
||||||
|
.message__avatar { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 9px; color: var(--muted); }
|
||||||
|
.message--assistant .message__avatar { color: var(--accent); border-color: #315b59; }
|
||||||
|
.message__label { display: block; margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.message__body p { margin-bottom: 10px; line-height: 1.7; }
|
||||||
|
.scroll-button { position: sticky; bottom: 8px; left: 50%; width: 38px; height: 38px; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow); }
|
||||||
|
.composer-area { padding: 0 clamp(16px, 4vw, 56px) 16px; }
|
||||||
|
.composer { max-width: 820px; margin: 0 auto; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow); }
|
||||||
|
.composer:focus-within { border-color: #4c7775; }
|
||||||
|
.composer__input { width: 100%; min-height: 76px; max-height: 190px; resize: none; padding: 16px 18px 8px; border: 0; outline: 0; color: var(--text); background: transparent; line-height: 1.5; }
|
||||||
|
.composer__input::placeholder { color: #65716f; }
|
||||||
|
.composer__actions { min-height: 48px; justify-content: space-between; padding: 4px 7px 7px; }
|
||||||
|
.send-button { min-width: 88px; min-height: 40px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 0; border-radius: 10px; color: var(--accent-ink); background: var(--accent); font-weight: 750; }
|
||||||
|
.send-button--stop { color: white; background: var(--danger); }
|
||||||
|
.composer-hint, .composer-footnote { max-width: 820px; margin: 0 auto; color: var(--muted); font-size: 11px; }
|
||||||
|
.composer-hint { padding: 0 3px 8px; color: var(--warning); }
|
||||||
|
.composer-footnote { padding-top: 8px; text-align: center; }
|
||||||
|
.filter-label { display: grid; gap: 7px; padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.filter-label select { height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); }
|
||||||
|
.timeline { overflow-y: auto; padding: 6px 0 24px; }
|
||||||
|
.event-row { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 9px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||||
|
.event-row > svg { margin-top: 3px; color: #4e7573; }
|
||||||
|
.event-row__head { gap: 8px; min-width: 0; }
|
||||||
|
.event-row__head code { color: var(--muted); font-size: 10px; }
|
||||||
|
.event-row__head strong { overflow: hidden; text-overflow: ellipsis; font-size: 11px; white-space: nowrap; }
|
||||||
|
.event-row time { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||||
|
.timeline-note { padding: 0 16px; color: var(--muted); font-size: 10px; }
|
||||||
|
.state-view { gap: 10px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); }
|
||||||
|
.state-view svg { color: var(--accent); }
|
||||||
|
.state-view--error svg { color: var(--danger); }
|
||||||
|
.state-view strong { display: block; }
|
||||||
|
.state-view p { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
|
||||||
|
.spin { animation: spin 1.2s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||||||
|
@media (max-width: 720px) { .topbar { grid-template-columns: 1fr auto; padding: 0 12px; } .topbar__actions { grid-column: 2; } .thread-viewport { padding: 20px 16px; } .composer-area { padding: 0 10px 10px; } }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.app-shell { height: 100dvh; min-height: 620px; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||||
|
.workspace-grid { display: grid; grid-template-columns: minmax(220px, 272px) minmax(420px, 1fr) minmax(260px, 330px); min-height: 0; }
|
||||||
|
.workspace-grid > div { min-width: 0; min-height: 0; }
|
||||||
|
.workspace-grid > div + div { border-left: 1px solid var(--border); }
|
||||||
|
.side-panel, .chat-panel { height: 100%; min-height: 0; }
|
||||||
|
.side-panel { display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||||
|
.chat-panel { background: var(--bg); }
|
||||||
|
.mobile-tabs { display: none; }
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.workspace-grid { grid-template-columns: minmax(210px, 240px) minmax(400px, 1fr); }
|
||||||
|
.workspace-grid > div:last-child { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app-shell { min-height: 520px; grid-template-rows: 64px 52px minmax(0, 1fr); }
|
||||||
|
.topbar__status, .status-chip { display: none; }
|
||||||
|
.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface); }
|
||||||
|
.mobile-tabs button { min-height: 48px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); display: flex; gap: 7px; align-items: center; justify-content: center; }
|
||||||
|
.mobile-tabs button[aria-selected="true"] { border-bottom-color: var(--accent); color: var(--text); }
|
||||||
|
.workspace-grid { display: block; }
|
||||||
|
.workspace-grid > div { height: 100%; }
|
||||||
|
.workspace-grid > div + div { border-left: 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #111111;
|
||||||
|
--surface: #181919;
|
||||||
|
--surface-raised: #202222;
|
||||||
|
--surface-soft: #151b1b;
|
||||||
|
--border: #2c3030;
|
||||||
|
--border-strong: #3b4141;
|
||||||
|
--text: #f3f7f6;
|
||||||
|
--muted: #93a09e;
|
||||||
|
--accent: #25f4ee;
|
||||||
|
--accent-ink: #062f2d;
|
||||||
|
--danger: #fe2c55;
|
||||||
|
--warning: #ffca5c;
|
||||||
|
--focus: #a7fffb;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--shadow: 0 16px 40px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { App } from "../src/App.js";
|
||||||
|
import { createInitialHarnessState, type HarnessClientState } from "../src/store/reducer.js";
|
||||||
|
|
||||||
|
function makeStore() {
|
||||||
|
const sendMessage = vi.fn().mockResolvedValue({ runId: "run_1" });
|
||||||
|
const state: HarnessClientState = {
|
||||||
|
...createInitialHarnessState(),
|
||||||
|
sessionId: "ses_test",
|
||||||
|
connection: "open",
|
||||||
|
skills: [{
|
||||||
|
name: "ads-analysis",
|
||||||
|
description: "分析广告投放指标",
|
||||||
|
source: "project",
|
||||||
|
filePath: ".agents/skills/ads-analysis/SKILL.md",
|
||||||
|
diagnostics: [],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getSnapshot: () => state,
|
||||||
|
subscribe: () => () => undefined,
|
||||||
|
sendMessage,
|
||||||
|
cancel: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||||
|
start: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getModelConfig: vi.fn().mockResolvedValue({
|
||||||
|
modelConfig: { configured: true, source: "environment", providerId: "openai", api: "openai-responses", modelId: "gpt-test", supportsImages: true },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("App", () => {
|
||||||
|
it("renders the three primary work areas", () => {
|
||||||
|
render(<App store={makeStore()} autoStart={false} modelConfigured />);
|
||||||
|
expect(screen.getByRole("region", { name: "Skills Catalog" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("main", { name: "Streaming Chat" })).toBeVisible();
|
||||||
|
expect(screen.getByRole("region", { name: "Event Timeline" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills but does not send the demo prompt", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const store = makeStore();
|
||||||
|
render(<App store={store} autoStart={false} modelConfigured />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "填入演示任务" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toContain("demo-account");
|
||||||
|
expect(store.sendMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables sending and points to settings when the model is missing", () => {
|
||||||
|
render(<App store={makeStore()} autoStart={false} modelConfigured={false} />);
|
||||||
|
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
|
||||||
|
expect(screen.getByText("请先配置模型连接")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
class TestResizeObserver implements ResizeObserver {
|
||||||
|
public readonly observe = () => undefined;
|
||||||
|
public readonly unobserve = () => undefined;
|
||||||
|
public readonly disconnect = () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = TestResizeObserver;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { StateView } from "../src/components/common/StateView.js";
|
||||||
|
|
||||||
|
describe("StateView", () => {
|
||||||
|
it.each([
|
||||||
|
["loading", "正在建立训练会话"],
|
||||||
|
["reconnecting", "事件流正在重连"],
|
||||||
|
["busy", "Agent 正在运行"],
|
||||||
|
["approval", "等待工具审批"],
|
||||||
|
["error", "会话启动失败"],
|
||||||
|
] as const)("renders the %s state", (kind, label) => {
|
||||||
|
render(<StateView kind={kind} title={label} />);
|
||||||
|
expect(screen.getByText(label)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
setupFiles: ["./tests/setup.ts"]
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user