Add support for image input in model resources and enhance related configurations

- Introduce a new `support_image_input` field in model resources, allowing models to indicate support for image input.
- Update the backend models, schemas, and database seed scripts to accommodate the new field.
- Enhance the AssistantConfig and related routes to handle image input capabilities, ensuring proper validation and error handling.
- Modify the frontend components to include toggles for enabling visual understanding and filtering models based on image input support.
- Implement necessary adjustments in the voice preview and pipeline to integrate video stream handling alongside audio functionalities.
This commit is contained in:
Xin Wang
2026-07-07 19:41:45 +08:00
parent 32a52d318a
commit c5db918830
18 changed files with 411 additions and 116 deletions

View File

@@ -5,6 +5,7 @@ import {
ChevronDown,
ChevronUp,
Copy,
Eye,
Loader2,
MoreHorizontal,
Pencil,
@@ -37,6 +38,8 @@ import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -69,6 +72,7 @@ type ResourceDraft = {
interfaceType: string;
values: Record<string, unknown>;
secrets: Record<string, unknown>;
supportImageInput: boolean;
};
const emptyDraft: ResourceDraft = {
@@ -77,6 +81,7 @@ const emptyDraft: ResourceDraft = {
interfaceType: "",
values: {},
secrets: {},
supportImageInput: false,
};
function defaults(definition: InterfaceDefinition): Record<string, unknown> {
@@ -115,6 +120,44 @@ function fieldValue(draft: ResourceDraft, field: InterfaceField): unknown {
: draft.values[field.key];
}
function extraBodyText(values: Record<string, unknown>): string {
const value = values.extraBody;
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
return JSON.stringify(value, null, 2);
}
function parseExtraBody(text: string):
| { ok: true; value: Record<string, unknown> | null }
| { ok: false; error: string } {
const trimmed = text.trim();
if (!trimmed) return { ok: true, value: null };
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: "Extra Body 必须是 JSON 对象" };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch {
return { ok: false, error: "Extra Body 不是合法 JSON" };
}
}
function valuesWithExtraBody(
values: Record<string, unknown>,
extraBody: Record<string, unknown> | null,
): Record<string, unknown> {
const next = { ...values };
delete next.extraBody;
if (extraBody) next.extraBody = extraBody;
return next;
}
function isSelectableDefinition(definition: InterfaceDefinition): boolean {
return (
definition.capability !== "LLM" || definition.interfaceType === "openai-llm"
);
}
export function ComponentsModelsPage() {
const [resources, setResources] = useState<ModelResource[]>([]);
const [definitions, setDefinitions] = useState<InterfaceDefinition[]>([]);
@@ -128,6 +171,7 @@ export function ComponentsModelsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<ResourceDraft>(emptyDraft);
const [extraBodyDraft, setExtraBodyDraft] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [testing, setTesting] = useState(false);
@@ -161,7 +205,8 @@ export function ComponentsModelsPage() {
void load();
}, [load]);
const availableDefinitions = definitions.filter(
const selectableDefinitions = definitions.filter(isSelectableDefinition);
const availableDefinitions = selectableDefinitions.filter(
(definition) => definition.capability === draft.capability,
);
const selectedDefinition = definitions.find(
@@ -239,11 +284,12 @@ export function ComponentsModelsPage() {
values: definition ? defaults(definition) : {},
secrets: {},
}));
setExtraBodyDraft("");
setTestResult(null);
}
function selectCapability(capability: ModelType) {
const first = definitions.find(
const first = selectableDefinitions.find(
(definition) => definition.capability === capability,
);
setDraft((previous) => ({
@@ -252,12 +298,15 @@ export function ComponentsModelsPage() {
interfaceType: first?.interfaceType ?? "",
values: first ? defaults(first) : {},
secrets: {},
supportImageInput:
capability === "LLM" ? previous.supportImageInput : false,
}));
setExtraBodyDraft("");
setTestResult(null);
}
function openCreate() {
const first = definitions.find(
const first = selectableDefinitions.find(
(definition) => definition.capability === "LLM",
);
setEditingId(null);
@@ -265,7 +314,9 @@ export function ComponentsModelsPage() {
...emptyDraft,
interfaceType: first?.interfaceType ?? "",
values: first ? defaults(first) : {},
supportImageInput: false,
});
setExtraBodyDraft("");
setSaveError("");
setTestResult(null);
setDialogOpen(true);
@@ -279,7 +330,9 @@ export function ComponentsModelsPage() {
interfaceType: resource.interfaceType,
values: resource.values,
secrets: resource.secrets,
supportImageInput: resource.supportImageInput,
});
setExtraBodyDraft(extraBodyText(resource.values));
setSaveError("");
setTestResult(null);
setDialogOpen(true);
@@ -294,9 +347,12 @@ export function ComponentsModelsPage() {
setTestResult(null);
}
const parsedExtraBody = parseExtraBody(extraBodyDraft);
const extraBodyError = parsedExtraBody.ok ? "" : parsedExtraBody.error;
const canSave = Boolean(
draft.name.trim() &&
selectedDefinition &&
!extraBodyError &&
selectedDefinition.fieldSchema.fields.every((field) => {
if (!field.required) return true;
const value = fieldValue(draft, field);
@@ -305,11 +361,17 @@ export function ComponentsModelsPage() {
);
function payload() {
const extraBody = parsedExtraBody.ok ? parsedExtraBody.value : null;
return {
name: draft.name.trim(),
interfaceType: draft.interfaceType,
values: draft.values,
values:
draft.capability === "LLM"
? valuesWithExtraBody(draft.values, extraBody)
: draft.values,
secrets: draft.secrets,
supportImageInput:
draft.capability === "LLM" ? draft.supportImageInput : false,
enabled: editingId
? resources.find((resource) => resource.id === editingId)?.enabled ?? true
: true,
@@ -457,8 +519,19 @@ export function ComponentsModelsPage() {
width: "md:w-[360px]",
cell: (resource) => (
<>
<div className="truncate font-medium text-foreground">
{resource.name}
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-foreground">
{resource.name}
</span>
{resource.supportImageInput && (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-surface-strong text-muted-foreground"
title="支持图片输入"
aria-label="支持图片输入"
>
<Eye size={12} />
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-muted-soft">
{String(
@@ -652,6 +725,20 @@ export function ComponentsModelsPage() {
</Select>
</Field>
</div>
{draft.capability === "LLM" && (
<div className="flex items-center justify-between rounded-xl border border-hairline p-4">
<span className="text-sm font-medium"></span>
<Switch
checked={draft.supportImageInput}
onCheckedChange={(checked) =>
setDraft((previous) => ({
...previous,
supportImageInput: checked,
}))
}
/>
</div>
)}
</FieldSection>
<FieldSection title="连接与鉴权信息" scrollable fill>
@@ -667,6 +754,25 @@ export function ComponentsModelsPage() {
</div>
<FieldSection title="可选项" scrollable tall>
{draft.capability === "LLM" && (
<Field label="Extra Body">
<Textarea
rows={7}
value={extraBodyDraft}
onChange={(event) => {
setExtraBodyDraft(event.target.value);
setTestResult(null);
}}
placeholder={'例如:{\n "thinking": { "type": "disabled" }\n}'}
className="font-mono text-xs leading-5"
/>
{extraBodyError && (
<div className="text-xs text-destructive">
{extraBodyError}
</div>
)}
</Field>
)}
{optionalFields.length > 0 ? (
optionalFields.map((field) => (
<DynamicField
@@ -676,7 +782,7 @@ export function ComponentsModelsPage() {
onChange={(value) => updateField(field, value)}
/>
))
) : (
) : draft.capability === "LLM" ? null : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
</div>