Add fastgpt as seperate assistant mode

This commit is contained in:
Xin Wang
2026-03-11 08:37:34 +08:00
parent 13684d498b
commit f3612a710d
26 changed files with 2333 additions and 210 deletions

View File

@@ -263,6 +263,7 @@ export const AssistantsPage: React.FC = () => {
botCannotBeInterrupted: false,
interruptionSensitivity: 180,
configMode: 'platform',
appId: '',
};
try {
const created = await createAssistant(newAssistantPayload);
@@ -874,6 +875,20 @@ export const AssistantsPage: React.FC = () => {
/>
</div>
{selectedAssistant.configMode === 'fastgpt' && (
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Key className="w-4 h-4 mr-2 text-primary" /> ID (APP ID)
</label>
<Input
value={selectedAssistant.appId || ''}
onChange={(e) => updateAssistant('appId', e.target.value)}
placeholder="璇疯緭鍏?FastGPT App ID..."
className="bg-white/5 border-white/10 focus:border-primary/50 font-mono text-xs"
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Terminal className="w-4 h-4 mr-2 text-primary" /> (API KEY)
@@ -2226,6 +2241,23 @@ type DebugChoicePromptOption = {
value: string;
};
type DebugFastGPTInteractiveOption = {
id: string;
label: string;
value: string;
description?: string;
};
type DebugFastGPTInteractiveField = {
name: string;
label: string;
inputType: string;
required: boolean;
placeholder?: string;
defaultValue?: string;
options: DebugFastGPTInteractiveOption[];
};
type DebugTextPromptDialogState = {
open: boolean;
message: string;
@@ -2243,9 +2275,31 @@ type DebugChoicePromptDialogState = {
voiceText?: string;
};
type DebugFastGPTInteractiveDialogState = {
open: boolean;
interactionType: 'userSelect' | 'userInput';
title: string;
description: string;
prompt: string;
options: DebugFastGPTInteractiveOption[];
form: DebugFastGPTInteractiveField[];
multiple: boolean;
required: boolean;
selectedValues: string[];
fieldValues: Record<string, string>;
pendingResult?: DebugPromptPendingResult;
submitLabel: string;
cancelLabel: string;
};
type DebugPromptQueueItem =
| { kind: 'text'; payload: Omit<DebugTextPromptDialogState, 'open'> }
| { kind: 'choice'; payload: Omit<DebugChoicePromptDialogState, 'open'> };
| { kind: 'choice'; payload: Omit<DebugChoicePromptDialogState, 'open'> }
| {
kind: 'fastgpt';
payload: Omit<DebugFastGPTInteractiveDialogState, 'open' | 'selectedValues' | 'fieldValues'>
& Partial<Pick<DebugFastGPTInteractiveDialogState, 'selectedValues' | 'fieldValues'>>;
};
const normalizeChoicePromptOptions = (rawOptions: unknown[]): DebugChoicePromptOption[] => {
const usedIds = new Set<string>();
@@ -2277,6 +2331,74 @@ const normalizeChoicePromptOptions = (rawOptions: unknown[]): DebugChoicePromptO
return resolved;
};
const normalizeFastGPTInteractiveOptions = (rawOptions: unknown[]): DebugFastGPTInteractiveOption[] => {
const usedIds = new Set<string>();
const resolved: DebugFastGPTInteractiveOption[] = [];
rawOptions.forEach((rawOption, index) => {
let id = `option_${index + 1}`;
let label = '';
let value = '';
let description = '';
if (typeof rawOption === 'string' || typeof rawOption === 'number' || typeof rawOption === 'boolean') {
label = String(rawOption).trim();
value = label;
} else if (rawOption && typeof rawOption === 'object') {
const row = rawOption as Record<string, unknown>;
label = String(row.label ?? row.text ?? row.name ?? row.value ?? '').trim();
value = String(row.value ?? row.label ?? row.name ?? row.id ?? '').trim();
id = String(row.id ?? value ?? id).trim() || id;
description = String(
row.description
?? row.desc
?? row.intro
?? row.summary
?? row.remark
?? ''
).trim();
}
if (!label && !value) return;
if (!label) label = value;
if (!value) value = label;
if (usedIds.has(id)) {
let suffix = 2;
while (usedIds.has(`${id}_${suffix}`)) suffix += 1;
id = `${id}_${suffix}`;
}
usedIds.add(id);
resolved.push({ id, label, value, description });
});
return resolved;
};
const normalizeFastGPTInteractiveFields = (rawForm: unknown[]): DebugFastGPTInteractiveField[] => {
const usedNames = new Set<string>();
const resolved: DebugFastGPTInteractiveField[] = [];
rawForm.forEach((rawField, index) => {
if (!rawField || typeof rawField !== 'object') return;
const row = rawField as Record<string, unknown>;
let name = String(row.name ?? row.key ?? row.id ?? row.label ?? `field_${index + 1}`).trim() || `field_${index + 1}`;
if (usedNames.has(name)) {
let suffix = 2;
while (usedNames.has(`${name}_${suffix}`)) suffix += 1;
name = `${name}_${suffix}`;
}
usedNames.add(name);
resolved.push({
name,
label: String(row.label ?? row.name ?? name).trim() || name,
inputType: String(row.input_type ?? row.inputType ?? row.type ?? 'text').trim() || 'text',
required: Boolean(row.required),
placeholder: String(row.placeholder ?? row.description ?? row.desc ?? '').trim() || undefined,
defaultValue:
row.default === undefined || row.default === null
? (row.defaultValue === undefined || row.defaultValue === null ? undefined : String(row.defaultValue))
: String(row.default),
options: normalizeFastGPTInteractiveOptions(Array.isArray(row.options) ? row.options : []),
});
});
return resolved;
};
// Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping)
const TranscriptionLog: React.FC<{
scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -2372,8 +2494,24 @@ export const DebugDrawer: React.FC<{
promptType: 'text',
});
const [choicePromptDialog, setChoicePromptDialog] = useState<DebugChoicePromptDialogState>({ open: false, question: '', options: [] });
const [fastgptInteractiveDialog, setFastgptInteractiveDialog] = useState<DebugFastGPTInteractiveDialogState>({
open: false,
interactionType: 'userSelect',
title: '',
description: '',
prompt: '',
options: [],
form: [],
multiple: false,
required: true,
selectedValues: [],
fieldValues: {},
submitLabel: 'Continue',
cancelLabel: 'Cancel',
});
const textPromptDialogRef = useRef(textPromptDialog);
const choicePromptDialogRef = useRef(choicePromptDialog);
const fastgptInteractiveDialogRef = useRef(fastgptInteractiveDialog);
const promptDialogQueueRef = useRef<DebugPromptQueueItem[]>([]);
const promptAudioRef = useRef<HTMLAudioElement | null>(null);
const [textSessionStarted, setTextSessionStarted] = useState(false);
@@ -2558,6 +2696,9 @@ export const DebugDrawer: React.FC<{
if (choicePromptDialogRef.current.open) {
closeChoicePromptDialog('dismiss', undefined, { force: true, skipQueueAdvance: true });
}
if (fastgptInteractiveDialogRef.current.open) {
closeFastGPTInteractiveDialog('cancel', { force: true, skipQueueAdvance: true });
}
stopVoiceCapture();
stopMedia();
closeWs();
@@ -2565,6 +2706,21 @@ export const DebugDrawer: React.FC<{
promptDialogQueueRef.current = [];
setTextPromptDialog({ open: false, message: '', promptType: 'text' });
setChoicePromptDialog({ open: false, question: '', options: [] });
setFastgptInteractiveDialog({
open: false,
interactionType: 'userSelect',
title: '',
description: '',
prompt: '',
options: [],
form: [],
multiple: false,
required: true,
selectedValues: [],
fieldValues: {},
submitLabel: 'Continue',
cancelLabel: 'Cancel',
});
if (audioCtxRef.current) {
void audioCtxRef.current.close();
audioCtxRef.current = null;
@@ -2592,6 +2748,10 @@ export const DebugDrawer: React.FC<{
choicePromptDialogRef.current = choicePromptDialog;
}, [choicePromptDialog]);
useEffect(() => {
fastgptInteractiveDialogRef.current = fastgptInteractiveDialog;
}, [fastgptInteractiveDialog]);
useEffect(() => {
dynamicVariableSeqRef.current = 0;
setDynamicVariables([]);
@@ -2865,7 +3025,34 @@ export const DebugDrawer: React.FC<{
}
};
const hasActivePromptDialog = () => textPromptDialogRef.current.open || choicePromptDialogRef.current.open;
const hasActivePromptDialog = () =>
textPromptDialogRef.current.open
|| choicePromptDialogRef.current.open
|| fastgptInteractiveDialogRef.current.open;
const buildFastGPTFieldValues = (
fields: DebugFastGPTInteractiveField[],
initialValues?: Record<string, string>
): Record<string, string> => {
const nextValues: Record<string, string> = {};
fields.forEach((field) => {
const initial = initialValues?.[field.name];
if (initial !== undefined) {
nextValues[field.name] = initial;
return;
}
if (field.defaultValue !== undefined) {
nextValues[field.name] = field.defaultValue;
return;
}
if (field.options.length > 0 && ['select', 'dropdown', 'radio'].includes(field.inputType.toLowerCase())) {
nextValues[field.name] = field.options[0]?.value || '';
return;
}
nextValues[field.name] = '';
});
return nextValues;
};
const activatePromptDialog = (item: DebugPromptQueueItem) => {
if (item.kind === 'text') {
@@ -2882,6 +3069,30 @@ export const DebugDrawer: React.FC<{
}
return;
}
if (item.kind === 'fastgpt') {
const nextVoiceText = String(item.payload.prompt || item.payload.description || item.payload.title || '').trim();
const normalizedForm = item.payload.form || [];
setFastgptInteractiveDialog({
open: true,
interactionType: item.payload.interactionType,
title: item.payload.title,
description: item.payload.description,
prompt: item.payload.prompt,
options: item.payload.options,
form: normalizedForm,
multiple: item.payload.multiple,
required: item.payload.required,
selectedValues: item.payload.selectedValues || [],
fieldValues: buildFastGPTFieldValues(normalizedForm, item.payload.fieldValues),
pendingResult: item.payload.pendingResult,
submitLabel: item.payload.submitLabel,
cancelLabel: item.payload.cancelLabel,
});
if (nextVoiceText) {
void playPromptVoice(nextVoiceText);
}
return;
}
const nextVoiceText = String(item.payload.voiceText || '').trim();
setChoicePromptDialog({
open: true,
@@ -2940,6 +3151,111 @@ export const DebugDrawer: React.FC<{
}
};
const closeFastGPTInteractiveDialog = (
action: 'submit' | 'cancel',
opts?: { force?: boolean; skipQueueAdvance?: boolean }
) => {
const snapshot = fastgptInteractiveDialogRef.current;
if (!snapshot.open && !opts?.force) return;
const pending = snapshot.pendingResult;
const selectedValues = snapshot.selectedValues;
const fieldValues = snapshot.fieldValues;
const interactionType = snapshot.interactionType;
stopPromptVoicePlayback();
setFastgptInteractiveDialog({
open: false,
interactionType: 'userSelect',
title: '',
description: '',
prompt: '',
options: [],
form: [],
multiple: false,
required: true,
selectedValues: [],
fieldValues: {},
submitLabel: 'Continue',
cancelLabel: 'Cancel',
});
if (pending?.waitForResponse) {
const primarySelected = selectedValues[0] || '';
emitClientToolResult(
{
tool_call_id: pending.toolCallId,
name: pending.toolName,
output: action === 'cancel'
? {
version: 'fastgpt_interactive_v1',
action: 'cancel',
result: {},
}
: {
version: 'fastgpt_interactive_v1',
action: 'submit',
result: {
type: interactionType,
selected: interactionType === 'userSelect' && !snapshot.multiple ? primarySelected : '',
selected_values: interactionType === 'userSelect' ? selectedValues : [],
fields: interactionType === 'userInput' ? fieldValues : {},
text: '',
},
},
status: action === 'cancel'
? { code: 499, message: 'user_cancelled' }
: { code: 200, message: 'ok' },
},
pending.toolDisplayName
);
}
if (!opts?.skipQueueAdvance) {
openNextPromptDialog(true);
}
};
const updateFastGPTFieldValue = (fieldName: string, value: string) => {
setFastgptInteractiveDialog((prev) => ({
...prev,
fieldValues: {
...prev.fieldValues,
[fieldName]: value,
},
}));
};
const toggleFastGPTSelectedValue = (optionValue: string) => {
setFastgptInteractiveDialog((prev) => {
if (!prev.multiple) {
return { ...prev, selectedValues: [optionValue] };
}
const hasValue = prev.selectedValues.includes(optionValue);
return {
...prev,
selectedValues: hasValue
? prev.selectedValues.filter((item) => item !== optionValue)
: [...prev.selectedValues, optionValue],
};
});
};
const canSubmitFastGPTInteractiveDialog = (snapshot: DebugFastGPTInteractiveDialogState = fastgptInteractiveDialog) => {
if (!snapshot.open) return false;
if (snapshot.interactionType === 'userSelect') {
if (!snapshot.required) return true;
return snapshot.selectedValues.length > 0;
}
return snapshot.form.every((field) => {
if (!field.required) return true;
return String(snapshot.fieldValues[field.name] || '').trim().length > 0;
});
};
const fastgptInteractiveHeaderText = fastgptInteractiveDialog.title
|| fastgptInteractiveDialog.description
|| fastgptInteractiveDialog.prompt
|| 'FastGPT';
const closeChoicePromptDialog = (
action: 'select' | 'dismiss',
selectedOption?: DebugChoicePromptOption,
@@ -3844,6 +4160,71 @@ export const DebugDrawer: React.FC<{
return;
}
}
} else if (toolName === 'fastgpt.interactive') {
const interaction = parsedArgs?.interaction && typeof parsedArgs.interaction === 'object'
? parsedArgs.interaction as Record<string, any>
: {};
const interactionType = interaction?.type === 'userInput' ? 'userInput' : 'userSelect';
const options = normalizeFastGPTInteractiveOptions(
Array.isArray(interaction?.options) ? interaction.options : []
);
const form = normalizeFastGPTInteractiveFields(
Array.isArray(interaction?.form) ? interaction.form : []
);
const title = String(interaction?.title || '').trim();
const description = String(interaction?.description || '').trim();
const prompt = String(interaction?.prompt || title || description || '').trim();
const submitLabel = String(interaction?.submit_label || interaction?.submitLabel || 'Continue').trim() || 'Continue';
const cancelLabel = String(interaction?.cancel_label || interaction?.cancelLabel || 'Cancel').trim() || 'Cancel';
const multiple = Boolean(interaction?.multiple);
const required = interaction?.required === undefined ? true : Boolean(interaction.required);
if (interactionType === 'userSelect' && options.length === 0) {
resultPayload.output = { message: "Argument 'interaction.options' requires at least 1 valid entry" };
resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else if (interactionType === 'userInput' && form.length === 0) {
resultPayload.output = { message: "Argument 'interaction.form' requires at least 1 valid entry" };
resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else {
enqueuePromptDialog({
kind: 'fastgpt',
payload: {
interactionType,
title,
description,
prompt,
options,
form,
multiple,
required,
pendingResult: {
toolCallId: toolCallId,
toolName,
toolDisplayName,
waitForResponse,
},
submitLabel,
cancelLabel,
},
});
if (waitForResponse) {
return;
}
resultPayload.output = {
message: 'fastgpt_interactive_shown',
interaction: {
type: interactionType,
title,
description,
prompt,
options,
form,
multiple,
required,
},
};
resultPayload.status = { code: 200, message: 'ok' };
}
}
} catch (err) {
resultPayload.output = {
@@ -4568,9 +4949,8 @@ export const DebugDrawer: React.FC<{
<div className="w-full flex justify-center items-center">
{mode === 'text' && textSessionStarted && (
<Button
variant="destructive"
size="lg"
className="w-full font-bold shadow-lg shadow-destructive/20 hover:shadow-destructive/40 transition-all"
className="w-full h-12 rounded-full border-0 bg-red-500 text-base font-bold text-white shadow-[0_0_20px_rgba(239,68,68,0.35)] hover:bg-red-600 hover:shadow-[0_0_24px_rgba(220,38,38,0.45)] active:translate-y-px focus-visible:ring-red-400/40"
onClick={closeWs}
>
<PhoneOff className="h-5 w-5 mr-2" />
@@ -4579,9 +4959,8 @@ export const DebugDrawer: React.FC<{
)}
{mode !== 'text' && callStatus === 'active' && (
<Button
variant="destructive"
size="lg"
className="w-full font-bold shadow-lg shadow-destructive/20 hover:shadow-destructive/40 transition-all"
className="w-full h-12 rounded-full border-0 bg-red-500 text-base font-bold text-white shadow-[0_0_20px_rgba(239,68,68,0.35)] hover:bg-red-600 hover:shadow-[0_0_24px_rgba(220,38,38,0.45)] active:translate-y-px focus-visible:ring-red-400/40"
onClick={handleHangup}
>
<PhoneOff className="h-5 w-5 mr-2" />
@@ -4705,6 +5084,143 @@ export const DebugDrawer: React.FC<{
</div>
</div>
)}
{fastgptInteractiveDialog.open && (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/55 backdrop-blur-[1px]">
<div className="relative w-[92%] max-w-lg rounded-xl border border-white/15 bg-card/95 p-4 shadow-2xl animate-in zoom-in-95 duration-200">
{!fastgptInteractiveDialog.required && (
<button
type="button"
onClick={() => closeFastGPTInteractiveDialog('cancel')}
className="absolute right-3 top-3 rounded-sm opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
title="关闭"
>
<X className="h-4 w-4" />
</button>
)}
<div className="mb-4 pr-6">
<div className="text-[10px] font-black tracking-[0.14em] uppercase text-cyan-300">
{fastgptInteractiveHeaderText}
</div>
{fastgptInteractiveDialog.prompt
&& fastgptInteractiveDialog.prompt !== fastgptInteractiveHeaderText && (
<h3 className="mt-2 text-base font-semibold text-foreground">
{fastgptInteractiveDialog.prompt}
</h3>
)}
{fastgptInteractiveDialog.description
&& fastgptInteractiveDialog.description !== fastgptInteractiveHeaderText
&& fastgptInteractiveDialog.description !== fastgptInteractiveDialog.prompt && (
<p className="mt-2 text-sm leading-6 text-foreground/90 whitespace-pre-wrap break-words">
{fastgptInteractiveDialog.description}
</p>
)}
{fastgptInteractiveDialog.prompt
&& fastgptInteractiveDialog.prompt !== fastgptInteractiveHeaderText
&& fastgptInteractiveDialog.prompt !== fastgptInteractiveDialog.description && (
<p className="mt-2 text-sm leading-6 text-foreground whitespace-pre-wrap break-words">
{fastgptInteractiveDialog.prompt}
</p>
)}
</div>
{fastgptInteractiveDialog.interactionType === 'userSelect' ? (
<div className="space-y-2">
{fastgptInteractiveDialog.options.map((option) => {
const selected = fastgptInteractiveDialog.selectedValues.includes(option.value);
return (
<button
key={option.id}
type="button"
onClick={() => toggleFastGPTSelectedValue(option.value)}
className={`w-full rounded-lg border px-3 py-3 text-left transition-colors ${selected
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-white/10 bg-black/10 text-foreground hover:border-primary/30 hover:bg-white/5'
}`}
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border ${selected ? 'border-primary bg-primary/20' : 'border-white/25'
}`}
>
<div className={`h-2 w-2 rounded-full ${selected ? 'bg-primary' : 'bg-transparent'}`} />
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{option.label}</div>
{option.description && (
<div className="mt-1 text-xs text-muted-foreground">{option.description}</div>
)}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="space-y-3">
{fastgptInteractiveDialog.form.map((field) => {
const value = fastgptInteractiveDialog.fieldValues[field.name] || '';
const fieldType = field.inputType.toLowerCase();
const useTextarea = ['textarea', 'multiline', 'longtext'].includes(fieldType);
const useSelect = field.options.length > 0 && ['select', 'dropdown', 'radio'].includes(fieldType);
return (
<label key={field.name} className="block space-y-1.5">
<div className="text-xs font-medium text-foreground/90">
{field.label}
{field.required && <span className="ml-1 text-rose-300">*</span>}
</div>
{useTextarea ? (
<textarea
value={value}
onChange={(event) => updateFastGPTFieldValue(field.name, event.target.value)}
placeholder={field.placeholder || ''}
rows={4}
className="min-h-[96px] w-full rounded-md border border-white/10 bg-black/20 px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/50 focus:ring-1 focus:ring-primary/40"
/>
) : useSelect ? (
<select
value={value}
onChange={(event) => updateFastGPTFieldValue(field.name, event.target.value)}
className="flex h-9 w-full rounded-md border border-white/10 bg-black/20 px-3 py-1 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/40 [&>option]:bg-card [&>option]:text-foreground"
>
{!field.required && <option value=""></option>}
{field.options.map((option) => (
<option key={option.id} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<Input
type={fieldType === 'number' ? 'number' : fieldType === 'email' ? 'email' : 'text'}
value={value}
onChange={(event) => updateFastGPTFieldValue(field.name, event.target.value)}
placeholder={field.placeholder || ''}
className="border-white/10 bg-black/20"
/>
)}
</label>
);
})}
</div>
)}
<div className="mt-4 flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => closeFastGPTInteractiveDialog('cancel')}
>
{fastgptInteractiveDialog.cancelLabel || 'Cancel'}
</Button>
<Button
size="sm"
onClick={() => closeFastGPTInteractiveDialog('submit')}
disabled={!canSubmitFastGPTInteractiveDialog(fastgptInteractiveDialog)}
>
{fastgptInteractiveDialog.submitLabel || 'Continue'}
</Button>
</div>
</div>
</div>
)}
</Drawer>
{isOpen && (
<Dialog

View File

@@ -95,6 +95,7 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none',
apiUrl: readField(raw, ['apiUrl', 'api_url'], ''),
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
appId: readField(raw, ['appId', 'app_id'], ''),
llmModelId: readField(raw, ['llmModelId', 'llm_model_id'], ''),
asrModelId: readField(raw, ['asrModelId', 'asr_model_id'], ''),
embeddingModelId: readField(raw, ['embeddingModelId', 'embedding_model_id'], ''),
@@ -302,6 +303,7 @@ export const createAssistant = async (data: Partial<Assistant>): Promise<Assista
configMode: data.configMode || 'platform',
apiUrl: data.apiUrl || '',
apiKey: data.apiKey || '',
appId: data.appId || '',
llmModelId: data.llmModelId || '',
asrModelId: data.asrModelId || '',
embeddingModelId: data.embeddingModelId || '',
@@ -335,6 +337,7 @@ export const updateAssistant = async (id: string, data: Partial<Assistant>): Pro
configMode: data.configMode,
apiUrl: data.apiUrl,
apiKey: data.apiKey,
appId: data.appId,
llmModelId: data.llmModelId,
asrModelId: data.asrModelId,
embeddingModelId: data.embeddingModelId,

View File

@@ -25,6 +25,7 @@ export interface Assistant {
configMode?: 'platform' | 'dify' | 'fastgpt' | 'none';
apiUrl?: string;
apiKey?: string;
appId?: string;
llmModelId?: string;
asrModelId?: string;
embeddingModelId?: string;