llm-to-agent/apps/web/src/app/app-controller.ts
2026-08-14 14:54:59 +08:00

320 lines
9.0 KiB
TypeScript

import van, { type State } from "vanjs-core";
import type {
ConversationResponse,
ConversationSummaryResponse,
ProjectResponse,
InteractionAnswerRequest,
InteractionResponse,
} from "@great-agent/web-contracts";
import {
getConversation,
listConversations,
listProjectConversations,
} from "../api/conversations";
import {
createProject,
deleteProject,
listProjects,
renameProject,
} from "../api/projects";
import { startRun, streamRun } from "../api/runs";
import {
answerInteraction as submitInteractionAnswer,
cancelWaitingRun,
listInteractions,
} from "../api/interactions";
export type Selection =
| { kind: "none" }
| { kind: "ordinary-draft" }
| { kind: "project-create" }
| { kind: "project-draft"; projectId: string }
| { kind: "project-rename"; projectId: string }
| { kind: "project-delete"; projectId: string }
| { kind: "conversation"; id: string };
export type AppController = Readonly<{
selection: State<Selection>;
recent: State<ConversationSummaryResponse[]>;
projects: State<ProjectResponse[]>;
projectConversations: State<Record<string, ConversationSummaryResponse[]>>;
active: State<ConversationResponse | null>;
interactions: State<InteractionResponse[]>;
loading: State<boolean>;
sending: State<boolean>;
assistantDraft: State<string>;
error: State<string>;
initialize(): Promise<void>;
startNewTask(): void;
startProjectCreation(): void;
createProject(name: string, workspaceRoot: string): Promise<void>;
openProject(id: string): Promise<void>;
startProjectTask(id: string): void;
startProjectRename(id: string): void;
renameProject(id: string, name: string): Promise<void>;
startProjectDelete(id: string): void;
deleteProject(id: string): Promise<void>;
openConversation(id: string): Promise<void>;
send(content: string): Promise<void>;
answerInteraction(
id: string,
answer: InteractionAnswerRequest,
): Promise<void>;
cancelInteraction(runId: string): Promise<void>;
}>;
export function createAppController(): AppController {
const selection = van.state<Selection>({ kind: "none" });
const recent = van.state<ConversationSummaryResponse[]>([]);
const projects = van.state<ProjectResponse[]>([]);
const projectConversations = van.state<
Record<string, ConversationSummaryResponse[]>
>({});
const active = van.state<ConversationResponse | null>(null);
const interactions = van.state<InteractionResponse[]>([]);
const loading = van.state(false);
const sending = van.state(false);
const assistantDraft = van.state("");
const error = van.state("");
async function initialize() {
loading.val = true;
error.val = "";
try {
[recent.val, projects.val] = await Promise.all([
listConversations(),
listProjects(),
]);
} catch (cause) {
error.val = readMessage(cause);
} finally {
loading.val = false;
}
}
function select(next: Selection) {
selection.val = next;
active.val = null;
interactions.val = [];
error.val = "";
}
function startNewTask() {
select({ kind: "ordinary-draft" });
}
function startProjectCreation() {
select({ kind: "project-create" });
}
function startProjectTask(projectId: string) {
select({ kind: "project-draft", projectId });
}
function startProjectRename(projectId: string) {
select({ kind: "project-rename", projectId });
}
function startProjectDelete(projectId: string) {
select({ kind: "project-delete", projectId });
}
async function createNewProject(name: string, workspaceRoot: string) {
await perform(async () => {
const created = await createProject(name, workspaceRoot);
projects.val = [created, ...projects.val];
projectConversations.val = {
...projectConversations.val,
[created.id]: [],
};
select({ kind: "project-draft", projectId: created.id });
});
}
async function openProject(id: string) {
await perform(async () => {
const conversations = await listProjectConversations(id);
projectConversations.val = {
...projectConversations.val,
[id]: conversations,
};
if (conversations[0]) await openConversation(conversations[0].id);
else select({ kind: "project-draft", projectId: id });
});
}
async function renameExistingProject(id: string, name: string) {
await perform(async () => {
const updated = await renameProject(id, name);
projects.val = projects.val.map((project) =>
project.id === id ? updated : project,
);
select({ kind: "project-draft", projectId: id });
});
}
async function deleteExistingProject(id: string) {
await perform(async () => {
await deleteProject(id);
projects.val = projects.val.filter((project) => project.id !== id);
const next = { ...projectConversations.val };
delete next[id];
projectConversations.val = next;
select({ kind: "none" });
});
}
async function openConversation(id: string) {
await perform(async () => {
active.val = await getConversation(id);
interactions.val = await listInteractions(id);
selection.val = { kind: "conversation", id };
});
}
async function send(content: string) {
if (!content.trim() || sending.val) return;
sending.val = true;
assistantDraft.val = "";
error.val = "";
try {
const current = selection.val;
const input =
current.kind === "conversation"
? {
kind: "existing" as const,
conversationId: current.id,
message: content,
}
: current.kind === "project-draft"
? {
kind: "project" as const,
projectId: current.projectId,
message: content,
}
: { kind: "ordinary" as const, message: content };
const started = await startRun(input);
active.val = await getConversation(started.conversationId);
selection.val = { kind: "conversation", id: started.conversationId };
await refreshLists(active.val.projectId);
await streamRun(started.runId, collectDelta);
await refreshActive(started.conversationId);
} catch (cause) {
error.val = readMessage(cause);
} finally {
sending.val = false;
}
}
async function answerInteraction(
id: string,
answer: InteractionAnswerRequest,
) {
if (sending.val) return;
sending.val = true;
assistantDraft.val = "";
error.val = "";
try {
const resolved = await submitInteractionAnswer(id, answer);
interactions.val = interactions.val.map((item) =>
item.id === id ? resolved.interaction : item,
);
if (resolved.resumed) {
await streamRun(
resolved.interaction.runId,
collectDelta,
resolved.resumeFromSequence,
);
}
await refreshActive(resolved.interaction.conversationId);
} catch (cause) {
error.val = readMessage(cause);
} finally {
sending.val = false;
}
}
async function cancelInteraction(runId: string) {
if (sending.val) return;
sending.val = true;
error.val = "";
try {
await cancelWaitingRun(runId);
if (active.val) interactions.val = await listInteractions(active.val.id);
} catch (cause) {
error.val = readMessage(cause);
} finally {
sending.val = false;
}
}
function collectDelta(event: {
type: string;
payload: Record<string, unknown>;
}) {
if (
event.type === "message.delta" &&
typeof event.payload.delta === "string"
)
assistantDraft.val += event.payload.delta;
}
async function refreshActive(conversationId: string) {
[active.val, interactions.val] = await Promise.all([
getConversation(conversationId),
listInteractions(conversationId),
]);
assistantDraft.val = "";
}
async function refreshLists(projectId: string | null) {
if (!projectId) recent.val = await listConversations();
else {
const conversations = await listProjectConversations(projectId);
projectConversations.val = {
...projectConversations.val,
[projectId]: conversations,
};
projects.val = await listProjects();
}
}
async function perform(action: () => Promise<void>) {
loading.val = true;
error.val = "";
try {
await action();
} catch (cause) {
error.val = readMessage(cause);
} finally {
loading.val = false;
}
}
return {
selection,
recent,
projects,
projectConversations,
active,
interactions,
loading,
sending,
assistantDraft,
error,
initialize,
startNewTask,
startProjectCreation,
createProject: createNewProject,
openProject,
startProjectTask,
startProjectRename,
renameProject: renameExistingProject,
startProjectDelete,
deleteProject: deleteExistingProject,
openConversation,
send,
answerInteraction,
cancelInteraction,
};
}
function readMessage(cause: unknown): string {
return cause instanceof Error ? cause.message : "操作失败,请重试";
}