150 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|