fix(chat): pin composer, set reply language, and track model loading

This commit is contained in:
Jeffrey Wu
2026-09-07 12:00:27 +08:00
parent 5c61371d7e
commit f4e3f41c2c
9 changed files with 299 additions and 36 deletions
+1 -1
View File
@@ -9,4 +9,4 @@ import { HarnessToolsProvider } from "./components/ToolUIs";
import { useHarnessState, useHarnessStore } from "./assistant/HarnessContext";
type MobileTab = "skills" | "chat" | "events";
export function App() { const store = useHarnessStore(); const state = useHarnessState(); const [settings, setSettings] = useState(false); const [tab, setTab] = useState<MobileTab>("chat"); return <HarnessToolsProvider><div className="app-shell"><header className="topbar"><div className="brand"><span className="brand-mark"><Bot/></span><div><strong>Agent Studio Mini</strong><small>Pi Coding Agent Harness</small></div></div><div className="session-summary"><span className={`run-dot ${state.isRunning ? "running" : ""}`}/><span>{state.sessionId?.slice(0, 16) ?? "creating session…"}</span><span className="model-badge">{state.model.modelId ?? "Model missing"}</span>{state.model.supportsImages && <span className="vision-badge">Vision</span>}<button className="icon-button" title="Reset Session" aria-label="重置 Session" disabled={state.isRunning} onClick={() => void store.reset()}><RotateCcw/></button><button className="settings-button" onClick={() => setSettings(true)}><Settings/>Settings</button></div></header><nav className="mobile-tabs" aria-label="移动端面板切换">{(["skills", "chat", "events"] as MobileTab[]).map((item) => <button key={item} className={tab === item ? "active" : ""} onClick={() => setTab(item)}>{item}</button>)}</nav><div className="workspace-grid"><div className={`mobile-panel ${tab === "skills" ? "shown" : ""}`}><SkillsPanel/></div><div className={`mobile-panel center ${tab === "chat" ? "shown" : ""}`}><ChatPanel openSettings={() => setSettings(true)}/></div><div className={`mobile-panel ${tab === "events" ? "shown" : ""}`}><TimelinePanel/></div></div>{settings && <SettingsDialog open onOpenChange={setSettings}/>}<ApprovalDialog/></div></HarnessToolsProvider>; }
export function App() { const store = useHarnessStore(); const state = useHarnessState(); const [settings, setSettings] = useState(false); const [tab, setTab] = useState<MobileTab>("chat"); return <HarnessToolsProvider><div className="app-shell"><header className="topbar"><div className="brand"><span className="brand-mark"><Bot/></span><div><strong>Agent Studio Mini</strong><small>Pi Coding Agent Harness</small></div></div><div className="session-summary"><span className={`run-dot ${state.isRunning ? "running" : ""}`}/><span>{state.sessionId?.slice(0, 16) ?? "creating session…"}</span><span className="model-badge">{state.modelLoadState === "loading" ? "正在读取模型…" : state.modelLoadState === "error" ? "模型读取失败" : state.model.modelId ?? "Model missing"}</span>{state.model.supportsImages && <span className="vision-badge">Vision</span>}<button className="icon-button" title="Reset Session" aria-label="重置 Session" disabled={state.isRunning || state.modelLoadState === "loading"} onClick={() => void store.reset()}><RotateCcw/></button><button className="settings-button" onClick={() => setSettings(true)}><Settings/>Settings</button></div></header><nav className="mobile-tabs" aria-label="移动端面板切换">{(["skills", "chat", "events"] as MobileTab[]).map((item) => <button key={item} className={tab === item ? "active" : ""} onClick={() => setTab(item)}>{item}</button>)}</nav><div className="workspace-grid"><div className={`mobile-panel ${tab === "skills" ? "shown" : ""}`}><SkillsPanel/></div><div className={`mobile-panel center ${tab === "chat" ? "shown" : ""}`}><ChatPanel openSettings={() => setSettings(true)}/></div><div className={`mobile-panel ${tab === "events" ? "shown" : ""}`}><TimelinePanel/></div></div>{settings && <SettingsDialog open onOpenChange={setSettings}/>}<ApprovalDialog/></div></HarnessToolsProvider>; }
+1 -1
View File
@@ -8,7 +8,7 @@ export function HarnessRuntime({ store, children }: PropsWithChildren<{ store: H
const runtime = useExternalStoreRuntime({
messages: state.messages,
isRunning: state.isRunning,
isSendDisabled: !state.model.configured || !state.model.keyConfigured || state.connection !== "online",
isSendDisabled: state.modelLoadState !== "loaded" || !state.model.configured || !state.model.keyConfigured || state.connection !== "online",
setMessages: store.setMessages,
onNew: store.onNew,
onCancel: store.onCancel,
+35 -7
View File
@@ -8,6 +8,8 @@ export interface ClientState {
events: HarnessEvent[];
skills: SkillCatalogItem[];
model: RedactedModelConnection;
modelLoadState: "loading" | "loaded" | "error";
modelLoadError?: string | undefined;
isRunning: boolean;
connection: ConnectionState;
error?: string | undefined;
@@ -19,7 +21,7 @@ const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended",
const modelStorageKey = "agent-studio-mini:model-connection:v1";
export class HarnessClientStore {
private state: ClientState = { messages: [], events: [], skills: [], model: { configured: false, source: "missing", keyConfigured: false }, isRunning: false, connection: "connecting" };
private state: ClientState = { messages: [], events: [], skills: [], modelLoadState: "loading", model: { configured: false, source: "missing", keyConfigured: false }, isRunning: false, connection: "connecting" };
private readonly listeners = new Set<Listener>();
private readonly seen = new Set<string>();
private readonly attachments = new Map<string, LocalAttachment>();
@@ -42,6 +44,7 @@ export class HarnessClientStore {
}
private async initializeOnce(): Promise<void> {
this.update({ modelLoadState: "loading", modelLoadError: undefined });
try {
// Session readiness must not depend on the optional Skills catalog request.
const created = await api<{ sessionId: string }>("/api/sessions", { method: "POST" });
@@ -51,7 +54,7 @@ export class HarnessClientStore {
await this.restorePersistedModel();
void this.refreshSkills();
} catch (error) {
this.update({ connection: "offline", error: `Session 初始化失败:${messageOf(error)}` });
this.update({ connection: "offline", modelLoadState: "error", modelLoadError: `Session 初始化失败:${messageOf(error)}`, error: `Session 初始化失败:${messageOf(error)}` });
}
}
@@ -155,7 +158,9 @@ export class HarnessClientStore {
async reset(): Promise<void> {
const old = this.state.sessionId;
this.update({ modelLoadState: "loading", modelLoadError: undefined, connection: "connecting" });
this.generation++;
try {
for (const image of this.reportImages.values()) if (image) URL.revokeObjectURL(image);
this.reportImages.clear();
this.source?.close();
@@ -163,12 +168,35 @@ export class HarnessClientStore {
for (const item of this.attachments.values()) URL.revokeObjectURL(item.objectUrl);
this.attachments.clear(); this.seen.clear(); this.lastSequence = 0;
const created = await api<{ sessionId: string }>("/api/sessions", { method: "POST" });
this.state = { ...this.state, sessionId: created.sessionId, messages: [], events: [], model: { configured: false, source: "missing", keyConfigured: false }, isRunning: false, connection: "connecting", error: undefined };
this.state = { ...this.state, sessionId: created.sessionId, messages: [], events: [], model: { configured: false, source: "missing", keyConfigured: false }, modelLoadState: "loading", modelLoadError: undefined, isRunning: false, connection: "connecting", error: undefined };
for (const listener of this.listeners) listener();
await this.refreshModel(); await this.restorePersistedModel(); this.connect();
this.connect();
await this.refreshModel(); await this.restorePersistedModel();
} catch (error) {
this.update({ connection: "offline", modelLoadState: "error", modelLoadError: `Session 重置失败:${messageOf(error)}` });
}
async refreshModel(): Promise<void> { if (!this.state.sessionId) return; const model = await api<RedactedModelConnection>(`/api/sessions/${this.state.sessionId}/model-config`); this.update({ model }); }
async saveModel(input: unknown): Promise<void> { const sessionId = await this.requireSession(); const parsed = modelConnectionInputSchema.parse(input); const model = await api<RedactedModelConnection>(`/api/sessions/${sessionId}/model-config`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(parsed) }); persistModel(withoutCredential(parsed)); this.update({ model }); }
}
async refreshModel(): Promise<void> {
if (!this.state.sessionId) return;
const sessionId = this.state.sessionId;
const generation = this.generation;
this.update({ modelLoadState: "loading", modelLoadError: undefined });
try {
const model = await api<RedactedModelConnection>(`/api/sessions/${sessionId}/model-config`);
if (generation !== this.generation || sessionId !== this.state.sessionId) return;
this.update({ model, modelLoadState: "loaded", modelLoadError: undefined });
} catch (error) {
if (generation !== this.generation || sessionId !== this.state.sessionId) return;
this.update({ modelLoadState: "error", modelLoadError: `服务器模型配置读取失败:${messageOf(error)}` });
}
}
async retryModel(): Promise<void> {
if (!this.state.sessionId) { await this.initialize(); return; }
if (this.state.connection === "offline") this.connect();
await this.refreshModel();
await this.restorePersistedModel();
}
async saveModel(input: unknown): Promise<void> { const sessionId = await this.requireSession(); const parsed = modelConnectionInputSchema.parse(input); const model = await api<RedactedModelConnection>(`/api/sessions/${sessionId}/model-config`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(parsed) }); persistModel(withoutCredential(parsed)); this.update({ model, modelLoadState: "loaded", modelLoadError: undefined }); }
async clearCredentials(): Promise<void> { const sessionId = await this.requireSession(); await api(`/api/sessions/${sessionId}/model-config/credentials`, { method: "DELETE" }); await this.refreshModel(); }
forgetPersistedModel(): void { removePersistedModel(); }
async testModel(): Promise<string> { const sessionId = await this.requireSession(); const result = await api<{ message: string }>(`/api/sessions/${sessionId}/model-config/test`, { method: "POST" }); return result.message; }
@@ -195,7 +223,7 @@ export class HarnessClientStore {
};
private async restorePersistedModel(): Promise<void> {
if (!this.state.sessionId || this.state.model.configured) return;
if (!this.state.sessionId || this.state.modelLoadState !== "loaded" || this.state.model.configured) return;
const persisted = readPersistedModel();
if (!persisted) return;
try {
+10 -7
View File
@@ -4,7 +4,7 @@ import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimiti
import { ImagePlus, Send, Square, X } from "lucide-react";
import * as Dialog from "@radix-ui/react-dialog";
import { ToolFallback } from "./ToolUIs";
import { useHarnessState } from "../assistant/HarnessContext";
import { useHarnessState, useHarnessStore } from "../assistant/HarnessContext";
const demoPrompt = "请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。";
@@ -27,23 +27,26 @@ function ComposerAttachment() {
export function ChatPanel({ openSettings }: { openSettings: () => void }) {
const state = useHarnessState();
const store = useHarnessStore();
const aui = useAui();
const [dragging, setDragging] = useState(false);
const modelReady = state.model.configured && state.model.keyConfigured;
const modelReady = state.modelLoadState === "loaded" && state.model.configured && state.model.keyConfigured;
const fillDemo = () => aui.composer().setText(demoPrompt);
return <main className="chat-panel" aria-label="Streaming Chat">
<div className="chat-toolbar"><div><strong>{state.isRunning ? "Agent 正在运行" : "准备就绪"}</strong><span className={`connection ${state.connection}`}>{state.connection}</span></div><button className="text-button" onClick={fillDemo}> Demo Prompt</button></div>
{!modelReady && <button className="config-banner" onClick={openSettings}>{state.model.configured ? "模型配置已恢复,请在 Settings 重新输入 API Key。" : "尚未配置模型。打开 Settings 后才能发送消息。"}</button>}
{state.error && <div className="error-banner" role="alert">{state.error}</div>}
<ThreadPrimitive.Root className="thread-root"><ThreadPrimitive.Viewport className="thread-viewport"><ThreadPrimitive.Empty><div className="empty-state"><div className="orb">π</div><h2>Agent Loop </h2><p> Demo Prompt Skill Tool </p></div></ThreadPrimitive.Empty><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }}/><ThreadPrimitive.ViewportFooter className="composer-footer">
{state.modelLoadState === "loading" && <div className="model-loading-banner" role="status"></div>}
{state.modelLoadState === "error" && <div className="error-banner" role="alert">{state.modelLoadError ?? "服务器模型配置读取失败"}<button className="text-button" onClick={() => void store.retryModel()}></button></div>}
{state.modelLoadState === "loaded" && !modelReady && <button className="config-banner" onClick={openSettings}>{state.model.configured ? "模型已配置,但缺少 API Key。请在 Settings 输入 API Key。" : "尚未配置模型。打开 Settings 后才能发送消息。"}</button>}
{state.error && state.error !== state.modelLoadError && <div className="error-banner" role="alert">{state.error}</div>}
<ThreadPrimitive.Root className="thread-root"><ThreadPrimitive.Viewport className="thread-viewport"><ThreadPrimitive.Empty><div className="empty-state"><div className="orb">π</div><h2>Agent Loop </h2><p> Demo Prompt Skill Tool </p></div></ThreadPrimitive.Empty><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }}/></ThreadPrimitive.Viewport><div className="composer-footer">
<ComposerPrimitive.Root className={`composer ${dragging ? "dragging" : ""}`}>
<ComposerPrimitive.AttachmentDropzone className="dropzone" onDragEnter={() => setDragging(true)} onDragLeave={() => setDragging(false)} onDrop={() => setDragging(false)}>
<ComposerPrimitive.Attachments components={{ Attachment: ComposerAttachment }}/>
<ComposerPrimitive.Input className="composer-input" placeholder={modelReady ? "给 Agent Studio Mini 发送任务…" : state.model.configured ? "请重新输入 API Key" : "请先配置模型连接"} aria-label="消息输入"/>
<ComposerPrimitive.Input className="composer-input" placeholder={state.modelLoadState === "loading" ? "正在读取服务器模型配置…" : state.modelLoadState === "error" ? "模型配置读取失败,请重试" : modelReady ? "给 Agent Studio Mini 发送任务…" : state.model.configured ? "请重新输入 API Key" : "请先配置模型连接"} aria-label="消息输入"/>
<div className="composer-actions"><ComposerPrimitive.AddAttachment className="icon-button" aria-label="添加图片" title="选择、拖拽或粘贴 JPEG/PNG/WebP"><ImagePlus/></ComposerPrimitive.AddAttachment><span className="composer-hint"> 4 · 5 MiB/</span>{state.isRunning ? <ComposerPrimitive.Cancel className="send-button stop" aria-label="停止运行"><Square size={16}/></ComposerPrimitive.Cancel> : <ComposerPrimitive.Send className="send-button" aria-label="发送消息"><Send size={17}/></ComposerPrimitive.Send>}</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
</ThreadPrimitive.ViewportFooter></ThreadPrimitive.Viewport></ThreadPrimitive.Root>
</div></ThreadPrimitive.Root>
<div className="sr-live" aria-live="polite">{state.isRunning ? "" : state.messages.at(-1)?.role === "assistant" ? "Assistant 回复已完成" : ""}</div>
</main>;
}
+10 -7
View File
@@ -15,7 +15,7 @@ button, input, select, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
button:disabled { opacity: .46; cursor: not-allowed; }
.app-shell { display: grid; grid-template-rows: 64px 1fr; height: 100%; }
.app-shell { display: grid; grid-template-rows: 64px minmax(0, 1fr); height: 100%; height: 100dvh; }
.topbar { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 16px; border-bottom: 1px solid var(--border); background: rgb(12 13 15 / .9); backdrop-filter: blur(14px); z-index: 10; }
.brand, .session-summary, .brand > div, .chat-toolbar > div { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand > div { align-items: flex-start; flex-direction: column; gap: 1px; }
@@ -52,14 +52,17 @@ button:disabled { opacity: .46; cursor: not-allowed; }
.diagnostic.valid { color: var(--cyan); border-color: #267a7a; }.diagnostic.warning { color: #ffd166; border-color: #806c2d; }.diagnostic.error { color: var(--pink); border-color: #8d2940; }
.diagnostic-message { color: #ffb4c4; font-size: 11px; margin-top: 8px; }
.panel-state { padding: 20px; color: var(--muted); font-size: 13px; text-align: center; }
.chat-panel { height: 100%; min-height: 0; display: grid; grid-template-rows: auto auto auto 1fr; background: var(--surface-0); }
.chat-panel { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--surface-0); }
.chat-toolbar { min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid var(--border); font-size: 12px; }
.connection { text-transform: capitalize; color: var(--muted); }.connection.online { color: var(--cyan); }.connection.reconnecting { color: #ffd166; }.connection.offline { color: var(--pink); }
.text-button { min-height: 32px; color: var(--cyan); }
.config-banner, .error-banner { margin: 10px 16px 0; border-radius: 8px; padding: 10px 12px; text-align: left; }
.config-banner, .error-banner, .model-loading-banner { flex-shrink: 0; margin: 10px 16px 0; border-radius: 8px; padding: 10px 12px; text-align: left; }
.config-banner { border: 1px solid #756326; background: #28230f; color: #ffe395; cursor: pointer; }.error-banner { border: 1px solid #7e293b; background: #2b1118; color: #ffbdca; }
.thread-root, .thread-viewport { min-height: 0; height: 100%; }
.thread-viewport { overflow-y: auto; padding: 16px 0 0; scroll-padding-bottom: 170px; }
.chat-toolbar { flex-shrink: 0; }
.model-loading-banner { color: var(--muted); background: var(--surface-1); }
.error-banner .text-button { margin-left: 12px; }
.thread-root { flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; }
.thread-viewport { min-height: 0; overflow-y: auto; padding: 16px 0; scroll-padding-bottom: 16px; }
.empty-state { min-height: 52vh; display: grid; place-items: center; align-content: center; text-align: center; padding: 32px; color: var(--muted); }
.empty-state h2 { margin: 16px 0 6px; color: var(--text); font-size: 22px; }.empty-state p { max-width: 460px; line-height: 1.55; }
.orb { width: 70px; height: 70px; display: grid; place-items: center; border-radius: 50%; color: var(--cyan); font: 700 28px Georgia, serif; background: radial-gradient(circle at 35% 30%, #27535a, #121c20 62%); border: 1px solid #2e6f74; box-shadow: 0 0 50px rgb(37 244 238 / .12); }
@@ -78,7 +81,7 @@ button:disabled { opacity: .46; cursor: not-allowed; }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--border); }.metrics div { padding: 10px; border-right: 1px solid var(--border); }.metrics span, .metrics strong { display: block; }.metrics span { color: var(--muted); font-size: 10px; }.metrics strong { margin-top: 4px; font-size: 14px; }
.approval-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); }
.accent { color: #071011; background: var(--cyan); border-color: var(--cyan); font-weight: 750; }.accent:hover { background: #66fffa !important; }.danger { color: #ffb3c3; border-color: #7e2d40; background: #291319; }.danger:hover { border-color: var(--pink) !important; background: #36151e !important; }
.composer-footer { position: sticky; bottom: 0; padding: 16px; background: linear-gradient(transparent, var(--surface-0) 18%); }
.composer-footer { padding: 16px; padding-bottom: max(16px, env(safe-area-inset-bottom)); background: var(--surface-0); }
.composer { width: min(780px, 100%); margin: 0 auto; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.composer.dragging { border-color: var(--cyan); box-shadow: 0 0 0 3px rgb(37 244 238 / .12); }
.dropzone { padding: 8px; }
@@ -101,7 +104,7 @@ button:disabled { opacity: .46; cursor: not-allowed; }
@keyframes pulse { 50% { opacity: .45; transform: scale(.8); } } @keyframes fade { from { opacity: 0; } }
@media (max-width: 1050px) { .workspace-grid { grid-template-columns: 230px minmax(380px, 1fr); }.workspace-grid > .mobile-panel:last-child { display: none; }.session-summary > span:nth-child(2) { display: none; } }
@media (max-width: 720px) {
html, body, #root { overflow: hidden; }.app-shell { grid-template-rows: 58px 44px 1fr; }.brand small, .model-badge, .vision-badge, .session-summary > .run-dot { display: none; }.topbar { padding: 0 10px; }.settings-button { width: 42px; padding: 0; font-size: 0; }.settings-button svg { width: 18px; }.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-1); }.mobile-tabs button { border: 0; background: transparent; color: var(--muted); text-transform: capitalize; }.mobile-tabs button.active { color: var(--cyan); box-shadow: inset 0 -2px var(--cyan); }.workspace-grid { display: block; min-height: 0; }.mobile-panel { display: none !important; height: 100%; }.mobile-panel.shown { display: block !important; }.side-panel { border: 0; }.chat-toolbar { padding: 6px 10px; }.text-button { padding: 0 8px; }.composer-footer { padding: 10px; }.message { width: calc(100% - 20px); }.metrics { grid-template-columns: repeat(2, 1fr); }.form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: span 1 !important; }.presets { overflow-x: auto; }.presets button { flex: 0 0 auto; }.dialog-actions { flex-wrap: wrap; }.dialog-actions button { flex: 1; }.dialog-actions .danger { margin: 0; }.composer-hint { display: none; }
html, body, #root { overflow: hidden; }.app-shell { grid-template-rows: 58px 44px minmax(0, 1fr); }.brand small, .model-badge, .vision-badge, .session-summary > .run-dot { display: none; }.topbar { padding: 0 10px; }.settings-button { width: 42px; padding: 0; font-size: 0; }.settings-button svg { width: 18px; }.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-1); }.mobile-tabs button { border: 0; background: transparent; color: var(--muted); text-transform: capitalize; }.mobile-tabs button.active { color: var(--cyan); box-shadow: inset 0 -2px var(--cyan); }.workspace-grid { display: block; min-height: 0; }.mobile-panel { display: none !important; height: 100%; }.mobile-panel.shown { display: block !important; }.side-panel { border: 0; }.chat-toolbar { padding: 6px 10px; }.text-button { padding: 0 8px; }.composer-footer { padding: 10px; padding-bottom: max(10px, env(safe-area-inset-bottom)); }.message { width: calc(100% - 20px); }.metrics { grid-template-columns: repeat(2, 1fr); }.form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: span 1 !important; }.presets { overflow-x: auto; }.presets button { flex: 0 0 auto; }.dialog-actions { flex-wrap: wrap; }.dialog-actions button { flex: 1; }.dialog-actions .danger { margin: 0; }.composer-hint { display: none; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }
+109
View File
@@ -64,3 +64,112 @@ describe("generated report events", () => {
expect(Array.isArray(messages[0]?.content) && messages[0].content.filter((part) => part.type === "image")).toHaveLength(1);
});
});
const environmentModel = { configured: true, source: "environment", providerId: "openai", modelId: "env-model", keyConfigured: true } as const;
const savedMetadata = { providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false };
class ModelEventSource {
static readonly CLOSED = 2;
static current: ModelEventSource;
readonly readyState = 1;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor() { ModelEventSource.current = this; }
addEventListener(): void {}
close(): void {}
}
function response(body: unknown) { return { ok: true, json: async () => body }; }
function gate() { let release!: () => void; const promise = new Promise<void>((resolve) => { release = resolve; }); return { promise, release }; }
describe("model configuration loading", () => {
it("loads environment defaults on initialization and Reset before considering browser metadata", async () => {
localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify(savedMetadata));
const sessionGate = gate(), modelGate = gate(), deleteGate = gate(), resetModelGate = gate();
let sessions = 0;
const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
if (url === "/api/sessions") { await sessionGate.promise; return response({ sessionId: `ses_${++sessions}` }); }
if (init?.method === "DELETE") { await deleteGate.promise; return response({}); }
if (url === "/api/skills") return response({ skills: [] });
await (sessions === 1 ? modelGate : resetModelGate).promise;
return response(environmentModel);
});
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("EventSource", ModelEventSource);
const store = new HarnessClientStore();
expect(store.getSnapshot().modelLoadState).toBe("loading");
const initializing = store.initialize();
expect(store.getSnapshot().modelLoadState).toBe("loading");
sessionGate.release();
await vi.waitFor(() => expect(store.getSnapshot().sessionId).toBe("ses_1"));
ModelEventSource.current.onopen?.();
expect(store.getSnapshot()).toMatchObject({ connection: "online", modelLoadState: "loading" });
modelGate.release(); await initializing;
expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: environmentModel });
const resetting = store.reset();
expect(store.getSnapshot().modelLoadState).toBe("loading");
deleteGate.release();
await vi.waitFor(() => expect(store.getSnapshot().sessionId).toBe("ses_2"));
expect(store.getSnapshot().modelLoadState).toBe("loading");
resetModelGate.release(); await resetting; ModelEventSource.current.onopen?.();
expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: environmentModel, connection: "online" });
expect(fetcher.mock.calls.some(([, init]) => init?.method === "PUT")).toBe(false);
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith("/test"))).toBe(false);
});
it("keeps model errors across SSE open and retries without restoring metadata over unknown server config", async () => {
localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify(savedMetadata));
let fail = true;
const fetcher = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url === "/api/sessions") return response({ sessionId: "ses_1" });
if (url === "/api/skills") return response({ skills: [] });
if (fail) throw new Error("model endpoint unavailable");
return response(environmentModel);
});
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("EventSource", ModelEventSource);
const store = new HarnessClientStore(); await store.initialize(); ModelEventSource.current.onopen?.();
expect(store.getSnapshot()).toMatchObject({ modelLoadState: "error", modelLoadError: "服务器模型配置读取失败:model endpoint unavailable", connection: "online" });
expect(localStorage.getItem("agent-studio-mini:model-connection:v1")).not.toBeNull();
fail = false; await store.retryModel();
expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", modelLoadError: undefined, model: environmentModel });
expect(fetcher).toHaveBeenCalledTimes(4);
});
it("restores only non-sensitive metadata after a successful retry confirms no server model", async () => {
localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify(savedMetadata));
const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_1";
const fetcher = vi.fn().mockRejectedValueOnce(new Error("unavailable"))
.mockResolvedValueOnce(response({ configured: false, source: "missing", keyConfigured: false }))
.mockResolvedValueOnce(response({ configured: true, source: "session", keyConfigured: false }));
vi.stubGlobal("fetch", fetcher);
await store.refreshModel(); expect(store.getSnapshot().modelLoadState).toBe("error");
await store.retryModel();
expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: { configured: true, keyConfigured: false } });
expect(JSON.parse(fetcher.mock.calls[2]![1].body)).toEqual(savedMetadata);
expect(sessionStorage.length).toBe(0);
});
it("marks a successful save loaded and refreshes authoritative state after clearing credentials", async () => {
const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_1";
vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("unavailable"))
.mockResolvedValueOnce(response(environmentModel))
.mockResolvedValueOnce(response({}))
.mockResolvedValueOnce(response({ ...environmentModel, keyConfigured: false })));
await store.refreshModel(); expect(store.getSnapshot().modelLoadState).toBe("error");
await store.saveModel({ providerId: "openai", api: "openai-responses", modelId: "env-model", supportsImages: false, apiKey: "secret" });
expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", modelLoadError: undefined });
await store.clearCredentials();
expect(store.getSnapshot()).toMatchObject({ modelLoadState: "loaded", model: { configured: true, keyConfigured: false } });
expect(JSON.stringify(localStorage)).not.toContain("secret");
});
it("ignores an old Session model response arriving after Reset", async () => {
const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_old";
const oldGate = gate();
vi.stubGlobal("EventSource", ModelEventSource);
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") return response({});
if (url === "/api/sessions") return response({ sessionId: "ses_new" });
if (url.includes("ses_old")) { await oldGate.promise; return response({ configured: false, source: "missing", keyConfigured: false }); }
return response(environmentModel);
}));
const oldRefresh = store.refreshModel(); await store.reset(); oldGate.release(); await oldRefresh;
expect(store.getSnapshot()).toMatchObject({ sessionId: "ses_new", modelLoadState: "loaded", model: environmentModel });
});
});
+88 -5
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HarnessEvent } from "@agent-studio/shared";
@@ -11,12 +11,95 @@ import { AppErrorBoundary } from "../src/components/AppErrorBoundary";
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe("web UI", () => {
it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /尚未配置模型/ })).toBeInTheDocument(); });
it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("status")).toHaveTextContent("正在读取服务器模型配置"); expect(screen.queryByText(/尚未配置模型|重新输入 API Key|Model missing/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); });
it("keeps Composer outside the scrolling message viewport and inside the Thread", async () => {
const store = new HarnessClientStore();
store.setMessages(Array.from({ length: 30 }, (_, index) => ({ id: `message-${index}`, role: "assistant", content: [{ type: "text", text: `回复 ${index}` }] })));
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
const input = screen.getByLabelText("消息输入");
expect(input.closest(".thread-viewport")).toBeNull();
const root = input.closest(".thread-root")!;
expect(root.querySelector(".thread-viewport")).toContainElement(screen.getByText("回复 29"));
expect(root.lastElementChild).toHaveClass("composer-footer");
expect(root.lastElementChild).toContainElement(input);
await userEvent.setup().click(screen.getByRole("button", { name: "chat" }));
expect(input.closest(".mobile-panel")).toHaveClass("shown");
});
it("enables sending after an environment model loads, and preserves send and stop controls", async () => {
const store = new HarnessClientStore();
Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online" });
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const fetcher = vi.fn(async (url: string) => {
if (url.endsWith("/model-config")) { await gate; return { ok: true, json: async () => ({ configured: true, source: "environment", keyConfigured: true }) }; }
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetcher);
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
const user = userEvent.setup();
await user.type(screen.getByLabelText("消息输入"), "请分析账户");
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
let refresh!: Promise<void>;
act(() => { refresh = store.refreshModel(); });
expect(screen.queryByText(/尚未配置模型|重新输入 API Key/)).not.toBeInTheDocument();
await act(async () => { release(); await refresh; });
expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "发送消息" }));
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/messages", expect.objectContaining({ method: "POST", body: JSON.stringify({ text: "请分析账户", attachmentIds: [] }) }));
act(() => store.reduce(harnessEvent(1, "run.started", { modelId: "env-model" })));
await user.click(screen.getByRole("button", { name: "停止运行" }));
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/cancel", expect.objectContaining({ method: "POST" }));
});
it("preserves attachment drag-and-drop upload and removal in the fixed Composer", async () => {
const store = new HarnessClientStore();
Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online", modelLoadState: "loaded", model: { configured: true, source: "environment", keyConfigured: true, supportsImages: true } });
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ attachmentId: "att_1", name: "photo.png", mediaType: "image/png" }) });
vi.stubGlobal("fetch", fetcher);
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:attachment") });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: vi.fn() });
const { container } = render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
const user = userEvent.setup();
expect(screen.getByRole("button", { name: "添加图片" })).toBeEnabled();
const dropzone = container.querySelector(".dropzone")!;
const dataTransfer = { types: ["Files"], files: [new File(["png"], "photo.png", { type: "image/png" })] };
fireEvent.dragEnter(dropzone, { dataTransfer });
fireEvent.drop(dropzone, { dataTransfer });
await waitFor(() => expect(screen.getByText("photo.png")).toBeInTheDocument());
expect(screen.getByText("photo.png").closest(".composer-footer")).not.toBeNull();
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/attachments", expect.objectContaining({ method: "POST", body: expect.any(FormData) }));
await user.click(screen.getByRole("button", { name: "移除图片" }));
await waitFor(() => expect(screen.queryByText("photo.png")).not.toBeInTheDocument());
expect(fetcher).toHaveBeenCalledWith("/api/sessions/ses_1/attachments/att_1", expect.objectContaining({ method: "DELETE" }));
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:attachment");
});
it("shows a recoverable read error without claiming missing credentials", async () => {
const store = new HarnessClientStore();
Object.assign(store.getSnapshot(), { sessionId: "ses_1", connection: "online", model: { configured: true, source: "environment", keyConfigured: true } });
vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("HTTP 503"))
.mockResolvedValueOnce({ ok: true, json: async () => ({ configured: true, source: "environment", keyConfigured: true }) }));
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
const user = userEvent.setup(); await user.type(screen.getByLabelText("消息输入"), "任务");
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
await act(() => store.refreshModel());
expect(screen.getByRole("alert")).toHaveTextContent("服务器模型配置读取失败:HTTP 503");
expect(screen.queryByText(/尚未配置模型|重新输入 API Key/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "重试读取模型配置" }));
await waitFor(() => expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled());
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("only offers missing-model Settings after the server confirms it is unconfigured", async () => {
const store = new HarnessClientStore(); store.getSnapshot().modelLoadState = "loaded";
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
await userEvent.setup().click(screen.getByRole("button", { name: /尚未配置模型/ }));
expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password");
});
it("renders the mock metrics Tool UI", () => { render(<MockAdsMetricsUI {...toolProps("mock_ads_metrics", { accountId: "demo-account", days: 7 }, { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } })}/>); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); });
it("renders approval actions inside save_report_draft", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><SaveReportDraftUI {...toolProps("save_report_draft", { content: "报告正文" }, undefined, { id: "apr_1" })}/></HarnessRuntime>); expect(screen.getByRole("button", { name: "批准一次" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument(); });
it("opens settings with password input and does not use Web Storage", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password"); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); });
it("prompts for a new key when local model metadata was restored", () => { const store = new HarnessClientStore(); store.getSnapshot().model = { configured: true, source: "session", providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false, keyConfigured: false }; render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByRole("button", { name: /模型配置已恢复/ })).toBeInTheDocument(); expect(screen.getByLabelText("消息输入")).toHaveAttribute("placeholder", "请重新输入 API Key"); });
it("omits empty optional model fields when saving settings", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); const save = vi.spyOn(store, "saveModel").mockResolvedValue(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); await user.type(screen.getByLabelText("API Key"), "sk-fake"); await user.click(screen.getByRole("button", { name: "保存配置" })); expect(save).toHaveBeenCalledWith({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fake", modelId: "gpt-5.4", supportsImages: true }); });
it("opens settings with password input and does not use Web Storage", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: "Settings" })); expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password"); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); });
it("prompts for a new key when local model metadata was restored", () => { const store = new HarnessClientStore(); store.getSnapshot().modelLoadState = "loaded"; store.getSnapshot().model = { configured: true, source: "session", providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false, keyConfigured: false }; render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByRole("button", { name: /模型配置,但缺少 API Key/ })).toBeInTheDocument(); expect(screen.getByLabelText("消息输入")).toHaveAttribute("placeholder", "请重新输入 API Key"); });
it("omits empty optional model fields when saving settings", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); const save = vi.spyOn(store, "saveModel").mockResolvedValue(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: "Settings" })); await user.type(screen.getByLabelText("API Key"), "sk-fake"); await user.click(screen.getByRole("button", { name: "保存配置" })); expect(save).toHaveBeenCalledWith({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fake", modelId: "gpt-5.4", supportsImages: true }); });
it("fills the classroom demo prompt without sending it", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: "填入 Demo Prompt" })); expect((screen.getByLabelText("消息输入") as HTMLTextAreaElement).value).toContain("ads-analysis"); expect(store.getSnapshot().events).toHaveLength(0); });
it("shows a recoverable fallback instead of a black screen for render failures", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const Broken = () => { throw new Error("tool renderer failed"); }; render(<AppErrorBoundary><Broken/></AppErrorBoundary>); expect(screen.getByRole("alert")).toHaveTextContent("界面渲染出现异常"); expect(screen.getByRole("button", { name: "重新加载页面" })).toBeInTheDocument(); });
it("renders a duplicated Pi Tool sequence and approval without crashing", () => {
+9
View File
@@ -3,6 +3,14 @@ import path from "node:path";
import { DefaultResourceLoader, SettingsManager, type InlineExtension, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
import type { SkillCatalogItem } from "@agent-studio/shared";
const languagePolicy = `会话回复语言规则:
默认使用中文回复,包括中文请求。
仅当首条用户输入中的自然语言请求明确为英文时,当前 Session 才使用英文回复。
判断时忽略 Skill 名称、Skill XML/正文、命令、路径、代码和技术标识;这些内容不作为英文发起会话的依据。
首条输入仅包含 Skill 内容或 Skill 名称,或自然语言无法明确判断时,选择中文;不要等待后续请求再选择语言。
选定的回复语言在当前 Session 内保持一致;Reset 创建新 Session 后重新判断。
保留代码、路径、命令和技术标识的原文。`;
export interface SkillIndexEntry extends SkillCatalogItem { canonicalPath: string }
export class SkillCatalog {
@@ -68,6 +76,7 @@ export class SkillCatalog {
settingsManager: SettingsManager.inMemory(),
additionalSkillPaths: this.canonicalDirectories,
extensionFactories,
appendSystemPromptOverride: (base) => [...base, languagePolicy],
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
+28
View File
@@ -103,6 +103,34 @@ describe("SkillCatalog", () => {
it("discovers .agents and .pi skills without reading bodies into the catalog", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await createSkill(root, ".pi/skills/two", "two", "Second skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect(catalog.list().filter((item) => ["one", "two"].includes(item.name))).toHaveLength(2); expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
});
it("appends the Session language policy while preserving default prompts, skills and extensions", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-language-"));
const agentDir = path.join(root, "agent-dir");
await createSkill(root, ".agents/skills/one", "one", "First skill");
await mkdir(path.join(root, ".pi/skills"), { recursive: true });
await writeFile(path.join(root, ".pi/APPEND_SYSTEM.md"), "Existing appended instruction");
const catalog = new SkillCatalog(root, agentDir);
await catalog.reload();
const extension = vi.fn();
const loader = await catalog.createSessionLoader(root, agentDir, [extension]);
expect(loader.getSystemPrompt()).toBeUndefined(); // Pi still builds its default Tool/Skill prompt.
expect(loader.getSystemPromptSource()).toBeUndefined();
const appended = loader.getAppendSystemPrompt();
expect(appended[0]).toBe("Existing appended instruction");
const policy = appended.at(-1)!;
expect(policy).toContain("默认使用中文回复,包括中文请求");
expect(policy).toContain("仅当首条用户输入中的自然语言请求明确为英文时");
expect(policy).toContain("Skill 名称、Skill XML/正文、命令、路径、代码和技术标识");
expect(policy).toContain("首条输入仅包含 Skill 内容或 Skill 名称,或自然语言无法明确判断时,选择中文");
expect(policy).toContain("当前 Session 内保持一致");
expect(policy).toContain("Reset 创建新 Session 后重新判断");
expect(loader.getSkills().skills.some((skill) => skill.name === "one")).toBe(true);
expect(extension).toHaveBeenCalledOnce();
await writeFile(path.join(root, ".pi/SYSTEM.md"), "Existing system prompt");
const next = await catalog.createSessionLoader(root, agentDir, []);
expect(next.getSystemPrompt()).toBe("Existing system prompt");
expect(next.getAppendSystemPrompt()).toEqual(appended);
});
it("loads path-delimited AGENT_SKILLS_DIRS and de-duplicates canonical paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const extra = path.join(root, "extra"); await createSkill(root, "extra/custom", "custom", "Extra skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), [extra, extra].join(path.delimiter)); await catalog.reload(); expect(catalog.list().filter((item) => item.name === "custom")).toHaveLength(1);
});