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:
@@ -426,10 +426,35 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
// 按资源类型生成 {value:id, label:name} 选项
|
||||
const credOptions = (type: ModelResource["capability"]) =>
|
||||
modelResources
|
||||
.filter((c) => c.capability === type)
|
||||
.filter((c) => {
|
||||
if (c.capability !== type) return false;
|
||||
if (type === "LLM" && visionEnabled) return c.supportImageInput;
|
||||
return true;
|
||||
})
|
||||
.map((c) => ({ value: c.id, label: c.name }));
|
||||
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name }));
|
||||
|
||||
function modelSupportsImageInput(resourceId: string): boolean {
|
||||
return Boolean(
|
||||
modelResources.find((resource) => resource.id === resourceId)
|
||||
?.supportImageInput,
|
||||
);
|
||||
}
|
||||
|
||||
function handleVisionEnabledChange(enabled: boolean) {
|
||||
setVisionEnabled(enabled);
|
||||
if (enabled && form.model && !modelSupportsImageInput(form.model)) {
|
||||
updateForm("model", "");
|
||||
}
|
||||
if (
|
||||
enabled &&
|
||||
openCodeForm.model &&
|
||||
!modelSupportsImageInput(openCodeForm.model)
|
||||
) {
|
||||
updateOpenCodeForm("model", "");
|
||||
}
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
router.push("/assistants/new");
|
||||
}
|
||||
@@ -1369,7 +1394,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
>
|
||||
<ToggleRow
|
||||
title="允许用户打断"
|
||||
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
||||
checked={difyForm.enableInterrupt}
|
||||
onChange={(checked) =>
|
||||
updateDifyForm("enableInterrupt", checked)
|
||||
@@ -1483,7 +1508,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
>
|
||||
<ToggleRow
|
||||
title="允许用户打断"
|
||||
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
||||
checked={fastGptForm.enableInterrupt}
|
||||
onChange={(checked) =>
|
||||
updateFastGptForm("enableInterrupt", checked)
|
||||
@@ -1572,6 +1597,12 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
title="模型与语音配置"
|
||||
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
||||
>
|
||||
<ToggleRow
|
||||
title="视觉理解"
|
||||
hint="开启后,大语言模型只能选择支持图片输入的资源;开始对话时会允许助手按需理解当前视频画面。"
|
||||
checked={visionEnabled}
|
||||
onChange={handleVisionEnabledChange}
|
||||
/>
|
||||
<ResourceSelectField
|
||||
label="大语言模型"
|
||||
value={openCodeForm.model}
|
||||
@@ -1602,7 +1633,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
>
|
||||
<ToggleRow
|
||||
title="允许用户打断"
|
||||
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
||||
checked={openCodeForm.enableInterrupt}
|
||||
onChange={(checked) =>
|
||||
updateOpenCodeForm("enableInterrupt", checked)
|
||||
@@ -1724,15 +1755,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-hairline pt-4">
|
||||
<ToggleRow
|
||||
icon={<Eye size={16} />}
|
||||
title="视觉理解"
|
||||
hint="开启后,右侧调试面板可切换到视频流预览,并可同时选择麦克风与摄像头。"
|
||||
checked={visionEnabled}
|
||||
onChange={setVisionEnabled}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
@@ -1754,6 +1776,12 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
title="模型配置"
|
||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||||
>
|
||||
<ToggleRow
|
||||
title="视觉理解"
|
||||
hint="开启后,大语言模型只能选择支持图片输入的资源;开始对话时会允许助手按需理解当前视频画面。"
|
||||
checked={visionEnabled}
|
||||
onChange={handleVisionEnabledChange}
|
||||
/>
|
||||
<ResourceSelectField
|
||||
label="大语言模型"
|
||||
value={form.model}
|
||||
@@ -1824,7 +1852,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
>
|
||||
<ToggleRow
|
||||
title="允许用户打断"
|
||||
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
||||
hint="用户说话时,助手可以停止当前播报并重新理解用户输入。"
|
||||
checked={form.enableInterrupt}
|
||||
onChange={(checked) => updateForm("enableInterrupt", checked)}
|
||||
/>
|
||||
@@ -1839,7 +1867,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
|
||||
type VizStyle = "aura" | "nebula" | "bars" | "wave";
|
||||
|
||||
// 调试面板顶部主视图:聊天记录 / 视频流(仅视觉理解开启时可选)
|
||||
// 调试面板顶部主视图:聊天记录 / 视频流
|
||||
type DebugView = "chat" | "video";
|
||||
type DebugInputMode = "mic" | "text";
|
||||
|
||||
@@ -1919,28 +1947,27 @@ function DebugDrawer({
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
||||
const [view, setView] = useState<DebugView>("chat");
|
||||
const effectiveView: DebugView = vision ? view : "chat";
|
||||
const stopCamera = camera.stop;
|
||||
const startCamera = camera.start;
|
||||
const cameraShouldRun =
|
||||
vision &&
|
||||
(preview.status === "connecting" || preview.status === "connected");
|
||||
const recording =
|
||||
preview.status === "connecting" || preview.status === "connected";
|
||||
const shouldKeepCamera = vision && recording;
|
||||
|
||||
useEffect(() => {
|
||||
if (!vision) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setView("chat");
|
||||
stopCamera();
|
||||
}
|
||||
}, [vision, stopCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cameraShouldRun) {
|
||||
if (shouldKeepCamera) {
|
||||
void startCamera();
|
||||
} else {
|
||||
stopCamera();
|
||||
}
|
||||
}, [cameraShouldRun, camera.deviceId, startCamera, stopCamera]);
|
||||
}, [shouldKeepCamera, startCamera, stopCamera]);
|
||||
|
||||
const selectCamera = useCallback(
|
||||
async (deviceId: string) => {
|
||||
const nextStream = await camera.selectCamera(deviceId);
|
||||
await preview.replaceVideoStream(nextStream);
|
||||
},
|
||||
[camera, preview],
|
||||
);
|
||||
|
||||
const containerClass = asSheet
|
||||
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
|
||||
@@ -1959,7 +1986,7 @@ function DebugDrawer({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{SHOW_VOICE_VIZ && effectiveView === "chat" && (
|
||||
{SHOW_VOICE_VIZ && view === "chat" && (
|
||||
<>
|
||||
{!showTranscript && (
|
||||
<SegmentedIconGroup label="可视化样式">
|
||||
@@ -1995,33 +2022,29 @@ function DebugDrawer({
|
||||
)}
|
||||
<SegmentedIconGroup label="预览视图">
|
||||
<SegmentedIconButton
|
||||
selected={effectiveView === "chat"}
|
||||
selected={view === "chat"}
|
||||
label="聊天记录"
|
||||
onClick={() => setView("chat")}
|
||||
>
|
||||
<MessageSquareText size={14} />
|
||||
</SegmentedIconButton>
|
||||
{vision && (
|
||||
<SegmentedIconButton
|
||||
selected={effectiveView === "video"}
|
||||
label="视频流"
|
||||
onClick={() => setView("video")}
|
||||
>
|
||||
<Video size={14} />
|
||||
</SegmentedIconButton>
|
||||
)}
|
||||
<SegmentedIconButton
|
||||
selected={view === "video"}
|
||||
label="视频流"
|
||||
onClick={() => setView("video")}
|
||||
>
|
||||
<Video size={14} />
|
||||
</SegmentedIconButton>
|
||||
</SegmentedIconGroup>
|
||||
</div>
|
||||
</div>
|
||||
{vision && (
|
||||
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
|
||||
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
||||
<CameraDeviceField camera={camera} />
|
||||
</div>
|
||||
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
|
||||
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
||||
<CameraDeviceField camera={camera} onSelect={selectCamera} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DebugVoicePanel
|
||||
view={effectiveView}
|
||||
view={view}
|
||||
showTranscript={showTranscript}
|
||||
vizStyle={vizStyle}
|
||||
assistantId={assistantId}
|
||||
@@ -2100,7 +2123,13 @@ function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CameraDeviceField({ camera }: { camera: CameraPreview }) {
|
||||
function CameraDeviceField({
|
||||
camera,
|
||||
onSelect,
|
||||
}: {
|
||||
camera: CameraPreview;
|
||||
onSelect?: (deviceId: string) => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<DeviceSelectField
|
||||
icon={<Video size={15} className="shrink-0 text-muted-soft" />}
|
||||
@@ -2109,7 +2138,7 @@ function CameraDeviceField({ camera }: { camera: CameraPreview }) {
|
||||
fallbackLabel="摄像头"
|
||||
value={camera.deviceId}
|
||||
devices={camera.devices}
|
||||
onSelect={(deviceId) => void camera.selectCamera(deviceId)}
|
||||
onSelect={(deviceId) => void (onSelect ?? camera.selectCamera)(deviceId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2179,9 +2208,8 @@ function DebugVoicePanel({
|
||||
const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
|
||||
const idleOrFailed = status === "idle" || status === "failed";
|
||||
const showIdleHub =
|
||||
messages.length === 0 &&
|
||||
idleOrFailed &&
|
||||
(inChatView || (vision && view === "video"));
|
||||
(view === "video" || (messages.length === 0 && inChatView));
|
||||
|
||||
function handleSendText() {
|
||||
if (sendText(textDraft)) {
|
||||
@@ -2189,26 +2217,36 @@ function DebugVoicePanel({
|
||||
}
|
||||
}
|
||||
|
||||
const startConversation = useCallback(async () => {
|
||||
const videoStream = vision ? await camera.start() : null;
|
||||
await connect({
|
||||
visionEnabled: vision,
|
||||
videoStream,
|
||||
});
|
||||
}, [camera, connect, vision]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
||||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||||
{view === "video" && showIdleHub ? (
|
||||
<DebugIdleHub
|
||||
status={status}
|
||||
error={error}
|
||||
assistantId={assistantId}
|
||||
connect={connect}
|
||||
/>
|
||||
) : view === "video" ? (
|
||||
<DebugVideoPanel camera={camera} />
|
||||
{view === "video" ? (
|
||||
showIdleHub ? (
|
||||
<DebugIdleHub
|
||||
status={status}
|
||||
error={error}
|
||||
assistantId={assistantId}
|
||||
connect={startConversation}
|
||||
/>
|
||||
) : (
|
||||
<DebugVideoPanel camera={camera} />
|
||||
)
|
||||
) : !SHOW_VOICE_VIZ || showTranscript ? (
|
||||
showIdleHub ? (
|
||||
<DebugIdleHub
|
||||
status={status}
|
||||
error={error}
|
||||
assistantId={assistantId}
|
||||
connect={connect}
|
||||
connect={startConversation}
|
||||
/>
|
||||
) : (
|
||||
<DebugTranscriptPanel messages={messages} recording={recording} />
|
||||
@@ -2283,7 +2321,7 @@ function DebugVoicePanel({
|
||||
if (recording) {
|
||||
disconnect();
|
||||
} else {
|
||||
void connect();
|
||||
void startConversation();
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
@@ -2372,7 +2410,7 @@ function DebugVoicePanel({
|
||||
if (recording) {
|
||||
disconnect();
|
||||
} else {
|
||||
void connect();
|
||||
void startConversation();
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
|
||||
Reference in New Issue
Block a user