Skip to content

Commit 5da8dfe

Browse files
authored
Merge pull request #121 from DEEIX-AI/image_stream
feat: implement media image streaming capabilities
2 parents 4ac13f9 + f9b99fd commit 5da8dfe

11 files changed

Lines changed: 356 additions & 55 deletions

File tree

backend/internal/application/conversation/service_media_generation.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/base64"
7+
"encoding/json"
78
"fmt"
89
"io"
910
"net/http"
@@ -33,6 +34,12 @@ const (
3334

3435
const maxMediaImageEditInputImages = 16
3536

37+
type mediaImageCapabilities struct {
38+
Image struct {
39+
Stream *bool `json:"stream"`
40+
} `json:"image"`
41+
}
42+
3643
// MediaImageInput 定义媒体图片任务的应用层入参。
3744
type MediaImageInput struct {
3845
UserID uint
@@ -301,7 +308,7 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) (
301308
generateInput.ImageEditMask = maskPart
302309
}
303310
var output *llm.GenerateOutput
304-
if llm.SupportsImageGenerationStream(routeConfig.Protocol, routeConfig.UpstreamModel) {
311+
if mediaImageStreamEnabled(routeConfig.Protocol, routeConfig.UpstreamModel, route.ModelCapabilitiesJSON) {
305312
output, err = s.llmClient.GenerateStream(ctx, routeConfig, generateInput, func(event llm.GenerateStreamEvent) error {
306313
if event.Usage != (llm.Usage{}) && input.OnEvent != nil {
307314
if streamErr := input.OnEvent("usage", map[string]interface{}{
@@ -556,6 +563,22 @@ func emitMediaImageDelta(onEvent func(string, map[string]interface{}) error, eve
556563
})
557564
}
558565

566+
func mediaImageStreamEnabled(protocol string, upstreamModel string, capabilitiesJSON string) bool {
567+
return llm.SupportsImageGenerationStream(protocol, upstreamModel) && !mediaImageStreamExplicitlyDisabled(capabilitiesJSON)
568+
}
569+
570+
func mediaImageStreamExplicitlyDisabled(capabilitiesJSON string) bool {
571+
raw := strings.TrimSpace(capabilitiesJSON)
572+
if raw == "" {
573+
return false
574+
}
575+
var caps mediaImageCapabilities
576+
if err := json.Unmarshal([]byte(raw), &caps); err != nil {
577+
return false
578+
}
579+
return caps.Image.Stream != nil && !*caps.Image.Stream
580+
}
581+
559582
// readGeneratedImage 读取上游图片结果,并统一校验为可保存的图片字节。
560583
// 上游临时 URL 只用于服务端下载,最终不会直接写入消息内容,避免长期依赖外部地址。
561584
func (s *Service) readGeneratedImage(ctx context.Context, image llm.GeneratedImage) ([]byte, string, error) {

backend/internal/application/conversation/service_media_generation_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package conversation
33
import (
44
"bytes"
55
"testing"
6+
7+
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
68
)
79

810
func TestDetectGeneratedImageMIMERejectsNonImageBytes(t *testing.T) {
@@ -32,3 +34,57 @@ func TestStripBase64DataURLPrefix(t *testing.T) {
3234
t.Fatalf("unexpected stripped data URL: %q", got)
3335
}
3436
}
37+
38+
func TestMediaImageStreamEnabledUsesModelCapabilitiesOverride(t *testing.T) {
39+
tests := []struct {
40+
name string
41+
protocol string
42+
upstreamModel string
43+
capabilitiesJSON string
44+
want bool
45+
}{
46+
{
47+
name: "openai gpt image defaults to stream",
48+
protocol: llm.AdapterOpenAIImageGenerations,
49+
upstreamModel: "gpt-image-2",
50+
want: true,
51+
},
52+
{
53+
name: "explicit false disables stream",
54+
protocol: llm.AdapterOpenAIImageGenerations,
55+
upstreamModel: "gpt-image-2",
56+
capabilitiesJSON: `{"image":{"stream":false}}`,
57+
want: false,
58+
},
59+
{
60+
name: "explicit true preserves protocol default",
61+
protocol: llm.AdapterOpenAIImageGenerations,
62+
upstreamModel: "gpt-image-2",
63+
capabilitiesJSON: `{"image":{"stream":true}}`,
64+
want: true,
65+
},
66+
{
67+
name: "invalid json keeps protocol default",
68+
protocol: llm.AdapterGoogleImageGeneration,
69+
upstreamModel: "gemini-3-pro-image",
70+
capabilitiesJSON: `{`,
71+
want: true,
72+
},
73+
{
74+
name: "unsupported protocol cannot be enabled by capabilities",
75+
protocol: llm.AdapterXAIImage,
76+
upstreamModel: "grok-2-image",
77+
capabilitiesJSON: `{"image":{"stream":true}}`,
78+
want: false,
79+
},
80+
}
81+
82+
for _, tt := range tests {
83+
t.Run(tt.name, func(t *testing.T) {
84+
got := mediaImageStreamEnabled(tt.protocol, tt.upstreamModel, tt.capabilitiesJSON)
85+
if got != tt.want {
86+
t.Fatalf("mediaImageStreamEnabled() = %v, want %v", got, tt.want)
87+
}
88+
})
89+
}
90+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client";
2+
3+
import type { Variants } from "motion/react";
4+
import { motion, useAnimation } from "motion/react";
5+
import type { HTMLAttributes } from "react";
6+
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
7+
8+
import { cn } from "@/lib/utils";
9+
10+
export interface FolderArchiveIconHandle {
11+
startAnimation: () => void;
12+
stopAnimation: () => void;
13+
}
14+
15+
interface FolderArchiveIconProps extends HTMLAttributes<HTMLDivElement> {
16+
size?: number;
17+
}
18+
19+
const BLINK_VARIANTS: Variants = {
20+
normal: { opacity: 1 },
21+
animate: {
22+
opacity: [1, 0, 1],
23+
transition: { duration: 0.6, ease: "easeInOut" },
24+
},
25+
};
26+
27+
const FolderArchiveIcon = forwardRef<
28+
FolderArchiveIconHandle,
29+
FolderArchiveIconProps
30+
>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
31+
const controls = useAnimation();
32+
const isControlledRef = useRef(false);
33+
34+
useImperativeHandle(ref, () => {
35+
isControlledRef.current = true;
36+
return {
37+
startAnimation: () => controls.start("animate"),
38+
stopAnimation: () => controls.start("normal"),
39+
};
40+
});
41+
42+
const handleMouseEnter = useCallback(
43+
(e: React.MouseEvent<HTMLDivElement>) => {
44+
if (isControlledRef.current) {
45+
onMouseEnter?.(e);
46+
} else {
47+
controls.start("animate");
48+
}
49+
},
50+
[controls, onMouseEnter]
51+
);
52+
53+
const handleMouseLeave = useCallback(
54+
(e: React.MouseEvent<HTMLDivElement>) => {
55+
if (isControlledRef.current) {
56+
onMouseLeave?.(e);
57+
} else {
58+
controls.start("normal");
59+
}
60+
},
61+
[controls, onMouseLeave]
62+
);
63+
64+
return (
65+
<div
66+
className={cn(className)}
67+
onMouseEnter={handleMouseEnter}
68+
onMouseLeave={handleMouseLeave}
69+
{...props}
70+
>
71+
<svg
72+
fill="none"
73+
height={size}
74+
stroke="currentColor"
75+
strokeLinecap="round"
76+
strokeLinejoin="round"
77+
strokeWidth="2"
78+
viewBox="0 0 24 24"
79+
width={size}
80+
xmlns="http://www.w3.org/2000/svg"
81+
>
82+
<path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1" />
83+
<motion.g animate={controls} initial="normal" variants={BLINK_VARIANTS}>
84+
<circle cx="15" cy="19" r="2" />
85+
<path d="M15 11v-1" />
86+
<path d="M15 17v-2" />
87+
</motion.g>
88+
</svg>
89+
</div>
90+
);
91+
});
92+
93+
FolderArchiveIcon.displayName = "FolderArchiveIcon";
94+
95+
export { FolderArchiveIcon };

frontend/features/admin/components/sections/models/model-capabilities-config.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,40 @@ function parseCapabilitiesObject(raw: string): Record<string, unknown> | null {
102102
}
103103
}
104104

105+
export function imageStreamEnabledFromCapabilities(raw: string): boolean {
106+
const payload = parseCapabilitiesObject(raw);
107+
if (!payload) {
108+
return true;
109+
}
110+
const image = payload.image;
111+
if (!isPlainJSONObject(image)) {
112+
return true;
113+
}
114+
return image.stream !== false;
115+
}
116+
117+
export function setImageStreamEnabledInCapabilities(raw: string, enabled: boolean): string | null {
118+
const payload = parseCapabilitiesObject(raw);
119+
if (!payload) {
120+
return null;
121+
}
122+
if (enabled) {
123+
const image = payload.image;
124+
if (isPlainJSONObject(image)) {
125+
delete image.stream;
126+
if (Object.keys(image).length === 0) {
127+
delete payload.image;
128+
}
129+
}
130+
} else {
131+
payload.image = {
132+
...(isPlainJSONObject(payload.image) ? payload.image : {}),
133+
stream: false,
134+
};
135+
}
136+
return Object.keys(payload).length > 0 ? JSON.stringify(payload, null, 2) : "";
137+
}
138+
105139
function optionPathSegments(path: string): string[] {
106140
return path
107141
.split(".")

frontend/features/admin/components/sections/models/model-sheet.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "@/components/ui/accordion";
1414
import { Badge } from "@/components/ui/badge";
1515
import { Button } from "@/components/ui/button";
16+
import { Checkbox } from "@/components/ui/checkbox";
1617
import {
1718
Combobox,
1819
ComboboxContent,
@@ -77,9 +78,11 @@ import {
7778
import { parseProtocolsJSON } from "@/features/chat/model/chat-adapter-options";
7879
import { JsonCodeEditor } from "@/shared/components/json-code-editor";
7980
import {
81+
imageStreamEnabledFromCapabilities,
8082
MODEL_CAPABILITIES_PLACEHOLDER,
8183
ModelCapabilitiesGuideButton,
8284
ModelCapabilitiesQuickConfig,
85+
setImageStreamEnabledInCapabilities,
8386
} from "@/features/admin/components/sections/models/model-capabilities-config";
8487
import type { NativeToolDefinition } from "@/shared/lib/model-option-policy";
8588

@@ -118,6 +121,14 @@ const MODEL_SHEET_VENDOR_OPTIONS: VendorOption[] = [
118121
}),
119122
];
120123

124+
const IMAGE_MEDIA_PROTOCOLS = new Set([
125+
"openai_image_generations",
126+
"openai_image_edits",
127+
"google_image_generation",
128+
"xai_image",
129+
"xai_image_edits",
130+
]);
131+
121132
function buildInitialState(target: AdminLLMModelDTO | null): FormState {
122133
if (!target) {
123134
return {
@@ -248,6 +259,17 @@ export function ModelSheet({ open, mode, target, onClose, onSuccess }: ModelShee
248259
])),
249260
[sources, target?.protocolsJSON],
250261
);
262+
const imageStreamEnabled = imageStreamEnabledFromCapabilities(form.capabilitiesJSON);
263+
const showImageStreamControl = routeProtocols.some((protocol) => IMAGE_MEDIA_PROTOCOLS.has(protocol.trim()));
264+
265+
function updateImageStreamEnabled(enabled: boolean) {
266+
const nextValue = setImageStreamEnabledInCapabilities(form.capabilitiesJSON, enabled);
267+
if (nextValue === null) {
268+
toast.error(t("sheet.capabilitiesQuick.invalidJSON"));
269+
return;
270+
}
271+
setField("capabilitiesJSON", nextValue);
272+
}
251273

252274
function handleClose() {
253275
onClose();
@@ -590,6 +612,22 @@ export function ModelSheet({ open, mode, target, onClose, onSuccess }: ModelShee
590612
<ModelCapabilitiesGuideButton t={t} />
591613
</div>
592614
</div>
615+
{showImageStreamControl ? (
616+
<label
617+
htmlFor="model-image-stream-enabled"
618+
className="mb-2 flex min-w-0 items-center gap-2 px-1 py-1"
619+
>
620+
<Checkbox
621+
id="model-image-stream-enabled"
622+
checked={imageStreamEnabled}
623+
disabled={pending}
624+
onCheckedChange={(checked) => updateImageStreamEnabled(checked === true)}
625+
/>
626+
<span className="min-w-0 truncate text-xs font-medium text-foreground">
627+
{t("sheet.imageStreamEnabled")}
628+
</span>
629+
</label>
630+
) : null}
593631
<JsonCodeEditor
594632
id="model-capabilities-json"
595633
value={form.capabilitiesJSON}

0 commit comments

Comments
 (0)