Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -33,6 +34,12 @@ const (

const maxMediaImageEditInputImages = 16

type mediaImageCapabilities struct {
Image struct {
Stream *bool `json:"stream"`
} `json:"image"`
}

// MediaImageInput 定义媒体图片任务的应用层入参。
type MediaImageInput struct {
UserID uint
Expand Down Expand Up @@ -301,7 +308,7 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) (
generateInput.ImageEditMask = maskPart
}
var output *llm.GenerateOutput
if llm.SupportsImageGenerationStream(routeConfig.Protocol, routeConfig.UpstreamModel) {
if mediaImageStreamEnabled(routeConfig.Protocol, routeConfig.UpstreamModel, route.ModelCapabilitiesJSON) {
output, err = s.llmClient.GenerateStream(ctx, routeConfig, generateInput, func(event llm.GenerateStreamEvent) error {
if event.Usage != (llm.Usage{}) && input.OnEvent != nil {
if streamErr := input.OnEvent("usage", map[string]interface{}{
Expand Down Expand Up @@ -556,6 +563,22 @@ func emitMediaImageDelta(onEvent func(string, map[string]interface{}) error, eve
})
}

func mediaImageStreamEnabled(protocol string, upstreamModel string, capabilitiesJSON string) bool {
return llm.SupportsImageGenerationStream(protocol, upstreamModel) && !mediaImageStreamExplicitlyDisabled(capabilitiesJSON)
}

func mediaImageStreamExplicitlyDisabled(capabilitiesJSON string) bool {
raw := strings.TrimSpace(capabilitiesJSON)
if raw == "" {
return false
}
var caps mediaImageCapabilities
if err := json.Unmarshal([]byte(raw), &caps); err != nil {
return false
}
return caps.Image.Stream != nil && !*caps.Image.Stream
}

// readGeneratedImage 读取上游图片结果,并统一校验为可保存的图片字节。
// 上游临时 URL 只用于服务端下载,最终不会直接写入消息内容,避免长期依赖外部地址。
func (s *Service) readGeneratedImage(ctx context.Context, image llm.GeneratedImage) ([]byte, string, error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package conversation
import (
"bytes"
"testing"

"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
)

func TestDetectGeneratedImageMIMERejectsNonImageBytes(t *testing.T) {
Expand Down Expand Up @@ -32,3 +34,57 @@ func TestStripBase64DataURLPrefix(t *testing.T) {
t.Fatalf("unexpected stripped data URL: %q", got)
}
}

func TestMediaImageStreamEnabledUsesModelCapabilitiesOverride(t *testing.T) {
tests := []struct {
name string
protocol string
upstreamModel string
capabilitiesJSON string
want bool
}{
{
name: "openai gpt image defaults to stream",
protocol: llm.AdapterOpenAIImageGenerations,
upstreamModel: "gpt-image-2",
want: true,
},
{
name: "explicit false disables stream",
protocol: llm.AdapterOpenAIImageGenerations,
upstreamModel: "gpt-image-2",
capabilitiesJSON: `{"image":{"stream":false}}`,
want: false,
},
{
name: "explicit true preserves protocol default",
protocol: llm.AdapterOpenAIImageGenerations,
upstreamModel: "gpt-image-2",
capabilitiesJSON: `{"image":{"stream":true}}`,
want: true,
},
{
name: "invalid json keeps protocol default",
protocol: llm.AdapterGoogleImageGeneration,
upstreamModel: "gemini-3-pro-image",
capabilitiesJSON: `{`,
want: true,
},
{
name: "unsupported protocol cannot be enabled by capabilities",
protocol: llm.AdapterXAIImage,
upstreamModel: "grok-2-image",
capabilitiesJSON: `{"image":{"stream":true}}`,
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mediaImageStreamEnabled(tt.protocol, tt.upstreamModel, tt.capabilitiesJSON)
if got != tt.want {
t.Fatalf("mediaImageStreamEnabled() = %v, want %v", got, tt.want)
}
})
}
}
95 changes: 95 additions & 0 deletions frontend/components/ui/folder-archive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";

import { cn } from "@/lib/utils";

export interface FolderArchiveIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}

interface FolderArchiveIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}

const BLINK_VARIANTS: Variants = {
normal: { opacity: 1 },
animate: {
opacity: [1, 0, 1],
transition: { duration: 0.6, ease: "easeInOut" },
},
};

const FolderArchiveIcon = forwardRef<
FolderArchiveIconHandle,
FolderArchiveIconProps
>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);

useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});

const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
} else {
controls.start("animate");
}
},
[controls, onMouseEnter]
);

const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
} else {
controls.start("normal");
}
},
[controls, onMouseLeave]
);

return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
fill="none"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<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" />
<motion.g animate={controls} initial="normal" variants={BLINK_VARIANTS}>
<circle cx="15" cy="19" r="2" />
<path d="M15 11v-1" />
<path d="M15 17v-2" />
</motion.g>
</svg>
</div>
);
});

FolderArchiveIcon.displayName = "FolderArchiveIcon";

export { FolderArchiveIcon };
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,40 @@ function parseCapabilitiesObject(raw: string): Record<string, unknown> | null {
}
}

export function imageStreamEnabledFromCapabilities(raw: string): boolean {
const payload = parseCapabilitiesObject(raw);
if (!payload) {
return true;
}
const image = payload.image;
if (!isPlainJSONObject(image)) {
return true;
}
return image.stream !== false;
}

export function setImageStreamEnabledInCapabilities(raw: string, enabled: boolean): string | null {
const payload = parseCapabilitiesObject(raw);
if (!payload) {
return null;
}
if (enabled) {
const image = payload.image;
if (isPlainJSONObject(image)) {
delete image.stream;
if (Object.keys(image).length === 0) {
delete payload.image;
}
}
} else {
payload.image = {
...(isPlainJSONObject(payload.image) ? payload.image : {}),
stream: false,
};
}
return Object.keys(payload).length > 0 ? JSON.stringify(payload, null, 2) : "";
}

function optionPathSegments(path: string): string[] {
return path
.split(".")
Expand Down
38 changes: 38 additions & 0 deletions frontend/features/admin/components/sections/models/model-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Combobox,
ComboboxContent,
Expand Down Expand Up @@ -77,9 +78,11 @@ import {
import { parseProtocolsJSON } from "@/features/chat/model/chat-adapter-options";
import { JsonCodeEditor } from "@/shared/components/json-code-editor";
import {
imageStreamEnabledFromCapabilities,
MODEL_CAPABILITIES_PLACEHOLDER,
ModelCapabilitiesGuideButton,
ModelCapabilitiesQuickConfig,
setImageStreamEnabledInCapabilities,
} from "@/features/admin/components/sections/models/model-capabilities-config";
import type { NativeToolDefinition } from "@/shared/lib/model-option-policy";

Expand Down Expand Up @@ -118,6 +121,14 @@ const MODEL_SHEET_VENDOR_OPTIONS: VendorOption[] = [
}),
];

const IMAGE_MEDIA_PROTOCOLS = new Set([
"openai_image_generations",
"openai_image_edits",
"google_image_generation",
"xai_image",
"xai_image_edits",
]);

function buildInitialState(target: AdminLLMModelDTO | null): FormState {
if (!target) {
return {
Expand Down Expand Up @@ -248,6 +259,17 @@ export function ModelSheet({ open, mode, target, onClose, onSuccess }: ModelShee
])),
[sources, target?.protocolsJSON],
);
const imageStreamEnabled = imageStreamEnabledFromCapabilities(form.capabilitiesJSON);
const showImageStreamControl = routeProtocols.some((protocol) => IMAGE_MEDIA_PROTOCOLS.has(protocol.trim()));

function updateImageStreamEnabled(enabled: boolean) {
const nextValue = setImageStreamEnabledInCapabilities(form.capabilitiesJSON, enabled);
if (nextValue === null) {
toast.error(t("sheet.capabilitiesQuick.invalidJSON"));
return;
}
setField("capabilitiesJSON", nextValue);
}

function handleClose() {
onClose();
Expand Down Expand Up @@ -590,6 +612,22 @@ export function ModelSheet({ open, mode, target, onClose, onSuccess }: ModelShee
<ModelCapabilitiesGuideButton t={t} />
</div>
</div>
{showImageStreamControl ? (
<label
htmlFor="model-image-stream-enabled"
className="mb-2 flex min-w-0 items-center gap-2 px-1 py-1"
>
<Checkbox
id="model-image-stream-enabled"
checked={imageStreamEnabled}
disabled={pending}
onCheckedChange={(checked) => updateImageStreamEnabled(checked === true)}
/>
<span className="min-w-0 truncate text-xs font-medium text-foreground">
{t("sheet.imageStreamEnabled")}
</span>
</label>
) : null}
<JsonCodeEditor
id="model-capabilities-json"
value={form.capabilitiesJSON}
Expand Down
Loading
Loading