Enhance Beryl Agent detail and report views
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
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 { useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type RefObject } from "react";
|
||||
import { BarChart3, BookOpen, ChevronDown, ChevronRight, FileChartColumn, FolderOpen, History, LayoutDashboard, Lightbulb, MessageSquareText, PackageSearch, Plus, Settings2, Sparkles } from "lucide-react";
|
||||
import { Monitoring } from "./Monitoring.js";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
ComposerPrimitive,
|
||||
ThreadPrimitive,
|
||||
unstable_useComposerInput,
|
||||
useExternalStoreRuntime,
|
||||
type AppendMessage,
|
||||
type ThreadMessageLike,
|
||||
@@ -24,6 +25,21 @@ type Msg = {
|
||||
status?: "running" | "complete" | "incomplete";
|
||||
};
|
||||
type PendingImage = ImageAttachment & { previewUrl: string };
|
||||
const quickTasks = [
|
||||
["日报", "汇总昨天表现与关键异常", "生成指定店铺的昨日投放日报", FileChartColumn],
|
||||
["周报", "看清趋势、漏斗与行动建议", "生成指定店铺的上周投放周报", BarChart3],
|
||||
["分析投放", "定位商品与视频转化问题", "分析指定店铺最近一段时间的投放效果", PackageSearch],
|
||||
["策划新素材", "从数据洞察生成创意方向", "根据我的产品和投放数据策划下一批广告素材", Lightbulb],
|
||||
] as const;
|
||||
|
||||
function QuickTasks({disabled,inputRef}:{disabled:boolean;inputRef:RefObject<HTMLTextAreaElement|null>}) {
|
||||
const composer = unstable_useComposerInput();
|
||||
return <div className="quick-grid">
|
||||
{quickTasks.map(([title,copy,prompt,Icon],index)=><button type="button" className="quick-card" key={title} onClick={()=>{composer.setText(prompt);requestAnimationFrame(()=>inputRef.current?.focus());}} disabled={disabled}>
|
||||
<span className="quick-number">0{index+1}</span><span className="quick-icon"><Icon size={19}/></span><strong>{title}</strong><small>{copy}</small><ChevronRight size={17}/>
|
||||
</button>)}
|
||||
</div>;
|
||||
}
|
||||
const demo =
|
||||
"请使用 demo-report Skill,读取 demo-account 最近 7 天演示数据,分析 Campaign,生成含 3 条建议的报告草稿。保存后读取报告并核对指标,明确验收是否通过。";
|
||||
import { modelStorageKey, readSavedModel, saveModel } from "./model-storage.js";
|
||||
@@ -58,6 +74,8 @@ async function fetchJsonWithRetry<T>(
|
||||
|
||||
export function App() {
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [activePanel,setActivePanel]=useState<"history"|"daily"|"weekly"|"creative"|"prelaunch"|"library"|null>(null);
|
||||
const skillDrawerRef=useRef<HTMLDetailsElement|null>(null);
|
||||
const [session, setSession] = useState("");
|
||||
useEffect(() => { if (session) localStorage.setItem("beryl-session-id", session); }, [session]);
|
||||
const [skills, setSkills] = useState<SkillCatalogItem[]>([]);
|
||||
@@ -71,6 +89,7 @@ export function App() {
|
||||
const [attachmentError, setAttachmentError] = useState("");
|
||||
const es = useRef<EventSource | undefined>(undefined);
|
||||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const create = useCallback(async (resume = false) => {
|
||||
es.current?.close();
|
||||
const d = await fetchJsonWithRetry<{ sessionId: string }>(
|
||||
@@ -110,9 +129,17 @@ export function App() {
|
||||
useEffect(() => {
|
||||
void fetchJsonWithRetry<{ skills: SkillCatalogItem[] }>("/api/skills")
|
||||
.then((d) => setSkills(d.skills));
|
||||
void create(true);
|
||||
void create();
|
||||
return () => es.current?.close();
|
||||
}, [create]);
|
||||
useEffect(()=>{
|
||||
const closeDrawer=(event:MouseEvent)=>{
|
||||
const drawer=skillDrawerRef.current;
|
||||
if(drawer?.open&&!drawer.contains(event.target as Node))drawer.open=false;
|
||||
};
|
||||
document.addEventListener("mousedown",closeDrawer);
|
||||
return()=>document.removeEventListener("mousedown",closeDrawer);
|
||||
},[]);
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const source = new EventSource(`/api/sessions/${session}/events`);
|
||||
@@ -350,22 +377,26 @@ export function App() {
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
{monitorOpen&&<Monitoring onClose={()=>setMonitorOpen(false)}/>}
|
||||
{monitorOpen&&<Monitoring onClose={()=>setMonitorOpen(false)}/>}
|
||||
{activePanel&&<FeaturePanel kind={activePanel} session={session} messages={msgs} onClose={()=>setActivePanel(null)}/>}
|
||||
<div className="app">
|
||||
<main>
|
||||
<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>
|
||||
<button className="nav-item active" onClick={()=>setActivePanel(null)}><LayoutDashboard size={18}/>工作台</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("history")}><History size={18}/>历史任务</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("daily")}><FileChartColumn size={18}/>日报</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("weekly")}><BarChart3 size={18}/>周报</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("creative")}><Lightbulb size={18}/>素材规划</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("prelaunch")}><BookOpen size={18}/>投前方案</button>
|
||||
<button className="nav-item" onClick={()=>setActivePanel("library")}><FolderOpen size={18}/>素材库</button>
|
||||
</nav>
|
||||
<div className="nav-spacer" />
|
||||
<details className="skill-drawer">
|
||||
<details className="skill-drawer" ref={skillDrawerRef}>
|
||||
<summary><Sparkles size={17}/>技能目录 <span>{skills.length}</span></summary>
|
||||
<div className="skill-list">
|
||||
<div className="skill-list-head"><strong>全部技能</strong><button type="button" aria-label="关闭技能目录" onClick={()=>{if(skillDrawerRef.current)skillDrawerRef.current.open=false;}}>×</button></div>
|
||||
{skills.map((s) => <article key={s.filePath}><strong>{s.name}</strong><small>{s.diagnostics.length ? "需要检查" : "可使用"}</small></article>)}
|
||||
</div>
|
||||
</details>
|
||||
@@ -374,8 +405,8 @@ export function App() {
|
||||
</aside>
|
||||
<section className="chat">
|
||||
<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><small>BERYL INTELLIGENCE · 运营工作台</small><h1>今天想先做什么?</h1><p>Beryl 已准备好读取广告数据、分析表现并生成报告。</p></div>
|
||||
<div className="header-actions"><button className="store-switcher">Lemonz Home <ChevronDown size={14}/></button><span className="sync-state"><i/>数据已连接</span><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">
|
||||
@@ -405,23 +436,13 @@ export function App() {
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
<QuickTasks disabled={!configured || running} inputRef={composerInputRef}/>
|
||||
<div className="starter-note"><MessageSquareText size={17}/><span>也可以直接在下方输入你想完成的任务</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ComposerPrimitive.Root className="composer">
|
||||
<div className="composer-heading"><span>新任务</span><small>输入自然语言即可开始</small></div>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="attachment-tray" aria-label="已粘贴图片">
|
||||
{pendingImages.map((image) => (
|
||||
@@ -444,6 +465,7 @@ export function App() {
|
||||
</p>
|
||||
)}
|
||||
<ComposerPrimitive.Input
|
||||
ref={composerInputRef}
|
||||
aria-label="消息"
|
||||
submitMode="enter"
|
||||
placeholder={configured ? "输入任务,例如:生成 Lemonz Home 上周周报" : "请先连接模型,再开始任务"}
|
||||
@@ -456,6 +478,7 @@ export function App() {
|
||||
onPaste={(event) => void onPasteImage(event)}
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
<span className="composer-tip">Enter 发送 · Shift + Enter 换行</span>
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<button type="button" disabled={!running}>停止</button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
@@ -523,6 +546,30 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const panelTitles={history:"历史任务",daily:"日报",weekly:"周报",creative:"素材规划",prelaunch:"投前方案",library:"素材库"} as const;
|
||||
function FeaturePanel({kind,session,messages,onClose}:{kind:keyof typeof panelTitles;session:string;messages:Msg[];onClose:()=>void}){
|
||||
const [reportReady,setReportReady]=useState<boolean|null>(null);
|
||||
useEffect(()=>{
|
||||
const close=(event:KeyboardEvent)=>{if(event.key==="Escape")onClose();};
|
||||
window.addEventListener("keydown",close);
|
||||
return()=>window.removeEventListener("keydown",close);
|
||||
},[onClose]);
|
||||
useEffect(()=>{
|
||||
if(kind!=="daily"&&kind!=="weekly")return;
|
||||
let live=true;
|
||||
void fetch(`/api/sessions/${session}/reports/${kind}`).then(response=>{if(live)setReportReady(response.ok);}).catch(()=>{if(live)setReportReady(false);});
|
||||
return()=>{live=false;};
|
||||
},[kind,session]);
|
||||
return <div className="feature-overlay" onMouseDown={event=>{if(event.target===event.currentTarget)onClose();}}>
|
||||
<section className="feature-panel" role="dialog" aria-modal="true" aria-label={panelTitles[kind]} onMouseDown={event=>event.stopPropagation()}>
|
||||
<div className="feature-panel-head"><button type="button" className="feature-back" onClick={onClose}>← 返回工作台</button><div><small>BERYL WORKSPACE</small><h1>{panelTitles[kind]}</h1></div><button type="button" className="dialog-close" onClick={onClose} aria-label={`关闭${panelTitles[kind]}`}>×</button></div>
|
||||
{kind==="history"&&<div className="history-list">{messages.length?messages.map(message=><article key={message.id}><small>{message.role==="user"?"你的任务":"Agent 结果"}</small><p>{message.text||"处理中…"}</p></article>):<div className="panel-empty"><History size={28}/><h2>暂无历史任务</h2><p>完成任务后,当前会话记录会显示在这里。</p></div>}</div>}
|
||||
{(kind==="daily"||kind==="weekly")&&<div className="report-panel">{reportReady===null?<div className="panel-empty"><p>正在读取报告…</p></div>:reportReady?<><img src={`/api/sessions/${session}/reports/${kind}`} alt={`${panelTitles[kind]}图片`}/><a className="report-open" href={`/api/sessions/${session}/reports/${kind}`} target="_blank" rel="noreferrer">打开大图</a></>:<div className="panel-empty"><FileChartColumn size={28}/><h2>暂无{panelTitles[kind]}</h2><p>返回工作台生成{panelTitles[kind]}后,会自动出现在这里。</p></div>}</div>}
|
||||
{(kind==="creative"||kind==="prelaunch"||kind==="library")&&<div className="panel-empty"><Sparkles size={28}/><h2>{panelTitles[kind]}</h2><p>该功能已打开。返回工作台输入任务,Agent 会调用对应技能处理。</p></div>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
swads_whoami: "连接 SW Ads",
|
||||
metrics_catalog: "读取指标目录",
|
||||
@@ -752,6 +799,13 @@ function Settings({
|
||||
const [input, setInput] = useState<ModelConnectionInput>(
|
||||
() => readSavedModel() ?? defaultModelInput,
|
||||
);
|
||||
useEffect(() => {
|
||||
const close = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", close);
|
||||
return () => window.removeEventListener("keydown", close);
|
||||
}, [onClose]);
|
||||
const save = async () => {
|
||||
const submit = (sessionId: string) =>
|
||||
fetch(`/api/sessions/${sessionId}/model-config`, {
|
||||
@@ -791,8 +845,12 @@ function Settings({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="模型连接"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div onMouseDown={(event) => event.stopPropagation()}>
|
||||
<button className="dialog-close" onClick={onClose} aria-label="关闭模型设置" title="关闭">×</button>
|
||||
<h2>模型连接</h2>
|
||||
<label>
|
||||
Provider
|
||||
|
||||
@@ -18,7 +18,8 @@ export function Monitoring({onClose}:{onClose:()=>void}) {
|
||||
const num=(n:number|null|undefined)=>n==null?"—":n.toFixed(2);
|
||||
const fatigueRows=state?.fatigueRows?.filter(r=>(!accountFilter||`${r.account.tenant}:${r.account.id}`===accountFilter)&&(!fatigueState||r.state===fatigueState))??[];
|
||||
return <div className="monitor-overlay" onMouseDown={(event)=>{if(event.target===event.currentTarget)onClose();}}>
|
||||
<section className="monitor-dialog" role="dialog" aria-modal="true" aria-label="SW Ads 预警中心">
|
||||
<section className="monitor-dialog" role="dialog" aria-modal="true" aria-label="SW Ads 预警中心" onMouseDown={event=>event.stopPropagation()}>
|
||||
<button className="dialog-close" onClick={onClose} aria-label="关闭 SW Ads 预警中心" title="关闭">×</button>
|
||||
<button className="monitor-back" onClick={onClose} aria-label="返回工作台">← 返回工作台</button><h1>SW Ads · 投放与素材疲劳预警</h1>
|
||||
<p>只读监控 · 所有建议需人工确认 · ROI = 平台归因成交金额 ÷ 广告消耗,不代表净利润</p>
|
||||
<p>范围:所有可访问的 TikTok 账户({state?.accounts?.length??0} 个)。每次检查刷新账户列表;各账户按自己的时区、币种和 Campaign 目标判断,不合并不同币种。</p>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ModelConnectionInput } from "@agent-studio/shared";
|
||||
export const modelStorageKey = "agent-studio:model-config:v1";
|
||||
export function saveModel(input: ModelConnectionInput) {
|
||||
const safe = { ...input };
|
||||
delete safe.apiKey;
|
||||
localStorage.setItem(modelStorageKey, JSON.stringify(safe));
|
||||
}
|
||||
export function readSavedModel(): ModelConnectionInput | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(modelStorageKey);
|
||||
if (!value) return undefined;
|
||||
const input = JSON.parse(value) as ModelConnectionInput;
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("INVALID_CONFIG");
|
||||
delete input.apiKey;
|
||||
saveModel(input); // Migrate older configurations without retaining credentials.
|
||||
return input;
|
||||
} catch {
|
||||
localStorage.removeItem(modelStorageKey);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,20 @@ main{height:100vh;grid-template-columns:260px minmax(560px,1fr) 350px}
|
||||
@media(max-width:680px){main{grid-template-columns:1fr}.nav-shell{display:none}.workspace-header{height:auto;padding:16px 18px}.workspace-header h1{font-size:21px}.header-actions button:first-child{display:none}.messages{padding:18px}.composer{margin:10px 18px 18px}.quick-grid{grid-template-columns:1fr}.dashboard-empty{margin-top:1vh}}
|
||||
.thread-root{min-height:0;flex:1}.thread-root>.viewport{height:100%}
|
||||
.monitor-overlay{position:fixed;inset:0;z-index:1000;padding:20px;background:#17314255;backdrop-filter:blur(4px)}.monitor-dialog{position:relative;width:100%;height:100%;overflow:auto;padding:28px;background:#fff;color:#172b35;border:1px solid #ccd6da;border-radius:14px;box-shadow:0 18px 70px #10263345}.monitor-back{position:sticky;top:0;z-index:4;float:right;display:flex;align-items:center;gap:6px;margin:0 0 12px 18px;background:#0d9488;color:#fff;border-color:#0d9488;font-weight:700;box-shadow:0 6px 18px #0d948833}.monitor-back:hover{background:#0b7f76}.monitor-dialog>h1{padding-right:170px}@media(max-width:680px){.monitor-overlay{padding:0}.monitor-dialog{border-radius:0;padding:18px}.monitor-back{float:none;width:100%;justify-content:center;margin:0 0 18px}.monitor-dialog>h1{padding-right:0;font-size:22px}}
|
||||
|
||||
/* Premium workspace theme — Stitch direction */
|
||||
:root{--nav:#0b1724;--nav-hover:#142536;--teal:#0b8f83;--ink:#0e2132;--canvas:#f7f8f6;--border:#dfe5e3}
|
||||
.app{background:var(--canvas)}
|
||||
main{background:var(--canvas)}
|
||||
.nav-shell{position:relative;background:linear-gradient(180deg,#0b1724 0%,#0d1d2b 100%);padding:24px 16px 20px}.nav-shell::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.16;background-image:linear-gradient(#ffffff08 1px,transparent 1px),linear-gradient(90deg,#ffffff08 1px,transparent 1px);background-size:28px 28px}.nav-shell>*{position:relative;z-index:1}.brand-mark{padding-bottom:30px}.brand-mark>span{border-radius:12px;background:#0b8f83;box-shadow:0 10px 28px #0005}.brand-mark strong{letter-spacing:-.02em}.nav-item{border-radius:12px;transition:background .18s ease,color .18s ease,transform .18s ease}.nav-item:hover{transform:translateX(2px)}.nav-item.active{background:#edf9f6;color:#087b70;box-shadow:0 8px 24px #020b1238}.connection-card{border:1px solid #23394a;border-radius:12px;background:#0e2131;padding:13px 12px}
|
||||
.workspace-header{height:108px;padding:20px 34px;background:#fbfcf9f2;border-color:#e1e6e3}.workspace-header small{font-family:"JetBrains Mono",ui-monospace,monospace;color:#718078;font-size:10px;letter-spacing:.13em}.workspace-header h1{font-size:27px;font-weight:650;letter-spacing:-.035em}.workspace-header p{margin:7px 0 0;color:#74817b;font-size:13px}.header-actions{align-items:center;flex-wrap:wrap;justify-content:flex-end}.header-actions button{height:40px;border-radius:10px}.header-actions button:last-child{background:#0b8f83;border-color:#0b8f83;box-shadow:0 7px 18px #0b8f8326}.header-actions .store-switcher{gap:10px;background:#fffefc;color:#24372f;font-weight:650}.sync-state{display:flex;align-items:center;gap:7px;padding:0 4px;color:#63736b;font-size:12px}.sync-state i{width:7px;height:7px;border-radius:50%;background:#19a974;box-shadow:0 0 0 4px #19a97416}
|
||||
.chat{position:relative;background-color:#f7f8f6;background-image:radial-gradient(#8c9c9417 .7px,transparent .7px);background-size:18px 18px}.chat::after{content:"";position:absolute;inset:108px 0 auto;height:180px;pointer-events:none;background:linear-gradient(180deg,#eef5f140,transparent)}.thread-root,.workspace-header{position:relative;z-index:1}
|
||||
.dashboard-empty{max-width:920px;margin-top:5vh}.quick-grid{gap:16px}.quick-card{min-height:146px;grid-template-columns:46px 1fr 20px;grid-template-rows:24px auto auto;column-gap:14px;padding:18px 19px;border-radius:12px;border-color:#dfe5e3;background:#fdfefc;box-shadow:0 1px 2px #11271e08,0 10px 30px #16382b08;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease}.quick-card:hover{transform:translateY(-3px);border-color:#8fc8bd;box-shadow:0 2px 3px #11271e0a,0 18px 40px #16382b12}.quick-number{grid-column:1/4;align-self:start;font-family:"JetBrains Mono",ui-monospace,monospace;color:#9aa8a2;font-size:10px;letter-spacing:.12em}.quick-card .quick-icon{grid-row:2/4;grid-column:1;width:42px;height:42px;border-radius:12px;background:#e7f5f1;color:#087f74}.quick-card strong{grid-column:2;grid-row:2;font-size:17px;letter-spacing:-.01em}.quick-card small{grid-column:2;grid-row:3}.quick-card>svg{grid-column:3;grid-row:2/4;transition:transform .2s ease}.quick-card:hover>svg{transform:translateX(3px);color:#0b8f83}.starter-note{margin-top:24px;color:#7c8983}
|
||||
.composer{border-radius:12px;background:#fffefc;border-color:#d9e1dd;box-shadow:0 2px 3px #11271e08,0 20px 50px #183e2d10;padding:14px 15px}.composer:focus-within{border-color:#5db3a7;box-shadow:0 0 0 3px #0b8f8310,0 22px 55px #183e2d14}.composer-heading{display:flex;align-items:center;gap:9px;padding-bottom:8px;border-bottom:1px solid #edf0ed}.composer-heading span{color:#263a32;font-size:12px;font-weight:700}.composer-heading small{color:#8b9791;font-size:11px}.composer-actions{align-items:center}.composer-tip{margin-right:auto;color:#8a9690;font-size:11px}.composer-actions button{border-radius:9px}.composer-actions button:last-child{background:#0b8f83;border-color:#0b8f83;min-width:72px}
|
||||
.message.assistant{border-radius:12px;border-color:#e0e6e3;background:#fffefc;box-shadow:0 10px 34px #17382b0a}.message b{font-family:"JetBrains Mono",ui-monospace,monospace;letter-spacing:.13em}
|
||||
.loop-panel{padding:24px 20px;background:#f3f5f2;border-color:#dfe5e3}.loop-panel>h2{font-family:"JetBrains Mono",ui-monospace,monospace;letter-spacing:.11em}.loop-status,.phase-card,.tool-card{border-radius:12px;border-color:#dfe5e3;background:#fffefc}.loop-status{padding:17px;box-shadow:0 8px 26px #17382b08}.phase-list li:not(:last-child)::after{background:#d8e1dc}.phase-list .active i{border-color:#0b8f83;background:#e8f5f1;color:#0b8f83}.tool-row{border-radius:9px;background:#f3f6f3}
|
||||
.monitor-dialog{border-radius:12px;background:#fffefc}.monitor-back{border-radius:10px;background:#0b8f83}
|
||||
.modal>div{position:relative}
|
||||
.dialog-close{position:absolute;top:12px;right:12px;z-index:6;width:38px;height:38px;display:grid;place-items:center;padding:0;border-radius:10px;background:#fff;color:#43544d;border:1px solid #d8e0dc;font-size:23px;line-height:1;box-shadow:0 5px 16px #18382b14}.dialog-close:hover{background:#f1f5f2;color:#102a20}.monitor-dialog>.dialog-close{position:sticky;float:right;top:0;margin-left:10px}.monitor-dialog>.monitor-back{margin-right:0}
|
||||
.skill-list-head{position:sticky;top:-10px;z-index:2;display:flex;align-items:center;justify-content:space-between;margin:-10px -10px 6px;padding:12px 12px 9px;background:#fff;border-bottom:1px solid #e5ebe8}.skill-list-head>strong{font-size:12px}.skill-list-head>button{width:28px;height:28px;padding:0;border-radius:8px;font-size:18px}
|
||||
.feature-overlay{position:fixed;inset:0;z-index:900;padding:22px;background:#0b17245c;backdrop-filter:blur(5px)}.feature-panel{position:relative;width:min(1080px,100%);height:100%;margin:auto;overflow:auto;padding:28px;background:#fbfcf9;color:#102436;border:1px solid #dce4df;border-radius:14px;box-shadow:0 28px 90px #08172252}.feature-panel-head{position:sticky;top:-28px;z-index:5;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px;margin:-28px -28px 24px;padding:22px 28px;background:#fbfcf9f2;border-bottom:1px solid #e1e7e3;backdrop-filter:blur(12px)}.feature-panel-head small{font-family:"JetBrains Mono",ui-monospace,monospace;color:#74817b;font-size:10px;letter-spacing:.12em}.feature-panel-head h1{margin:3px 0 0;font-size:25px}.feature-panel-head .dialog-close{position:static}.feature-back{background:#fff;color:#284036}.history-list{display:grid;gap:12px;max-width:820px;margin:auto}.history-list article{padding:18px 20px;background:#fff;border:1px solid #dfe6e2;border-radius:11px;box-shadow:0 8px 26px #17382b08}.history-list article small{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:#0b8f83}.history-list article p{margin:8px 0 0;white-space:pre-wrap}.panel-empty{min-height:360px;display:grid;place-content:center;justify-items:center;text-align:center;color:#718078}.panel-empty h2{margin:14px 0 4px;color:#21372e;font-size:20px;text-transform:none;letter-spacing:0}.panel-empty p{margin:4px 0}.report-panel{max-width:820px;margin:auto;text-align:center}.report-panel>img{display:block;width:100%;height:auto;border:1px solid #dfe6e2;border-radius:12px;background:#fff;box-shadow:0 16px 42px #17382b12}.report-open{display:inline-flex;margin-top:14px;padding:10px 16px;border-radius:9px;background:#0b8f83;color:#fff;text-decoration:none}@media(max-width:680px){.feature-overlay{padding:0}.feature-panel{border-radius:0;padding:18px}.feature-panel-head{top:-18px;margin:-18px -18px 18px;padding:16px 18px;grid-template-columns:1fr auto}.feature-panel-head>.feature-back{grid-column:1/3;justify-self:start}.feature-panel-head h1{font-size:21px}}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { modelStorageKey, readSavedModel, saveModel } from "../src/model-storage.js";
|
||||
import type { ModelConnectionInput } from "@agent-studio/shared";
|
||||
afterEach(() => localStorage.clear());
|
||||
const config = { providerId: "test", modelId: "test", apiKey: "test-secret" } as ModelConnectionInput;
|
||||
it("never persists the API key", () => {
|
||||
saveModel(config);
|
||||
expect(localStorage.getItem(modelStorageKey)).not.toContain("test-secret");
|
||||
expect(readSavedModel()?.apiKey).toBeUndefined();
|
||||
expect(config.apiKey).toBe("test-secret");
|
||||
});
|
||||
it("removes keys from legacy saved configurations", () => {
|
||||
localStorage.setItem(modelStorageKey, JSON.stringify(config));
|
||||
expect(readSavedModel()?.apiKey).toBeUndefined();
|
||||
expect(localStorage.getItem(modelStorageKey)).not.toContain("test-secret");
|
||||
});
|
||||
Reference in New Issue
Block a user