Redesign Beryl Agent workspace
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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 {
|
||||
AssistantRuntimeProvider,
|
||||
ComposerPrimitive,
|
||||
@@ -18,12 +20,13 @@ type Msg = {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
images?: Array<{ src: string; alt: string }>;
|
||||
download?: { href: string; title: string };
|
||||
status?: "running" | "complete" | "incomplete";
|
||||
};
|
||||
type PendingImage = ImageAttachment & { previewUrl: string };
|
||||
const demo =
|
||||
"请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。";
|
||||
const modelStorageKey = "agent-studio:model-config:v1";
|
||||
"请使用 demo-report Skill,读取 demo-account 最近 7 天演示数据,分析 Campaign,生成含 3 条建议的报告草稿。保存后读取报告并核对指标,明确验收是否通过。";
|
||||
import { modelStorageKey, readSavedModel, saveModel } from "./model-storage.js";
|
||||
const defaultModelInput: ModelConnectionInput = {
|
||||
providerId: "openai",
|
||||
api: "openai-responses",
|
||||
@@ -33,19 +36,6 @@ const defaultModelInput: ModelConnectionInput = {
|
||||
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>(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
@@ -67,7 +57,9 @@ async function fetchJsonWithRetry<T>(
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [session, setSession] = useState("");
|
||||
useEffect(() => { if (session) localStorage.setItem("beryl-session-id", session); }, [session]);
|
||||
const [skills, setSkills] = useState<SkillCatalogItem[]>([]);
|
||||
const [events, setEvents] = useState<HarnessEvent[]>([]);
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
@@ -79,13 +71,14 @@ export function App() {
|
||||
const [attachmentError, setAttachmentError] = useState("");
|
||||
const es = useRef<EventSource | undefined>(undefined);
|
||||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||||
const create = useCallback(async () => {
|
||||
const create = useCallback(async (resume = false) => {
|
||||
es.current?.close();
|
||||
const d = await fetchJsonWithRetry<{ sessionId: string }>(
|
||||
"/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);
|
||||
localStorage.setItem("beryl-session-id", d.sessionId);
|
||||
setEvents([]);
|
||||
setMsgs([]);
|
||||
setRunning(false);
|
||||
@@ -117,7 +110,7 @@ export function App() {
|
||||
useEffect(() => {
|
||||
void fetchJsonWithRetry<{ skills: SkillCatalogItem[] }>("/api/skills")
|
||||
.then((d) => setSkills(d.skills));
|
||||
void create();
|
||||
void create(true);
|
||||
return () => es.current?.close();
|
||||
}, [create]);
|
||||
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")
|
||||
setMsgs((old) =>
|
||||
old.map((m) =>
|
||||
@@ -233,7 +240,7 @@ export function App() {
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
const response = await fetch(`/api/sessions/${session}/messages`, {
|
||||
const send = (id: string) => fetch(`/api/sessions/${id}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -241,6 +248,22 @@ export function App() {
|
||||
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) {
|
||||
pendingImages.forEach((image) => URL.revokeObjectURL(image.previewUrl));
|
||||
setPendingImages([]);
|
||||
@@ -327,41 +350,34 @@ export function App() {
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
{monitorOpen&&<Monitoring onClose={()=>setMonitorOpen(false)}/>}
|
||||
<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>
|
||||
<aside>
|
||||
<h2>Skills Catalog</h2>
|
||||
{skills.map((s) => (
|
||||
<article key={s.filePath}>
|
||||
<strong>{s.name}</strong>
|
||||
<p>{s.description}</p>
|
||||
<small>
|
||||
{s.source} ·{" "}
|
||||
{s.diagnostics.length ? "⚠ Diagnostic" : "✓ Valid"}
|
||||
</small>
|
||||
</article>
|
||||
))}
|
||||
<aside className="nav-shell">
|
||||
<div className="brand-mark"><span>B</span><div><strong>Beryl Agent</strong><small>你的跨境投放搭档</small></div></div>
|
||||
<nav aria-label="主导航">
|
||||
<button className="nav-item active"><LayoutDashboard size={18}/>工作台</button>
|
||||
<button className="nav-item"><FileChartColumn size={18}/>数据报告</button>
|
||||
<button className="nav-item"><Lightbulb size={18}/>素材创意</button>
|
||||
<button className="nav-item"><BookOpen size={18}/>投前方案</button>
|
||||
<button className="nav-item"><FolderOpen size={18}/>素材库</button>
|
||||
</nav>
|
||||
<div className="nav-spacer" />
|
||||
<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>
|
||||
<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">
|
||||
<div className="messages" ref={messagesRef}>
|
||||
{msgs.length ? (
|
||||
@@ -369,6 +385,7 @@ export function App() {
|
||||
<div key={m.id} className={`message ${m.role}`}>
|
||||
<b>{m.role === "user" ? "YOU" : "AGENT"}</b>
|
||||
<p>{m.text || "…"}</p>
|
||||
{m.download && <a href={m.download.href} download>{m.download.title}</a>}
|
||||
{m.images?.map((reportImage) => (
|
||||
<a
|
||||
className="report-image-link"
|
||||
@@ -387,16 +404,20 @@ export function App() {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="empty">
|
||||
<h1>Beryl 的素材和数据 Agent</h1>
|
||||
<p>
|
||||
连接模型后,完成广告数据分析、素材洞察、脚本生成与视频生产。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(demo)}
|
||||
>
|
||||
复制 Demo Prompt
|
||||
</button>
|
||||
<div className="empty dashboard-empty">
|
||||
<div className="quick-grid">
|
||||
{([
|
||||
["日报", "汇总昨天表现与关键异常", "生成 Lemonz Home 昨日投放日报", FileChartColumn],
|
||||
["周报", "看清趋势、漏斗与行动建议", "生成 Lemonz Home 上周投放周报", BarChart3],
|
||||
["分析投放", "定位商品与视频转化问题", "分析最近三周厨具投放效果", PackageSearch],
|
||||
["策划新素材", "从数据洞察生成创意方向", "为厨具规划下一批广告素材", Lightbulb],
|
||||
] as const).map(([title, copy, prompt, Icon]) => (
|
||||
<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}>
|
||||
<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>
|
||||
@@ -424,15 +445,22 @@ export function App() {
|
||||
)}
|
||||
<ComposerPrimitive.Input
|
||||
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)}
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<button disabled={!running}>停止</button>
|
||||
<button type="button" disabled={!running}>停止</button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<button disabled={!configured || running}>发送</button>
|
||||
<button type="submit" disabled={!configured || running}>发送</button>
|
||||
</ComposerPrimitive.Send>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
|
||||
Reference in New Issue
Block a user