feat(web): add tools approvals models and images

This commit is contained in:
Wenxin Duan
2026-09-08 10:20:21 +08:00
parent 145033974e
commit 36b6cbac3e
18 changed files with 627 additions and 20 deletions
+67 -11
View File
@@ -1,15 +1,26 @@
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useEffect, useState, useSyncExternalStore } from "react";
import type { ImageAttachment, ModelConnectionInput } from "@agent-studio/shared";
import { HarnessAttachmentAdapter } from "./assistant/attachment-adapter.js";
import { useHarnessRuntime, type HarnessRuntimeStore } from "./assistant/use-harness-runtime.js";
import { Thread } from "./components/chat/Thread.js";
import { AppShell } from "./components/layout/AppShell.js";
import { SkillsPanel } from "./components/skills/SkillsPanel.js";
import { EventTimeline } from "./components/timeline/EventTimeline.js";
import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js";
import { ModelSettings, type ModelView } from "./components/settings/ModelSettings.js";
import { HarnessClientStore } from "./store/harness-client-store.js";
type AppStore = HarnessRuntimeStore & {
start(): Promise<void>;
getModelConfig(): Promise<{ modelConfig: { configured: boolean; modelId?: string; supportsImages?: boolean } }>;
getModelConfig(): Promise<{ modelConfig: ModelView }>;
putModelConfig?: (input: ModelConnectionInput) => Promise<{ modelConfig: ModelView }>;
testModelConfig?: () => Promise<{ status: "ok" | "deferred"; message: string }>;
clearCredentials?: () => Promise<unknown>;
approve?: (approvalId: string) => Promise<unknown>;
deny?: (approvalId: string) => Promise<unknown>;
uploadAttachment?: (file: File) => Promise<{ attachment: ImageAttachment }>;
deleteAttachment?: (attachmentId: string) => Promise<unknown>;
};
export type AppProps = {
@@ -21,43 +32,56 @@ export type AppProps = {
export function App({ store: suppliedStore, autoStart = true, modelConfigured: suppliedModelConfigured }: AppProps) {
const [ownedStore] = useState(() => new HarnessClientStore());
const store = suppliedStore ?? ownedStore;
const [settingsOpen, setSettingsOpen] = useState(false);
const snapshot = useSyncExternalStore(
(subscriber) => store.subscribe(subscriber),
() => store.getSnapshot(),
() => store.getSnapshot(),
);
const [model, setModel] = useState({
const [model, setModel] = useState<ModelView>({
configured: suppliedModelConfigured ?? false,
source: suppliedModelConfigured === true ? "session" : "missing",
modelId: suppliedModelConfigured === true ? "Configured model" : "No model",
supportsImages: false,
});
const runtime = useHarnessRuntime(store, { isSendDisabled: !model.configured });
const [attachmentAdapter] = useState(() => {
return store.uploadAttachment === undefined || store.deleteAttachment === undefined
? undefined
: new HarnessAttachmentAdapter({
uploadAttachment: (file) => store.uploadAttachment?.(file) ?? Promise.reject(new Error("附件上传接口不可用")),
deleteAttachment: (attachmentId) => store.deleteAttachment?.(attachmentId) ?? Promise.reject(new Error("附件删除接口不可用")),
});
});
const activeAttachmentAdapter = model.supportsImages === true ? attachmentAdapter : undefined;
const runtime = useHarnessRuntime(store, {
isSendDisabled: !model.configured,
...(activeAttachmentAdapter === undefined ? {} : { attachmentAdapter: activeAttachmentAdapter }),
});
const busy = Object.values(snapshot.runs).some((run) => run.status === "running");
const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending");
const pendingApproval = Object.values(snapshot.approvals).find((approval) => approval.status === "pending");
useEffect(() => {
if (!autoStart) return undefined;
let active = true;
void store.start().then(async () => {
const response = await store.getModelConfig();
if (active) setModel({
configured: response.modelConfig.configured,
modelId: response.modelConfig.modelId ?? "No model",
supportsImages: response.modelConfig.supportsImages ?? false,
});
if (active) setModel(response.modelConfig);
}).catch(() => undefined);
return () => { active = false; };
}, [autoStart, store]);
useEffect(() => () => attachmentAdapter?.dispose(), [attachmentAdapter]);
return (
<AssistantRuntimeProvider runtime={runtime}>
<AppShell
topBar={{
...(snapshot.sessionId === undefined ? {} : { sessionId: snapshot.sessionId }),
connection: snapshot.connection,
modelLabel: model.modelId,
supportsImages: model.supportsImages,
onOpenSettings: () => { window.dispatchEvent(new CustomEvent("agent-studio:open-settings")); },
modelLabel: model.modelId ?? "No model",
supportsImages: model.supportsImages ?? false,
onOpenSettings: () => setSettingsOpen(true),
onReset: () => window.location.reload(),
}}
skills={<SkillsPanel skills={snapshot.skills} />}
@@ -68,9 +92,41 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
connection={snapshot.connection}
error={snapshot.error}
approvalPending={approvalPending}
attachmentAdapter={activeAttachmentAdapter}
/>}
timeline={<EventTimeline events={snapshot.timeline} />}
/>
{pendingApproval !== undefined ? (
<ApprovalDialog
request={pendingApproval}
onApprove={(approvalId) => store.approve?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))}
onDeny={(approvalId) => store.deny?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))}
/>
) : null}
<ModelSettings
open={settingsOpen}
busy={busy}
current={model}
onClose={() => setSettingsOpen(false)}
onSave={async (input) => {
if (store.putModelConfig === undefined) throw new Error("模型配置接口不可用");
const result = await store.putModelConfig(input);
setModel(result.modelConfig);
return result.modelConfig;
}}
onTest={async () => {
if (store.testModelConfig === undefined) throw new Error("连接测试接口不可用");
return store.testModelConfig();
}}
onClear={async () => {
if (store.clearCredentials !== undefined) await store.clearCredentials();
setModel((current) => {
const next = { ...current, configured: false, source: "missing" as const };
delete next.keyHint;
return next;
});
}}
/>
</AssistantRuntimeProvider>
);
}
+3 -3
View File
@@ -3,8 +3,8 @@ import {
type ImageAttachment,
type MessageInput,
type ModelConnectionInput,
type RedactedModelConnection,
} from "@agent-studio/shared";
import type { ModelView } from "../components/settings/ModelSettings.js";
import type { ClientSkill } from "../store/reducer.js";
type RequestOptions = Omit<RequestInit, "body"> & { body?: unknown };
@@ -45,11 +45,11 @@ export class HarnessApi {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/cancel`, { method: "POST" });
}
public getModelConfig(sessionId: string): Promise<{ modelConfig: RedactedModelConnection; requestId: string }> {
public getModelConfig(sessionId: string): Promise<{ modelConfig: ModelView; requestId: string }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model-config`);
}
public putModelConfig(sessionId: string, input: ModelConnectionInput): Promise<{ modelConfig: RedactedModelConnection; requestId: string }> {
public putModelConfig(sessionId: string, input: ModelConnectionInput): Promise<{ modelConfig: ModelView; requestId: string }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model-config`, { method: "PUT", body: input });
}
@@ -0,0 +1,65 @@
import type { Attachment, AttachmentAdapter, CompleteAttachment, PendingAttachment } from "@assistant-ui/react";
type AttachmentApi = {
uploadAttachment(file: File): Promise<{ attachment: { attachmentId: string } }>;
deleteAttachment(attachmentId: string): Promise<unknown>;
};
export class HarnessAttachmentAdapter implements AttachmentAdapter {
public readonly accept = "image/jpeg,image/png,image/webp";
readonly #previews = new Map<string, string>();
public constructor(private readonly api: AttachmentApi) {}
public async add({ file }: { file: File }): Promise<PendingAttachment> {
if (!this.accept.split(",").includes(file.type)) throw new Error("仅支持 JPEG、PNG 和 WebP 图片");
const preview = URL.createObjectURL(file);
try {
const response = await this.api.uploadAttachment(file);
this.#previews.set(response.attachment.attachmentId, preview);
return {
id: response.attachment.attachmentId,
type: "image",
name: file.name,
contentType: file.type,
file,
status: { type: "requires-action", reason: "composer-send" },
};
} catch (error) {
URL.revokeObjectURL(preview);
throw error;
}
}
public async remove(attachment: Attachment): Promise<void> {
if (attachment.id.startsWith("att_")) await this.api.deleteAttachment(attachment.id);
this.#revoke(attachment.id);
}
public send(attachment: PendingAttachment): Promise<CompleteAttachment> {
this.#revoke(attachment.id);
return Promise.resolve({
id: attachment.id,
type: "image",
name: attachment.name,
contentType: attachment.contentType,
status: { type: "complete" },
content: [],
});
}
public previewUrl(attachmentId: string): string | undefined {
return this.#previews.get(attachmentId);
}
public dispose(): void {
for (const url of this.#previews.values()) URL.revokeObjectURL(url);
this.#previews.clear();
}
#revoke(attachmentId: string): void {
const preview = this.#previews.get(attachmentId);
if (preview !== undefined) URL.revokeObjectURL(preview);
this.#previews.delete(attachmentId);
}
}
@@ -1,6 +1,7 @@
import { useExternalStoreRuntime, type AppendMessage } from "@assistant-ui/react";
import { useMemo, useSyncExternalStore } from "react";
import type { MessageInput } from "@agent-studio/shared";
import type { AttachmentAdapter } from "@assistant-ui/react";
import { toThreadMessageLike } from "./message-mapper.js";
import type { HarnessClientState } from "../store/reducer.js";
@@ -11,7 +12,7 @@ export type HarnessRuntimeStore = {
cancel(): Promise<unknown>;
};
export function useHarnessRuntime(store: HarnessRuntimeStore, options: { isSendDisabled?: boolean } = {}) {
export function useHarnessRuntime(store: HarnessRuntimeStore, options: { isSendDisabled?: boolean; attachmentAdapter?: AttachmentAdapter } = {}) {
const snapshot = useSyncExternalStore(
(subscriber) => store.subscribe(subscriber),
() => store.getSnapshot(),
@@ -33,6 +34,7 @@ export function useHarnessRuntime(store: HarnessRuntimeStore, options: { isSendD
convertMessage: (message) => message,
isRunning,
isSendDisabled: options.isSendDisabled,
...(options.attachmentAdapter === undefined ? {} : { adapters: { attachments: options.attachmentAdapter } }),
onNew: async (message) => {
await store.sendMessage(extractMessageInput(message));
},
@@ -0,0 +1,83 @@
import { ShieldAlert } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ClientApproval } from "../../store/reducer.js";
export function ApprovalDialog({ request, onApprove, onDeny }: {
request: ClientApproval;
onApprove: (approvalId: string) => Promise<unknown>;
onDeny: (approvalId: string) => Promise<unknown>;
}) {
const [submitting, setSubmitting] = useState(false);
const approveRef = useRef<HTMLButtonElement>(null);
const pending = request.status === "pending";
const [remaining, setRemaining] = useState(() => secondsRemaining(request.expiresAt));
useEffect(() => {
approveRef.current?.focus();
}, []);
useEffect(() => {
const timer = window.setInterval(() => setRemaining(secondsRemaining(request.expiresAt)), 1_000);
return () => window.clearInterval(timer);
}, [request.expiresAt]);
const decide = async (decision: "approve" | "deny") => {
if (submitting || !pending) return;
setSubmitting(true);
try {
await (decision === "approve" ? onApprove(request.approvalId) : onDeny(request.approvalId));
} catch {
setSubmitting(false);
}
};
return (
<div className="dialog-backdrop" onMouseDown={(event) => event.preventDefault()}>
<section
className="dialog approval-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="approval-title"
onKeyDown={(event) => trapFocus(event)}
>
<span className="dialog-icon dialog-icon--warning"><ShieldAlert size={22} aria-hidden="true" /></span>
<span className="eyebrow">SERVER APPROVAL</span>
<h2 id="approval-title">允许写入报告草稿吗?</h2>
<p>Agent 请求执行 <code>{request.toolName}</code>。批准后只会写入当前训练会话的固定报告文件。</p>
<dl className="approval-details">
<div><dt>风险</dt><dd>{riskLabel(request.riskLevel)}</dd></div>
<div><dt>目标</dt><dd>report.md</dd></div>
<div><dt>审批 ID</dt><dd><code>{request.approvalId}</code></dd></div>
<div><dt>剩余时间</dt><dd>{remaining > 0 ? `${String(remaining)} 秒` : "等待服务器确认"}</dd></div>
</dl>
<div className="dialog-actions">
<button type="button" className="secondary-button" disabled={submitting || !pending} onClick={() => void decide("deny")}>拒绝</button>
<button ref={approveRef} type="button" className="send-button" disabled={submitting || !pending} onClick={() => void decide("approve")}>批准并保存</button>
</div>
<p className="dialog-note">提交后等待服务器事件确认,重复点击不会重复执行。</p>
</section>
</div>
);
}
function riskLabel(level: ClientApproval["riskLevel"]): string {
return level === "high" ? "高" : level === "medium" ? "中" : "低";
}
function secondsRemaining(expiresAt: string): number {
return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1_000));
}
function trapFocus(event: React.KeyboardEvent<HTMLElement>): void {
if (event.key !== "Tab") return;
const buttons = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
if (buttons.length === 0) return;
const first = buttons[0];
const last = buttons.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
@@ -0,0 +1,22 @@
import { AttachmentPrimitive, useAuiState } from "@assistant-ui/react";
import { Image, X } from "lucide-react";
import { useRef, useState } from "react";
import type { HarnessAttachmentAdapter } from "../../assistant/attachment-adapter.js";
import { ImagePreviewDialog } from "./ImagePreviewDialog.js";
export function ImageAttachment({ adapter }: { adapter: HarnessAttachmentAdapter }) {
const attachment = useAuiState((state) => state.attachment);
const [previewOpen, setPreviewOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const preview = adapter.previewUrl(attachment.id);
return (
<AttachmentPrimitive.Root className="image-attachment">
<button ref={triggerRef} type="button" className="image-attachment__preview" onClick={() => setPreviewOpen(true)} disabled={preview === undefined} aria-label={`预览 ${attachment.name}`}>
{preview === undefined ? <Image size={18} aria-hidden="true" /> : <img src={preview} alt="" />}
</button>
<AttachmentPrimitive.Name />
<AttachmentPrimitive.Remove className="icon-button" aria-label={`移除 ${attachment.name}`}><X size={15} /></AttachmentPrimitive.Remove>
<ImagePreviewDialog open={previewOpen} url={preview} name={attachment.name} onClose={() => { setPreviewOpen(false); queueMicrotask(() => triggerRef.current?.focus()); }} />
</AttachmentPrimitive.Root>
);
}
@@ -0,0 +1,16 @@
import { X } from "lucide-react";
import { useEffect, useRef } from "react";
export function ImagePreviewDialog({ open, url, name, onClose }: { open: boolean; url: string | undefined; name: string; onClose: () => void }) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => { if (open) closeRef.current?.focus(); }, [open]);
if (!open || url === undefined) return null;
return (
<div className="dialog-backdrop dialog-backdrop--preview" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<section className="image-preview-dialog" role="dialog" aria-modal="true" aria-label={`${name} 图片预览`} onKeyDown={(event) => { if (event.key === "Escape") onClose(); }}>
<button ref={closeRef} className="icon-button" type="button" aria-label="关闭图片预览" onClick={onClose}><X size={19} /></button>
<img src={url} alt={name} />
</section>
</div>
);
}
+9 -1
View File
@@ -1,16 +1,23 @@
import { ComposerPrimitive } from "@assistant-ui/react";
import { ArrowUp, Paperclip, Square } from "lucide-react";
import type { HarnessAttachmentAdapter } from "../../assistant/attachment-adapter.js";
import { ImageAttachment } from "../attachments/ImageAttachment.js";
export type ComposerProps = {
busy: boolean;
modelConfigured: boolean;
attachmentAdapter: HarnessAttachmentAdapter | undefined;
};
export function Composer({ busy, modelConfigured }: ComposerProps) {
export function Composer({ busy, modelConfigured, attachmentAdapter }: ComposerProps) {
return (
<div className="composer-area">
{!modelConfigured ? <p className="composer-hint">请先配置模型连接</p> : null}
<ComposerPrimitive.AttachmentDropzone className="composer-dropzone">
<ComposerPrimitive.Root className="composer">
{attachmentAdapter === undefined ? null : (
<ComposerPrimitive.Attachments components={{ Image: () => <ImageAttachment adapter={attachmentAdapter} /> }} />
)}
<ComposerPrimitive.Input
className="composer__input"
placeholder={modelConfigured ? "向 Agent 描述任务,Enter 发送" : "配置模型后即可开始"}
@@ -32,6 +39,7 @@ export function Composer({ busy, modelConfigured }: ComposerProps) {
)}
</div>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
<p className="composer-footnote">工具执行与审批状态以服务器事件为准</p>
</div>
);
+11 -3
View File
@@ -3,16 +3,21 @@ import { Bot, ChevronDown, Sparkles, User } from "lucide-react";
import { Composer } from "./Composer.js";
import { StateView } from "../common/StateView.js";
import type { ConnectionStatus } from "../../store/reducer.js";
import type { HarnessAttachmentAdapter } from "../../assistant/attachment-adapter.js";
import { AdsMetricsTool } from "../tools/AdsMetricsTool.js";
import { SaveReportTool } from "../tools/SaveReportTool.js";
import { ToolFallback } from "../tools/ToolFallback.js";
const demoPrompt = "请分析 demo-account 最近 7 天广告数据,并生成一份简短报告草稿。";
export function Thread({ runtime, busy, modelConfigured, connection, error, approvalPending }: {
export function Thread({ runtime, busy, modelConfigured, connection, error, approvalPending, attachmentAdapter }: {
runtime: AssistantRuntime;
busy: boolean;
modelConfigured: boolean;
connection: ConnectionStatus;
error: string | undefined;
approvalPending: boolean;
attachmentAdapter: HarnessAttachmentAdapter | undefined;
}) {
return (
<main className="chat-panel" aria-label="Streaming Chat">
@@ -41,7 +46,7 @@ export function Thread({ runtime, busy, modelConfigured, connection, error, appr
<ChevronDown size={18} aria-hidden="true" />
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer busy={busy} modelConfigured={modelConfigured} />
<Composer busy={busy} modelConfigured={modelConfigured} attachmentAdapter={attachmentAdapter} />
</ThreadPrimitive.Root>
</main>
);
@@ -60,7 +65,10 @@ function AssistantMessage() {
return (
<MessagePrimitive.Root className="message message--assistant">
<span className="message__avatar"><Bot size={16} aria-hidden="true" /></span>
<div className="message__body"><span className="message__label">Agent</span><MessagePrimitive.Parts /></div>
<div className="message__body">
<span className="message__label">Agent</span>
<MessagePrimitive.Parts components={{ tools: { by_name: { mock_ads_metrics: AdsMetricsTool, save_report_draft: SaveReportTool }, Fallback: ToolFallback } }} />
</div>
</MessagePrimitive.Root>
);
}
@@ -0,0 +1,108 @@
import type { ModelApi, ModelConnectionInput } from "@agent-studio/shared";
import { Eye, EyeOff, KeyRound, X } from "lucide-react";
import { useEffect, useRef, useState, type FormEvent } from "react";
export type ModelView = {
configured: boolean;
source: "session" | "environment" | "missing";
providerId?: string;
api?: ModelApi;
baseUrl?: string;
modelId?: string;
supportsImages?: boolean;
keyHint?: string;
};
export function ModelSettings({ open, busy, current, onClose, onSave, onTest, onClear }: {
open: boolean;
busy: boolean;
current?: ModelView;
onClose: () => void;
onSave: (input: ModelConnectionInput) => Promise<ModelView>;
onTest: () => Promise<{ status: "ok" | "deferred"; message: string }>;
onClear: () => Promise<unknown>;
}) {
const [providerId, setProviderId] = useState("openai");
const [api, setApi] = useState<ModelApi>("openai-responses");
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [modelId, setModelId] = useState("");
const [supportsImages, setSupportsImages] = useState(true);
const [reveal, setReveal] = useState(false);
const [saved, setSaved] = useState<ModelView | undefined>(current);
const [message, setMessage] = useState("");
const firstInput = useRef<HTMLSelectElement>(null);
useEffect(() => {
if (open) firstInput.current?.focus();
}, [open]);
useEffect(() => {
setSaved(current);
if (current === undefined) return;
setProviderId(current.providerId ?? "openai");
setApi(current.api ?? "openai-responses");
setBaseUrl(current.baseUrl ?? "");
setModelId(current.modelId ?? "");
setSupportsImages(current.supportsImages ?? true);
}, [current]);
if (!open) return null;
const submit = async (event: FormEvent) => {
event.preventDefault();
if (busy) return;
setMessage("");
try {
const result = await onSave({
providerId,
api,
...(baseUrl.trim() === "" ? {} : { baseUrl: baseUrl.trim() }),
...(apiKey === "" ? {} : { apiKey }),
modelId: modelId.trim(),
supportsImages,
});
setSaved(result);
setApiKey("");
setMessage("配置已保存在当前会话内存中");
} catch (error) {
setMessage(translateModelError(error));
}
};
return (
<div className="dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<section className="dialog settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title" onKeyDown={(event) => { if (event.key === "Escape") onClose(); }}>
<div className="dialog-title-row">
<div><span className="eyebrow">SESSION MODEL</span><h2 id="settings-title">模型连接</h2></div>
<button className="icon-button" type="button" aria-label="关闭模型设置" onClick={onClose}><X size={19} /></button>
</div>
<p className="settings-intro">密钥仅发送到本机服务并保存在当前会话内存中,不会写入浏览器存储。</p>
<form className="settings-form" onSubmit={(event) => void submit(event)}>
<label>服务商<select ref={firstInput} value={providerId} onChange={(event) => {
const value = event.target.value;
setProviderId(value);
setApi(value === "anthropic" ? "anthropic-messages" : "openai-responses");
}}><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option><option value="compatible">OpenAI Compatible</option></select></label>
<label>API 类型<select value={api} onChange={(event) => setApi(event.target.value as ModelApi)}><option value="openai-responses">OpenAI Responses</option><option value="openai-completions">OpenAI Completions</option><option value="anthropic-messages">Anthropic Messages</option></select></label>
<label className="settings-form__wide">Base URL(可选)<input type="url" value={baseUrl} placeholder="https://api.example.com/v1" onChange={(event) => setBaseUrl(event.target.value)} /></label>
<label>模型 ID<input aria-label="模型 ID" required value={modelId} onChange={(event) => setModelId(event.target.value)} /></label>
<label>图片能力<select value={supportsImages ? "yes" : "no"} onChange={(event) => setSupportsImages(event.target.value === "yes")}><option value="yes">支持图片</option><option value="no">仅文本</option></select></label>
<label className="settings-form__wide">API Key<div className="password-field"><KeyRound size={17} /><input aria-label="API Key" type={reveal ? "text" : "password"} value={apiKey} autoComplete="off" onChange={(event) => setApiKey(event.target.value)} /><button type="button" aria-label={reveal ? "隐藏 API Key" : "显示 API Key"} onClick={() => setReveal((value) => !value)}>{reveal ? <EyeOff size={17} /> : <Eye size={17} />}</button></div></label>
{saved?.keyHint === undefined ? null : <p className="key-hint">服务器中的密钥:<strong>{saved.keyHint}</strong></p>}
{message === "" ? null : <p className="settings-message" role="status">{message}</p>}
{busy ? <p className="settings-message settings-message--warning">运行期间不能更改模型配置</p> : null}
<div className="settings-actions">
<button className="secondary-button" type="button" onClick={() => void onClear()}>清除密钥</button>
<button className="secondary-button" type="button" onClick={() => void onTest().then((result) => setMessage(result.message))}>测试连接</button>
<button className="send-button" type="submit" disabled={busy}>保存配置</button>
</div>
</form>
</section>
</div>
);
}
function translateModelError(error: unknown): string {
if (!(error instanceof Error)) return "保存失败,请检查输入";
if ("code" in error && error.code === "BASE_URL_NOT_ALLOWED") return "该 Base URL 不符合安全策略";
return error.message;
}
@@ -0,0 +1,33 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { BarChart3, CheckCircle2, LoaderCircle } from "lucide-react";
type AdsArgs = { accountId?: string; account?: string; days?: number };
type AdsResult = { spend?: number; revenue?: number; roas?: number; conversions?: number };
export function AdsMetricsTool({ args, result, status }: ToolCallMessagePartProps<AdsArgs, AdsResult>) {
const complete = status?.type === "complete" || result !== undefined;
const account = args.accountId ?? args.account ?? "等待参数";
return (
<section className="tool-card" aria-label="广告指标工具">
<div className="tool-card__head">
<span className="tool-card__icon"><BarChart3 size={17} aria-hidden="true" /></span>
<div><span className="eyebrow">READ ONLY TOOL</span><strong>广告指标</strong></div>
<span className={`tool-status ${complete ? "tool-status--success" : ""}`}>
{complete ? <CheckCircle2 size={14} /> : <LoaderCircle className="spin" size={14} />}
{complete ? "完成" : "读取中"}
</span>
</div>
<dl className="tool-summary">
<div><dt>账户</dt><dd>{account}</dd></div>
<div><dt>范围</dt><dd>{args.days === undefined ? "待定" : `${String(args.days)} 天`}</dd></div>
<div><dt>Spend</dt><dd>{formatMetric(result?.spend)}</dd></div>
<div><dt>Revenue</dt><dd>{formatMetric(result?.revenue)}</dd></div>
<div><dt>ROAS</dt><dd>{formatMetric(result?.roas)}</dd></div>
</dl>
</section>
);
}
function formatMetric(value: number | undefined): string {
return value === undefined ? "..." : String(value);
}
@@ -0,0 +1,32 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { CheckCircle2, FilePenLine, LoaderCircle, XCircle } from "lucide-react";
type SaveArgs = { title?: string; body?: string };
type SaveResult = { relativePath?: string; path?: string };
export function SaveReportTool({ args, result, status }: ToolCallMessagePartProps<SaveArgs, SaveResult>) {
const failed = status?.type === "incomplete";
const complete = status?.type === "complete" || result !== undefined;
return (
<section className="tool-card tool-card--write" aria-label="保存报告草稿工具">
<div className="tool-card__head">
<span className="tool-card__icon"><FilePenLine size={17} aria-hidden="true" /></span>
<div><span className="eyebrow">APPROVAL REQUIRED</span><strong>保存报告草稿</strong></div>
<span className={`tool-status ${failed ? "tool-status--failed" : complete ? "tool-status--success" : "tool-status--waiting"}`}>
{failed ? <XCircle size={14} /> : complete ? <CheckCircle2 size={14} /> : <LoaderCircle className="spin" size={14} />}
{failed ? "未执行" : complete ? "已保存" : "等待审批"}
</span>
</div>
<div className="report-summary">
<strong>{args.title?.trim() || "未命名报告"}</strong>
<p>{summarize(args.body)}</p>
{complete ? <code>{result?.relativePath ?? result?.path ?? "report.md"}</code> : null}
</div>
</section>
);
}
function summarize(body: string | undefined): string {
if (body === undefined || body.trim() === "") return "正文参数尚未完成";
return body.length > 180 ? `${body.slice(0, 180)}...` : body;
}
@@ -0,0 +1,18 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { Wrench } from "lucide-react";
export function ToolFallback({ toolName, status }: ToolCallMessagePartProps) {
return (
<section className="tool-card" aria-label={`${toolName} 工具`}>
<div className="tool-card__head">
<span className="tool-card__icon"><Wrench size={17} aria-hidden="true" /></span>
<div><span className="eyebrow">TOOL</span><strong>{safeName(toolName)}</strong></div>
<span className="tool-status">{status?.type === "complete" ? "完成" : status?.type === "incomplete" ? "失败" : "运行中"}</span>
</div>
</section>
);
}
function safeName(name: string): string {
return name.slice(0, 80).replace(/[^a-zA-Z0-9._-]/g, "?");
}
+60 -1
View File
@@ -48,6 +48,7 @@
.scroll-button { position: sticky; bottom: 8px; left: 50%; width: 38px; height: 38px; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow); }
.composer-area { padding: 0 clamp(16px, 4vw, 56px) 16px; }
.composer { max-width: 820px; margin: 0 auto; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow); }
.composer-dropzone { display: block; }
.composer:focus-within { border-color: #4c7775; }
.composer__input { width: 100%; min-height: 76px; max-height: 190px; resize: none; padding: 16px 18px 8px; border: 0; outline: 0; color: var(--text); background: transparent; line-height: 1.5; }
.composer__input::placeholder { color: #65716f; }
@@ -72,7 +73,65 @@
.state-view--error svg { color: var(--danger); }
.state-view strong { display: block; }
.state-view p { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
.tool-card { margin: 14px 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
.tool-card--write { border-color: #584d32; }
.tool-card__head { min-height: 58px; padding: 10px 12px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.tool-card__head > div { display: grid; gap: 3px; }
.tool-card__icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; color: var(--accent); background: #17302e; }
.tool-card--write .tool-card__icon { color: var(--warning); background: #332b19; }
.tool-status { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; }
.tool-status--success { color: var(--accent); }
.tool-status--failed { color: var(--danger); }
.tool-status--waiting { color: var(--warning); }
.tool-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); }
.tool-summary > div { min-width: 0; padding: 10px 12px; }
.tool-summary > div + div { border-left: 1px solid var(--border); }
.tool-summary dt, .approval-details dt { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }
.tool-summary dd, .approval-details dd { margin: 5px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: "SFMono-Regular", Consolas, monospace; font-size: 11px; }
.report-summary { padding: 12px; border-top: 1px solid var(--border); }
.report-summary p { margin: 6px 0; max-height: 58px; overflow: hidden; color: var(--muted); font-size: 12px; }
.report-summary code { color: var(--accent); font-size: 10px; }
.dialog-backdrop { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; padding: 20px; background: rgb(0 0 0 / 68%); }
.dialog { width: min(520px, 100%); max-height: calc(100dvh - 40px); overflow-y: auto; padding: 24px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: 0 28px 80px rgb(0 0 0 / 58%); }
.dialog h2 { margin: 8px 0 10px; font-size: 22px; }
.dialog > p { color: var(--muted); line-height: 1.6; }
.dialog-icon { width: 46px; height: 46px; display: grid; place-items: center; margin-bottom: 18px; border-radius: 13px; }
.dialog-icon--warning { color: var(--warning); background: #322b1b; }
.approval-details { display: grid; gap: 1px; margin: 18px 0; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.approval-details > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); padding: 10px 12px; background: var(--surface-raised); }
.approval-details dd { margin-top: 0; text-align: right; }
.dialog-actions, .settings-actions, .dialog-title-row { display: flex; align-items: center; }
.dialog-actions { justify-content: flex-end; gap: 8px; }
.dialog-actions .secondary-button { margin-top: 0; }
.dialog-note { margin: 12px 0 0; text-align: right; font-size: 10px; }
.dialog-title-row { justify-content: space-between; }
.dialog-title-row h2 { margin-bottom: 0; }
.settings-dialog { width: min(650px, 100%); }
.settings-intro { margin-bottom: 20px; }
.settings-form { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.settings-form label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; }
.settings-form input, .settings-form select { width: 100%; height: 44px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); background: var(--surface-raised); outline: 0; }
.settings-form input:focus, .settings-form select:focus { border-color: #4d8b88; }
.settings-form__wide, .settings-actions, .settings-message, .key-hint { grid-column: 1 / -1; }
.password-field { position: relative; }
.password-field > svg { position: absolute; left: 12px; top: 14px; color: var(--muted); }
.password-field input { padding-left: 40px; padding-right: 48px; }
.password-field button { position: absolute; right: 2px; top: 2px; width: 40px; height: 40px; display: grid; place-items: center; border: 0; color: var(--muted); background: transparent; }
.settings-actions { justify-content: flex-end; gap: 8px; margin-top: 5px; }
.settings-actions .secondary-button { margin-top: 0; }
.settings-message, .key-hint { margin: 0; font-size: 11px; }
.settings-message { color: var(--accent); }
.settings-message--warning { color: var(--warning); }
.image-attachment { display: grid; grid-template-columns: 42px minmax(0, 1fr) 36px; align-items: center; gap: 8px; margin: 10px 10px 0; padding: 7px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-raised); }
.image-attachment__preview { width: 42px; height: 42px; overflow: hidden; display: grid; place-items: center; border: 0; border-radius: 7px; color: var(--muted); background: #101313; }
.image-attachment__preview img { width: 100%; height: 100%; object-fit: cover; }
.image-attachment > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.image-attachment .icon-button { width: 36px; height: 36px; }
.dialog-backdrop--preview { z-index: 30; }
.image-preview-dialog { position: relative; width: min(820px, 100%); max-height: calc(100dvh - 40px); padding: 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
.image-preview-dialog .icon-button { position: absolute; z-index: 1; right: 16px; top: 16px; background: rgb(0 0 0 / 70%); color: white; }
.image-preview-dialog img { display: block; width: 100%; max-height: calc(100dvh - 66px); border-radius: 11px; object-fit: contain; }
.spin { animation: spin 1.2s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
@media (max-width: 720px) { .topbar { grid-template-columns: 1fr auto; padding: 0 12px; } .topbar__actions { grid-column: 2; } .thread-viewport { padding: 20px 16px; } .composer-area { padding: 0 10px 10px; } }
@media (max-width: 720px) { .topbar { grid-template-columns: 1fr auto; padding: 0 12px; } .topbar__actions { grid-column: 2; } .thread-viewport { padding: 20px 16px; } .composer-area { padding: 0 10px 10px; } .tool-summary { grid-template-columns: repeat(2, 1fr); } .tool-summary > div + div { border-left: 0; } .settings-form { grid-template-columns: 1fr; } .settings-form > * { grid-column: 1; } .settings-actions { flex-wrap: wrap; } }
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { HarnessAttachmentAdapter } from "../src/assistant/attachment-adapter.js";
describe("HarnessAttachmentAdapter", () => {
it("uploads immediately, emits no base64, and revokes its local preview", async () => {
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
const upload = vi.fn().mockResolvedValue({
attachment: { attachmentId: "att_server", sessionId: "ses_1", name: "one.png", mediaType: "image/png", byteSize: 68, width: 1, height: 1, status: "ready" },
});
const remove = vi.fn().mockResolvedValue({ removed: true });
const adapter = new HarnessAttachmentAdapter({ uploadAttachment: upload, deleteAttachment: remove });
const file = new File([new Uint8Array([137, 80, 78, 71])], "one.png", { type: "image/png" });
const pending = await adapter.add({ file });
expect(upload).toHaveBeenCalledWith(file);
expect(adapter.previewUrl("att_server")).toBe("blob:preview");
const complete = await adapter.send(pending);
expect(JSON.stringify(complete)).not.toContain("base64");
expect(revokeObjectURL).toHaveBeenCalledWith("blob:preview");
expect(createObjectURL).toHaveBeenCalledWith(file);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ModelSettings } from "../src/components/settings/ModelSettings.js";
describe("ModelSettings", () => {
it("saves through the server, clears the local key, and never uses browser storage", async () => {
const user = userEvent.setup();
const localSpy = vi.spyOn(Storage.prototype, "setItem");
const save = vi.fn().mockResolvedValue({ configured: true, source: "session", keyHint: "••••cret" });
render(<ModelSettings open onClose={vi.fn()} busy={false} onSave={save} onTest={vi.fn()} onClear={vi.fn()} />);
await user.type(screen.getByLabelText("API Key"), "sk-browser-secret");
await user.type(screen.getByLabelText("模型 ID"), "gpt-test");
await user.click(screen.getByRole("button", { name: "保存配置" }));
expect(save).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-browser-secret", modelId: "gpt-test" }));
expect(screen.getByLabelText("API Key")).toHaveValue("");
expect(screen.getByText("••••cret")).toBeVisible();
expect(localSpy).not.toHaveBeenCalled();
});
});
+2
View File
@@ -11,3 +11,5 @@ class TestResizeObserver implements ResizeObserver {
}
globalThis.ResizeObserver = TestResizeObserver;
URL.createObjectURL = () => "blob:test";
URL.revokeObjectURL = () => undefined;
+49
View File
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ApprovalDialog } from "../src/components/approvals/ApprovalDialog.js";
import { AdsMetricsTool } from "../src/components/tools/AdsMetricsTool.js";
import type { ClientApproval } from "../src/store/reducer.js";
const pendingApproval: ClientApproval = {
approvalId: "ap_1",
toolCallId: "tool_1",
toolName: "save_report_draft",
arguments: { title: "周报", body: "内容" },
riskLevel: "medium",
expiresAt: "2026-09-07T09:00:00.000Z",
status: "pending",
};
describe("tool and approval UI", () => {
it("disables both decisions after submitting approval", async () => {
const user = userEvent.setup();
let resolveApproval: (() => void) | undefined;
const approve = vi.fn(() => new Promise<void>((resolve) => { resolveApproval = resolve; }));
render(<ApprovalDialog request={pendingApproval} onApprove={approve} onDeny={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "批准并保存" }));
expect(screen.getByRole("button", { name: "批准并保存" })).toBeDisabled();
expect(screen.getByRole("button", { name: "拒绝" })).toBeDisabled();
expect(approve).toHaveBeenCalledTimes(1);
resolveApproval?.();
});
it("renders safe ads metrics fields", () => {
render(<AdsMetricsTool
type="tool-call"
toolName="mock_ads_metrics"
toolCallId="tool_1"
args={{ account: "demo-account", days: 7 }}
argsText=""
result={{ spend: 1024.5, revenue: 3088.2, roas: 3.01 }}
status={{ type: "complete" }}
addResult={vi.fn()}
resume={vi.fn()}
respondToApproval={vi.fn()}
/>);
expect(screen.getByText("demo-account")).toBeVisible();
expect(screen.getByText("3.01")).toBeVisible();
});
});