mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
Several UIs rendered the raw daemon name (runtime.name) and dropped the user's custom_name alias: the Skills "Copy from runtime" selector, the Skills list/detail source, the agent creation runtime chip, the agent runtime filter, the agent overview, the skills/MCP runtime hints, the runtime delete confirmations, the desktop runtime window title, the onboarding runtime cards/hints, and the task transcript chip. Route every user-visible runtime label through the shared display contract: - runtimeDisplayLabel (alias + provider) for standalone labels - runtimeDisplayName (alias only) where a provider icon/text sits beside it - machine.title + runtimeRowLabel(runtime, machine.title) inside machine groups The Skills "Copy from runtime" selector is now machine-grouped via the shared buildRuntimeMachines/runtimeRowLabel helpers instead of a flat raw-name list; runtime.name stays the source of identity for hostname parsing, grouping, search, and protocol payloads only. Add a conventions rule (en + zh) forbidding raw runtime.name in user-visible JSX/i18n/Select labels/document titles. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
1914 lines
68 KiB
TypeScript
1914 lines
68 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ArrowLeft,
|
|
Bot,
|
|
Check,
|
|
ChevronRight,
|
|
FileText,
|
|
Loader2,
|
|
MessageSquare,
|
|
Search,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { api, ApiError } from "@multica/core/api";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import {
|
|
agentTemplateDetailOptions,
|
|
agentTemplateListOptions,
|
|
} from "@multica/core/agents";
|
|
import {
|
|
chatKeys,
|
|
chatMessagesOptions,
|
|
pendingChatTaskOptions,
|
|
} from "@multica/core/chat/queries";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { useWorkspacePaths } from "@multica/core/paths";
|
|
import {
|
|
runtimeDisplayLabel,
|
|
runtimeListOptions,
|
|
runtimeModelsOptions,
|
|
} from "@multica/core/runtimes";
|
|
import type {
|
|
Agent,
|
|
AgentInvocationTargetInput,
|
|
AgentTemplateSummary,
|
|
ChatMessage,
|
|
CreateAgentRequest,
|
|
MemberWithUser,
|
|
RuntimeDevice,
|
|
RuntimeModel,
|
|
} from "@multica/core/types";
|
|
import {
|
|
agentListOptions,
|
|
memberListOptions,
|
|
skillListOptions,
|
|
workspaceKeys,
|
|
} from "@multica/core/workspace/queries";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import { Textarea } from "@multica/ui/components/ui/textarea";
|
|
import {
|
|
UI_EASE_OUT,
|
|
UI_MOTION_DURATION,
|
|
} from "@multica/ui/lib/motion";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { AvatarUploadControl } from "../../common/avatar-upload-control";
|
|
import { useAppForeground } from "../../common/use-app-foreground";
|
|
import { ChatInput } from "../../chat/components/chat-input";
|
|
import { useChatDraftRestore } from "../../chat/components/use-chat-draft-restore";
|
|
import {
|
|
ChatMessageList,
|
|
ChatMessageSkeleton,
|
|
} from "../../chat/components/chat-message-list";
|
|
import { useNavigation } from "../../navigation";
|
|
import { useT } from "../../i18n";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import {
|
|
SettingsCard,
|
|
SettingsSection,
|
|
} from "../../settings/components/settings-layout";
|
|
import { ModelDropdown } from "./model-dropdown";
|
|
import { RuntimePicker, isRuntimeUsableForUser } from "./runtime-picker";
|
|
import { SkillMultiSelect } from "./skill-multi-select";
|
|
|
|
type StudioMode = "choose" | "templates" | "blank" | "template" | "ai";
|
|
type StudioScreenKey =
|
|
| "choose"
|
|
| "templates"
|
|
| "configure"
|
|
| "ai-setup"
|
|
| "ai-builder";
|
|
type TransitionDirection = 1 | -1;
|
|
type PermissionScope = "private" | "workspace" | "members";
|
|
|
|
export function getAgentCreationScreenKey(
|
|
mode: StudioMode,
|
|
builderSessionId: string,
|
|
): StudioScreenKey {
|
|
if (mode === "blank" || mode === "template") return "configure";
|
|
if (mode === "ai") return builderSessionId ? "ai-builder" : "ai-setup";
|
|
return mode;
|
|
}
|
|
|
|
export interface AgentDraft {
|
|
name: string;
|
|
description: string;
|
|
instructions: string;
|
|
avatarUrl: string | null;
|
|
runtimeId: string;
|
|
model: string;
|
|
skillIds: Set<string>;
|
|
permissionScope: PermissionScope;
|
|
memberIds: Set<string>;
|
|
/** Team grants are not editable in this form yet, but duplicates must preserve them. */
|
|
teamIds: Set<string>;
|
|
}
|
|
|
|
export interface BuilderDraftPayload {
|
|
name?: unknown;
|
|
description?: unknown;
|
|
instructions?: unknown;
|
|
model?: unknown;
|
|
skill_ids?: unknown;
|
|
permission_scope?: unknown;
|
|
member_ids?: unknown;
|
|
}
|
|
|
|
export interface AgentCreateErrors {
|
|
nameError: string | null;
|
|
formError: string | null;
|
|
}
|
|
|
|
export function classifyAgentCreateError(
|
|
error: unknown,
|
|
fallbackMessage: string,
|
|
conflictMessage: string,
|
|
): AgentCreateErrors {
|
|
const message =
|
|
error instanceof Error && error.message ? error.message : fallbackMessage;
|
|
return error instanceof ApiError && error.status === 409
|
|
? { nameError: conflictMessage, formError: null }
|
|
: { nameError: null, formError: message };
|
|
}
|
|
|
|
const BUILDER_INPUT_PREFIX = "MULTICA_AGENT_BUILDER_INPUT\n";
|
|
const EMPTY_CHAT_MESSAGES: ChatMessage[] = [];
|
|
const EMPTY_DRAFT: AgentDraft = {
|
|
name: "",
|
|
description: "",
|
|
instructions: "",
|
|
avatarUrl: null,
|
|
runtimeId: "",
|
|
model: "",
|
|
skillIds: new Set(),
|
|
permissionScope: "private",
|
|
memberIds: new Set(),
|
|
teamIds: new Set(),
|
|
};
|
|
|
|
export function AgentCreationStudio() {
|
|
const { t } = useT("agents");
|
|
const wsId = useWorkspaceId();
|
|
const paths = useWorkspacePaths();
|
|
const navigation = useNavigation();
|
|
const qc = useQueryClient();
|
|
const currentUser = useAuthStore((state) => state.user);
|
|
const duplicateId = navigation.searchParams.get("duplicate");
|
|
const squadId = navigation.searchParams.get("squad");
|
|
const shouldReduceMotion = useReducedMotion() ?? false;
|
|
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { data: runtimes = [], isLoading: runtimesLoading } = useQuery(
|
|
runtimeListOptions(wsId),
|
|
);
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
|
const { data: templates = [], isLoading: templatesLoading } = useQuery(
|
|
agentTemplateListOptions(),
|
|
);
|
|
|
|
const duplicateAgent = duplicateId
|
|
? agents.find((agent) => agent.id === duplicateId) ?? null
|
|
: null;
|
|
const [mode, setMode] = useState<StudioMode>(duplicateId ? "blank" : "choose");
|
|
const [transitionDirection, setTransitionDirection] =
|
|
useState<TransitionDirection>(1);
|
|
const [draft, setDraft] = useState<AgentDraft>(EMPTY_DRAFT);
|
|
const [sourceTemplate, setSourceTemplate] = useState<AgentTemplateSummary | null>(null);
|
|
const [selectedTemplate, setSelectedTemplate] = useState<AgentTemplateSummary | null>(null);
|
|
const [templateSearch, setTemplateSearch] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const [nameError, setNameError] = useState<string | null>(null);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [builderSessionId, setBuilderSessionId] = useState("");
|
|
const [builderStarting, setBuilderStarting] = useState(false);
|
|
const [builderClosing, setBuilderClosing] = useState(false);
|
|
const [builderSwitchingRuntime, setBuilderSwitchingRuntime] = useState(false);
|
|
const [builderError, setBuilderError] = useState<string | null>(null);
|
|
const [builderRestoreDraft, setBuilderRestoreDraft] = useState<{
|
|
id: string;
|
|
content: string;
|
|
} | null>(null);
|
|
const duplicateAppliedRef = useRef(false);
|
|
const appliedAssistantMessageRef = useRef<string | null>(null);
|
|
const builderSessionIdRef = useRef("");
|
|
|
|
// The builder chat is a real chat_session, so cancelling a started-but-empty
|
|
// run defers the empty/non-empty judgment exactly as it does in the main chat
|
|
// (#5219): stopBuilder's response carries no restore_to_input, and the prompt
|
|
// arrives later as a durable chat_draft_restore row. Without this hook the
|
|
// studio composer would simply never see it. The two sources are exclusive —
|
|
// the synchronous cancel answers immediately, the durable one lands after the
|
|
// daemon acks — so whichever exists is handed to the composer.
|
|
//
|
|
// Gated on app foreground: this studio is a dedicated route, so being mounted
|
|
// means the surface is on screen, but a backgrounded tab must not fetch/apply/
|
|
// consume a restore the user is waiting on elsewhere. It recovers on its next
|
|
// fetch once the tab is refocused.
|
|
const appForeground = useAppForeground();
|
|
const {
|
|
restoreDraftRequest: durableRestoreRequest,
|
|
handleRestoreDraftApplied: handleDurableRestoreApplied,
|
|
} = useChatDraftRestore(builderSessionId || null, appForeground);
|
|
const builderRestoreRequest = useMemo(
|
|
() => pickBuilderRestore(builderRestoreDraft, durableRestoreRequest),
|
|
[builderRestoreDraft, durableRestoreRequest],
|
|
);
|
|
|
|
useEffect(() => {
|
|
builderSessionIdRef.current = builderSessionId;
|
|
}, [builderSessionId]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
const sessionId = builderSessionIdRef.current;
|
|
if (sessionId) {
|
|
// Covers route/sidebar navigation that bypasses the Studio back button.
|
|
// The endpoint atomically cancels any running task before deletion.
|
|
void api.deleteChatSession(sessionId).catch(() => {
|
|
// A hard page unload may interrupt cleanup; runtime teardown remains
|
|
// the final safety net for an orphaned hidden Builder Agent.
|
|
});
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const hasUnsavedDraft =
|
|
mode !== "choose" &&
|
|
mode !== "templates" &&
|
|
(builderSessionId.length > 0 ||
|
|
draft.name.trim().length > 0 ||
|
|
draft.description.trim().length > 0 ||
|
|
draft.instructions.trim().length > 0 ||
|
|
draft.skillIds.size > 0);
|
|
|
|
useEffect(() => {
|
|
if (!hasUnsavedDraft || creating) return;
|
|
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
};
|
|
window.addEventListener("beforeunload", warnBeforeUnload);
|
|
return () => window.removeEventListener("beforeunload", warnBeforeUnload);
|
|
}, [creating, hasUnsavedDraft]);
|
|
|
|
const templateSlug = selectedTemplate?.slug ?? "";
|
|
const templateDetailQuery = useQuery({
|
|
...agentTemplateDetailOptions(templateSlug),
|
|
enabled: templateSlug.length > 0,
|
|
});
|
|
|
|
const builderMessagesQuery = useQuery({
|
|
...chatMessagesOptions(builderSessionId),
|
|
refetchInterval: builderSessionId ? 1500 : false,
|
|
});
|
|
const builderPendingQuery = useQuery({
|
|
...pendingChatTaskOptions(builderSessionId),
|
|
refetchInterval: builderSessionId ? 1500 : false,
|
|
});
|
|
const builderMessages = builderMessagesQuery.data ?? EMPTY_CHAT_MESSAGES;
|
|
const builderPending = !!builderPendingQuery.data?.task_id;
|
|
const builderDisplayMessages = useMemo(
|
|
() =>
|
|
builderMessages.map((message) => ({
|
|
...message,
|
|
content:
|
|
message.role === "user"
|
|
? decodeBuilderInput(message.content)
|
|
: stripBuilderDraft(message.content),
|
|
})),
|
|
[builderMessages],
|
|
);
|
|
|
|
const usableRuntimes = useMemo(
|
|
() =>
|
|
runtimes.filter(
|
|
(runtime) =>
|
|
runtime.status === "online" &&
|
|
isRuntimeUsableForUser(runtime, currentUser?.id ?? null),
|
|
),
|
|
[currentUser?.id, runtimes],
|
|
);
|
|
const selectedRuntime =
|
|
runtimes.find((runtime) => runtime.id === draft.runtimeId) ?? null;
|
|
const builderModelsQuery = useQuery(
|
|
runtimeModelsOptions(
|
|
mode === "ai" && selectedRuntime?.status === "online"
|
|
? selectedRuntime.id
|
|
: null,
|
|
),
|
|
);
|
|
// `null` means discovery is not available yet (or failed), while `[]` is
|
|
// an authoritative catalog with no selectable models. In both cases the
|
|
// builder may preserve the user's current value but cannot invent one.
|
|
const builderModelCatalog = useMemo(
|
|
() =>
|
|
builderModelsQuery.isSuccess
|
|
? builderModelsQuery.data.supported
|
|
? builderModelsQuery.data.models
|
|
: []
|
|
: null,
|
|
[builderModelsQuery.data, builderModelsQuery.isSuccess],
|
|
);
|
|
const validBuilderModelIds = useMemo(
|
|
() =>
|
|
builderModelCatalog === null
|
|
? null
|
|
: new Set(builderModelCatalog.map((model) => model.id)),
|
|
[builderModelCatalog],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (draft.runtimeId || usableRuntimes.length === 0) return;
|
|
setDraft((current) => ({ ...current, runtimeId: usableRuntimes[0]?.id ?? "" }));
|
|
}, [draft.runtimeId, usableRuntimes]);
|
|
|
|
useEffect(() => {
|
|
if (!duplicateAgent || duplicateAppliedRef.current) return;
|
|
duplicateAppliedRef.current = true;
|
|
const duplicateAccess = deriveDuplicateAccess(duplicateAgent);
|
|
setDraft({
|
|
name: `${duplicateAgent.name}${t(($) => $.create_dialog.duplicate_copy_suffix)}`,
|
|
description: duplicateAgent.description ?? "",
|
|
instructions: duplicateAgent.instructions ?? "",
|
|
avatarUrl: duplicateAgent.avatar_url ?? null,
|
|
runtimeId:
|
|
duplicateAgent.runtime_id &&
|
|
runtimes.some(
|
|
(runtime) =>
|
|
runtime.id === duplicateAgent.runtime_id &&
|
|
isRuntimeUsableForUser(runtime, currentUser?.id ?? null),
|
|
)
|
|
? duplicateAgent.runtime_id
|
|
: usableRuntimes[0]?.id ?? "",
|
|
model: duplicateAgent.model ?? "",
|
|
skillIds: new Set(duplicateAgent.skills.map((skill) => skill.id)),
|
|
...duplicateAccess,
|
|
});
|
|
}, [currentUser?.id, duplicateAgent, runtimes, t, usableRuntimes]);
|
|
|
|
const skillIdSet = useMemo(
|
|
() => new Set(workspaceSkills.map((skill) => skill.id)),
|
|
[workspaceSkills],
|
|
);
|
|
const memberIdSet = useMemo(
|
|
() => new Set(members.map((member) => member.user_id)),
|
|
[members],
|
|
);
|
|
// Realtime chat updates can mutate the cached messages array in place. Use
|
|
// the latest structured message's scalar identity/content as effect inputs
|
|
// so a draft is still applied when the array reference itself is unchanged.
|
|
const latestBuilderDraftMessage = [...builderMessages]
|
|
.reverse()
|
|
.find(
|
|
(message) =>
|
|
message.role === "assistant" && parseBuilderDraft(message.content),
|
|
);
|
|
const latestBuilderDraftMessageId = latestBuilderDraftMessage?.id;
|
|
const latestBuilderDraftMessageContent = latestBuilderDraftMessage?.content;
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!latestBuilderDraftMessageId ||
|
|
!latestBuilderDraftMessageContent ||
|
|
latestBuilderDraftMessageId === appliedAssistantMessageRef.current
|
|
) {
|
|
return;
|
|
}
|
|
const payload = parseBuilderDraft(latestBuilderDraftMessageContent);
|
|
if (!payload) return;
|
|
appliedAssistantMessageRef.current = latestBuilderDraftMessageId;
|
|
setDraft((current) =>
|
|
mergeBuilderDraft(
|
|
current,
|
|
payload,
|
|
skillIdSet,
|
|
memberIdSet,
|
|
validBuilderModelIds,
|
|
),
|
|
);
|
|
}, [
|
|
latestBuilderDraftMessageContent,
|
|
latestBuilderDraftMessageId,
|
|
memberIdSet,
|
|
skillIdSet,
|
|
validBuilderModelIds,
|
|
]);
|
|
|
|
const filteredTemplates = useMemo(() => {
|
|
const query = templateSearch.trim().toLowerCase();
|
|
if (!query) return templates;
|
|
return templates.filter(
|
|
(template) =>
|
|
template.name.toLowerCase().includes(query) ||
|
|
template.description.toLowerCase().includes(query) ||
|
|
template.category?.toLowerCase().includes(query),
|
|
);
|
|
}, [templateSearch, templates]);
|
|
|
|
const accessInvalid =
|
|
draft.permissionScope === "members" &&
|
|
draft.memberIds.size === 0 &&
|
|
draft.teamIds.size === 0;
|
|
const canCreate =
|
|
draft.name.trim().length > 0 &&
|
|
selectedRuntime != null &&
|
|
isRuntimeUsableForUser(selectedRuntime, currentUser?.id ?? null) &&
|
|
!accessInvalid &&
|
|
!creating;
|
|
const currentModeLabel =
|
|
mode === "choose"
|
|
? t(($) => $.creation_studio.step_choose)
|
|
: mode === "templates"
|
|
? t(($) => $.creation_studio.step_template)
|
|
: mode === "ai"
|
|
? t(($) => $.creation_studio.step_ai)
|
|
: t(($) => $.creation_studio.step_configure);
|
|
|
|
const resetCreationMode = () => {
|
|
setTransitionDirection(-1);
|
|
setMode("choose");
|
|
setSelectedTemplate(null);
|
|
setSourceTemplate(null);
|
|
setBuilderSessionId("");
|
|
setNameError(null);
|
|
setFormError(null);
|
|
};
|
|
|
|
const deleteBuilderSession = async () => {
|
|
if (!builderSessionId) return true;
|
|
setBuilderClosing(true);
|
|
setBuilderError(null);
|
|
try {
|
|
await api.deleteChatSession(builderSessionId);
|
|
qc.removeQueries({ queryKey: chatKeys.messages(builderSessionId) });
|
|
qc.removeQueries({ queryKey: chatKeys.pendingTask(builderSessionId) });
|
|
builderSessionIdRef.current = "";
|
|
return true;
|
|
} catch (error) {
|
|
setBuilderError(
|
|
error instanceof Error
|
|
? error.message
|
|
: t(($) => $.creation_studio.builder.stop_failed),
|
|
);
|
|
return false;
|
|
} finally {
|
|
setBuilderClosing(false);
|
|
}
|
|
};
|
|
|
|
const goBack = async () => {
|
|
if (mode === "choose") {
|
|
navigation.push(paths.agents());
|
|
return;
|
|
}
|
|
setTransitionDirection(-1);
|
|
if (mode === "templates" && selectedTemplate) {
|
|
setSelectedTemplate(null);
|
|
return;
|
|
}
|
|
if (duplicateId) {
|
|
if (!(await deleteBuilderSession())) return;
|
|
navigation.push(paths.agents());
|
|
return;
|
|
}
|
|
if (!(await deleteBuilderSession())) return;
|
|
resetCreationMode();
|
|
};
|
|
|
|
const chooseBlank = () => {
|
|
setSourceTemplate(null);
|
|
setDraft((current) => ({
|
|
...EMPTY_DRAFT,
|
|
runtimeId: current.runtimeId || usableRuntimes[0]?.id || "",
|
|
}));
|
|
setTransitionDirection(1);
|
|
setMode("blank");
|
|
};
|
|
|
|
const chooseAI = () => {
|
|
setTransitionDirection(1);
|
|
setMode("ai");
|
|
};
|
|
|
|
const applyTemplate = () => {
|
|
const detail = templateDetailQuery.data;
|
|
if (!selectedTemplate || !detail) return;
|
|
setSourceTemplate(selectedTemplate);
|
|
setDraft((current) => ({
|
|
...EMPTY_DRAFT,
|
|
name: detail.name,
|
|
description: detail.description,
|
|
instructions: detail.instructions,
|
|
runtimeId: current.runtimeId || usableRuntimes[0]?.id || "",
|
|
}));
|
|
setTransitionDirection(1);
|
|
setMode("template");
|
|
};
|
|
|
|
const startBuilder = async () => {
|
|
if (!selectedRuntime || selectedRuntime.status !== "online") return;
|
|
setBuilderStarting(true);
|
|
setBuilderError(null);
|
|
try {
|
|
const session = await api.createAgentBuilderSession({
|
|
runtime_id: selectedRuntime.id,
|
|
model: draft.model.trim() || undefined,
|
|
});
|
|
if (!session.session_id) throw new Error(t(($) => $.creation_studio.builder.start_failed));
|
|
setTransitionDirection(1);
|
|
setBuilderSessionId(session.session_id);
|
|
} catch (error) {
|
|
setBuilderError(
|
|
error instanceof Error ? error.message : t(($) => $.creation_studio.builder.start_failed),
|
|
);
|
|
} finally {
|
|
setBuilderStarting(false);
|
|
}
|
|
};
|
|
|
|
// Rebinds the conversation's execution runtime on the server BEFORE the draft
|
|
// reflects the new selection. Updating the draft first is what produced
|
|
// MUL-5163: the picker showed runtime B while every subsequent message still
|
|
// ran on the runtime the session was created with.
|
|
const switchBuilderRuntime = async (runtimeId: string) => {
|
|
if (!runtimeId || runtimeId === draft.runtimeId) return;
|
|
// Before the conversation exists there is no carrier to rebind, so the
|
|
// picker is still plain draft state.
|
|
if (!builderSessionId) {
|
|
setDraft((current) => ({ ...current, runtimeId, model: "" }));
|
|
return;
|
|
}
|
|
if (builderSwitchingRuntime) return;
|
|
setBuilderSwitchingRuntime(true);
|
|
setBuilderError(null);
|
|
try {
|
|
const result = await api.switchAgentBuilderRuntime(builderSessionId, {
|
|
runtime_id: runtimeId,
|
|
});
|
|
// Follow the runtime the server says it bound. Resolving here at all means
|
|
// the rebind committed, so refusing to move the draft would leave the
|
|
// picker pointing at a runtime that no longer executes anything — the same
|
|
// split this whole change removes. The client fallback already resolves an
|
|
// unparseable success body to the requested id.
|
|
const boundRuntimeId = result.runtime_id || runtimeId;
|
|
// Model ids are per-runtime; clear it so the new runtime resolves its own
|
|
// default instead of keeping one it may not serve.
|
|
setDraft((current) => ({ ...current, runtimeId: boundRuntimeId, model: "" }));
|
|
toast.success(t(($) => $.creation_studio.builder.switch_runtime_success));
|
|
} catch (error) {
|
|
setBuilderError(
|
|
error instanceof Error
|
|
? error.message
|
|
: t(($) => $.creation_studio.builder.switch_runtime_failed),
|
|
);
|
|
} finally {
|
|
setBuilderSwitchingRuntime(false);
|
|
}
|
|
};
|
|
|
|
const sendBuilderMessage = async (content: string): Promise<boolean> => {
|
|
const text = content.trim();
|
|
// builderSwitchingRuntime blocks the send while a rebind is in flight: the
|
|
// server serialises the two anyway, but letting the message through would
|
|
// mean the user cannot tell which runtime answered it.
|
|
if (!text || !builderSessionId || builderPending || builderSwitchingRuntime) return false;
|
|
setBuilderError(null);
|
|
try {
|
|
const encodedContent = encodeBuilderInput(
|
|
text,
|
|
draft,
|
|
workspaceSkills,
|
|
members,
|
|
selectedRuntime,
|
|
builderModelCatalog,
|
|
);
|
|
const result = await api.sendChatMessage(
|
|
builderSessionId,
|
|
encodedContent,
|
|
);
|
|
const createdAt = new Date().toISOString();
|
|
qc.setQueryData<ChatMessage[]>(
|
|
chatKeys.messages(builderSessionId),
|
|
(current = []) =>
|
|
current.some((message) => message.id === result.message_id)
|
|
? current
|
|
: [
|
|
...current,
|
|
{
|
|
id: result.message_id,
|
|
chat_session_id: builderSessionId,
|
|
role: "user",
|
|
content: encodedContent,
|
|
task_id: result.task_id,
|
|
created_at: createdAt,
|
|
},
|
|
],
|
|
);
|
|
qc.setQueryData(chatKeys.pendingTask(builderSessionId), {
|
|
task_id: result.task_id,
|
|
status: "queued",
|
|
created_at: createdAt,
|
|
});
|
|
await Promise.all([
|
|
qc.invalidateQueries({ queryKey: chatKeys.messages(builderSessionId) }),
|
|
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(builderSessionId) }),
|
|
]);
|
|
return true;
|
|
} catch (error) {
|
|
setBuilderError(
|
|
error instanceof Error ? error.message : t(($) => $.creation_studio.builder.send_failed),
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const stopBuilder = async () => {
|
|
const taskId = builderPendingQuery.data?.task_id;
|
|
if (!taskId || !builderSessionId) return;
|
|
qc.setQueryData(chatKeys.pendingTask(builderSessionId), {});
|
|
try {
|
|
const result = await api.cancelTaskById(taskId);
|
|
const restored = result.cancelled_chat_message;
|
|
if (restored?.restore_to_input) {
|
|
setBuilderRestoreDraft({
|
|
id: restored.message_id,
|
|
content: decodeBuilderInput(restored.content),
|
|
});
|
|
}
|
|
await Promise.all([
|
|
qc.invalidateQueries({ queryKey: chatKeys.messages(builderSessionId) }),
|
|
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(builderSessionId) }),
|
|
]);
|
|
} catch (error) {
|
|
setBuilderError(
|
|
error instanceof Error
|
|
? error.message
|
|
: t(($) => $.creation_studio.builder.stop_failed),
|
|
);
|
|
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(builderSessionId) });
|
|
}
|
|
};
|
|
|
|
const createAgent = async () => {
|
|
if (!canCreate || !selectedRuntime) return;
|
|
setCreating(true);
|
|
setNameError(null);
|
|
setFormError(null);
|
|
try {
|
|
const invocationTargets = buildInvocationTargets(draft);
|
|
let agent: Agent;
|
|
if (sourceTemplate) {
|
|
const response = await api.createAgentFromTemplate({
|
|
template_slug: sourceTemplate.slug,
|
|
name: draft.name.trim(),
|
|
description: draft.description.trim(),
|
|
instructions: draft.instructions.trim(),
|
|
avatar_url: draft.avatarUrl ?? undefined,
|
|
runtime_id: selectedRuntime.id,
|
|
model: draft.model.trim() || undefined,
|
|
permission_mode:
|
|
draft.permissionScope === "private" ? "private" : "public_to",
|
|
invocation_targets: invocationTargets,
|
|
extra_skill_ids: [...draft.skillIds],
|
|
});
|
|
agent = response.agent;
|
|
} else {
|
|
const request: CreateAgentRequest = {
|
|
name: draft.name.trim(),
|
|
description: draft.description.trim(),
|
|
instructions: draft.instructions.trim() || undefined,
|
|
avatar_url: draft.avatarUrl ?? undefined,
|
|
runtime_id: selectedRuntime.id,
|
|
model: draft.model.trim() || undefined,
|
|
permission_mode:
|
|
draft.permissionScope === "private" ? "private" : "public_to",
|
|
invocation_targets: invocationTargets,
|
|
skill_ids: [...draft.skillIds],
|
|
template: mode === "ai" ? "agent_builder" : undefined,
|
|
};
|
|
if (duplicateAgent) {
|
|
if (duplicateAgent.custom_args.length > 0) {
|
|
request.custom_args = duplicateAgent.custom_args;
|
|
}
|
|
request.max_concurrent_tasks = duplicateAgent.max_concurrent_tasks;
|
|
}
|
|
agent = await api.createAgent(request);
|
|
}
|
|
|
|
if (!agent.id) throw new Error(t(($) => $.creation_studio.create_failed));
|
|
if (squadId) {
|
|
try {
|
|
await api.addSquadMember(squadId, {
|
|
member_type: "agent",
|
|
member_id: agent.id,
|
|
});
|
|
await Promise.all([
|
|
qc.invalidateQueries({
|
|
queryKey: [...workspaceKeys.squads(wsId), squadId, "members"],
|
|
}),
|
|
qc.invalidateQueries({
|
|
queryKey: [...workspaceKeys.squads(wsId), squadId],
|
|
}),
|
|
]);
|
|
} catch (error) {
|
|
toast.warning(
|
|
t(($) => $.create_dialog.squad_join_failed_toast, {
|
|
name: agent.name || draft.name.trim(),
|
|
error: error instanceof Error ? error.message : "unknown error",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
if (builderSessionId) {
|
|
// The Agent is already committed. Builder cleanup is best-effort and
|
|
// must never turn a successful create into a retryable create error.
|
|
try {
|
|
await api.deleteChatSession(builderSessionId);
|
|
builderSessionIdRef.current = "";
|
|
} catch {
|
|
// Runtime teardown also removes orphaned system builders.
|
|
}
|
|
}
|
|
await qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
toast.success(t(($) => $.creation_studio.created, { name: agent.name || draft.name.trim() }));
|
|
navigation.push(squadId ? paths.squadDetail(squadId) : paths.agentDetail(agent.id));
|
|
} catch (error) {
|
|
const nextErrors = classifyAgentCreateError(
|
|
error,
|
|
t(($) => $.creation_studio.create_failed),
|
|
t(($) => $.creation_studio.name_conflict),
|
|
);
|
|
setNameError(nextErrors.nameError);
|
|
setFormError(nextErrors.formError);
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const screenKey = getAgentCreationScreenKey(mode, builderSessionId);
|
|
const screenVariants = {
|
|
initial: (direction: TransitionDirection) => ({
|
|
opacity: 0,
|
|
transform: shouldReduceMotion
|
|
? "translateX(0)"
|
|
: direction === 1
|
|
? "translateX(8px)"
|
|
: "translateX(-8px)",
|
|
}),
|
|
animate: {
|
|
opacity: 1,
|
|
transform: "translateX(0)",
|
|
transition: {
|
|
duration: shouldReduceMotion
|
|
? UI_MOTION_DURATION.fast
|
|
: UI_MOTION_DURATION.standard,
|
|
ease: UI_EASE_OUT,
|
|
},
|
|
},
|
|
exit: (direction: TransitionDirection) => ({
|
|
opacity: 0,
|
|
transform: shouldReduceMotion
|
|
? "translateX(0)"
|
|
: direction === 1
|
|
? "translateX(-8px)"
|
|
: "translateX(8px)",
|
|
transition: {
|
|
duration: UI_MOTION_DURATION.fast,
|
|
ease: UI_EASE_OUT,
|
|
},
|
|
}),
|
|
};
|
|
|
|
const updateAgentName = (name: string) => {
|
|
setNameError(null);
|
|
setDraft((current) => ({ ...current, name }));
|
|
};
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
|
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-5">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => void goBack()} disabled={builderClosing} aria-label={t(($) => $.creation_studio.back)}>
|
|
<ArrowLeft className="size-4" aria-hidden="true" />
|
|
</Button>
|
|
<div className="min-w-0">
|
|
<h1 className="truncate text-sm font-semibold">
|
|
{duplicateAgent
|
|
? t(($) => $.creation_studio.duplicate_title, { name: duplicateAgent.name })
|
|
: squadId
|
|
? t(($) => $.creation_studio.squad_title)
|
|
: t(($) => $.creation_studio.title)}
|
|
</h1>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{currentModeLabel}
|
|
</p>
|
|
</div>
|
|
{mode !== "choose" && mode !== "templates" && (
|
|
<div className="ml-auto hidden items-center gap-2 text-xs text-muted-foreground sm:flex">
|
|
<span className="rounded-full bg-muted px-2 py-1">
|
|
{sourceTemplate?.name ?? (mode === "ai" ? t(($) => $.creation_studio.modes.ai.title) : t(($) => $.creation_studio.modes.blank.title))}
|
|
</span>
|
|
{selectedRuntime && (
|
|
<span className="rounded-full bg-muted px-2 py-1">
|
|
{runtimeDisplayLabel(selectedRuntime)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<AnimatePresence
|
|
mode="wait"
|
|
initial={false}
|
|
custom={transitionDirection}
|
|
>
|
|
<motion.div
|
|
key={screenKey}
|
|
custom={transitionDirection}
|
|
variants={screenVariants}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
className="flex min-h-0 flex-1 flex-col"
|
|
>
|
|
{mode === "choose" && (
|
|
<ModeChooser
|
|
onBlank={chooseBlank}
|
|
onAI={chooseAI}
|
|
/>
|
|
)}
|
|
|
|
{mode === "templates" && (
|
|
<TemplateChooser
|
|
templates={filteredTemplates}
|
|
loading={templatesLoading}
|
|
search={templateSearch}
|
|
onSearch={setTemplateSearch}
|
|
selected={selectedTemplate}
|
|
onSelect={setSelectedTemplate}
|
|
detail={templateDetailQuery.data}
|
|
detailLoading={templateDetailQuery.isLoading}
|
|
onUse={applyTemplate}
|
|
/>
|
|
)}
|
|
|
|
{(mode === "blank" || mode === "template") && (
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
<div className="mx-auto w-full max-w-4xl px-5 py-8 sm:px-8">
|
|
{duplicateAgent && (
|
|
<div className="mb-5 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm">
|
|
{t(($) => $.creation_studio.duplicate_env_notice)}
|
|
</div>
|
|
)}
|
|
<ConfigurationPanel
|
|
draft={draft}
|
|
onChange={setDraft}
|
|
runtimes={runtimes}
|
|
runtimesLoading={runtimesLoading}
|
|
members={members}
|
|
currentUserId={currentUser?.id ?? null}
|
|
nameError={nameError}
|
|
onNameChange={updateAgentName}
|
|
/>
|
|
</div>
|
|
<StudioFooter
|
|
canCreate={canCreate}
|
|
creating={creating}
|
|
squad={!!squadId}
|
|
error={formError}
|
|
onCreate={createAgent}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "ai" && !builderSessionId && (
|
|
<BuilderSetup
|
|
draft={draft}
|
|
onChange={setDraft}
|
|
runtimes={runtimes}
|
|
runtimesLoading={runtimesLoading}
|
|
members={members}
|
|
currentUserId={currentUser?.id ?? null}
|
|
selectedRuntime={selectedRuntime}
|
|
starting={builderStarting}
|
|
error={builderError}
|
|
onStart={startBuilder}
|
|
onConnectRuntime={() => navigation.push(paths.runtimes())}
|
|
/>
|
|
)}
|
|
|
|
{mode === "ai" && builderSessionId && (
|
|
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1.1fr)_minmax(420px,0.9fr)]">
|
|
<BuilderConversation
|
|
sessionId={builderSessionId}
|
|
messages={builderDisplayMessages}
|
|
loading={builderMessagesQuery.isLoading}
|
|
pendingTask={builderPendingQuery.data}
|
|
runtimeOnline={selectedRuntime?.status === "online"}
|
|
onSend={sendBuilderMessage}
|
|
onStop={() => void stopBuilder()}
|
|
restoreDraftRequest={builderRestoreRequest}
|
|
onRestoreDraftApplied={() => {
|
|
if (builderRestoreDraft) {
|
|
setBuilderRestoreDraft(null);
|
|
return;
|
|
}
|
|
handleDurableRestoreApplied();
|
|
}}
|
|
error={builderError}
|
|
/>
|
|
<div className="min-h-0 overflow-y-auto border-l bg-muted/10">
|
|
<div className="mx-auto max-w-2xl px-5 py-6">
|
|
<div className="mb-6">
|
|
<h2 className="text-base font-semibold tracking-tight">
|
|
{t(($) => $.creation_studio.live_draft)}
|
|
</h2>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t(($) => $.creation_studio.live_draft_hint)}
|
|
</p>
|
|
</div>
|
|
<ConfigurationPanel
|
|
compact
|
|
draft={draft}
|
|
onChange={setDraft}
|
|
runtimes={runtimes}
|
|
runtimesLoading={runtimesLoading}
|
|
members={members}
|
|
currentUserId={currentUser?.id ?? null}
|
|
nameError={nameError}
|
|
onNameChange={updateAgentName}
|
|
onRuntimeSelect={(runtimeId) => {
|
|
void switchBuilderRuntime(runtimeId);
|
|
}}
|
|
runtimeSwitchPending={builderPending}
|
|
runtimeSwitchInFlight={builderSwitchingRuntime}
|
|
/>
|
|
</div>
|
|
<StudioFooter
|
|
canCreate={canCreate && !builderPending}
|
|
creating={creating}
|
|
squad={!!squadId}
|
|
error={formError}
|
|
onCreate={createAgent}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ModeChooser({
|
|
onBlank,
|
|
onAI,
|
|
}: {
|
|
onBlank: () => void;
|
|
onAI: () => void;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
const modes = [
|
|
{
|
|
icon: FileText,
|
|
title: t(($) => $.creation_studio.modes.blank.title),
|
|
description: t(($) => $.creation_studio.modes.blank.description),
|
|
action: onBlank,
|
|
},
|
|
{
|
|
icon: MessageSquare,
|
|
title: t(($) => $.creation_studio.modes.ai.title),
|
|
description: t(($) => $.creation_studio.modes.ai.description),
|
|
action: onAI,
|
|
recommended: true,
|
|
},
|
|
];
|
|
return (
|
|
<main className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-5 py-10">
|
|
<div className="w-full max-w-5xl">
|
|
<div className="mx-auto max-w-2xl text-center">
|
|
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
{t(($) => $.creation_studio.eyebrow)}
|
|
</div>
|
|
<h2 className="mt-2 text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
|
|
{t(($) => $.creation_studio.choose_title)}
|
|
</h2>
|
|
<p className="mt-3 text-pretty text-sm text-muted-foreground">
|
|
{t(($) => $.creation_studio.choose_description)}
|
|
</p>
|
|
</div>
|
|
<div className="mx-auto mt-9 grid max-w-3xl gap-4 md:grid-cols-2">
|
|
{modes.map(({ icon: Icon, title, description, action, recommended }) => (
|
|
<button
|
|
key={title}
|
|
type="button"
|
|
onClick={action}
|
|
className={cn(
|
|
"group relative flex min-h-56 flex-col items-start rounded-xl border bg-card p-5 text-left",
|
|
"transition-[border-color,background-color,transform] hover:-translate-y-0.5 hover:border-primary/40 hover:bg-accent/30",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
recommended && "border-primary/30 bg-primary/[0.025]",
|
|
)}
|
|
>
|
|
{recommended && (
|
|
<span className="absolute right-4 top-4 rounded-full bg-primary/10 px-2 py-1 text-[10px] font-medium text-primary">
|
|
{t(($) => $.creation_studio.recommended)}
|
|
</span>
|
|
)}
|
|
<span className="flex size-11 items-center justify-center rounded-lg bg-muted text-muted-foreground group-hover:text-foreground">
|
|
<Icon className="size-5" aria-hidden="true" />
|
|
</span>
|
|
<span className="mt-7 text-base font-semibold">{title}</span>
|
|
<span className="mt-2 text-sm leading-6 text-muted-foreground">{description}</span>
|
|
<span className="mt-auto flex items-center gap-1 pt-5 text-xs font-medium text-foreground">
|
|
{t(($) => $.creation_studio.continue)}
|
|
<ChevronRight className="size-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function TemplateChooser({
|
|
templates,
|
|
loading,
|
|
search,
|
|
onSearch,
|
|
selected,
|
|
onSelect,
|
|
detail,
|
|
detailLoading,
|
|
onUse,
|
|
}: {
|
|
templates: AgentTemplateSummary[];
|
|
loading: boolean;
|
|
search: string;
|
|
onSearch: (value: string) => void;
|
|
selected: AgentTemplateSummary | null;
|
|
onSelect: (template: AgentTemplateSummary) => void;
|
|
detail: { instructions: string } | undefined;
|
|
detailLoading: boolean;
|
|
onUse: () => void;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
return (
|
|
<main className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(360px,0.9fr)_minmax(0,1.1fr)]">
|
|
<section className="flex min-h-0 flex-col border-r">
|
|
<div className="border-b p-4">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
|
<Input
|
|
name="template-search"
|
|
autoComplete="off"
|
|
value={search}
|
|
onChange={(event) => onSearch(event.target.value)}
|
|
placeholder={t(($) => $.creation_studio.templates.search)}
|
|
className="pl-9"
|
|
aria-label={t(($) => $.creation_studio.templates.search_label)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
{loading ? (
|
|
<div className="flex justify-center py-16"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>
|
|
) : templates.length === 0 ? (
|
|
<div className="py-16 text-center text-sm text-muted-foreground">{t(($) => $.creation_studio.templates.empty)}</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{templates.map((template) => (
|
|
<button
|
|
key={template.slug}
|
|
type="button"
|
|
onClick={() => onSelect(template)}
|
|
className={cn(
|
|
"flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
selected?.slug === template.slug ? "border-primary bg-primary/5" : "border-transparent hover:bg-muted",
|
|
)}
|
|
>
|
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted"><Bot className="size-4" /></span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-medium">{template.name}</span>
|
|
{template.category && <span className="ml-auto shrink-0 text-[10px] text-muted-foreground">{template.category}</span>}
|
|
</span>
|
|
<span className="mt-1 line-clamp-2 block text-xs leading-5 text-muted-foreground">{template.description}</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
<section className="min-h-0 overflow-y-auto">
|
|
{!selected ? (
|
|
<div className="flex h-full min-h-80 flex-col items-center justify-center px-8 text-center text-muted-foreground">
|
|
<Bot className="size-8" />
|
|
<p className="mt-3 text-sm">{t(($) => $.creation_studio.templates.select_hint)}</p>
|
|
</div>
|
|
) : (
|
|
<div className="mx-auto max-w-3xl p-6 sm:p-8">
|
|
<div className="flex items-start gap-4">
|
|
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary"><Bot className="size-6" /></span>
|
|
<div><h2 className="text-xl font-semibold">{selected.name}</h2><p className="mt-1 text-sm leading-6 text-muted-foreground">{selected.description}</p></div>
|
|
</div>
|
|
{selected.skills.length > 0 && (
|
|
<div className="mt-7"><h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t(($) => $.creation_studio.templates.skills)}</h3><div className="mt-3 space-y-2">{selected.skills.map((skill) => <div key={skill.source_url} className="flex items-start gap-2 rounded-lg border bg-card p-3"><Check className="mt-0.5 size-4 shrink-0 text-success" /><div><div className="text-sm font-medium">{skill.cached_name}</div><div className="mt-0.5 text-xs text-muted-foreground">{skill.cached_description}</div></div></div>)}</div></div>
|
|
)}
|
|
<div className="mt-7"><h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t(($) => $.creation_studio.templates.instructions)}</h3><div className="mt-3 max-h-80 overflow-y-auto whitespace-pre-wrap rounded-lg border bg-muted/30 p-4 text-sm leading-6">{detailLoading ? <Loader2 className="size-4 animate-spin" /> : detail?.instructions}</div></div>
|
|
<div className="mt-7 flex justify-end"><Button onClick={onUse} disabled={detailLoading || !detail}>{t(($) => $.creation_studio.templates.use)}<ChevronRight className="size-4" /></Button></div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function ConfigurationPanel({
|
|
draft,
|
|
onChange,
|
|
runtimes,
|
|
runtimesLoading,
|
|
members,
|
|
currentUserId,
|
|
nameError,
|
|
onNameChange,
|
|
compact = false,
|
|
onRuntimeSelect,
|
|
runtimeSwitchPending = false,
|
|
runtimeSwitchInFlight = false,
|
|
}: {
|
|
draft: AgentDraft;
|
|
onChange: (draft: AgentDraft) => void;
|
|
runtimes: RuntimeDevice[];
|
|
runtimesLoading: boolean;
|
|
members: MemberWithUser[];
|
|
currentUserId: string | null;
|
|
nameError: string | null;
|
|
onNameChange: (name: string) => void;
|
|
compact?: boolean;
|
|
/** Builder sessions rebind the server-side carrier instead of only editing
|
|
* the draft. Absent for the plain create flows, where the draft is the only
|
|
* state that exists. */
|
|
onRuntimeSelect?: (runtimeId: string) => void;
|
|
/** A builder reply is in flight, so the server would refuse the rebind. */
|
|
runtimeSwitchPending?: boolean;
|
|
/** A rebind request is in flight. */
|
|
runtimeSwitchInFlight?: boolean;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
const selectedRuntime = runtimes.find((runtime) => runtime.id === draft.runtimeId) ?? null;
|
|
const set = <K extends keyof AgentDraft>(key: K, value: AgentDraft[K]) => onChange({ ...draft, [key]: value });
|
|
const otherMembers = members.filter((member) => member.user_id !== currentUserId);
|
|
const runtimeLocked = runtimeSwitchPending || runtimeSwitchInFlight;
|
|
const handleRuntimeSelect = (id: string) => {
|
|
if (id === draft.runtimeId) return;
|
|
if (onRuntimeSelect) {
|
|
onRuntimeSelect(id);
|
|
return;
|
|
}
|
|
// Model is per-runtime; clear it on runtime change so the new
|
|
// runtime resolves its own default instead of a stale value.
|
|
onChange({ ...draft, runtimeId: id, model: "" });
|
|
};
|
|
|
|
return (
|
|
<div className={cn("space-y-8", compact && "space-y-6")}>
|
|
<SettingsSection
|
|
title={t(($) => $.creation_studio.sections.identity)}
|
|
description={t(($) => $.creation_studio.sections.identity_hint)}
|
|
>
|
|
<SettingsCard>
|
|
<DraftFieldRow
|
|
compact={compact}
|
|
label={t(($) => $.create_dialog.avatar.change_aria)}
|
|
>
|
|
<div className={cn(!compact && "sm:flex sm:justify-end")}>
|
|
<AvatarUploadControl
|
|
variant="agent"
|
|
value={draft.avatarUrl}
|
|
name={draft.name}
|
|
size={compact ? 52 : 56}
|
|
onUploaded={(url) => set("avatarUrl", url)}
|
|
onClear={() => set("avatarUrl", null)}
|
|
/>
|
|
</div>
|
|
</DraftFieldRow>
|
|
<AgentNameField
|
|
compact={compact}
|
|
name={draft.name}
|
|
error={nameError}
|
|
onChange={onNameChange}
|
|
/>
|
|
<DraftFieldRow
|
|
compact={compact}
|
|
align="start"
|
|
label={t(($) => $.create_dialog.description_label)}
|
|
htmlFor="agent-create-description"
|
|
>
|
|
<Textarea
|
|
id="agent-create-description"
|
|
name="agent-description"
|
|
autoComplete="off"
|
|
aria-label={t(($) => $.create_dialog.description_label)}
|
|
value={draft.description}
|
|
onChange={(event) => set("description", event.target.value)}
|
|
placeholder={t(($) => $.create_dialog.description_placeholder)}
|
|
rows={compact ? 3 : 4}
|
|
className="resize-y"
|
|
/>
|
|
</DraftFieldRow>
|
|
</SettingsCard>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title={t(($) => $.creation_studio.sections.behavior)}
|
|
description={t(($) => $.creation_studio.sections.behavior_hint)}
|
|
>
|
|
<SettingsCard>
|
|
<DraftFieldRow
|
|
compact
|
|
label={t(($) => $.create_dialog.instructions.label)}
|
|
htmlFor="agent-create-instructions"
|
|
>
|
|
<Textarea
|
|
id="agent-create-instructions"
|
|
name="agent-instructions"
|
|
autoComplete="off"
|
|
aria-label={t(($) => $.create_dialog.instructions.label)}
|
|
value={draft.instructions}
|
|
onChange={(event) => set("instructions", event.target.value)}
|
|
placeholder={t(($) => $.create_dialog.instructions.editor_placeholder)}
|
|
rows={compact ? 9 : 12}
|
|
className="min-h-44 resize-y font-mono text-[13px] leading-6"
|
|
/>
|
|
</DraftFieldRow>
|
|
<div className="px-4 py-4">
|
|
<SkillMultiSelect
|
|
selectedIds={draft.skillIds}
|
|
onChange={(ids) => set("skillIds", ids)}
|
|
/>
|
|
</div>
|
|
</SettingsCard>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title={t(($) => $.creation_studio.sections.execution)}
|
|
description={t(($) => $.creation_studio.sections.execution_hint)}
|
|
>
|
|
<SettingsCard>
|
|
<div className={cn("grid gap-4 px-4 py-4", !compact && "sm:grid-cols-2")}>
|
|
<div className="min-w-0">
|
|
<RuntimePicker
|
|
runtimes={runtimes}
|
|
runtimesLoading={runtimesLoading}
|
|
members={members}
|
|
currentUserId={currentUserId}
|
|
selectedRuntimeId={draft.runtimeId}
|
|
onSelect={handleRuntimeSelect}
|
|
disabled={runtimeLocked}
|
|
/>
|
|
{/* A silently greyed-out picker is the worst version of this: the
|
|
user reaches for it exactly when the current runtime has gone
|
|
wrong, so say what unblocks it instead of just refusing. */}
|
|
{runtimeSwitchPending && (
|
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
|
{t(($) => $.creation_studio.builder.switch_runtime_pending)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<ModelDropdown
|
|
runtimeId={selectedRuntime?.id ?? null}
|
|
runtimeOnline={selectedRuntime?.status === "online"}
|
|
value={draft.model}
|
|
onChange={(value) => set("model", value)}
|
|
// A successful switch clears the model, so an edit made while the
|
|
// rebind is in flight would be silently discarded.
|
|
disabled={!selectedRuntime || runtimeSwitchInFlight}
|
|
/>
|
|
</div>
|
|
</SettingsCard>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title={t(($) => $.creation_studio.sections.access)}
|
|
description={t(($) => $.creation_studio.sections.access_hint)}
|
|
>
|
|
<SettingsCard>
|
|
<div
|
|
className="space-y-1 p-2"
|
|
role="radiogroup"
|
|
aria-label={t(($) => $.creation_studio.sections.access)}
|
|
>
|
|
{(["private", "workspace", "members"] as PermissionScope[]).map(
|
|
(scope) => (
|
|
<button
|
|
key={scope}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={draft.permissionScope === scope}
|
|
onClick={() => set("permissionScope", scope)}
|
|
className={cn(
|
|
"flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
|
|
"hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
draft.permissionScope === scope && "bg-muted",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"mt-1 flex size-3.5 shrink-0 items-center justify-center rounded-full border",
|
|
draft.permissionScope === scope && "border-primary",
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
{draft.permissionScope === scope ? (
|
|
<span className="size-1.5 rounded-full bg-primary" />
|
|
) : null}
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block text-sm font-medium">
|
|
{t(($) => $.creation_studio.access[scope].title)}
|
|
</span>
|
|
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
|
|
{t(($) => $.creation_studio.access[scope].description)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
{draft.permissionScope === "members" ? (
|
|
<div className="max-h-48 overflow-y-auto p-2">
|
|
{otherMembers.map((member) => {
|
|
const checked = draft.memberIds.has(member.user_id);
|
|
return (
|
|
<label
|
|
key={member.user_id}
|
|
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 hover:bg-muted"
|
|
>
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) => {
|
|
const next = new Set(draft.memberIds);
|
|
if (value === true) next.add(member.user_id);
|
|
else next.delete(member.user_id);
|
|
set("memberIds", next);
|
|
}}
|
|
/>
|
|
<ActorAvatar
|
|
actorType="member"
|
|
actorId={member.user_id}
|
|
size="sm"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-sm">
|
|
{member.name}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
{draft.memberIds.size === 0 ? (
|
|
<p className="px-2 py-1 text-xs text-destructive">
|
|
{t(($) => $.creation_studio.access.members.required)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</SettingsCard>
|
|
</SettingsSection>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AgentNameField({
|
|
name,
|
|
error,
|
|
onChange,
|
|
compact = false,
|
|
}: {
|
|
name: string;
|
|
error: string | null;
|
|
onChange: (name: string) => void;
|
|
compact?: boolean;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
const errorId = "agent-create-name-error";
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!error) return;
|
|
inputRef.current?.focus();
|
|
inputRef.current?.select();
|
|
}, [error]);
|
|
|
|
return (
|
|
<DraftFieldRow
|
|
compact={compact}
|
|
label={t(($) => $.create_dialog.name_label)}
|
|
htmlFor="agent-create-name"
|
|
>
|
|
<div className="space-y-1.5">
|
|
<Input
|
|
ref={inputRef}
|
|
id="agent-create-name"
|
|
name="agent-name"
|
|
autoComplete="off"
|
|
aria-label={t(($) => $.create_dialog.name_label)}
|
|
aria-invalid={!!error}
|
|
aria-describedby={error ? errorId : undefined}
|
|
value={name}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
placeholder={t(($) => $.create_dialog.name_placeholder)}
|
|
/>
|
|
{error ? (
|
|
<p id={errorId} className="text-xs text-destructive">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</DraftFieldRow>
|
|
);
|
|
}
|
|
|
|
function DraftFieldRow({
|
|
label,
|
|
children,
|
|
compact = false,
|
|
align = "center",
|
|
htmlFor,
|
|
}: {
|
|
label: string;
|
|
children: ReactNode;
|
|
compact?: boolean;
|
|
align?: "center" | "start";
|
|
htmlFor?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"gap-3 px-4 py-4",
|
|
compact
|
|
? "flex flex-col"
|
|
: "grid sm:grid-cols-[minmax(0,1fr)_minmax(280px,1.2fr)] sm:gap-8",
|
|
!compact && (align === "center" ? "sm:items-center" : "sm:items-start"),
|
|
)}
|
|
>
|
|
{htmlFor ? (
|
|
<label htmlFor={htmlFor} className="text-sm font-medium">
|
|
{label}
|
|
</label>
|
|
) : (
|
|
<div className="text-sm font-medium">{label}</div>
|
|
)}
|
|
<div className="min-w-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BuilderSetup({ draft, onChange, runtimes, runtimesLoading, members, currentUserId, selectedRuntime, starting, error, onStart, onConnectRuntime }: { draft: AgentDraft; onChange: (draft: AgentDraft) => void; runtimes: RuntimeDevice[]; runtimesLoading: boolean; members: MemberWithUser[]; currentUserId: string | null; selectedRuntime: RuntimeDevice | null; starting: boolean; error: string | null; onStart: () => void; onConnectRuntime: () => void; }) {
|
|
const { t } = useT("agents");
|
|
const hasOnline = runtimes.some((runtime) => runtime.status === "online" && isRuntimeUsableForUser(runtime, currentUserId));
|
|
return <main className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-5 py-10"><div className="w-full max-w-xl rounded-xl border bg-card p-6 shadow-sm"><span className="flex size-11 items-center justify-center rounded-lg bg-primary/10 text-primary"><MessageSquare className="size-5" /></span><h2 className="mt-5 text-xl font-semibold">{t(($) => $.creation_studio.builder.setup_title)}</h2><p className="mt-2 text-sm leading-6 text-muted-foreground">{t(($) => $.creation_studio.builder.setup_description)}</p><div className="mt-6 space-y-4"><RuntimePicker runtimes={runtimes} runtimesLoading={runtimesLoading} members={members} currentUserId={currentUserId} selectedRuntimeId={draft.runtimeId} onSelect={(runtimeId) => { if (runtimeId !== draft.runtimeId) onChange({ ...draft, runtimeId, model: "" }); }} /><ModelDropdown runtimeId={selectedRuntime?.id ?? null} runtimeOnline={selectedRuntime?.status === "online"} value={draft.model} onChange={(model) => onChange({ ...draft, model })} disabled={!selectedRuntime} /></div>{error && <div role="alert" className="mt-4 text-sm text-destructive">{error}</div>}<div className="mt-6 flex justify-end">{hasOnline ? <Button onClick={onStart} disabled={starting || selectedRuntime?.status !== "online"}>{starting && <Loader2 className="size-4 animate-spin" />}{t(($) => $.creation_studio.builder.start)}</Button> : <Button onClick={onConnectRuntime}>{t(($) => $.creation_studio.builder.connect_runtime)}</Button>}</div></div></main>;
|
|
}
|
|
|
|
function BuilderConversation({
|
|
sessionId,
|
|
messages,
|
|
loading,
|
|
pendingTask,
|
|
runtimeOnline,
|
|
onSend,
|
|
onStop,
|
|
restoreDraftRequest,
|
|
onRestoreDraftApplied,
|
|
error,
|
|
}: {
|
|
sessionId: string;
|
|
messages: ChatMessage[];
|
|
loading: boolean;
|
|
pendingTask: { task_id?: string; status?: string; created_at?: string } | undefined;
|
|
runtimeOnline: boolean;
|
|
onSend: (content: string) => Promise<boolean>;
|
|
onStop: () => void;
|
|
restoreDraftRequest: { id: string; content: string } | null;
|
|
onRestoreDraftApplied: () => void;
|
|
error: string | null;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
const pending = !!pendingTask?.task_id;
|
|
const draftKey = `agent-builder:${sessionId}`;
|
|
const prompts = [
|
|
t(($) => $.creation_studio.builder.prompt_review),
|
|
t(($) => $.creation_studio.builder.prompt_research),
|
|
t(($) => $.creation_studio.builder.prompt_assistant),
|
|
];
|
|
|
|
return (
|
|
<section className="flex min-h-0 flex-col bg-background">
|
|
<header className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b px-5 py-2.5">
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-sm font-semibold">
|
|
{t(($) => $.creation_studio.builder.chat_title)}
|
|
</h2>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{t(($) => $.creation_studio.builder.chat_hint)}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
|
<span
|
|
className={cn(
|
|
"size-2 rounded-full",
|
|
runtimeOnline ? "bg-success" : "bg-muted-foreground/40",
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
{runtimeOnline
|
|
? t(($) => $.creation_studio.builder.runtime_online)
|
|
: t(($) => $.creation_studio.builder.runtime_offline)}
|
|
</div>
|
|
</header>
|
|
|
|
{loading ? (
|
|
<ChatMessageSkeleton />
|
|
) : messages.length > 0 || pending ? (
|
|
<ChatMessageList
|
|
messages={messages}
|
|
pendingTask={pendingTask}
|
|
availability={runtimeOnline ? "online" : "offline"}
|
|
transformContent={stripBuilderDraft}
|
|
/>
|
|
) : (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-5 py-8">
|
|
<div className="w-full max-w-xl text-center">
|
|
<h3 className="text-balance text-lg font-semibold">
|
|
{t(($) => $.creation_studio.builder.empty_title)}
|
|
</h3>
|
|
<p className="mx-auto mt-2 max-w-md text-pretty text-sm leading-6 text-muted-foreground">
|
|
{t(($) => $.creation_studio.builder.empty_description)}
|
|
</p>
|
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
|
{prompts.map((prompt) => (
|
|
<button
|
|
key={prompt}
|
|
type="button"
|
|
onClick={() => void onSend(prompt)}
|
|
className="rounded-full border bg-background px-3 py-1.5 text-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{prompt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error ? (
|
|
<div
|
|
role="alert"
|
|
aria-live="polite"
|
|
className="mx-5 mb-3 rounded-md bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
|
>
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<ChatInput
|
|
onSend={(content) => onSend(content)}
|
|
onStop={onStop}
|
|
isRunning={pending}
|
|
disabled={!runtimeOnline}
|
|
agentName={t(($) => $.creation_studio.builder.chat_title)}
|
|
draftKeyOverride={draftKey}
|
|
editorKeyOverride={draftKey}
|
|
restoreDraftRequest={restoreDraftRequest}
|
|
onRestoreDraftApplied={onRestoreDraftApplied}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function StudioFooter({
|
|
canCreate,
|
|
creating,
|
|
squad,
|
|
error,
|
|
onCreate,
|
|
}: {
|
|
canCreate: boolean;
|
|
creating: boolean;
|
|
squad: boolean;
|
|
error: string | null;
|
|
onCreate: () => void;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
return (
|
|
<div className="sticky bottom-0 mt-8 flex items-center justify-between gap-3 border-t bg-background/95 px-5 py-3 backdrop-blur">
|
|
{error ? (
|
|
<p
|
|
role="alert"
|
|
className="min-w-0 flex-1 break-words text-sm text-destructive"
|
|
>
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
className="ml-auto shrink-0"
|
|
onClick={onCreate}
|
|
disabled={!canCreate}
|
|
>
|
|
{creating && <Loader2 className="size-4 animate-spin" />}
|
|
{creating
|
|
? t(($) => $.creation_studio.creating)
|
|
: squad
|
|
? t(($) => $.creation_studio.create_and_add)
|
|
: t(($) => $.creation_studio.create_and_open)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
export function buildInvocationTargets(
|
|
draft: AgentDraft,
|
|
): AgentInvocationTargetInput[] {
|
|
if (draft.permissionScope === "private") return [];
|
|
if (draft.permissionScope === "workspace") {
|
|
return [{ target_type: "workspace" }];
|
|
}
|
|
return [
|
|
...[...draft.memberIds].map((targetId) => ({
|
|
target_type: "member" as const,
|
|
target_id: targetId,
|
|
})),
|
|
...[...draft.teamIds].map((targetId) => ({
|
|
target_type: "team" as const,
|
|
target_id: targetId,
|
|
})),
|
|
];
|
|
}
|
|
|
|
export function deriveDuplicateAccess(
|
|
agent: Pick<Agent, "permission_mode" | "invocation_targets">,
|
|
): Pick<AgentDraft, "permissionScope" | "memberIds" | "teamIds"> {
|
|
if (agent.permission_mode !== "public_to") {
|
|
return {
|
|
permissionScope: "private",
|
|
memberIds: new Set(),
|
|
teamIds: new Set(),
|
|
};
|
|
}
|
|
|
|
const targets = agent.invocation_targets ?? [];
|
|
if (targets.some((target) => target.target_type === "workspace")) {
|
|
return {
|
|
permissionScope: "workspace",
|
|
memberIds: new Set(),
|
|
teamIds: new Set(),
|
|
};
|
|
}
|
|
|
|
const memberIds = targets
|
|
.filter((target) => target.target_type === "member" && target.target_id)
|
|
.map((target) => target.target_id as string);
|
|
const teamIds = targets
|
|
.filter((target) => target.target_type === "team" && target.target_id)
|
|
.map((target) => target.target_id as string);
|
|
if (memberIds.length === 0 && teamIds.length === 0) {
|
|
return {
|
|
permissionScope: "private",
|
|
memberIds: new Set(),
|
|
teamIds: new Set(),
|
|
};
|
|
}
|
|
return {
|
|
permissionScope: "members",
|
|
memberIds: new Set(memberIds),
|
|
teamIds: new Set(teamIds),
|
|
};
|
|
}
|
|
export function parseBuilderDraft(content: string): BuilderDraftPayload | null {
|
|
const match = content.match(/<agent_draft>([\s\S]*?)<\/agent_draft>/);
|
|
if (!match?.[1]) return null;
|
|
try {
|
|
const value = JSON.parse(match[1]);
|
|
return value && typeof value === "object"
|
|
? (value as BuilderDraftPayload)
|
|
: null;
|
|
} catch {
|
|
// Some CLI-backed models emit literal newlines in the Markdown
|
|
// instructions string even when asked for compact JSON. Repair only JSON
|
|
// control characters that occur inside strings; object structure and all
|
|
// other syntax still have to pass JSON.parse.
|
|
try {
|
|
const value = JSON.parse(escapeJsonStringControlCharacters(match[1]));
|
|
return value && typeof value === "object"
|
|
? (value as BuilderDraftPayload)
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function escapeJsonStringControlCharacters(value: string): string {
|
|
let result = "";
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
for (const character of value) {
|
|
if (!inString) {
|
|
result += character;
|
|
if (character === '"') inString = true;
|
|
continue;
|
|
}
|
|
if (escaped) {
|
|
result += character;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (character === "\\") {
|
|
result += character;
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (character === '"') {
|
|
result += character;
|
|
inString = false;
|
|
continue;
|
|
}
|
|
if (character === "\n") {
|
|
result += "\\n";
|
|
} else if (character === "\r") {
|
|
result += "\\r";
|
|
} else if (character === "\t") {
|
|
result += "\\t";
|
|
} else {
|
|
result += character;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function stripBuilderDraft(content: string): string {
|
|
return content.replace(/<agent_draft>[\s\S]*?<\/agent_draft>/g, "").trim();
|
|
}
|
|
|
|
export function encodeBuilderInput(
|
|
request: string,
|
|
draft: AgentDraft,
|
|
skills: Array<{ id: string; name: string; description: string }>,
|
|
members: Array<{ user_id: string; name: string }>,
|
|
runtime: Pick<RuntimeDevice, "id" | "name" | "provider"> | null,
|
|
models: RuntimeModel[] | null,
|
|
): string {
|
|
return (
|
|
BUILDER_INPUT_PREFIX +
|
|
JSON.stringify(
|
|
{
|
|
user_request: request,
|
|
current_draft: {
|
|
name: draft.name,
|
|
description: draft.description,
|
|
instructions: draft.instructions,
|
|
model: draft.model,
|
|
skill_ids: [...draft.skillIds],
|
|
permission_scope: draft.permissionScope,
|
|
member_ids: [...draft.memberIds],
|
|
},
|
|
selected_runtime: runtime
|
|
? {
|
|
id: runtime.id,
|
|
name: runtime.name,
|
|
provider: runtime.provider,
|
|
}
|
|
: null,
|
|
available_runtime_models:
|
|
models === null
|
|
? null
|
|
: models.map((model) => ({
|
|
id: model.id,
|
|
label: model.label,
|
|
provider: model.provider,
|
|
})),
|
|
available_workspace_skills: skills.map((skill) => ({
|
|
id: skill.id,
|
|
name: skill.name,
|
|
description: skill.description,
|
|
})),
|
|
available_workspace_members: members.map((member) => ({
|
|
id: member.user_id,
|
|
name: member.name,
|
|
})),
|
|
},
|
|
null,
|
|
2,
|
|
)
|
|
);
|
|
}
|
|
|
|
export function decodeBuilderInput(content: string): string {
|
|
if (!content.startsWith(BUILDER_INPUT_PREFIX)) return content;
|
|
try {
|
|
const parsed = JSON.parse(content.slice(BUILDER_INPUT_PREFIX.length)) as {
|
|
user_request?: unknown;
|
|
};
|
|
return typeof parsed.user_request === "string"
|
|
? parsed.user_request
|
|
: content;
|
|
} catch {
|
|
return content;
|
|
}
|
|
}
|
|
|
|
export interface BuilderRestore {
|
|
id: string;
|
|
content: string;
|
|
}
|
|
|
|
/**
|
|
* Chooses which cancelled prompt the builder composer should adopt (#5219).
|
|
*
|
|
* Two sources, never both: cancelling a task the daemon never started answers
|
|
* synchronously (`cancelled_chat_message.restore_to_input`), while cancelling a
|
|
* started-but-empty one defers the judgment and delivers the prompt later as a
|
|
* durable chat_draft_restore row. The durable copy is the raw chat_message
|
|
* content, i.e. still in the builder's encoded wire form, so it is decoded here
|
|
* exactly as the synchronous path decodes its own.
|
|
*
|
|
* The session id is deliberately not carried over: the builder composer keys its
|
|
* draft by `agent-builder:<id>`, so ChatInput's session guard would never match
|
|
* a raw session id — and it does not need to, since this composer only ever
|
|
* shows the builder session.
|
|
*/
|
|
export function pickBuilderRestore(
|
|
synchronous: BuilderRestore | null,
|
|
durable: { id: string; content: string } | null,
|
|
): BuilderRestore | null {
|
|
if (synchronous) return synchronous;
|
|
if (!durable) return null;
|
|
return { id: durable.id, content: decodeBuilderInput(durable.content) };
|
|
}
|
|
|
|
export function mergeBuilderDraft(
|
|
current: AgentDraft,
|
|
payload: BuilderDraftPayload,
|
|
validSkillIds: Set<string>,
|
|
validMemberIds: Set<string>,
|
|
validModelIds: ReadonlySet<string> | null,
|
|
): AgentDraft {
|
|
const scope =
|
|
payload.permission_scope === "workspace" ||
|
|
payload.permission_scope === "members" ||
|
|
payload.permission_scope === "private"
|
|
? payload.permission_scope
|
|
: current.permissionScope;
|
|
const skillIds = Array.isArray(payload.skill_ids)
|
|
? payload.skill_ids.filter(
|
|
(id): id is string =>
|
|
typeof id === "string" && validSkillIds.has(id),
|
|
)
|
|
: [...current.skillIds];
|
|
const memberIds = Array.isArray(payload.member_ids)
|
|
? payload.member_ids.filter(
|
|
(id): id is string =>
|
|
typeof id === "string" && validMemberIds.has(id),
|
|
)
|
|
: [...current.memberIds];
|
|
// The current value may be a deliberate custom entry from ModelDropdown,
|
|
// so preserving it is always safe. Only catalog IDs may be introduced by
|
|
// the builder; failed discovery therefore cannot turn into fail-open input.
|
|
const model =
|
|
typeof payload.model === "string" &&
|
|
(payload.model === current.model ||
|
|
(validModelIds !== null &&
|
|
validModelIds.size > 0 &&
|
|
(payload.model === "" || validModelIds.has(payload.model))))
|
|
? payload.model
|
|
: current.model;
|
|
|
|
return {
|
|
...current,
|
|
name: typeof payload.name === "string" ? payload.name : current.name,
|
|
description:
|
|
typeof payload.description === "string"
|
|
? payload.description
|
|
: current.description,
|
|
instructions:
|
|
typeof payload.instructions === "string"
|
|
? payload.instructions
|
|
: current.instructions,
|
|
model,
|
|
skillIds: new Set(skillIds),
|
|
permissionScope: scope,
|
|
memberIds: new Set(scope === "members" ? memberIds : []),
|
|
teamIds: current.teamIds,
|
|
};
|
|
}
|