fix(chat): pin composer, set reply language, and track model loading
This commit is contained in:
@@ -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>; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,20 +158,45 @@ export class HarnessClientStore {
|
||||
|
||||
async reset(): Promise<void> {
|
||||
const old = this.state.sessionId;
|
||||
this.update({ modelLoadState: "loading", modelLoadError: undefined, connection: "connecting" });
|
||||
this.generation++;
|
||||
for (const image of this.reportImages.values()) if (image) URL.revokeObjectURL(image);
|
||||
this.reportImages.clear();
|
||||
this.source?.close();
|
||||
if (old) await api(`/api/sessions/${old}`, { method: "DELETE" });
|
||||
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 };
|
||||
for (const listener of this.listeners) listener();
|
||||
await this.refreshModel(); await this.restorePersistedModel(); this.connect();
|
||||
try {
|
||||
for (const image of this.reportImages.values()) if (image) URL.revokeObjectURL(image);
|
||||
this.reportImages.clear();
|
||||
this.source?.close();
|
||||
if (old) await api(`/api/sessions/${old}`, { method: "DELETE" });
|
||||
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 }, modelLoadState: "loading", modelLoadError: undefined, isRunning: false, connection: "connecting", error: undefined };
|
||||
for (const listener of this.listeners) listener();
|
||||
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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user