Files
agent/apps/web/src/App.tsx
T

150 lines
6.6 KiB
TypeScript

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 { CompletionAnnouncer } from "./components/common/CompletionAnnouncer.js";
import { HarnessClientStore } from "./store/harness-client-store.js";
type AppStore = HarnessRuntimeStore & {
start(): Promise<void>;
dispose?: () => Promise<void>;
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 = {
store?: AppStore;
autoStart?: boolean;
modelConfigured?: boolean;
};
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<ModelView>({
configured: suppliedModelConfigured ?? false,
source: suppliedModelConfigured === true ? "session" : "missing",
modelId: suppliedModelConfigured === true ? "Configured model" : "No model",
supportsImages: false,
});
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");
const latestAssistantStatus = [...snapshot.messageOrder]
.reverse()
.map((messageId) => snapshot.messages[messageId])
.find((message) => message?.role === "assistant")?.status;
useEffect(() => {
if (!autoStart) return undefined;
let active = true;
void store.start().then(async () => {
const response = await store.getModelConfig();
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 ?? "No model",
supportsImages: model.supportsImages ?? false,
onOpenSettings: () => setSettingsOpen(true),
onReset: () => {
if (store.dispose === undefined) {
window.location.reload();
return;
}
void store.dispose()
.then(() => store.start())
.then(() => store.getModelConfig())
.then((response) => setModel(response.modelConfig))
.catch(() => undefined);
},
}}
skills={<SkillsPanel skills={snapshot.skills} />}
chat={<Thread
runtime={runtime}
busy={busy}
modelConfigured={model.configured}
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;
});
}}
/>
<CompletionAnnouncer status={latestAssistantStatus} />
</AssistantRuntimeProvider>
);
}