feat: add Tauri desktop app support and terminal management
This commit is contained in:
@@ -8,20 +8,47 @@ import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { ProviderModelState } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFormPreferences } from "./use-form-preferences";
|
||||
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
||||
|
||||
export type CreateAgentInitialValues = {
|
||||
workingDir?: string;
|
||||
// Explicit overrides from URL params or "New Agent" button
|
||||
export interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
provider?: AgentProvider;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
// Tracks which fields the user has explicitly modified in this session
|
||||
interface UserModifiedFields {
|
||||
serverId: boolean;
|
||||
provider: boolean;
|
||||
modeId: boolean;
|
||||
model: boolean;
|
||||
workingDir: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
workingDir: false,
|
||||
};
|
||||
|
||||
// Internal form state
|
||||
interface FormState {
|
||||
serverId: string | null;
|
||||
provider: AgentProvider;
|
||||
modeId: string;
|
||||
model: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
type UseAgentFormStateOptions = {
|
||||
initialServerId?: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialValues?: FormInitialValues;
|
||||
isVisible?: boolean;
|
||||
isCreateFlow?: boolean;
|
||||
isTargetDaemonReady?: boolean;
|
||||
@@ -66,6 +93,106 @@ const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
|
||||
fallbackDefinition?.defaultModeId ?? "";
|
||||
|
||||
/**
|
||||
* Pure function that resolves form state from multiple data sources.
|
||||
* Priority: explicit (URL params) > preferences > provider defaults > fallback
|
||||
*
|
||||
* Only resolves fields that haven't been user-modified.
|
||||
*/
|
||||
function resolveFormState(
|
||||
initialValues: FormInitialValues | undefined,
|
||||
preferences: FormPreferences | null,
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
userModified: UserModifiedFields,
|
||||
currentState: FormState
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
|
||||
// 1. Resolve provider first (other fields depend on it)
|
||||
if (!userModified.provider) {
|
||||
if (initialValues?.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
result.provider = initialValues.provider;
|
||||
} else if (
|
||||
preferences?.provider &&
|
||||
providerDefinitionMap.has(preferences.provider as AgentProvider)
|
||||
) {
|
||||
result.provider = preferences.provider as AgentProvider;
|
||||
}
|
||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
||||
}
|
||||
|
||||
const providerDef = providerDefinitionMap.get(result.provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
||||
|
||||
// 2. Resolve modeId (depends on provider)
|
||||
if (!userModified.modeId) {
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
|
||||
if (
|
||||
typeof initialValues?.modeId === "string" &&
|
||||
initialValues.modeId.length > 0 &&
|
||||
validModeIds.includes(initialValues.modeId)
|
||||
) {
|
||||
result.modeId = initialValues.modeId;
|
||||
} else if (
|
||||
providerPrefs?.mode &&
|
||||
validModeIds.includes(providerPrefs.mode)
|
||||
) {
|
||||
result.modeId = providerPrefs.mode;
|
||||
} else {
|
||||
result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve model (depends on provider + availableModels)
|
||||
if (!userModified.model) {
|
||||
const isValidModel = (m: string) =>
|
||||
availableModels?.some((am) => am.id === m) ?? false;
|
||||
|
||||
if (
|
||||
typeof initialValues?.model === "string" &&
|
||||
initialValues.model.length > 0
|
||||
) {
|
||||
// If models aren't loaded yet, trust the initial value
|
||||
// It will be validated once models load
|
||||
if (!availableModels || isValidModel(initialValues.model)) {
|
||||
result.model = initialValues.model;
|
||||
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve serverId (independent)
|
||||
if (!userModified.serverId) {
|
||||
if (initialValues?.serverId !== undefined) {
|
||||
result.serverId = initialValues.serverId;
|
||||
} else if (preferences?.serverId) {
|
||||
result.serverId = preferences.serverId;
|
||||
}
|
||||
// else keep current
|
||||
}
|
||||
|
||||
// 5. Resolve workingDir (independent)
|
||||
if (!userModified.workingDir) {
|
||||
if (initialValues?.workingDir !== undefined) {
|
||||
result.workingDir = initialValues.workingDir;
|
||||
} else if (preferences?.workingDir) {
|
||||
result.workingDir = preferences.workingDir;
|
||||
}
|
||||
// else keep current (empty string)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAgentFormState(
|
||||
options: UseAgentFormStateOptions = {}
|
||||
): UseAgentFormStateResult {
|
||||
@@ -80,29 +207,36 @@ export function useAgentFormState(
|
||||
const {
|
||||
preferences,
|
||||
isLoading: isPreferencesLoading,
|
||||
getProviderPreferences,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
} = useFormPreferences();
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(
|
||||
initialServerId
|
||||
);
|
||||
const [workingDir, setWorkingDir] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<AgentProvider>(DEFAULT_PROVIDER);
|
||||
const [selectedMode, setSelectedMode] = useState(
|
||||
DEFAULT_MODE_FOR_DEFAULT_PROVIDER
|
||||
);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
// Track which fields the user has explicitly modified
|
||||
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
|
||||
|
||||
const hasHydratedRef = useRef(false);
|
||||
const hasAppliedInitialValuesRef = useRef(false);
|
||||
const providerModelRequestTimersRef = useRef<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
>(new Map());
|
||||
// Form state
|
||||
const [formState, setFormState] = useState<FormState>(() => ({
|
||||
serverId: initialServerId,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
|
||||
model: "",
|
||||
workingDir: "",
|
||||
}));
|
||||
|
||||
// Track if we've done initial resolution (to avoid flickering)
|
||||
const hasResolvedRef = useRef(false);
|
||||
|
||||
// Reset user modifications when form becomes invisible
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setUserModified(INITIAL_USER_MODIFIED);
|
||||
hasResolvedRef.current = false;
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Get session state for provider models
|
||||
const sessionState = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId] : undefined
|
||||
formState.serverId ? state.sessions[formState.serverId] : undefined
|
||||
);
|
||||
const providerModels = sessionState?.providerModels;
|
||||
const requestProviderModels = sessionState?.methods?.requestProviderModels;
|
||||
@@ -111,9 +245,73 @@ export function useAgentFormState(
|
||||
[]
|
||||
);
|
||||
|
||||
// Get available models for current provider
|
||||
const modelState = providerModels?.get(formState.provider);
|
||||
const availableModels = modelState?.models ?? null;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
if (!initialValues && initialServerId === null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...initialValues,
|
||||
serverId: initialValues?.serverId ?? initialServerId,
|
||||
};
|
||||
}, [initialValues, initialServerId]);
|
||||
|
||||
// Resolve form state when data sources change
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isCreateFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for preferences to load before first resolution
|
||||
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveFormState(
|
||||
combinedInitialValues,
|
||||
preferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formState
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
if (
|
||||
resolved.serverId !== formState.serverId ||
|
||||
resolved.provider !== formState.provider ||
|
||||
resolved.modeId !== formState.modeId ||
|
||||
resolved.model !== formState.model ||
|
||||
resolved.workingDir !== formState.workingDir
|
||||
) {
|
||||
setFormState(resolved);
|
||||
}
|
||||
|
||||
hasResolvedRef.current = true;
|
||||
}, [
|
||||
isVisible,
|
||||
isCreateFlow,
|
||||
isPreferencesLoading,
|
||||
combinedInitialValues,
|
||||
preferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formState,
|
||||
]);
|
||||
|
||||
// Provider model request timers
|
||||
const providerModelRequestTimersRef = useRef<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
>(new Map());
|
||||
|
||||
// User setters - mark fields as modified and persist to preferences
|
||||
const setSelectedServerIdFromUser = useCallback(
|
||||
(value: string | null) => {
|
||||
setSelectedServerId(value);
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
||||
void updatePreferences({ serverId: value ?? undefined });
|
||||
},
|
||||
[updatePreferences]
|
||||
@@ -121,124 +319,86 @@ export function useAgentFormState(
|
||||
|
||||
const setProviderFromUser = useCallback(
|
||||
(provider: AgentProvider) => {
|
||||
setSelectedProvider(provider);
|
||||
setFormState((prev) => ({ ...prev, provider }));
|
||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||
void updatePreferences({ provider });
|
||||
|
||||
// Restore per-provider preferences if available
|
||||
const providerPrefs = getProviderPreferences(provider);
|
||||
// When provider changes, reset mode and model to provider defaults
|
||||
// (unless user has explicitly set them)
|
||||
const providerDef = providerDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
|
||||
setSelectedModel(providerPrefs?.model ?? "");
|
||||
setSelectedMode(providerPrefs?.mode ?? providerDef?.defaultModeId ?? "");
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
model: providerPrefs?.model ?? "",
|
||||
}));
|
||||
},
|
||||
[getProviderPreferences, updatePreferences]
|
||||
[preferences?.providerPreferences, updatePreferences]
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
setSelectedMode(modeId);
|
||||
void updateProviderPreferences(selectedProvider, { mode: modeId });
|
||||
setFormState((prev) => ({ ...prev, modeId }));
|
||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||
void updateProviderPreferences(formState.provider, { mode: modeId });
|
||||
},
|
||||
[selectedProvider, updateProviderPreferences]
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
setSelectedModel(modelId);
|
||||
void updateProviderPreferences(selectedProvider, { model: modelId });
|
||||
setFormState((prev) => ({ ...prev, model: modelId }));
|
||||
setUserModified((prev) => ({ ...prev, model: true }));
|
||||
void updateProviderPreferences(formState.provider, { model: modelId });
|
||||
},
|
||||
[selectedProvider, updateProviderPreferences]
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
}, []);
|
||||
|
||||
const setWorkingDirFromUser = useCallback(
|
||||
(value: string) => {
|
||||
setWorkingDir(value);
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
||||
void updatePreferences({ workingDir: value });
|
||||
},
|
||||
[updatePreferences]
|
||||
);
|
||||
|
||||
const applyInitialValues = useCallback(() => {
|
||||
if (!isCreateFlow || !initialValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(initialValues, "workingDir")) {
|
||||
setWorkingDir(initialValues.workingDir ?? "");
|
||||
}
|
||||
|
||||
if (initialValues.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
setSelectedProvider(initialValues.provider);
|
||||
}
|
||||
|
||||
if (typeof initialValues.modeId === "string" && initialValues.modeId.length > 0) {
|
||||
setSelectedMode(initialValues.modeId);
|
||||
}
|
||||
|
||||
if (typeof initialValues.model === "string" && initialValues.model.length > 0) {
|
||||
setSelectedModel(initialValues.model);
|
||||
}
|
||||
}, [initialValues, isCreateFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
hasAppliedInitialValuesRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasAppliedInitialValuesRef.current) {
|
||||
return;
|
||||
}
|
||||
applyInitialValues();
|
||||
hasAppliedInitialValuesRef.current = true;
|
||||
}, [applyInitialValues, isVisible]);
|
||||
const setSelectedServerId = useCallback((value: string | null) => {
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
if (!requestProviderModels) {
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
requestProviderModels(selectedProvider, {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
requestProviderModels(formState.provider, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
});
|
||||
}, [requestProviderModels, selectedProvider, workingDir]);
|
||||
|
||||
// Hydrate form state from preferences once loaded
|
||||
useEffect(() => {
|
||||
if (isPreferencesLoading || hasHydratedRef.current) return;
|
||||
hasHydratedRef.current = true;
|
||||
|
||||
const activeProvider =
|
||||
preferences.provider &&
|
||||
providerDefinitionMap.has(preferences.provider as AgentProvider)
|
||||
? (preferences.provider as AgentProvider)
|
||||
: DEFAULT_PROVIDER;
|
||||
|
||||
const providerPrefs = preferences.providerPreferences?.[activeProvider];
|
||||
const providerDef = providerDefinitionMap.get(activeProvider);
|
||||
|
||||
setSelectedProvider(activeProvider);
|
||||
if (preferences.workingDir) setWorkingDir(preferences.workingDir);
|
||||
setSelectedMode(providerPrefs?.mode ?? providerDef?.defaultModeId ?? "");
|
||||
if (providerPrefs?.model) setSelectedModel(providerPrefs.model);
|
||||
if (preferences.serverId) setSelectedServerId(preferences.serverId);
|
||||
}, [isPreferencesLoading, preferences]);
|
||||
}, [requestProviderModels, formState.provider, formState.workingDir]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
await updatePreferences({
|
||||
workingDir,
|
||||
provider: selectedProvider,
|
||||
serverId: selectedServerId ?? undefined,
|
||||
workingDir: formState.workingDir,
|
||||
provider: formState.provider,
|
||||
serverId: formState.serverId ?? undefined,
|
||||
});
|
||||
await updateProviderPreferences(selectedProvider, {
|
||||
mode: selectedMode,
|
||||
model: selectedModel,
|
||||
await updateProviderPreferences(formState.provider, {
|
||||
mode: formState.modeId,
|
||||
model: formState.model,
|
||||
});
|
||||
}, [
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
workingDir,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
formState.provider,
|
||||
formState.serverId,
|
||||
formState.workingDir,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
]);
|
||||
@@ -270,7 +430,7 @@ export function useAgentFormState(
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = sessionState.providerModels?.get(selectedProvider);
|
||||
const currentState = sessionState.providerModels?.get(formState.provider);
|
||||
if (currentState?.models?.length || currentState?.isLoading) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
@@ -279,7 +439,7 @@ export function useAgentFormState(
|
||||
const delayMs = options?.delayMs ?? 0;
|
||||
const trigger = () => {
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
sessionState.methods?.requestProviderModels(selectedProvider, {
|
||||
sessionState.methods?.requestProviderModels(formState.provider, {
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
});
|
||||
};
|
||||
@@ -290,7 +450,7 @@ export function useAgentFormState(
|
||||
trigger();
|
||||
}
|
||||
},
|
||||
[clearQueuedProviderModelRequest, getSessionState, selectedProvider]
|
||||
[clearQueuedProviderModelRequest, getSessionState, formState.provider]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -303,74 +463,53 @@ export function useAgentFormState(
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isTargetDaemonReady || !selectedServerId) {
|
||||
clearQueuedProviderModelRequest(selectedServerId);
|
||||
if (!isVisible || !isTargetDaemonReady || !formState.serverId) {
|
||||
clearQueuedProviderModelRequest(formState.serverId);
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
queueProviderModelFetch(selectedServerId, {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
queueProviderModelFetch(formState.serverId, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
delayMs: 180,
|
||||
});
|
||||
return () => {
|
||||
clearQueuedProviderModelRequest(selectedServerId);
|
||||
clearQueuedProviderModelRequest(formState.serverId);
|
||||
};
|
||||
}, [
|
||||
clearQueuedProviderModelRequest,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queueProviderModelFetch,
|
||||
selectedServerId,
|
||||
workingDir,
|
||||
formState.serverId,
|
||||
formState.workingDir,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(selectedProvider);
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const modelState = providerModels?.get(selectedProvider);
|
||||
const availableModels = modelState?.models ?? [];
|
||||
const isModelLoading = modelState?.isLoading ?? false;
|
||||
const modelError = modelState?.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeOptions.length === 0) {
|
||||
if (selectedMode !== "") {
|
||||
setSelectedMode("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const availableModeIds = modeOptions.map((mode) => mode.id);
|
||||
if (!availableModeIds.includes(selectedMode)) {
|
||||
const fallbackModeId = agentDefinition.defaultModeId ?? availableModeIds[0];
|
||||
setSelectedMode(fallbackModeId);
|
||||
}
|
||||
}, [agentDefinition, modeOptions, selectedMode]);
|
||||
|
||||
const workingDirIsEmpty = !workingDir.trim();
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
selectedServerId,
|
||||
selectedServerId: formState.serverId,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
selectedProvider,
|
||||
selectedProvider: formState.provider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
selectedModel: formState.model,
|
||||
setModelFromUser,
|
||||
workingDir,
|
||||
workingDir: formState.workingDir,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
availableModels: availableModels ?? [],
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
@@ -380,26 +519,31 @@ export function useAgentFormState(
|
||||
persistFormPreferences,
|
||||
}),
|
||||
[
|
||||
agentDefinition,
|
||||
availableModels,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
modeOptions,
|
||||
queueProviderModelFetch,
|
||||
clearQueuedProviderModelRequest,
|
||||
refreshProviderModels,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
formState.serverId,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
formState.workingDir,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setProviderFromUser,
|
||||
setModeFromUser,
|
||||
setModelFromUser,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
workingDir,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
queueProviderModelFetch,
|
||||
clearQueuedProviderModelRequest,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type CreateAgentInitialValues = FormInitialValues;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { TranscriptionResultMessage } from "@server/shared/messages";
|
||||
@@ -33,10 +32,7 @@ export type DictationOutcome =
|
||||
| { type: "failure"; requestId: string; errorMessage: string; timestamp: number };
|
||||
|
||||
export type UseDictationOptions = {
|
||||
agentId?: string;
|
||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||
client: DaemonClientV2 | null;
|
||||
mode?: "transcribe_only" | "auto_run";
|
||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onRetryAttempt?: (info: DictationRetryInfo) => void;
|
||||
@@ -121,6 +117,16 @@ type CapturedAudioPayload = {
|
||||
|
||||
type TranscriptionPayload = TranscriptionResultMessage["payload"];
|
||||
|
||||
const blobToBase64 = async (blob: Blob): Promise<string> => {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const deriveFormatFromMime = (mimeType?: string): string => {
|
||||
if (!mimeType || mimeType.length === 0) {
|
||||
return "webm";
|
||||
@@ -144,10 +150,7 @@ const buildCapturedAudioPayload = (blob: Blob, durationSeconds: number): Capture
|
||||
|
||||
export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const {
|
||||
agentId,
|
||||
sendAgentAudio,
|
||||
client,
|
||||
mode = "transcribe_only",
|
||||
onTranscript,
|
||||
onError,
|
||||
onRetryAttempt,
|
||||
@@ -172,7 +175,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const maxRetryAttempts = MAX_AUTO_RETRY_ATTEMPTS;
|
||||
|
||||
const transcriptionMutation = useMutation({
|
||||
mutationFn: async (requestId: string): Promise<TranscriptionPayload> => {
|
||||
mutationFn: async (): Promise<TranscriptionPayload> => {
|
||||
const capturedAudio = pendingAudioRef.current;
|
||||
if (!capturedAudio) {
|
||||
throw new Error("No recorded audio available for transcription");
|
||||
@@ -199,9 +202,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
}
|
||||
|
||||
console.info("[useDictation] sending transcription request", {
|
||||
requestId,
|
||||
attempt,
|
||||
agentId,
|
||||
size: capturedAudio.sizeBytes,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
});
|
||||
@@ -210,20 +211,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryAttempt(attempt);
|
||||
|
||||
try {
|
||||
const transcriptionPromise = client.waitForTranscriptionResult(
|
||||
requestId,
|
||||
TRANSCRIPTION_TIMEOUT_MS
|
||||
);
|
||||
try {
|
||||
await sendAgentAudio(agentId, capturedAudio.blob, requestId, {
|
||||
mode,
|
||||
});
|
||||
} catch (error) {
|
||||
transcriptionPromise.catch(() => {});
|
||||
throw new DictationAttemptError("dispatch", toError(error));
|
||||
}
|
||||
|
||||
return await transcriptionPromise;
|
||||
const base64Audio = await blobToBase64(capturedAudio.blob);
|
||||
return await client.transcribeAudio({
|
||||
audio: base64Audio,
|
||||
format: capturedAudio.format,
|
||||
timeout: TRANSCRIPTION_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
const normalized = toError(error);
|
||||
const reason: DictationRetryReason = isTimeoutError(normalized)
|
||||
@@ -256,7 +249,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryInfo(info);
|
||||
onRetryAttemptRef.current?.(info);
|
||||
console.warn("[useDictation] retry scheduled", {
|
||||
requestId,
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: MAX_AUTO_RETRY_ATTEMPTS,
|
||||
reason: attemptError.reason,
|
||||
@@ -383,12 +375,13 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
}, []);
|
||||
|
||||
const transmitDictation = useCallback(
|
||||
async (requestId: string) => runTranscription(requestId),
|
||||
async () => runTranscription(),
|
||||
[runTranscription]
|
||||
);
|
||||
|
||||
const handleTranscriptionSuccess = useCallback(
|
||||
(transcription: TranscriptionPayload, requestId: string) => {
|
||||
(transcription: TranscriptionPayload) => {
|
||||
const requestId = transcription.requestId;
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
@@ -409,16 +402,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
requestId,
|
||||
textLength: transcriptText.length,
|
||||
});
|
||||
onTranscriptRef.current?.(transcriptText, {
|
||||
requestId: transcription.requestId ?? requestId,
|
||||
});
|
||||
onTranscriptRef.current?.(transcriptText, { requestId });
|
||||
},
|
||||
[onTranscriptRef]
|
||||
);
|
||||
|
||||
const handleDictationFailure = useCallback(
|
||||
(failure: unknown, requestId: string | null) => {
|
||||
(failure: unknown) => {
|
||||
const normalized = toError(failure);
|
||||
const failureId = generateMessageId();
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
@@ -431,16 +423,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
if (capturedAudio) {
|
||||
setStatus("failed");
|
||||
setFailedRecording({
|
||||
requestId: requestId ?? generateMessageId(),
|
||||
requestId: failureId,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
sizeBytes: capturedAudio.sizeBytes,
|
||||
format: capturedAudio.format,
|
||||
recordedAt: capturedAudio.recordedAt,
|
||||
errorMessage: normalized.message,
|
||||
});
|
||||
if (requestId) {
|
||||
onPermanentFailureRef.current?.(normalized, { requestId });
|
||||
}
|
||||
onPermanentFailureRef.current?.(normalized, { requestId: failureId });
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
@@ -448,7 +438,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryAttempt(0);
|
||||
setLastOutcome({
|
||||
type: "failure",
|
||||
requestId: requestId ?? generateMessageId(),
|
||||
requestId: failureId,
|
||||
errorMessage: normalized.message,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -571,7 +561,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryAttempt(0);
|
||||
setLastOutcome(null);
|
||||
|
||||
let requestId: string | null = null;
|
||||
try {
|
||||
const audioData = await stopRecorder();
|
||||
const recordedDurationSeconds = durationRef.current;
|
||||
@@ -581,21 +570,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setIsRecording(false);
|
||||
setVolume(0);
|
||||
|
||||
requestId = generateMessageId();
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setPendingRequestId(requestId);
|
||||
|
||||
const transcription = await transmitDictation(requestId);
|
||||
handleTranscriptionSuccess(transcription, requestId);
|
||||
const transcription = await transmitDictation();
|
||||
handleTranscriptionSuccess(transcription);
|
||||
} catch (err) {
|
||||
resetTranscriptionMutation();
|
||||
handleDictationFailure(err, requestId);
|
||||
handleDictationFailure(err);
|
||||
}
|
||||
}, [
|
||||
agentId,
|
||||
canConfirm,
|
||||
isProcessing,
|
||||
mode,
|
||||
handleDictationFailure,
|
||||
handleTranscriptionSuccess,
|
||||
resetTranscriptionMutation,
|
||||
@@ -615,16 +598,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setIsProcessing(true);
|
||||
setLastOutcome(null);
|
||||
|
||||
const requestId = generateMessageId();
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setPendingRequestId(requestId);
|
||||
|
||||
try {
|
||||
const transcription = await transmitDictation(requestId);
|
||||
handleTranscriptionSuccess(transcription, requestId);
|
||||
const transcription = await transmitDictation();
|
||||
handleTranscriptionSuccess(transcription);
|
||||
} catch (err) {
|
||||
resetTranscriptionMutation();
|
||||
handleDictationFailure(err, requestId);
|
||||
handleDictationFailure(err);
|
||||
}
|
||||
}, [
|
||||
handleDictationFailure,
|
||||
@@ -687,17 +666,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
}
|
||||
}, [autoStopWhenHidden?.isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
pendingAudioRef.current = null;
|
||||
setStatus("idle");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
setFailedRecording(null);
|
||||
resetTranscriptionMutation();
|
||||
}, [agentId, resetTranscriptionMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user