fix(ai): 修复AI代理请求和图片生成功能

- 添加详细的错误日志记录,包括请求读取、渠道选择、请求构建和响应处理的失败情况
- 统一AI接口请求失败返回消息为"AI 接口请求失败"
- 添加对上游响应状态码>=400的检查和错误处理
- 在JWT解析中验证签名方法有效性,防止无效登录状态
- 移除JWT密钥警告日志,改为运行时生成随机密钥
- 调整应用配置模态框中云端和本地选项的显示顺序
- 添加图片生成元数据记录生成类型、模型、尺寸、质量和参考图等信息
- 实现空图片节点直接生成图片而不保留空框的功能
- 完善图片重试逻辑,优先使用保存的元数据进行重试
- 添加参考图片丢失时的错误提示
- 修复图片节点样式圆角显示问题
- 更新AI代理路径为/api/v1/*保持OpenAI风格
- 添加系统设置页面的安全警告提示
This commit is contained in:
HouYunFei
2026-05-21 17:50:14 +08:00
parent f9b2759499
commit d40b9b53c2
14 changed files with 214 additions and 51 deletions
+5 -6
View File
@@ -2,12 +2,11 @@
## Unreleased
+ [新增] 管理后台新增系统设置页面,按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON
+ [调整] 模型渠道配置改为列表和抽屉编辑,新增协议、权重、远程获取模型列表、模型清单测试和批量测试能力
+ [调整] 公开系统配置中的模型相关字段统一收拢到 `modelChannel` 配置组
+ [新增] 用户侧配置支持云端渠道和本地直连两种模式,云端模式通过后端渠道代理请求模型接口
+ [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置
+ [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。
+ [修复] 图片节点生成会用扁平 metadata 字段记录提示词、生成类型、模型、尺寸、质量、数量和参考图引用,重试优先使用这些字段
+ [修复] 参考图丢失时会直接提示无法继续重试,避免误发空的 `images/edits` 请求
+ [修复] AI 代理失败统一返回通用错误,前端按业务 `code``message.error`
+ [调整] 后端模型代理路径改为 OpenAI 风格的 `/api/v1/*`
+ [修复] 空图片节点生成和批量重试逻辑已整理,避免空框残留和重试走错接口
## v0.0.5 - 2026-05-20
+23 -1
View File
@@ -1,6 +1,10 @@
package config
import (
"crypto/rand"
"encoding/base64"
"strings"
"github.com/caarlos0/env/v11"
"github.com/joho/godotenv"
)
@@ -19,5 +23,23 @@ var Cfg Config
func Load() error {
_ = godotenv.Load()
return env.Parse(&Cfg)
if err := env.Parse(&Cfg); err != nil {
return err
}
if strings.TrimSpace(Cfg.JWTSecret) == "" || Cfg.JWTSecret == "infinite-canvas" {
secret, err := randomSecret()
if err != nil {
return err
}
Cfg.JWTSecret = secret
}
return nil
}
func randomSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
+9
View File
@@ -30,6 +30,15 @@
- 生成配置节点会读取上游文本内容作为生图提示词,并立即开始生成图片。
- 后续需要调整模型、比例、数量时,可以在生成配置节点里修改后再次生成。
### 用图片节点继续生成图片
- 选中空图片节点后点击“生成”,会直接在当前节点里生成结果,不再额外保留一个空框。
- 当数量为 `1` 时,生成结果会直接回填到当前图片节点。
- 当数量大于 `1` 时,当前图片节点会作为主图节点,右侧继续生成对应数量的子图节点。
- 如果图片节点本身已经有内容,则会沿用已有图片作为参考图,再生成新的图片批次。
- 每个生成出来的图片节点都会在自身 `metadata` 下记录提示词、生成类型、模型、尺寸、质量、数量和参考图引用,方便后续重试。
- 如果重试时参考图已经丢失或无法恢复,系统会直接提示,避免误发空的图生图请求。
### 推荐流程
1. 新建文本节点,写入图片创作想法。
+10
View File
@@ -7,9 +7,19 @@
- 公开系统配置中的模型相关字段应保存到 `modelChannel` 配置组下。
- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。
- 管理后台系统设置页面的公开配置应把私有配置中已启用渠道的模型列表作为系统可用模型的下拉选项,但系统可用模型仍由管理员手动选择。
- 管理后台私有配置页应提示:当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API,不建议公网部署,避免私有渠道额度被他人消耗。
- 用户侧配置弹窗应读取公共配置;未允许自定义渠道时固定使用云端渠道,允许时可切换云端渠道和本地直连。
- 用户侧云端模式应通过 `/api/v1/*` 代理路径请求后端模型接口,路径风格应与 OpenAI 保持一致。
- 根布局初始化组件应在页面刷新后统一加载当前用户信息和公共系统配置。
- 用户侧生图、画布节点生成和画布助手请求应按渠道模式分别走后端代理或浏览器本地直连。
- AI 代理请求上游失败时,后端日志应记录上游地址、状态或错误,前端只展示通用失败文案,不应暴露 EOF、socket hang up 等原始错误。
- 后端未显式配置 `JWT_SECRET` 或仍使用默认值时,应使用运行时随机密钥签发登录 token,管理员接口不能被默认密钥伪造 token 访问。
- 空图片节点生成图片时,数量为 1 应直接回填当前节点,数量大于 1 应将当前节点作为主图并继续生成子图,不应再留下额外空框。
- 空图片节点批量生成后,某个子图重试时应保持原来的生图方式,不应因为挂在批量主图下就误走编辑接口。
- 图片节点生成成功或失败后,节点 metadata 应以扁平字段保留当次提示词、生成类型、模型、尺寸、质量、数量和参考图引用;重试应优先使用这些字段。
- 图生图重试时如果参考图片已丢失,应提示无法继续重试,不应继续请求 `images/edits`
- 图片节点内容应统一按圆角裁切,主图边框圆角和图片内容不应再出现方角错位。
- 图片节点生成后的主图、子图和批量叠层都应保持圆角一致,不应出现方形图片顶出圆角边框的情况。
- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。
- 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。
- 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。
+1 -1
View File
@@ -41,7 +41,7 @@
| 模式 | 说明 |
| --- | --- |
| 云端渠道 | 使用后端 `/api/ai/*` 代理接口,请求会按模型名匹配 `private.value.channels` 中的可用渠道 |
| 云端渠道 | 使用后端 `/api/v1/*` 代理接口,请求会按模型名匹配 `private.value.channels` 中的可用渠道 |
| 本地直连 | 仅 `allowCustomChannel``true` 时可选,用户在浏览器本地配置 `baseUrl``apiKey` 和模型列表后直接请求模型接口 |
## private.value
+16 -4
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
@@ -27,17 +28,20 @@ func AIChatCompletions(w http.ResponseWriter, r *http.Request) {
func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
body, contentType, modelName, err := readAIRequest(r)
if err != nil {
Fail(w, err.Error())
log.Printf("AI proxy request read failed: %v", err)
Fail(w, "AI 接口请求失败")
return
}
channel, err := service.SelectModelChannel(modelName)
if err != nil {
Fail(w, err.Error())
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
Fail(w, "AI 接口请求失败")
return
}
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
if err != nil {
Fail(w, err.Error())
log.Printf("AI proxy build request failed: url=%s err=%v", service.BuildModelChannelURL(channel, path), err)
Fail(w, "AI 接口请求失败")
return
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
@@ -46,11 +50,19 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
}
response, err := http.DefaultClient.Do(request)
if err != nil {
Fail(w, err.Error())
log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err)
Fail(w, "AI 接口请求失败")
return
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
log.Printf("AI upstream error: url=%s status=%d body=%s", request.URL.String(), response.StatusCode, strings.TrimSpace(string(payload)))
Fail(w, "AI 接口请求失败")
return
}
for key, values := range response.Header {
if strings.EqualFold(key, "Content-Length") {
continue
+3 -3
View File
@@ -20,9 +20,9 @@ func New() *gin.Engine {
api.POST("/auth/login", gin.WrapF(handler.Login))
api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser))
api.GET("/settings", gin.WrapF(handler.Settings))
api.POST("/ai/images/generations", gin.WrapF(handler.AIImagesGenerations))
api.POST("/ai/images/edits", gin.WrapF(handler.AIImagesEdits))
api.POST("/ai/chat/completions", gin.WrapF(handler.AIChatCompletions))
api.POST("/v1/images/generations", gin.WrapF(handler.AIImagesGenerations))
api.POST("/v1/images/edits", gin.WrapF(handler.AIImagesEdits))
api.POST("/v1/chat/completions", gin.WrapF(handler.AIChatCompletions))
api.GET("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts))
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
+3 -3
View File
@@ -92,6 +92,9 @@ func Login(username string, password string) (model.AuthSession, error) {
func ParseToken(tokenText string) (TokenClaims, error) {
claims := TokenClaims{}
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("登录状态无效")
}
return []byte(config.Cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
@@ -215,7 +218,4 @@ func WarnDefaultSecurityConfig() {
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
}
if config.Cfg.JWTSecret == "infinite-canvas" {
log.Println("WARNING: using default JWT_SECRET, please set a long random value before deployment")
}
}
+6 -1
View File
@@ -2,7 +2,7 @@
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
import { json } from "@codemirror/lang-json";
import { App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd";
import { Alert, App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { EditorView } from "@uiw/react-codemirror";
@@ -294,6 +294,11 @@ export default function AdminSettingsPage() {
) : activeMode === "visual" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Flex vertical gap={12}>
<Alert
showIcon
type="warning"
message="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。"
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}></Button>
<Table
rowKey={(_, index) => String(index)}
@@ -33,7 +33,8 @@ import { CanvasToolbar } from "../components/canvas-toolbar";
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
import { useCanvasStore } from "../stores/use-canvas-store";
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ConnectionHandle, type ContextMenuState, type Position, type SelectionBox, type ViewportTransform } from "../types";
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type ConnectionHandle, type ContextMenuState, type Position, type SelectionBox, type ViewportTransform } from "../types";
import type { ReferenceImage } from "@/types/image";
type CanvasClipboard = {
nodes: CanvasNodeData[];
@@ -1316,6 +1317,7 @@ function InfiniteCanvasPage() {
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const title = buildAngleLabel(params);
const prompt = buildAnglePrompt(params);
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }]);
setAngleNodeId(null);
setRunningNodeId(childId);
setNodes((prev) => [...prev, {
@@ -1325,7 +1327,7 @@ function InfiniteCanvasPage() {
position: { x: node.position.x + node.width + 96, y: node.position.y },
width: imageConfig.width,
height: imageConfig.height,
metadata: { prompt, status: NODE_STATUS_LOADING },
metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata },
}]);
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
@@ -1334,7 +1336,7 @@ function InfiniteCanvasPage() {
const image = await requestEdit(generationConfig, prompt, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }]).then((items) => items[0]);
const uploaded = await uploadImage(image.dataUrl);
const size = fitImageNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
setNodes((prev) => prev.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...imageMetadata(uploaded), prompt } } : item));
setNodes((prev) => prev.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item));
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
setNodes((prev) => prev.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item));
@@ -1364,7 +1366,7 @@ function InfiniteCanvasPage() {
setNodes((prev) =>
prev.map((node) =>
node.id === target.nodeId
? { ...node, type: CanvasNodeType.Image, title: file.name, width: size.width, height: size.height, metadata: { ...node.metadata, ...imageMetadata(image), errorDetails: undefined, freeResize: false, isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined, primaryImageId: undefined, imageBatchExpanded: undefined } }
? { ...node, type: CanvasNodeType.Image, title: file.name, width: size.width, height: size.height, metadata: { ...node.metadata, ...imageMetadata(image), errorDetails: undefined, freeResize: false, isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined, batchUsesReferenceImages: undefined, generationType: undefined, model: undefined, size: undefined, quality: undefined, count: undefined, references: undefined, primaryImageId: undefined, imageBatchExpanded: undefined } }
: node,
),
);
@@ -1455,30 +1457,33 @@ function InfiniteCanvasPage() {
const count = getGenerationCount(generationConfig.count);
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const isEmptyImageNode = isImageNode && !sourceNode?.metadata?.content;
const sourceReference = isImageNode && sourceNode?.metadata?.content
? [{ id: sourceNode.id, name: `${sourceNode.title || sourceNode.id}.png`, type: sourceNode.metadata.mimeType || "image/png", dataUrl: sourceNode.metadata.content, storageKey: sourceNode.metadata.storageKey }]
: [];
const referenceImages = [...sourceReference, ...generationContext.referenceImages];
const generationType = referenceImages.length ? "edit" as const : "generation" as const;
const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages);
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text];
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
const gap = 96;
const rowGap = 36;
const rootId = nanoid();
const rootId = isEmptyImageNode ? nodeId : nanoid();
const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : [];
const targetIds = count > 1 ? childIds : [rootId];
pendingChildIds = [rootId, ...childIds];
pendingChildIds = isEmptyImageNode ? childIds : [rootId, ...childIds];
const rootNode: CanvasNodeData = {
id: rootId,
type: CanvasNodeType.Image,
title: effectivePrompt.slice(0, 32) || "Generated Image",
position: {
x: parentPosition.x + parentConfig.width + gap,
x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + gap,
y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2,
},
width: imageConfig.width,
height: imageConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, isBatchRoot: count > 1, batchChildIds: count > 1 ? childIds : undefined, imageBatchExpanded: count > 1 ? true : undefined },
width: isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width,
height: isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, isBatchRoot: count > 1, batchChildIds: count > 1 ? childIds : undefined, batchUsesReferenceImages: referenceImages.length > 0, ...generationMetadata, imageBatchExpanded: count > 1 ? true : undefined },
};
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id,
@@ -1490,9 +1495,12 @@ function InfiniteCanvasPage() {
},
width: imageConfig.width,
height: imageConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata },
}));
const batchConnections = [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
const batchConnections = [
...(isEmptyImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]),
...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId })),
];
setNodes((prev) => [
...prev.map((node) =>
@@ -1500,6 +1508,13 @@ function InfiniteCanvasPage() {
? isConfigNode ? {
...node,
metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
} : isEmptyImageNode ? {
...node,
position: rootNode.position,
width: rootNode.width,
height: rootNode.height,
title: rootNode.title,
metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined },
} : isImageNode ? {
...node,
metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined },
@@ -1513,7 +1528,7 @@ function InfiniteCanvasPage() {
}
: node,
),
rootNode,
...isEmptyImageNode ? [] : [rootNode],
...childNodes,
]);
setConnections((prev) => [...prev, ...batchConnections]);
@@ -1522,6 +1537,7 @@ function InfiniteCanvasPage() {
setDialogNodeId(nodeId);
let hasSuccess = false;
let hasFailure = false;
await Promise.all(targetIds.map(async (targetId) => {
try {
const image = referenceImages.length
@@ -1544,12 +1560,15 @@ function InfiniteCanvasPage() {
return true;
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
hasFailure = true;
setNodes((prev) => prev.map((node) => node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node));
return false;
}
}));
if (hasFailure) message.error(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
setNodes((prev) => prev.map((node) =>
node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
: node.id === nodeId && isEmptyImageNode ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "全部图片生成失败" } }
: node.id === rootId && !hasSuccess ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } }
: node,
));
@@ -1603,6 +1622,7 @@ function InfiniteCanvasPage() {
);
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
message.error(errorDetails);
setNodes((prev) =>
prev.map((node) =>
node.id === nodeId || pendingChildIds.includes(node.id)
@@ -1620,24 +1640,42 @@ function InfiniteCanvasPage() {
const handleRetryNode = useCallback(
async (node: CanvasNodeData) => {
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" };
const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null;
const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined;
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
const generationConfig = hasSavedImageMetadata && savedImageMetadata
? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, count: "1" }
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
const context = await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || ""));
const prompt = context.prompt.trim();
const context = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || ""));
const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim();
if (!prompt) {
message.warning("找不到提示词,无法重试");
return;
}
const generationType = savedImageMetadata?.generationType;
const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length);
const retryReferenceImages = hasSavedImageMetadata && savedImageMetadata
? await resolveMetadataReferences(savedImageMetadata)
: useReferenceImages
? context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)
: [];
if (useReferenceImages && !retryReferenceImages) {
message.error("参考图片已丢失,无法继续重试");
setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item));
return;
}
setRunningNodeId(node.id);
setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item));
try {
if (node.type === CanvasNodeType.Text) {
if (!context) return;
let streamed = "";
const answer = await requestImageQuestion(generationConfig, buildNodeChatMessages({ ...context, prompt }), (text) => {
streamed = text;
@@ -1647,21 +1685,25 @@ function InfiniteCanvasPage() {
return;
}
const image = context.referenceImages.length
? await requestEdit(generationConfig, prompt, context.referenceImages).then((items) => items[0])
const image = useReferenceImages
? await requestEdit(generationConfig, prompt, retryReferenceImages).then((items) => items[0])
: await requestGeneration(generationConfig, prompt).then((items) => items[0]);
const uploadedImage = await uploadImage(image.dataUrl);
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const imageSize = fitImageNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
const generationMetadata = savedImageMetadata?.generationType
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryReferenceImages || []);
setNodes((prev) => prev.map((item) => item.id === node.id ? {
...item,
type: CanvasNodeType.Image,
width: imageSize.width,
height: imageSize.height,
metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt },
metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata },
} : item));
} catch (error) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
message.error(errorDetails);
setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item));
} finally {
setRunningNodeId(null);
@@ -2237,6 +2279,31 @@ function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType };
}
function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
return {
generationType: type,
model: config.model,
size: config.size,
quality: config.quality,
count,
references: references.map(referenceUrl).filter((url): url is string => Boolean(url)),
};
}
function referenceUrl(image: ReferenceImage) {
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
}
async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
if (metadata.generationType !== "edit") return [];
if (!metadata.references?.length) return null;
const references = await Promise.all(metadata.references.map(async (url, index) => {
const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url;
return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null;
}));
return references.every(Boolean) ? references as ReferenceImage[] : null;
}
async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
return Promise.all(nodes.map(async (node) => {
const content = node.metadata?.content;
@@ -2306,7 +2373,7 @@ function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefine
model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model,
quality: config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
count: String(node?.metadata?.count || config.count || defaultConfig.count),
count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count),
};
}
@@ -2330,6 +2397,17 @@ function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connection
return null;
}
function sourceNodeReferenceImages(node: CanvasNodeData | null) {
if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return [];
return [{
id: node.id,
name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey,
}];
}
function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return false;
@@ -495,13 +495,15 @@ function ImageContent({
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
<img
src={node.metadata!.content!}
alt={node.title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`pointer-events-none block h-full w-full select-none rounded-[inherit] ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
/>
<div className="h-full w-full overflow-hidden rounded-3xl">
<img
src={node.metadata!.content!}
alt={node.title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
/>
</div>
{isBatchRoot ? (
<button
type="button"
+5
View File
@@ -17,6 +17,7 @@ export enum CanvasNodeType {
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
export type CanvasGenerationMode = "text" | "image";
export type CanvasImageGenerationType = "generation" | "edit";
export type CanvasNodeMetadata = {
content?: string;
@@ -25,15 +26,19 @@ export type CanvasNodeMetadata = {
errorDetails?: string;
fontSize?: number;
generationMode?: CanvasGenerationMode;
generationType?: CanvasImageGenerationType;
model?: string;
size?: string;
quality?: string;
count?: number;
references?: string[];
naturalWidth?: number;
naturalHeight?: number;
freeResize?: boolean;
isBatchRoot?: boolean;
batchRootId?: string;
batchChildIds?: string[];
batchUsesReferenceImages?: boolean;
primaryImageId?: string;
imageBatchExpanded?: boolean;
inputOrder?: string[];
@@ -70,7 +70,7 @@ export function AppConfigModal() {
size="middle"
value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[{ label: "云端渠道", value: "remote" }, { label: "本地直连", value: "local" }]}
options={[{ label: "本地直连", value: "local" },{ label: "云端渠道", value: "remote" }]}
/>
</Form.Item>
) : null}
+25 -4
View File
@@ -14,6 +14,8 @@ export type ChatCompletionMessage = {
type ImageApiResponse = {
data?: Array<Record<string, unknown>>;
error?: { message?: string };
code?: number;
msg?: string;
};
function resolveImageDataUrl(item: Record<string, unknown>) {
@@ -27,6 +29,9 @@ function resolveImageDataUrl(item: Record<string, unknown>) {
}
function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
}
const images =
payload.data
?.map(resolveImageDataUrl)
@@ -41,8 +46,9 @@ function parseImagePayload(payload: ImageApiResponse) {
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isAxiosError<{ error?: { message?: string } }>(error)) {
return error.response?.data?.error?.message || (error.response?.status ? `${fallback}${error.response.status}` : fallback);
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || (error.response?.status ? `${fallback}${error.response.status}` : fallback);
}
return error instanceof Error ? error.message : fallback;
}
@@ -64,7 +70,7 @@ function withSystemPrompt(config: AiConfig, prompt: string) {
}
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/ai${path}` : buildApiUrl(config.baseUrl, path);
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
@@ -134,7 +140,7 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
let processedLength = 0;
try {
await axios.post(
const response = await axios.post(
aiApiUrl(config, "/chat/completions"),
{
model: config.model,
@@ -162,6 +168,21 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
},
},
);
if (typeof response.data === "object" && response.data && "code" in response.data && (response.data as { code?: number; msg?: string }).code !== 0) {
throw new Error((response.data as { msg?: string }).msg || "请求失败");
}
if (typeof response.data === "string") {
let apiError = "";
try {
const payload = JSON.parse(response.data) as { code?: number; msg?: string };
if (typeof payload.code === "number" && payload.code !== 0) {
apiError = payload.msg || "请求失败";
}
} catch {
// ignore plain text stream content
}
if (apiError) throw new Error(apiError);
}
if (buffer) {
parseStreamChunk(buffer, (delta) => {
answer += delta;