Redesign Beryl Agent workspace

This commit is contained in:
2026-09-10 19:24:33 +08:00
parent dee9d6f11b
commit 802fabb20f
+91 -63
View File
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from "react";
import { BarChart3, BookOpen, ChevronRight, FileChartColumn, FolderOpen, LayoutDashboard, Lightbulb, MessageSquareText, PackageSearch, Plus, Settings2, Sparkles } from "lucide-react";
import { Monitoring } from "./Monitoring.js";
import { import {
AssistantRuntimeProvider, AssistantRuntimeProvider,
ComposerPrimitive, ComposerPrimitive,
@@ -18,12 +20,13 @@ type Msg = {
role: "user" | "assistant"; role: "user" | "assistant";
text: string; text: string;
images?: Array<{ src: string; alt: string }>; images?: Array<{ src: string; alt: string }>;
download?: { href: string; title: string };
status?: "running" | "complete" | "incomplete"; status?: "running" | "complete" | "incomplete";
}; };
type PendingImage = ImageAttachment & { previewUrl: string }; type PendingImage = ImageAttachment & { previewUrl: string };
const demo = const demo =
"请使用 ads-analysis Skill分析账户 demo-account 最近 7 天数据,找出异常 Campaign并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。"; "请使用 demo-report Skill读取 demo-account 最近 7 天演示数据,分析 Campaign生成含 3 条建议的报告草稿。保存后读取报告并核对指标,明确验收是否通过。";
const modelStorageKey = "agent-studio:model-config:v1"; import { modelStorageKey, readSavedModel, saveModel } from "./model-storage.js";
const defaultModelInput: ModelConnectionInput = { const defaultModelInput: ModelConnectionInput = {
providerId: "openai", providerId: "openai",
api: "openai-responses", api: "openai-responses",
@@ -33,19 +36,6 @@ const defaultModelInput: ModelConnectionInput = {
supportsVideo: false, supportsVideo: false,
}; };
function readSavedModel(): ModelConnectionInput | undefined {
try {
const value = localStorage.getItem(modelStorageKey);
return value ? (JSON.parse(value) as ModelConnectionInput) : undefined;
} catch {
return undefined;
}
}
function saveModel(input: ModelConnectionInput) {
localStorage.setItem(modelStorageKey, JSON.stringify(input));
}
async function fetchJsonWithRetry<T>( async function fetchJsonWithRetry<T>(
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit, init?: RequestInit,
@@ -67,7 +57,9 @@ async function fetchJsonWithRetry<T>(
} }
export function App() { export function App() {
const [monitorOpen,setMonitorOpen]=useState(false);
const [session, setSession] = useState(""); const [session, setSession] = useState("");
useEffect(() => { if (session) localStorage.setItem("beryl-session-id", session); }, [session]);
const [skills, setSkills] = useState<SkillCatalogItem[]>([]); const [skills, setSkills] = useState<SkillCatalogItem[]>([]);
const [events, setEvents] = useState<HarnessEvent[]>([]); const [events, setEvents] = useState<HarnessEvent[]>([]);
const [msgs, setMsgs] = useState<Msg[]>([]); const [msgs, setMsgs] = useState<Msg[]>([]);
@@ -79,13 +71,14 @@ export function App() {
const [attachmentError, setAttachmentError] = useState(""); const [attachmentError, setAttachmentError] = useState("");
const es = useRef<EventSource | undefined>(undefined); const es = useRef<EventSource | undefined>(undefined);
const messagesRef = useRef<HTMLDivElement | null>(null); const messagesRef = useRef<HTMLDivElement | null>(null);
const create = useCallback(async () => { const create = useCallback(async (resume = false) => {
es.current?.close(); es.current?.close();
const d = await fetchJsonWithRetry<{ sessionId: string }>( const d = await fetchJsonWithRetry<{ sessionId: string }>(
"/api/sessions", "/api/sessions",
{ method: "POST" }, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ resumeSessionId: resume ? localStorage.getItem("beryl-session-id") : undefined }) },
); );
setSession(d.sessionId); setSession(d.sessionId);
localStorage.setItem("beryl-session-id", d.sessionId);
setEvents([]); setEvents([]);
setMsgs([]); setMsgs([]);
setRunning(false); setRunning(false);
@@ -117,7 +110,7 @@ export function App() {
useEffect(() => { useEffect(() => {
void fetchJsonWithRetry<{ skills: SkillCatalogItem[] }>("/api/skills") void fetchJsonWithRetry<{ skills: SkillCatalogItem[] }>("/api/skills")
.then((d) => setSkills(d.skills)); .then((d) => setSkills(d.skills));
void create(); void create(true);
return () => es.current?.close(); return () => es.current?.close();
}, [create]); }, [create]);
useEffect(() => { useEffect(() => {
@@ -205,6 +198,20 @@ export function App() {
]; ];
}); });
} }
if (e.type === "tool.completed" && e.payload.toolName === "save_report_draft") {
setMsgs(old => old.some(m => m.id === e.eventId) ? old : [...old, {
id: e.eventId, role: "assistant", text: "报告草稿已保存,等待交付验收", status: "complete",
download: { href: `/api/sessions/${session}/reports/document`, title: "下载报告(Markdown" },
}]);
}
if (e.type === "run.completed") {
const details = (e.payload.delivery as { details?: { artifactId?: string; title?: string; deliveryAccepted?: boolean } })?.details;
if (details?.deliveryAccepted && details.artifactId && /^[0-9a-f-]{36}$/.test(details.artifactId))
setMsgs(old => old.some(m => m.id === e.eventId) ? old : [...old, {
id: e.eventId, role: "assistant", text: "PPT 文件已通过自动交付检查(事实与视觉仍需复核)", status: "complete",
download: { href: `/api/sessions/${session}/presentations/${details.artifactId}`, title: `${details.title ?? "演示文稿"} · 下载 PPTX` },
}]);
}
if (e.type === "run.completed") if (e.type === "run.completed")
setMsgs((old) => setMsgs((old) =>
old.map((m) => old.map((m) =>
@@ -233,7 +240,7 @@ export function App() {
.filter((p) => p.type === "text") .filter((p) => p.type === "text")
.map((p) => p.text) .map((p) => p.text)
.join(""); .join("");
const response = await fetch(`/api/sessions/${session}/messages`, { const send = (id: string) => fetch(`/api/sessions/${id}/messages`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -241,6 +248,22 @@ export function App() {
attachmentIds: pendingImages.map((image) => image.attachmentId), attachmentIds: pendingImages.map((image) => image.attachmentId),
}), }),
}); });
let response: Response;
try {
response = await send(session);
if (response.status === 404) {
if (pendingImages.length) throw new Error("会话已失效,请重置 Session 并重新粘贴图片。");
const fresh = await create();
response = await send(fresh);
}
if (!response.ok) {
const problem = await response.json();
throw new Error(problem.error?.message ?? `发送失败(${response.status}`);
}
} catch (error) {
setAttachmentError(error instanceof Error ? error.message : "连接失败,请检查服务后重试。");
throw error;
}
if (response.ok) { if (response.ok) {
pendingImages.forEach((image) => URL.revokeObjectURL(image.previewUrl)); pendingImages.forEach((image) => URL.revokeObjectURL(image.previewUrl));
setPendingImages([]); setPendingImages([]);
@@ -327,41 +350,34 @@ export function App() {
); );
return ( return (
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
{monitorOpen&&<Monitoring onClose={()=>setMonitorOpen(false)}/>}
<div className="app"> <div className="app">
<header>
<div>
<b>Agent Studio</b>
<span className="mini"> MINI</span>
</div>
<div className="badges">
<span>{running ? "● RUNNING" : "● READY"}</span>
<span>
{restoring
? "MODEL RESTORING"
: configured
? "MODEL CONNECTED"
: "MODEL MISSING"}
</span>
<button onClick={() => setSettings(true)}></button>
<button onClick={() => void create()}> Session</button>
</div>
</header>
<main> <main>
<aside> <aside className="nav-shell">
<h2>Skills Catalog</h2> <div className="brand-mark"><span>B</span><div><strong>Beryl Agent</strong><small></small></div></div>
{skills.map((s) => ( <nav aria-label="主导航">
<article key={s.filePath}> <button className="nav-item active"><LayoutDashboard size={18}/></button>
<strong>{s.name}</strong> <button className="nav-item"><FileChartColumn size={18}/></button>
<p>{s.description}</p> <button className="nav-item"><Lightbulb size={18}/></button>
<small> <button className="nav-item"><BookOpen size={18}/></button>
{s.source} ·{" "} <button className="nav-item"><FolderOpen size={18}/></button>
{s.diagnostics.length ? "⚠ Diagnostic" : "✓ Valid"} </nav>
</small> <div className="nav-spacer" />
</article> <details className="skill-drawer">
))} <summary><Sparkles size={17}/> <span>{skills.length}</span></summary>
<div className="skill-list">
{skills.map((s) => <article key={s.filePath}><strong>{s.name}</strong><small>{s.diagnostics.length ? "需要检查" : "可使用"}</small></article>)}
</div>
</details>
<button className="nav-item subtle" onClick={() => setSettings(true)}><Settings2 size={18}/></button>
<div className="connection-card"><i className={configured ? "online" : ""}/><div><strong>{configured ? "服务连接正常" : "等待连接模型"}</strong><small>SW Ads · TikTok</small></div></div>
</aside> </aside>
<section className="chat"> <section className="chat">
<ThreadPrimitive.Root> <div className="workspace-header">
<div><small></small><h1></h1></div>
<div className="header-actions"><button onClick={()=>setMonitorOpen(true)}><BarChart3 size={16}/>SW Ads </button><button title="新建会话" onClick={() => void create()}><Plus size={17}/></button></div>
</div>
<ThreadPrimitive.Root className="thread-root">
<ThreadPrimitive.Viewport className="viewport"> <ThreadPrimitive.Viewport className="viewport">
<div className="messages" ref={messagesRef}> <div className="messages" ref={messagesRef}>
{msgs.length ? ( {msgs.length ? (
@@ -369,6 +385,7 @@ export function App() {
<div key={m.id} className={`message ${m.role}`}> <div key={m.id} className={`message ${m.role}`}>
<b>{m.role === "user" ? "YOU" : "AGENT"}</b> <b>{m.role === "user" ? "YOU" : "AGENT"}</b>
<p>{m.text || "…"}</p> <p>{m.text || "…"}</p>
{m.download && <a href={m.download.href} download>{m.download.title}</a>}
{m.images?.map((reportImage) => ( {m.images?.map((reportImage) => (
<a <a
className="report-image-link" className="report-image-link"
@@ -387,16 +404,20 @@ export function App() {
</div> </div>
)) ))
) : ( ) : (
<div className="empty"> <div className="empty dashboard-empty">
<h1>Beryl Agent</h1> <div className="quick-grid">
<p> {([
广 ["日报", "汇总昨天表现与关键异常", "生成 Lemonz Home 昨日投放日报", FileChartColumn],
</p> ["周报", "看清趋势、漏斗与行动建议", "生成 Lemonz Home 上周投放周报", BarChart3],
<button ["分析投放", "定位商品与视频转化问题", "分析最近三周厨具投放效果", PackageSearch],
onClick={() => navigator.clipboard.writeText(demo)} ["策划新素材", "从数据洞察生成创意方向", "为厨具规划下一批广告素材", Lightbulb],
> ] as const).map(([title, copy, prompt, Icon]) => (
Demo Prompt <button className="quick-card" key={String(title)} onClick={() => void onNew({ role: "user", content: [{ type: "text", text: String(prompt) }] } as unknown as AppendMessage)} disabled={!configured || running}>
</button> <span className="quick-icon"><Icon size={20}/></span><strong>{String(title)}</strong><small>{String(copy)}</small><ChevronRight size={17}/>
</button>
))}
</div>
<div className="starter-note"><MessageSquareText size={17}/><span></span></div>
</div> </div>
)} )}
</div> </div>
@@ -424,15 +445,22 @@ export function App() {
)} )}
<ComposerPrimitive.Input <ComposerPrimitive.Input
aria-label="消息" aria-label="消息"
placeholder={configured ? "输入任务…" : "请先配置模型"} submitMode="enter"
placeholder={configured ? "输入任务,例如:生成 Lemonz Home 上周周报" : "请先连接模型,再开始任务"}
onKeyDown={(event) => {
if (event.key === "Enter" &&
(event.nativeEvent.isComposing || event.keyCode === 229 || !configured || running)) {
event.preventDefault();
}
}}
onPaste={(event) => void onPasteImage(event)} onPaste={(event) => void onPasteImage(event)}
/> />
<div className="composer-actions"> <div className="composer-actions">
<ComposerPrimitive.Cancel asChild> <ComposerPrimitive.Cancel asChild>
<button disabled={!running}></button> <button type="button" disabled={!running}></button>
</ComposerPrimitive.Cancel> </ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild> <ComposerPrimitive.Send asChild>
<button disabled={!configured || running}></button> <button type="submit" disabled={!configured || running}></button>
</ComposerPrimitive.Send> </ComposerPrimitive.Send>
</div> </div>
</ComposerPrimitive.Root> </ComposerPrimitive.Root>