diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index db15ee8..684d276 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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; - 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; + approve?: (approvalId: string) => Promise; + deny?: (approvalId: string) => Promise; + uploadAttachment?: (file: File) => Promise<{ attachment: ImageAttachment }>; + deleteAttachment?: (attachmentId: string) => Promise; }; 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({ 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 ( { 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={} @@ -68,9 +92,41 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s connection={snapshot.connection} error={snapshot.error} approvalPending={approvalPending} + attachmentAdapter={activeAttachmentAdapter} />} timeline={} /> + {pendingApproval !== undefined ? ( + store.approve?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))} + onDeny={(approvalId) => store.deny?.(approvalId) ?? Promise.reject(new Error("Approval API unavailable"))} + /> + ) : null} + 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; + }); + }} + /> ); } diff --git a/apps/web/src/api/harness-api.ts b/apps/web/src/api/harness-api.ts index fd84ea6..2d078dc 100644 --- a/apps/web/src/api/harness-api.ts +++ b/apps/web/src/api/harness-api.ts @@ -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 & { 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 }); } diff --git a/apps/web/src/assistant/attachment-adapter.ts b/apps/web/src/assistant/attachment-adapter.ts new file mode 100644 index 0000000..56ec8ba --- /dev/null +++ b/apps/web/src/assistant/attachment-adapter.ts @@ -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; +}; + +export class HarnessAttachmentAdapter implements AttachmentAdapter { + public readonly accept = "image/jpeg,image/png,image/webp"; + readonly #previews = new Map(); + + public constructor(private readonly api: AttachmentApi) {} + + public async add({ file }: { file: File }): Promise { + 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 { + if (attachment.id.startsWith("att_")) await this.api.deleteAttachment(attachment.id); + this.#revoke(attachment.id); + } + + public send(attachment: PendingAttachment): Promise { + 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); + } +} diff --git a/apps/web/src/assistant/use-harness-runtime.tsx b/apps/web/src/assistant/use-harness-runtime.tsx index e3d6526..bb2d067 100644 --- a/apps/web/src/assistant/use-harness-runtime.tsx +++ b/apps/web/src/assistant/use-harness-runtime.tsx @@ -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; }; -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)); }, diff --git a/apps/web/src/components/approvals/ApprovalDialog.tsx b/apps/web/src/components/approvals/ApprovalDialog.tsx new file mode 100644 index 0000000..1df6812 --- /dev/null +++ b/apps/web/src/components/approvals/ApprovalDialog.tsx @@ -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; + onDeny: (approvalId: string) => Promise; +}) { + const [submitting, setSubmitting] = useState(false); + const approveRef = useRef(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 ( +
event.preventDefault()}> +
trapFocus(event)} + > + + SERVER APPROVAL +

允许写入报告草稿吗?

+

Agent 请求执行 {request.toolName}。批准后只会写入当前训练会话的固定报告文件。

+
+
风险
{riskLabel(request.riskLevel)}
+
目标
report.md
+
审批 ID
{request.approvalId}
+
剩余时间
{remaining > 0 ? `${String(remaining)} 秒` : "等待服务器确认"}
+
+
+ + +
+

提交后等待服务器事件确认,重复点击不会重复执行。

+
+
+ ); +} + +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): void { + if (event.key !== "Tab") return; + const buttons = [...event.currentTarget.querySelectorAll("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(); + } +} diff --git a/apps/web/src/components/attachments/ImageAttachment.tsx b/apps/web/src/components/attachments/ImageAttachment.tsx new file mode 100644 index 0000000..2f2f8b5 --- /dev/null +++ b/apps/web/src/components/attachments/ImageAttachment.tsx @@ -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(null); + const preview = adapter.previewUrl(attachment.id); + return ( + + + + + { setPreviewOpen(false); queueMicrotask(() => triggerRef.current?.focus()); }} /> + + ); +} diff --git a/apps/web/src/components/attachments/ImagePreviewDialog.tsx b/apps/web/src/components/attachments/ImagePreviewDialog.tsx new file mode 100644 index 0000000..9eae73c --- /dev/null +++ b/apps/web/src/components/attachments/ImagePreviewDialog.tsx @@ -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(null); + useEffect(() => { if (open) closeRef.current?.focus(); }, [open]); + if (!open || url === undefined) return null; + return ( +
{ if (event.target === event.currentTarget) onClose(); }}> +
{ if (event.key === "Escape") onClose(); }}> + + {name} +
+
+ ); +} diff --git a/apps/web/src/components/chat/Composer.tsx b/apps/web/src/components/chat/Composer.tsx index 00ba423..42da5a5 100644 --- a/apps/web/src/components/chat/Composer.tsx +++ b/apps/web/src/components/chat/Composer.tsx @@ -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 (
{!modelConfigured ?

请先配置模型连接

: null} + + {attachmentAdapter === undefined ? null : ( + }} /> + )} +

工具执行与审批状态以服务器事件为准

); diff --git a/apps/web/src/components/chat/Thread.tsx b/apps/web/src/components/chat/Thread.tsx index 4e49e99..358fe1b 100644 --- a/apps/web/src/components/chat/Thread.tsx +++ b/apps/web/src/components/chat/Thread.tsx @@ -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 (
@@ -41,7 +46,7 @@ export function Thread({ runtime, busy, modelConfigured, connection, error, appr
); @@ -60,7 +65,10 @@ function AssistantMessage() { return ( -
Agent
+
+ Agent + +
); } diff --git a/apps/web/src/components/settings/ModelSettings.tsx b/apps/web/src/components/settings/ModelSettings.tsx new file mode 100644 index 0000000..f6592bd --- /dev/null +++ b/apps/web/src/components/settings/ModelSettings.tsx @@ -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; + onTest: () => Promise<{ status: "ok" | "deferred"; message: string }>; + onClear: () => Promise; +}) { + const [providerId, setProviderId] = useState("openai"); + const [api, setApi] = useState("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(current); + const [message, setMessage] = useState(""); + const firstInput = useRef(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 ( +
{ if (event.target === event.currentTarget) onClose(); }}> +
{ if (event.key === "Escape") onClose(); }}> +
+
SESSION MODEL

模型连接

+ +
+

密钥仅发送到本机服务并保存在当前会话内存中,不会写入浏览器存储。

+
void submit(event)}> + + + + + + + {saved?.keyHint === undefined ? null :

服务器中的密钥:{saved.keyHint}

} + {message === "" ? null :

{message}

} + {busy ?

运行期间不能更改模型配置

: null} +
+ + + +
+
+
+
+ ); +} + +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; +} diff --git a/apps/web/src/components/tools/AdsMetricsTool.tsx b/apps/web/src/components/tools/AdsMetricsTool.tsx new file mode 100644 index 0000000..8bb77a9 --- /dev/null +++ b/apps/web/src/components/tools/AdsMetricsTool.tsx @@ -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) { + const complete = status?.type === "complete" || result !== undefined; + const account = args.accountId ?? args.account ?? "等待参数"; + return ( +
+
+ +
READ ONLY TOOL广告指标
+ + {complete ? : } + {complete ? "完成" : "读取中"} + +
+
+
账户
{account}
+
范围
{args.days === undefined ? "待定" : `${String(args.days)} 天`}
+
Spend
{formatMetric(result?.spend)}
+
Revenue
{formatMetric(result?.revenue)}
+
ROAS
{formatMetric(result?.roas)}
+
+
+ ); +} + +function formatMetric(value: number | undefined): string { + return value === undefined ? "..." : String(value); +} diff --git a/apps/web/src/components/tools/SaveReportTool.tsx b/apps/web/src/components/tools/SaveReportTool.tsx new file mode 100644 index 0000000..0ec41fb --- /dev/null +++ b/apps/web/src/components/tools/SaveReportTool.tsx @@ -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) { + const failed = status?.type === "incomplete"; + const complete = status?.type === "complete" || result !== undefined; + return ( +
+
+ +
APPROVAL REQUIRED保存报告草稿
+ + {failed ? : complete ? : } + {failed ? "未执行" : complete ? "已保存" : "等待审批"} + +
+
+ {args.title?.trim() || "未命名报告"} +

{summarize(args.body)}

+ {complete ? {result?.relativePath ?? result?.path ?? "report.md"} : null} +
+
+ ); +} + +function summarize(body: string | undefined): string { + if (body === undefined || body.trim() === "") return "正文参数尚未完成"; + return body.length > 180 ? `${body.slice(0, 180)}...` : body; +} diff --git a/apps/web/src/components/tools/ToolFallback.tsx b/apps/web/src/components/tools/ToolFallback.tsx new file mode 100644 index 0000000..57d4529 --- /dev/null +++ b/apps/web/src/components/tools/ToolFallback.tsx @@ -0,0 +1,18 @@ +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { Wrench } from "lucide-react"; + +export function ToolFallback({ toolName, status }: ToolCallMessagePartProps) { + return ( +
+
+ +
TOOL{safeName(toolName)}
+ {status?.type === "complete" ? "完成" : status?.type === "incomplete" ? "失败" : "运行中"} +
+
+ ); +} + +function safeName(name: string): string { + return name.slice(0, 80).replace(/[^a-zA-Z0-9._-]/g, "?"); +} diff --git a/apps/web/src/styles/components.css b/apps/web/src/styles/components.css index 336e9b5..beba38e 100644 --- a/apps/web/src/styles/components.css +++ b/apps/web/src/styles/components.css @@ -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; } } diff --git a/apps/web/tests/attachments.test.tsx b/apps/web/tests/attachments.test.tsx new file mode 100644 index 0000000..fa7cb7c --- /dev/null +++ b/apps/web/tests/attachments.test.tsx @@ -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); + }); +}); diff --git a/apps/web/tests/settings.test.tsx b/apps/web/tests/settings.test.tsx new file mode 100644 index 0000000..2fa5207 --- /dev/null +++ b/apps/web/tests/settings.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/apps/web/tests/setup.ts b/apps/web/tests/setup.ts index 2441b1a..1f07705 100644 --- a/apps/web/tests/setup.ts +++ b/apps/web/tests/setup.ts @@ -11,3 +11,5 @@ class TestResizeObserver implements ResizeObserver { } globalThis.ResizeObserver = TestResizeObserver; +URL.createObjectURL = () => "blob:test"; +URL.revokeObjectURL = () => undefined; diff --git a/apps/web/tests/tools-approval.test.tsx b/apps/web/tests/tools-approval.test.tsx new file mode 100644 index 0000000..cde93f5 --- /dev/null +++ b/apps/web/tests/tools-approval.test.tsx @@ -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((resolve) => { resolveApproval = resolve; })); + render(); + + 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(); + expect(screen.getByText("demo-account")).toBeVisible(); + expect(screen.getByText("3.01")).toBeVisible(); + }); +});