feat(web): add tools approvals models and images
This commit is contained in:
+67
-11
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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, "?");
|
||||
}
|
||||
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user