fix(canvas): 分镜与视频模型 - 按端点协议适配并修复分镜生成
* fix(backend): 分镜生成 - 自动修复携带原始剧情并支持有界重试 * fix(canvas): 视频模型 - 按端点协议拉取模型并适配分镜视频节点 * fix(canvas): 视频模型 - 保留已有协议并刷新分镜节点模型 * fix(canvas): 引用图预览 - 修正提示面板代码格式 --------- Co-authored-by: sikongyue <3155157671@qq.com> Co-authored-by: ddCat <2695092045@qq.com>
This commit is contained in:
@@ -26,7 +26,7 @@ func RegisterChannelModelRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
// handler 不接触上游响应细节,密钥校验和模型目录解析统一收敛到 service。
|
||||
models, err := svc.FetchChannelModels(c.Request.Context(), user, input)
|
||||
models, err := svc.FetchChannelModelCatalog(c.Request.Context(), user, input)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
|
||||
@@ -28,11 +28,38 @@ type channelModelsPayload struct {
|
||||
}
|
||||
|
||||
type channelModelItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
|
||||
}
|
||||
|
||||
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
|
||||
items, err := s.FetchChannelModelCatalog(ctx, actor, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
models := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.ID)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
models = append(models, name)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同;
|
||||
// supportedEndpointTypes 由上游 /models 返回,前端据此推导能力与协议,不依赖模型名猜测。
|
||||
type ChannelModelCatalogItem struct {
|
||||
ID string `json:"id"`
|
||||
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
|
||||
if actor == nil || strings.TrimSpace(actor.ID) == "" {
|
||||
return nil, Unauthorized("请先登录")
|
||||
}
|
||||
@@ -99,17 +126,33 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp
|
||||
items = payload.Models
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
models := make([]string, 0, len(items))
|
||||
catalog := make([]ChannelModelCatalogItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(item.ID, item.Name)), "models/")
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
models = append(models, name)
|
||||
catalog = append(catalog, ChannelModelCatalogItem{ID: name, SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes)})
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
sort.Slice(catalog, func(left int, right int) bool {
|
||||
return catalog[left].ID < catalog[right].ID
|
||||
})
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func normalizeCatalogEndpointTypes(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
item := strings.TrimSpace(value)
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func channelModelsUpstreamError(err error) error {
|
||||
|
||||
@@ -26,7 +26,7 @@ func defaultPromptDefinitions() []PromptOperationDefinition {
|
||||
Operation: promptOperationStoryboardRepair, Label: "分镜修复", Category: "分镜", OutputType: "json", SchemaKey: "storyboard-plan/v3",
|
||||
Description: "修复模型返回的分镜结构、字段和镜头复杂度。",
|
||||
Variables: []PromptTemplateVariable{{Label: "校验错误", Placeholder: "{{校验错误}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}},
|
||||
DefaultContent: `你是影视分镜 JSON 修复导演。修复结构时必须保留剧情信息,通过拆镜或重新分配内容解决复杂度超限,不得用删除关键剧情掩盖错误。保持原项目的视觉媒介、角色身份、服装、道具和连续性,不要擅自改写画风。只修复校验错误和由此引发的镜头组织问题。`,
|
||||
DefaultContent: `你是影视分镜 JSON 修复导演。修复结构时必须保留剧情信息,通过拆镜或重新分配内容解决复杂度超限,不得用删除关键剧情掩盖错误。若校验错误是台词/旁白超长,必须拆镜或精简为单镜头可念完的台词,保留关键情节,不要把超长 dialogue 原样放回。保持原项目的视觉媒介、角色身份、服装、道具和连续性,不要擅自改写画风。只修复校验错误和由此引发的镜头组织问题。`,
|
||||
},
|
||||
{
|
||||
Operation: promptOperationStoryboardFirstFrame, Label: "分镜首帧", Category: "生成", OutputType: "text",
|
||||
@@ -213,6 +213,9 @@ func storyboardProtectedContext(values map[string]string) string {
|
||||
|
||||
func storyboardRepairProtectedContext(values map[string]string) string {
|
||||
return strings.Join([]string{
|
||||
"【剧情】\n" + values["剧情"],
|
||||
"【用户本次要求】\n" + values["用户要求"],
|
||||
"【当前画布资产】\n" + values["画布资产"],
|
||||
"【原始校验错误】\n" + values["校验错误"],
|
||||
"【当前项目画风】\n" + values["项目画风"],
|
||||
"【当前角色版本】\n" + values["角色版本"],
|
||||
@@ -226,6 +229,7 @@ func storyboardExecutionContract(durationRule string, countRule string) string {
|
||||
- ` + durationRule + `
|
||||
- ` + countRule + `
|
||||
- 单镜头最多 2 名主要角色、1 个主运镜、1 条主要动作链、3 个 timeBeats 和 3 个 mustHave;超限必须拆镜或在固定镜头数内重新分配。
|
||||
- dialogue 只写本镜头实际念出的台词或简短旁白,字数上限按 1 秒最多约 5 个中文字符计算(至少 24 字);超长台词/旁白必须拆镜或精简,不得用 dialogue 承载长段叙述。
|
||||
- characterIds 只能引用当前角色版本中的 assetId;没有角色时返回空数组。
|
||||
- styleGuide 最多 120 个中文字符;visualPrompt 只描述首帧,videoPrompt 只描述运动和结尾状态。
|
||||
- 画幅比例由视频节点参数控制,提示词不得写入具体比例,也不要讨论画幅配置。
|
||||
|
||||
@@ -1145,22 +1145,9 @@ func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Tas
|
||||
err = validateStoryboardPlan(plan, 0, 0, input.Characters)
|
||||
}
|
||||
if err != nil {
|
||||
_ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55)
|
||||
repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, err, input, text)
|
||||
if promptErr != nil {
|
||||
return nil, nil, promptErr
|
||||
}
|
||||
repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config, StreamText: true})
|
||||
if repairErr != nil {
|
||||
return nil, nil, fmt.Errorf("分镜结构修复失败:%w", repairErr)
|
||||
}
|
||||
repairedText, _ := repaired["text"].(string)
|
||||
plan, err = parseAgentStoryboardPlan(repairedText)
|
||||
if err == nil {
|
||||
err = validateStoryboardPlan(plan, 0, 0, input.Characters)
|
||||
}
|
||||
plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, 0, 0)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("分镜模型结构修复后仍不合法:%w", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return s.buildAgentStoryboardResult(task, plan, assets, input.ProjectStyle)
|
||||
@@ -1201,22 +1188,9 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task
|
||||
err = validateStoryboardPlan(plan, input.ShotDuration, input.ShotCount, input.Characters)
|
||||
}
|
||||
if err != nil {
|
||||
_ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55)
|
||||
repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, err, input, text)
|
||||
if promptErr != nil {
|
||||
return nil, nil, promptErr
|
||||
}
|
||||
repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config, StreamText: true})
|
||||
if repairErr != nil {
|
||||
return nil, nil, fmt.Errorf("分镜结构修复失败:%w", repairErr)
|
||||
}
|
||||
repairedText, _ := repaired["text"].(string)
|
||||
plan, err = parseAgentStoryboardPlan(repairedText)
|
||||
if err == nil {
|
||||
err = validateStoryboardPlan(plan, input.ShotDuration, input.ShotCount, input.Characters)
|
||||
}
|
||||
plan, err = s.repairStoryboardPlan(ctx, task, input, config, text, err, input.ShotDuration, input.ShotCount)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("分镜模型结构修复后仍不合法:%w", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(plan.Shots))
|
||||
@@ -1251,6 +1225,35 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task
|
||||
return map[string]interface{}{"title": plan.Title, "rows": rows}, nil, nil
|
||||
}
|
||||
|
||||
const maxStoryboardRepairAttempts = 2
|
||||
|
||||
func (s *Service) repairStoryboardPlan(ctx context.Context, task model.Task, input agentStoryboardInput, config providerConfig, originalText string, validationErr error, shotDuration int, shotCount int) (agentStoryboardPlan, error) {
|
||||
currentText := originalText
|
||||
currentErr := validationErr
|
||||
for attempt := 1; attempt <= maxStoryboardRepairAttempts; attempt++ {
|
||||
_ = s.repo.UpdateTaskProgress(task.ID, "修复分镜结构", 55+attempt*10)
|
||||
repairPrompt, promptErr := s.buildStoryboardRepairPrompt(task.UserID, task.Prompt, currentErr, input, currentText)
|
||||
if promptErr != nil {
|
||||
return agentStoryboardPlan{}, promptErr
|
||||
}
|
||||
repaired, repairErr := runTextTask(withProviderRequestKind(ctx, "repair"), canvasGenerationInput{Mode: "text", Prompt: repairPrompt, Config: config, StreamText: true})
|
||||
if repairErr != nil {
|
||||
return agentStoryboardPlan{}, fmt.Errorf("分镜结构修复失败:%w", repairErr)
|
||||
}
|
||||
repairedText, _ := repaired["text"].(string)
|
||||
plan, parseErr := parseAgentStoryboardPlan(repairedText)
|
||||
if parseErr == nil {
|
||||
parseErr = validateStoryboardPlan(plan, shotDuration, shotCount, input.Characters)
|
||||
}
|
||||
if parseErr == nil {
|
||||
return plan, nil
|
||||
}
|
||||
currentText = repairedText
|
||||
currentErr = parseErr
|
||||
}
|
||||
return agentStoryboardPlan{}, fmt.Errorf("分镜模型结构修复后仍不合法:%w", currentErr)
|
||||
}
|
||||
|
||||
func providerConfigReady(config providerConfig) bool {
|
||||
return strings.TrimSpace(config.Model) != "" && (strings.TrimSpace(config.ChannelID) != "" || (strings.TrimSpace(config.BaseURL) != "" && strings.TrimSpace(config.APIKey) != ""))
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ func (s *Service) buildAgentStoryboardPlannerPrompt(userID string, brief string,
|
||||
return removeFixedMediaRestrictions(compiled.Content), nil
|
||||
}
|
||||
|
||||
func (s *Service) buildStoryboardRepairPrompt(userID string, validationErr error, input agentStoryboardInput, original string) (string, error) {
|
||||
values := storyboardPromptValues("", input.Requirements, input.CanvasAssets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount)
|
||||
func (s *Service) buildStoryboardRepairPrompt(userID string, brief string, validationErr error, input agentStoryboardInput, original string) (string, error) {
|
||||
values := storyboardPromptValues(brief, input.Requirements, input.CanvasAssets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount)
|
||||
values["校验错误"] = validationErr.Error()
|
||||
values["原始输出"] = original
|
||||
compiled, err := s.compilePrompt(userID, promptOperationStoryboardRepair, values)
|
||||
|
||||
@@ -388,14 +388,22 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references:
|
||||
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.node.activeStroke, boxShadow: `0 4px 14px ${theme.spatial.shadow}` }}
|
||||
title={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
|
||||
aria-label={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
|
||||
onClick={() => canPreview ? setImagePreview(reference) : onInsert(reference)}
|
||||
onClick={() => (canPreview ? setImagePreview(reference) : onInsert(reference))}
|
||||
>
|
||||
<span className="block size-full overflow-hidden rounded-md">
|
||||
<ReferenceThumbnail reference={reference} />
|
||||
</span>
|
||||
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[var(--fs-micro)] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
|
||||
{canPreview ? <span className="absolute bottom-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm"><Maximize2 className="size-2" /></span> : null}
|
||||
{!canPreview ? <span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm"><AtSign className="size-2" /></span> : null}
|
||||
{canPreview ? (
|
||||
<span className="absolute bottom-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm">
|
||||
<Maximize2 className="size-2" />
|
||||
</span>
|
||||
) : null}
|
||||
{!canPreview ? (
|
||||
<span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm">
|
||||
<AtSign className="size-2" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{canPreview ? (
|
||||
<button
|
||||
|
||||
@@ -86,6 +86,19 @@ export function modelProtocolCapability(value?: string) {
|
||||
return modelProtocolDefinition(value)?.capability;
|
||||
}
|
||||
|
||||
export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProtocol | undefined {
|
||||
const normalized = new Set(endpointTypes.map((value) => value.trim().toLowerCase()));
|
||||
if (normalized.has("openai-chat") || normalized.has("chat-completion") || normalized.has("chat")) return "chat-completion";
|
||||
if (normalized.has("openai-response") || normalized.has("responses")) return "openai-response";
|
||||
if (normalized.has("openai-image") || normalized.has("image")) return "openai-image";
|
||||
if (normalized.has("openai-video") || normalized.has("video")) return "newapi-channel-2";
|
||||
if (normalized.has("openai-audio") || normalized.has("audio")) return "openai-audio";
|
||||
if (normalized.has("grok-image")) return "grok-image";
|
||||
if (normalized.has("gemini-veo") || normalized.has("gemini-video") || normalized.has("veo")) return "gemini-veo";
|
||||
if (normalized.has("volcengine-ark-video") || normalized.has("volcengine-ark")) return "volcengine-ark-video";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function modelProtocolSummary(value?: string) {
|
||||
const protocol = modelProtocolDefinition(value);
|
||||
if (!protocol) return "保存模型时选择协议后,将在这里显示实际请求方式。";
|
||||
|
||||
@@ -276,6 +276,7 @@ export function useCanvasStoryboard({
|
||||
if (!scriptNode || !rows.length) return;
|
||||
const videoSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
||||
const startLeft = scriptNode.position.x + scriptNode.width + 120;
|
||||
const videoModel = buildGenerationConfig(effectiveConfig, undefined, "video").model;
|
||||
const nextNodes = [...nodesRef.current];
|
||||
const nextConnections = [...connectionsRef.current];
|
||||
const videoNodeByRowId = new Map<string, string>();
|
||||
@@ -286,14 +287,14 @@ export function useCanvasStoryboard({
|
||||
if (existingIndex >= 0) {
|
||||
const existing = nextNodes[existingIndex];
|
||||
const existingMetadata = existing.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing.metadata);
|
||||
nextNodes[existingIndex] = { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), seconds: String(row.durationSeconds), shotIndex: row.shotNumber, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, generationMode: "video", videoEditOperation: existing.metadata?.videoEditOperation || "text_to_video" } };
|
||||
nextNodes[existingIndex] = { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, model: videoModel, ...storyboardPromptTemplateMetadata(row, "video"), seconds: String(row.durationSeconds), shotIndex: row.shotNumber, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, generationMode: "video", videoEditOperation: existing.metadata?.videoEditOperation || "text_to_video" } };
|
||||
storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false).forEach((referenceId) => {
|
||||
if (!nextConnections.some((connection) => connection.fromNodeId === referenceId && connection.toNodeId === existing.id)) nextConnections.push({ id: nanoid(), fromNodeId: referenceId, toNodeId: existing.id });
|
||||
});
|
||||
videoNodeByRowId.set(row.id, existing.id);
|
||||
return;
|
||||
}
|
||||
const videoNode = createCanvasNode(CanvasNodeType.Video, { x: startLeft + videoSpec.width / 2, y: scriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "text_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
const videoNode = createCanvasNode(CanvasNodeType.Video, { x: startLeft + videoSpec.width / 2, y: scriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, composerContent: prompt, model: videoModel, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "text_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
videoNode.title = `镜头 ${row.shotNumber} · 视频`;
|
||||
nextNodes.push(videoNode);
|
||||
nextConnections.push({ id: nanoid(), fromNodeId: scriptNode.id, toNodeId: videoNode.id, fromHandleId: `row:${row.id}` });
|
||||
@@ -320,7 +321,7 @@ export function useCanvasStoryboard({
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
if (!silent) message.success(createdCount ? `已创建 ${createdCount} 个视频节点` : "已同步现有视频节点的提示词");
|
||||
}, [connectionsRef, message, nodesRef, setConnections, setNodes]);
|
||||
}, [connectionsRef, effectiveConfig, message, nodesRef, setConnections, setNodes]);
|
||||
|
||||
const createAndGenerateScriptVideos = useCallback(async (nodeId: string, rowIds?: string[]) => {
|
||||
const videoModel = effectiveConfig.videoModel || effectiveConfig.model;
|
||||
@@ -358,7 +359,7 @@ export function useCanvasStoryboard({
|
||||
const targetById = new Map(targets.map((target) => [target.videoNode.id, target]));
|
||||
const nextNodes = nodesRef.current.map((node) => {
|
||||
const target = targetById.get(node.id);
|
||||
return target ? { ...node, metadata: { ...node.metadata, prompt: target.prompt, composerContent: target.prompt, ...storyboardPromptTemplateMetadata(target.row, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, videoStartFrameNodeId: undefined } } : node;
|
||||
return target ? { ...node, metadata: { ...node.metadata, prompt: target.prompt, composerContent: target.prompt, model: videoModel, ...storyboardPromptTemplateMetadata(target.row, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, videoStartFrameNodeId: undefined } } : node;
|
||||
});
|
||||
const dedicatedFirstFrameConnections = new Set(targets
|
||||
.filter((target) => Boolean(target.row.imageNodeId))
|
||||
@@ -453,8 +454,8 @@ export function useCanvasStoryboard({
|
||||
const existing = row.videoNodeId ? nextNodes.find((node) => node.id === row.videoNodeId && node.type === CanvasNodeType.Video) : undefined;
|
||||
const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata);
|
||||
const videoNode = existing
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, videoStartFrameNodeId: row.imageNodeId, seconds: String(row.durationSeconds) } }
|
||||
: createCanvasNode(CanvasNodeType.Video, { x: startX, y: currentScriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "image_to_video", videoStartFrameNodeId: row.imageNodeId, status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, model: videoModel, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, videoStartFrameNodeId: row.imageNodeId, seconds: String(row.durationSeconds) } }
|
||||
: createCanvasNode(CanvasNodeType.Video, { x: startX, y: currentScriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, model: videoModel, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "image_to_video", videoStartFrameNodeId: row.imageNodeId, status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
if (!existing) {
|
||||
videoNode.title = `镜头 ${row.shotNumber} · 视频`;
|
||||
nextNodes.push(videoNode);
|
||||
|
||||
@@ -8,7 +8,9 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
|
||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||
import {
|
||||
createModelChannel,
|
||||
@@ -166,8 +168,8 @@ export default function SettingsPage() {
|
||||
}
|
||||
setChannelLoading(channel.id, true);
|
||||
try {
|
||||
const models = await fetchChannelModels(channel, true);
|
||||
if (!models.length) {
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
if (!result.models.length) {
|
||||
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
|
||||
return;
|
||||
}
|
||||
@@ -179,7 +181,7 @@ export default function SettingsPage() {
|
||||
return;
|
||||
}
|
||||
updateChannels(
|
||||
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)),
|
||||
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)),
|
||||
latestConfig,
|
||||
);
|
||||
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
|
||||
@@ -203,27 +205,30 @@ export default function SettingsPage() {
|
||||
const results = await Promise.all(
|
||||
runnable.map(async (channel) => {
|
||||
try {
|
||||
const models = await fetchChannelModels(channel, true);
|
||||
return { channel, models, error: "" };
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
return { channel, result, error: "" };
|
||||
} catch (error) {
|
||||
return { channel, models: [] as string[], error: error instanceof Error ? error.message : "读取失败" };
|
||||
return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const latestConfig = useConfigStore.getState().config;
|
||||
const successful = results.filter((item) => {
|
||||
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
|
||||
return Boolean(item.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
|
||||
return Boolean(item.result.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
|
||||
});
|
||||
const stale = results.filter((item) => {
|
||||
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
|
||||
return Boolean(item.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
|
||||
return Boolean(item.result.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
|
||||
});
|
||||
const failed = results.filter((item) => !item.models.length);
|
||||
const failed = results.filter((item) => !item.result.models.length);
|
||||
if (successful.length) {
|
||||
const modelMap = new Map(successful.map((item) => [item.channel.id, item.models] as const));
|
||||
const resultMap = new Map(successful.map((item) => [item.channel.id, item.result] as const));
|
||||
updateChannels(
|
||||
latestConfig.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || channel.models } : channel)),
|
||||
latestConfig.channels.map((channel) => {
|
||||
const fetched = resultMap.get(channel.id);
|
||||
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel;
|
||||
}),
|
||||
latestConfig,
|
||||
);
|
||||
message.success(`已更新 ${successful.length} 个渠道的模型`);
|
||||
@@ -590,6 +595,45 @@ function normalizeImageCount(value: string) {
|
||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || Number(defaultConfig.canvasImageCount)))));
|
||||
}
|
||||
|
||||
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
// 拉取模型目录时按上游 supported_endpoint_types 推导协议和能力;
|
||||
// 保留已有定价,但目录端点类型与旧配置冲突时自动纠正协议,避免模型名猜测和 multipart 错配。
|
||||
function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
|
||||
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
|
||||
const next: ChannelModelCost[] = [];
|
||||
for (const item of catalog) {
|
||||
const existing = existingByModel.get(item.id);
|
||||
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
|
||||
if (existing) {
|
||||
if (inferredProtocol && existing.protocol !== inferredProtocol) {
|
||||
const inferredCapability = modelProtocolCapability(inferredProtocol);
|
||||
next.push({
|
||||
...existing,
|
||||
protocol: inferredProtocol,
|
||||
capability: inferredCapability || existing.capability,
|
||||
capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push(existing);
|
||||
continue;
|
||||
}
|
||||
const catalogProtocol = inferredProtocol || channel.interfaceType;
|
||||
const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined;
|
||||
if (!catalogProtocol || !catalogCapability) continue;
|
||||
next.push({
|
||||
model: item.id,
|
||||
capability: catalogCapability,
|
||||
protocol: catalogProtocol,
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits: 0,
|
||||
capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -1095,13 +1095,18 @@ export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKe
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelModels(channel: ModelChannel, viaBackend = false) {
|
||||
export type ChannelModelCatalogItem = { id: string; supportedEndpointTypes?: string[] };
|
||||
|
||||
export type ChannelModelFetchResult = { models: string[]; catalog: ChannelModelCatalogItem[] };
|
||||
|
||||
export async function fetchChannelModels(channel: ModelChannel, viaBackend = false): Promise<ChannelModelFetchResult> {
|
||||
if (!viaBackend) {
|
||||
return fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey, apiFormat: channel.apiFormat });
|
||||
const models = await fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey, apiFormat: channel.apiFormat });
|
||||
return { models, catalog: models.map((id) => ({ id })) };
|
||||
}
|
||||
try {
|
||||
// 登录态由同源后端代取模型目录,避免每个 OpenAI 兼容服务分别维护浏览器 CORS 白名单。
|
||||
const response = await axios.post<{ code?: number; data?: { models?: string[] }; msg?: string }>(
|
||||
const response = await axios.post<{ code?: number; data?: { models?: Array<string | ChannelModelCatalogItem> }; msg?: string }>(
|
||||
resolveBackendApiUrl("/api/ai/models"),
|
||||
{
|
||||
baseUrl: channel.baseUrl,
|
||||
@@ -1114,7 +1119,16 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal
|
||||
if (typeof response.data.code === "number" && response.data.code !== 0) {
|
||||
throw new Error(response.data.msg || "读取模型失败");
|
||||
}
|
||||
return Array.from(new Set((response.data.data?.models || []).map((model) => model.trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
||||
const catalog = new Map<string, ChannelModelCatalogItem>();
|
||||
for (const item of response.data.data?.models || []) {
|
||||
const entry = typeof item === "string" ? { id: item.trim() } : { id: String(item.id || "").trim(), supportedEndpointTypes: Array.isArray(item.supportedEndpointTypes) ? item.supportedEndpointTypes : undefined };
|
||||
if (!entry.id) continue;
|
||||
const existing = catalog.get(entry.id);
|
||||
catalog.set(entry.id, existing || entry);
|
||||
}
|
||||
const models = Array.from(catalog.keys()).sort((a, b) => a.localeCompare(b));
|
||||
const sortedCatalog = Array.from(catalog.values()).sort((a, b) => a.id.localeCompare(b.id));
|
||||
return { models, catalog: sortedCatalog };
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "读取模型失败"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user