Redesign Beryl Agent workspace
This commit is contained in:
@@ -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}>
|
||||||
|
<span className="quick-icon"><Icon size={20}/></span><strong>{String(title)}</strong><small>{String(copy)}</small><ChevronRight size={17}/>
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user