diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cc24ee6..9a7481c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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("chat"); return
Agent Studio MiniPi Coding Agent Harness
{state.sessionId?.slice(0, 16) ?? "creating session…"}{state.model.modelId ?? "Model missing"}{state.model.supportsImages && Vision}
setSettings(true)}/>
{settings && }
; } +export function App() { const store = useHarnessStore(); const state = useHarnessState(); const [settings, setSettings] = useState(false); const [tab, setTab] = useState("chat"); return
Agent Studio MiniPi Coding Agent Harness
{state.sessionId?.slice(0, 16) ?? "creating session…"}{state.modelLoadState === "loading" ? "正在读取模型…" : state.modelLoadState === "error" ? "模型读取失败" : state.model.modelId ?? "Model missing"}{state.model.supportsImages && Vision}
setSettings(true)}/>
{settings && }
; } diff --git a/apps/web/src/assistant/HarnessRuntime.tsx b/apps/web/src/assistant/HarnessRuntime.tsx index e68337e..0e25aa0 100644 --- a/apps/web/src/assistant/HarnessRuntime.tsx +++ b/apps/web/src/assistant/HarnessRuntime.tsx @@ -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, diff --git a/apps/web/src/assistant/store.ts b/apps/web/src/assistant/store.ts index 396f91a..58318b2 100644 --- a/apps/web/src/assistant/store.ts +++ b/apps/web/src/assistant/store.ts @@ -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(); private readonly seen = new Set(); private readonly attachments = new Map(); @@ -42,6 +44,7 @@ export class HarnessClientStore { } private async initializeOnce(): Promise { + 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 { 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 { if (!this.state.sessionId) return; const model = await api(`/api/sessions/${this.state.sessionId}/model-config`); this.update({ model }); } - async saveModel(input: unknown): Promise { const sessionId = await this.requireSession(); const parsed = modelConnectionInputSchema.parse(input); const model = await api(`/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 { + 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(`/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 { + 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 { const sessionId = await this.requireSession(); const parsed = modelConnectionInputSchema.parse(input); const model = await api(`/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 { const sessionId = await this.requireSession(); await api(`/api/sessions/${sessionId}/model-config/credentials`, { method: "DELETE" }); await this.refreshModel(); } forgetPersistedModel(): void { removePersistedModel(); } async testModel(): Promise { 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 { - 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 { diff --git a/apps/web/src/components/ChatPanel.tsx b/apps/web/src/components/ChatPanel.tsx index f4ff7a0..d0c00ea 100644 --- a/apps/web/src/components/ChatPanel.tsx +++ b/apps/web/src/components/ChatPanel.tsx @@ -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
{state.isRunning ? "Agent 正在运行" : "准备就绪"}{state.connection}
- {!modelReady && } - {state.error &&
{state.error}
} -
π

Agent Loop 从这里开始

选择 Demo Prompt,观察 Skill 按需加载、Tool 事件和人工审批。

+ {state.modelLoadState === "loading" &&
正在读取服务器模型配置…
} + {state.modelLoadState === "error" &&
{state.modelLoadError ?? "服务器模型配置读取失败"}
} + {state.modelLoadState === "loaded" && !modelReady && } + {state.error && state.error !== state.modelLoadError &&
{state.error}
} +
π

Agent Loop 从这里开始

选择 Demo Prompt,观察 Skill 按需加载、Tool 事件和人工审批。

setDragging(true)} onDragLeave={() => setDragging(false)} onDrop={() => setDragging(false)}> - +
最多 4 张 · 5 MiB/张{state.isRunning ? 停止 : 发送}
- +
{state.isRunning ? "" : state.messages.at(-1)?.role === "assistant" ? "Assistant 回复已完成" : ""}
; } diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index bab4317..3619dc5 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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; } } diff --git a/apps/web/tests/store.test.ts b/apps/web/tests/store.test.ts index a38204c..bb54e0d 100644 --- a/apps/web/tests/store.test.ts +++ b/apps/web/tests/store.test.ts @@ -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((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 }); + }); +}); diff --git a/apps/web/tests/ui.test.tsx b/apps/web/tests/ui.test.tsx index 2be3264..c98e9a8 100644 --- a/apps/web/tests/ui.test.tsx +++ b/apps/web/tests/ui.test.tsx @@ -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(); 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(); 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(); + 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((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(); + const user = userEvent.setup(); + await user.type(screen.getByLabelText("消息输入"), "请分析账户"); + expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); + let refresh!: Promise; + 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(); + 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(); + 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(); + 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.getByRole("alert")).toHaveTextContent("界面渲染出现异常"); expect(screen.getByRole("button", { name: "重新加载页面" })).toBeInTheDocument(); }); it("renders a duplicated Pi Tool sequence and approval without crashing", () => { diff --git a/packages/harness/src/skills.ts b/packages/harness/src/skills.ts index d19cdb7..04d03eb 100644 --- a/packages/harness/src/skills.ts +++ b/packages/harness/src/skills.ts @@ -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, diff --git a/packages/harness/tests/core.test.ts b/packages/harness/tests/core.test.ts index 8896f06..838d519 100644 --- a/packages/harness/tests/core.test.ts +++ b/packages/harness/tests/core.test.ts @@ -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); });