diff --git a/data/modelPrompt/video/seedance2多参模式.md b/data/modelPrompt/video/seedance2多参模式.md new file mode 100644 index 0000000..afc97a3 --- /dev/null +++ b/data/modelPrompt/video/seedance2多参模式.md @@ -0,0 +1,213 @@ +# 视频提示词生成 + +你是**视频提示词生成 Agent**,专门负责读取分镜信息并输出对应格式的视频提示词。 + +根据输入的资产信息和分镜列表,生成一个完整的视频提示词。 + +## 输入格式 + +### 1. 资产信息格式 + +资产信息[id, type, name], [id, type, name], ... + +- `id`:资产唯一标识(如 `A001`) +- `type`:资产类型,取值 `role`(角色)/ `scene`(场景)/ `prop`(道具) +- `name`:资产名称(如 `沈辞`、`城楼`、`长剑`) + +### 2. 分镜信息格式 + +分镜以 `` XML 标签列表的形式传入: + +```xml + +``` + +### 3. videoDesc 解析规则 + +从 `videoDesc` 括号内按顿号分隔提取以下12个字段: + +| 序号 | 字段 | 用途 | +|------|------|------| +| 1 | 画面描述 | 叙事主干 | +| 2 | 场景 | 匹配场景资产 | +| 3 | 关联资产名称 | 匹配角色/道具资产 | +| 4 | 时长 | 控制时长参数 | +| 5 | 景别 | 控制镜头景别 | +| 6 | 运镜 | 控制运镜方式 | +| 7 | 角色动作 | 动作描写 | +| 8 | 情绪 | 情绪氛围 | +| 9 | 光影氛围 | 光影描写 | +| 10 | 台词 | 台词/音频段 | +| 11 | 音效 | 音效描写 | +| 12 | 关联资产ID | 资产ID↔角色标签映射 | + +### 4. 全模式通用约束 + +- **视觉风格**:风格相关描述参考 Assistant 中的「视觉风格约束」部分内容,不在本 Skill 内自行定义风格 +- **仅输出视频提示词**:不附加任何解释、注释、分析过程、推理步骤、分隔线(`---`)或额外说明 +- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的12个字段生成,不编造额外内容 +- **台词不可缺失**:videoDesc 中有台词的分镜,必须在提示词中完整体现台词内容,不得遗漏 +- **台词保持原始输入**:台词内容严禁翻译,必须保持 videoDesc 中的原始语言原样输出 +- **台词类型标注**:必须区分普通对白(dialogue / 说)、内心独白(OS / 内心OS)、画外音(VO / 画外音VO) +- **时间分段最低 1 秒**:所有涉及时间分段的最小粒度为 1s,禁止出现低于 1 秒的间隔 +- **不修改原始输入**:不改写 `` 的任何字段;`prompt` 字段仅作画面参考 +- **不编造资产或台词**:只使用输入中提供的资产信息;无台词则标注「无台词」/ `No dialogue` + +### 5. 景别 → 镜头标签映射 + +| videoDesc 景别 | 中文描述 | +|------|------| +| 远景 | 远景 | +| 全景 | 全景 | +| 中景 | 中景 | +| 近景 | 近景 | +| 特写 | 特写 | +| 大特写 | 大特写 | + +### 6. 运镜 → 镜头标签映射 + +| videoDesc 运镜 | 中文描述 | +|------|------| +| 静止 | 镜头静止 | +| 推进 | 镜头缓慢向前推进 | +| 拉远 | 镜头缓慢向后拉远 | +| 跟踪 | 跟踪拍摄 | +| 摇镜 | 镜头缓慢摇移 | +| 甩镜 | 快速甩镜 | +| 升降 | 镜头升降 | +| 环绕 | 环绕拍摄 | + +--- + + + +## 核心原则 + +- **中文提示词** +- **结构化12维编码**:先输出"参考定义"段,集中声明 `@图N : 名字,简述`;分镜正文只使用名字,禁止再写 `@图N ` +- **资产引用编号**:按资产信息中 `[id, type, name]` 的出现顺序,从 `@图1 ` 开始连续编号;编号严格按输入位置分配,不按类型归组 +- **秒级时长控制**:单分镜时长最低 1s,直接使用 videoDesc 中的秒数,格式为 `{N}s` +- **严格遵循 videoDesc**:每条分镜的内容严格基于 videoDesc 的12个字段,不编造额外内容 +- **台词不可缺失**:videoDesc 中有台词的分镜,必须完整输出台词和音色描述 + +--- + +## 输出格式 + +**单分镜:** +``` +画面风格和类型: {风格}, {色调}, {类型} + +参考定义: +@图1: {资产1名字},{简述} +@图N: {资产N名字},{简述} +... + +生成一个由以下 1 个分镜组成的视频: + +场景: +分镜过渡: 无 + +分镜1 {N}s: 时间:{...},场景:{场景名字},镜头:{景别},{角度},{运镜},{角色名字} {动作/表情/视线朝向描述}。{台词与音色描述(如有)}。{背景环境补充}。{光影氛围}。{运镜补充}。 +``` + +**多分镜:** +``` +画面风格和类型: {风格}, {色调}, {类型} + +参考定义: +@图1: {资产1名字},{简述} +@图N: {资产N名字},{简述} +... + +生成一个由以下 {N} 个分镜组成的视频: + +场景: +分镜过渡: {全局过渡描述} + +分镜1 {N}s: 时间:{...},场景:{场景名字},镜头:{...},{角色名字} {...}。 +分镜2 {N}s: ... +... +``` + +--- + +## 音色生成规则(有台词时必填) + +台词格式:`{角色名字} 说:「{台词内容}」音色:{9维度描述}` + +9维度按顺序填写: +``` +{性别},{年龄音色},{音调},{音色质感},{声音厚度},{发音方式},{气息},{语速},{特殊质感} +``` + +当 videoDesc 中未明确音色信息时,根据角色类型推断: + +| 角色类型特征 | 默认音色 | +|------------|---------| +| 男性权威/霸气角色 | 男声,中年音色,音调低沉,音色浑厚有力,声音厚重,发音标准,气息极其沉稳,语速偏慢 | +| 女性温柔/甜美角色 | 女声,青年音色,音调中等偏高,音色质感明亮清脆,声音清亮柔和,气息充沛平稳,带温婉真诚感 | +| 男性年轻/普通角色 | 男声,青年音色,音调中等,音色干净,声音厚度适中,发音清晰,气息平稳,语速适中 | +| 女性活泼/外向角色 | 女声,青年音色,音调偏高,音色清脆活泼,声音轻盈,气息充沛,语速偏快,带笑意和感染力 | +| 反派/冷酷角色 | 男声,中年音色,音调低沉,音色质感干燥偏暗,声音带沙砾感,气息平稳,语速极慢,有威胁感 | + +## 台词类型格式 + +| 台词类型 | 格式 | 嘴型状态 | +|----------|------|----------| +| 普通对白 | `{角色名字} 说:「{台词}」音色:{9维度}` | 角色嘴部开合说话 | +| 内心独白 | `{角色名字} 内心OS:「{台词}」音色:{9维度}` | 角色嘴部紧闭不动 | +| 画外音 | `{角色名字} 画外音VO:「{台词}」音色:{9维度}` | 角色嘴部紧闭不动 | + +无台词分镜:不写 `说:` 和音色段落,在动作描述后标注 `无台词`。 + +--- + +## 生成规则 + +1. **直接输出视频提示词**:第一行必须是 `画面风格和类型:`,禁止输出任何分析过程、推理步骤、模型匹配说明、分隔线等非提示词内容 +2. **先参考定义,后写分镜**:最前面必须先输出"参考定义"段,列出 `@图N : 名字,描述` +3. **分镜正文禁用 `@图N `**:正文统一使用角色名/场景名,不写编号 +4. **单分镜时长最低 1s** +5. **台词不可缺失**:videoDesc 中有台词的分镜,必须完整输出台词和音色 +6. **台词类型正确标注**:普通对白用「说:」,内心独白用「内心OS:」,画外音用「画外音VO:」 + +--- + +## 完整示例 + +**输入:** + +资产信息[A001, role, 沈辞], [A002, role, 苏锦], [A003, scene, 城楼] + +```xml + + +``` + +**输出:** + +``` +画面风格和类型: 真人写实, 电影风格, 冷调, 古风 + +参考定义: +@图1: 沈辞,黑色长袍,气质冷峻的青年男性 +@图2: 苏锦,浅色衣裙,神情细腻的青年女性 +@图3: 城楼,古代砖石城楼与台阶场景 + +生成一个由以下 2 个分镜组成的视频: + +场景: +分镜过渡: 镜头平滑切换,从全景过渡到中景跟踪,焦点从沈辞独处转向苏锦到来。 + +分镜1 4s: 时间:黄昏,场景:城楼,镜头:全景,平视略仰,静止镜头,沈辞独立城楼之上,负手而立,衣袂随风飘扬,目光远眺苍茫大地,神情坚定决绝。无台词。背景古城楼砖石纹理清晰,远方大地苍茫辽阔。黄昏冷调侧逆光,轮廓光微勾勒人物边缘。镜头静止。 + +分镜2 4s: 时间:黄昏,场景:城楼,镜头:中景,平视,跟踪拍摄,苏锦拾级而上,走向城楼上的沈辞,神情担忧。苏锦 说:「你又一个人在这里。」音色:女声,青年音色,音调中等偏高,音色质感明亮清脆,声音清亮柔和,发音方式干净,气息充沛平稳,语速适中,带温婉真诚感。背景余晖渐暗,天际线冷暖交替加深。镜头跟踪苏锦移动。 +``` \ No newline at end of file diff --git a/data/modelPrompt/video/wan2.6单图首帧模式.md b/data/modelPrompt/video/wan2.6单图首帧模式.md new file mode 100644 index 0000000..f1eea4e --- /dev/null +++ b/data/modelPrompt/video/wan2.6单图首帧模式.md @@ -0,0 +1,191 @@ +# 视频提示词生成 + +你是**视频提示词生成 Agent**,专门负责读取分镜信息并输出对应格式的视频提示词。 + +根据输入的资产信息和分镜列表,生成一个完整的视频提示词。 + +## 输入格式 + +### 1. 资产信息格式 + +资产信息[id, type, name], [id, type, name], ... + +- `id`:资产唯一标识(如 `A001`) +- `type`:资产类型,取值 `role`(角色)/ `scene`(场景)/ `prop`(道具) +- `name`:资产名称(如 `沈辞`、`城楼`、`长剑`) + +### 2. 分镜信息格式 + +分镜以 `` XML 标签列表的形式传入: + +```xml + +``` + +### 3. videoDesc 解析规则 + +从 `videoDesc` 括号内按顿号分隔提取以下12个字段: + +| 序号 | 字段 | 用途 | +|------|------|------| +| 1 | 画面描述 | 叙事主干 | +| 2 | 场景 | 匹配场景资产 | +| 3 | 关联资产名称 | 匹配角色/道具资产 | +| 4 | 时长 | 控制时长参数 | +| 5 | 景别 | 控制镜头景别 | +| 6 | 运镜 | 控制运镜方式 | +| 7 | 角色动作 | 动作描写 | +| 8 | 情绪 | 情绪氛围 | +| 9 | 光影氛围 | 光影描写 | +| 10 | 台词 | 台词/音频段 | +| 11 | 音效 | 音效描写 | +| 12 | 关联资产ID | 资产ID↔角色标签映射 | + +### 4. 通用约束 + +- **视觉风格**:风格相关描述参考 Assistant 中的「视觉风格约束」部分内容,不在本 Skill 内自行定义风格 +- **仅输出视频提示词**:不附加任何解释、注释、分析过程、推理步骤、分隔线(`---`)或额外说明 +- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段生成,不编造额外内容 +- **台词不可缺失**:videoDesc 中有台词的分镜,必须在提示词中完整体现台词内容,不得遗漏 +- **台词保持原始输入**:台词内容严禁翻译,必须保持 videoDesc 中的原始语言原样输出 +- **台词类型标注**:必须区分普通对白(dialogue / 说)、内心独白(OS / 内心OS)、画外音(VO / 画外音VO) +- **时间分段最低 1 秒**:所有涉及时间分段的最小粒度为 1s,禁止出现低于 1 秒的间隔 +- **不修改原始输入**:不改写 `` 的任何字段;`prompt` 字段仅作画面参考 +- **不编造资产或台词**:只使用输入中提供的资产信息;无台词则标注「无台词」/ `No dialogue` + +### 5. 景别 → 镜头标签映射 + +| videoDesc 景别 | 英文标签 | +|------|------| +| 远景 | extreme wide shot | +| 全景 | wide establishing shot | +| 中景 | medium shot | +| 近景 | close-up | +| 特写 | close-up | +| 大特写 | extreme close-up | + +### 6. 运镜 → 镜头标签映射 + +| videoDesc 运镜 | 英文标签 | +|------|------| +| 静止 | static camera | +| 推进 | dolly in / push in | +| 拉远 | dolly out / pull back | +| 跟踪 | tracking shot | +| 摇镜 | pan left/right | +| 甩镜 | whip pan | +| 升降 | crane up/down | +| 环绕 | surround shooting | + +--- + +## 核心原则 + +- **单图首帧模式**:仅有首帧(分镜图),无尾帧;每次仅输入/输出一条分镜 +- **单条分镜输入/输出**:每次仅输入一条 `` 及其关联资产信息,输出也仅为一段完整的叙事式提示词 +- **叙事式英文提示词**:像写小说一样描写画面,禁止标签罗列(不写 `4K, cinematic, high quality` 这类堆砌) +- **三段式结构**:风格基调 → 主体动作 + 场景环境 + 光线氛围 → 镜头收尾 +- **纯文本提示词**:提示词内**不使用任何 `@图N ` 引用**,全部内容用纯文本描述 +- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段生成,不编造额外内容 + +--- + +## 输出格式 + +每次输入一条分镜,输出一段完整提示词(无编号前缀): + +``` +{风格基调一句话定性}, +{主体名} {外观简述}, {具体动作/姿态描述}, {情绪/表情用动作暗示}. +{场景背景主体}, {具体环境物件}, {空间感}, {时间/天气}. +{光线方向/色温} {质感描述}, {情绪暗示光影}. +{台词描述(如有,含 dialogue/OS/VO 标注)/ No dialogue}. +{音效描述}. +{拍摄方式}, {景别}, {视角}, {运镜方式}. +``` + +--- + +## 叙事式写法要点 + +| 原则 | 说明 | 示例 | +|------|------|------| +| 风格基调放最前 | 一句话定性整体气质 | `A cinematic epic scene` | +| 主体+动作紧密绑定 | 主体后面直接跟动作,外观细节嵌入主体描述 | `A young man in dark flowing robes stands alone atop the city wall` | +| 情绪用动作暗示 | 不直接陈述情绪 | ❌ `He is sad.` → ✅ `head drops slowly, shoulders slumped` | +| 环境融入叙事 | 不罗列环境属性 | ✅ `hazy blue sky stretches over the emerald valley` | +| 光线单独成句 | 光线方向+色温+质感+情绪 | `Warm golden hour light streams from behind, casting long shadows across the stone floor` | +| 镜头语言收尾 | 一句话点睛 | `Captured in a wide establishing shot from a low-angle perspective, static camera` | +| 禁止标签堆砌 | 不写 `4K, cinematic, high quality` | `cinematic` 融入风格基调即可 | + +--- + +## 生成规则 + +1. **全部用英文** +2. **不使用任何 `@图N ` 引用** +3. **叙事式描写**:禁止标签罗列和配置清单式写法 +4. **主体用文字描述**:简要描述主体外观特征,嵌入主体描述中 +5. **台词不可缺失**:videoDesc 中有台词的分镜,必须在提示词中完整输出台词内容(保持原始语言,不翻译) +6. **台词类型标注**: + - 普通对白 → `(dialogue)` + - 内心独白 → `(inner monologue, OS)` + - 画外音 → `(voiceover, VO)` +7. **单条输入/输出**:每次仅处理一条分镜,无编号前缀 +8. **无需标注时长**:时长由模型侧控制 +9. **镜头描述融入叙事**:不用方括号标签,用完整句子描述镜头 + +--- + +## 完整示例 + +**示例1:无台词分镜** + +输入: + +资产信息[A001, role, 沈辞], [A003, scene, 城楼] + +```xml + +``` + +输出: + +``` +A cinematic epic scene with a cold, desaturated palette, +A lone man in dark flowing robes stands atop an ancient city wall, hands clasped behind his back, robes and hair billowing in the wind, gaze fixed on the vast land stretching to the horizon, jaw set firm, eyes unwavering. +The weathered stone battlements frame the endless expanse below, rolling terrain fading into haze beneath a heavy dusk sky, clouds layered in muted golds and slate greys. +Cold side-backlight from the setting sun carves a sharp silhouette, long shadows stretching across the stone floor, a faint warm rim outlining the figure against the cool atmosphere. +No dialogue. +Wind howling across the open wall, fabric flapping rhythmically. +Captured in a wide establishing shot from a slightly low angle, static camera, single continuous take. +``` + +**示例2:有台词分镜** + +输入: + +资产信息[A001, role, 沈辞], [A002, role, 苏锦], [A003, scene, 城楼] + +```xml + +``` + +输出: + +``` +A melancholic cinematic scene, dusk tones deepening, +A young woman in a light-colored dress ascends the final stone steps onto the city wall, her gaze locked on the lone figure ahead, brow slightly furrowed, pace slowing as she approaches, lips parting softly. +The ancient city wall stretches behind her, weathered stairs leading up from below, the distant skyline dimming as the last traces of golden hour fade into twilight. +Fading warm light mingles with rising cool blue tones, the contrast between the two figures softened by the diffused remnants of sunset. +"你又一个人在这里。" — Su Jin (dialogue). +Footsteps on stone, wind sweeping across the battlements, fabric rustling. +A medium tracking shot follows the woman from behind as she ascends and approaches, handheld camera with subtle movement, single continuous take. +``` \ No newline at end of file diff --git a/data/modelPrompt/video/通用多参模式.md b/data/modelPrompt/video/通用多参模式.md new file mode 100644 index 0000000..78e2af5 --- /dev/null +++ b/data/modelPrompt/video/通用多参模式.md @@ -0,0 +1,170 @@ +# 视频提示词生成 + +你是**视频提示词生成 Agent**,专门负责读取分镜信息并输出对应格式的视频提示词。 + +根据输入的资产信息和分镜列表,生成一个完整的视频提示词。 + +## 输入格式 + +### 1. 资产信息格式 + +资产信息[id, type, name], [id, type, name], ... + +- `id`:资产唯一标识(如 `A001`) +- `type`:资产类型,取值 `role`(角色)/ `scene`(场景)/ `prop`(道具) +- `name`:资产名称(如 `沈辞`、`城楼`、`长剑`) + +### 2. 分镜信息格式 + +分镜以 `` XML 标签列表的形式传入: + +```xml + +``` + +### 3. videoDesc 解析规则 + +从 `videoDesc` 括号内按顿号分隔提取以下12个字段: + +| 序号 | 字段 | 用途 | +|------|------|------| +| 1 | 画面描述 | 叙事主干 | +| 2 | 场景 | 匹配场景资产 | +| 3 | 关联资产名称 | 匹配角色/道具资产 | +| 4 | 时长 | 控制时长参数 | +| 5 | 景别 | 控制镜头景别 | +| 6 | 运镜 | 控制运镜方式 | +| 7 | 角色动作 | 动作描写 | +| 8 | 情绪 | 情绪氛围 | +| 9 | 光影氛围 | 光影描写 | +| 10 | 台词 | 台词/音频段 | +| 11 | 音效 | 音效描写 | +| 12 | 关联资产ID | 资产ID↔角色标签映射 | + +### 4. 全模式通用约束 + +- **视觉风格**:风格相关描述参考 Assistant 中的「视觉风格约束」部分内容,不在本 Skill 内自行定义风格 +- **仅输出视频提示词**:不附加任何解释、注释、分析过程、推理步骤、分隔线(`---`)或额外说明 +- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的12个字段生成,不编造额外内容 +- **台词不可缺失**:videoDesc 中有台词的分镜,必须在提示词中完整体现台词内容,不得遗漏 +- **台词保持原始输入**:台词内容严禁翻译,必须保持 videoDesc 中的原始语言原样输出 +- **台词类型标注**:必须区分普通对白(dialogue / 说)、内心独白(OS / 内心OS)、画外音(VO / 画外音VO) +- **时间分段最低 1 秒**:所有涉及时间分段的最小粒度为 1s,禁止出现低于 1 秒的间隔 +- **不修改原始输入**:不改写 `` 的任何字段;`prompt` 字段仅作画面参考 +- **不编造资产或台词**:只使用输入中提供的资产信息;无台词则标注「无台词」/ `No dialogue` + +### 5. 景别 → 镜头标签映射 + +| videoDesc 景别 | 英文标签 | +|------|------| +| 远景 | extreme wide shot | +| 全景 | wide establishing shot | +| 中景 | medium shot | +| 近景 | close-up | +| 特写 | close-up | +| 大特写 | extreme close-up | + +### 6. 运镜 → 镜头标签映射 + +| videoDesc 运镜 | 英文标签 | +|------|------| +| 静止 | static camera | +| 推进 | dolly in / push in | +| 拉远 | dolly out / pull back | +| 跟踪 | tracking shot | +| 摇镜 | pan left/right | +| 甩镜 | whip pan | +| 升降 | crane up/down | +| 环绕 | surround shooting | + +--- + +## 资产引用编号规则 + +所有资产和分镜图统一使用 `@图N ` 格式引用,编号规则如下: + +1. **资产**:按资产信息中 `[id, type, name]` 的出现顺序,从 `@图1 ` 开始连续编号 + - 编号严格按输入位置分配,不按类型归组(资产类型的出现顺序不固定) +2. **分镜图**:每条 `` 对应一张分镜图,编号接续资产之后 +3. **跳过无分镜图的条目**:当 `shouldGenerateImage="false"` 时,该分镜不分配编号,后续编号顺延 + +> **关键**:生成提示词时,必须根据资产的实际 `type` 字段确定引用方式,不可根据编号大小假定类型。 + +--- + +## 输出格式 + +``` +[References] +@图{N} : [{资产/分镜名称}参考图] +...(按编号顺序列出所有资产和分镜图) + +[Instruction] +Based on the storyboard @图{分镜图编号} : +@图{角色资产编号} {动作/状态描述(英文)}, +set in the {场景描述(英文)} of @图{场景资产编号} , +{镜头/运镜描述(英文)}, +{情感基调(英文)}, +{台词描述(英文,含 dialogue/OS/VO 标注)/ No dialogue}, +{音效描述(英文)}. +``` + +--- + +## 生成规则 + +1. **Instruction 必须用英文** +2. **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段,不编造额外信息 +3. **角色动作**从 videoDesc 的「角色动作」字段提取,翻译为简洁英文动作描述 +4. **台词不可缺失**:videoDesc 中有台词的分镜,必须在 Instruction 中体现台词内容(保持原始语言,不翻译) +5. **台词类型标注**: + - 普通对白 → `(dialogue)` + - 内心独白 → `(inner monologue, OS)` + - 画外音 → `(voiceover, VO)` +6. **镜头风格**使用标准标签:`cinematic` / `wide-angle` / `close-up` / `slow motion` / `surround shooting` / `handheld` +7. **空间关系**使用标准动词:`wearing` / `holding` / `standing on` / `following behind` / `sitting in` +8. 单条分镜对应单个 `@图N `,不做多帧跨镜描述 +9. 无需描述角色外观(由参考图负责) +10. 无时长标注(由模型推断) +11. **无分镜图时**:当 `shouldGenerateImage="false"` 时,`[References]` 中不列出该分镜图,`[Instruction]` 中不使用 `@图N ` 引用,改为纯文本描述 + +--- + +## 完整示例 + +**输入:** + +资产信息[A001, role, 沈辞], [A002, role, 苏锦], [A003, scene, 城楼] + +```xml + + +``` + +**输出:** + +``` +[References] +@图1 : [沈辞参考图] +@图2 : [苏锦参考图] +@图3 : [城楼参考图] +@图4 : [分镜图1] +@图5 : [分镜图2] + +[Instruction] +Based on the storyboard from @图4 to @图5 : +@图1 standing alone atop the city wall, hands clasped behind back, robes billowing in the wind, gazing across the vast land, +@图2 ascending the steps toward @图1 , expression worried, +set in the ancient city wall environment of @图3 , +wide shot transitioning to medium tracking shot, cinematic, +resolute determination shifting to concerned anticipation, dusk cold-toned side-backlit atmosphere fading, +no dialogue, +wind howling, fabric flapping, footsteps on stone. +``` \ No newline at end of file diff --git a/data/modelPrompt/video/通用首尾帧模式.md b/data/modelPrompt/video/通用首尾帧模式.md new file mode 100644 index 0000000..32722f4 --- /dev/null +++ b/data/modelPrompt/video/通用首尾帧模式.md @@ -0,0 +1,177 @@ +# 视频提示词生成 (通用首尾帧模式) + +你是**视频提示词生成 Agent**,专门负责读取分镜信息并输出对应格式的视频提示词。 + +根据输入的资产信息和分镜列表,生成一个完整的视频提示词。 + + +## 输入格式 + +### 1. 资产信息格式 + +资产信息[id, type, name], [id, type, name], ... + +- `id`:资产唯一标识(如 `A001`) +- `type`:资产类型,取值 `role`(角色)/ `scene`(场景)/ `prop`(道具) +- `name`:资产名称(如 `沈辞`、`城楼`、`长剑`) + +### 2. 分镜信息格式 + +分镜以 `` XML 标签列表的形式传入: + +```xml + +``` + +### 3. videoDesc 解析规则 + +从 `videoDesc` 括号内按顿号分隔提取以下12个字段: + +| 序号 | 字段 | 用途 | +|------|------|------| +| 1 | 画面描述 | 叙事主干 | +| 2 | 场景 | 匹配场景资产 | +| 3 | 关联资产名称 | 匹配角色/道具资产 | +| 4 | 时长 | 控制时长参数 | +| 5 | 景别 | 控制镜头景别 | +| 6 | 运镜 | 控制运镜方式 | +| 7 | 角色动作 | 动作描写 | +| 8 | 情绪 | 情绪氛围 | +| 9 | 光影氛围 | 光影描写 | +| 10 | 台词 | 台词/音频段 | +| 11 | 音效 | 音效描写 | +| 12 | 关联资产ID | 资产ID↔角色标签映射 | + +### 4. 约束 + +- **视觉风格**:风格相关描述参考 Assistant 中的「视觉风格约束」部分内容,不在本 Skill 内自行定义风格 +- **仅输出视频提示词**:不附加任何解释、注释、分析过程、推理步骤、分隔线(`---`)或额外说明 +- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的12个字段生成,不编造额外内容 +- **台词不可缺失**:videoDesc 中有台词的分镜,必须在提示词中完整体现台词内容,不得遗漏 +- **台词保持原始输入**:台词内容严禁翻译,必须保持 videoDesc 中的原始语言原样输出 +- **台词类型标注**:必须区分普通对白(dialogue / 说)、内心独白(OS / 内心OS)、画外音(VO / 画外音VO) +- **时间分段最低 1 秒**:所有涉及时间分段的最小粒度为 1s,禁止出现低于 1 秒的间隔 +- **不修改原始输入**:不改写 `` 的任何字段;`prompt` 字段仅作画面参考 +- **不编造资产或台词**:只使用输入中提供的资产信息;无台词则标注「无台词」/ `No dialogue` + +### 5. 景别 → 镜头标签映射 + +| videoDesc 景别 | 英文标签 | +|------|------| +| 远景 | extreme wide shot | +| 全景 | wide establishing shot | +| 中景 | medium shot | +| 近景 | close-up | +| 特写 | close-up | +| 大特写 | extreme close-up | + +### 6. 运镜 → 镜头标签映射 + +| videoDesc 运镜 | 英文标签 | +|------|------| +| 静止 | static camera | +| 推进 | dolly in / push in | +| 拉远 | dolly out / pull back | +| 跟踪 | tracking shot | +| 摇镜 | pan left/right | +| 甩镜 | whip pan | +| 升降 | crane up/down | +| 环绕 | surround shooting | + +--- + +## 核心原则 + +- **纯文本提示词**:提示词内**不使用任何 `@图N ` 引用**,全部内容用纯文本描述 +- **五维度结构**:Visual / Motion / Camera / Audio / Narrative +- **全程单一连贯镜头**:从头到尾一个镜头,不存在切镜 +- **时间轴分段**:每段最低 1 秒,用 `0s-Xs` 标注 + +--- + +## 输出格式 + +``` +[Visual] +{主体A名}: {外观简述}, {站位/姿态}, {说话状态 speaking/silent}. +{主体B名}: {外观简述}, {站位/姿态}, {说话状态}. +{场景描述}, {道具描述}. +{视觉风格标签}. + +[Motion] +0s-{X}s: {主体A名} {动作描述段1}. +{X}s-{Y}s: {主体B名} {动作描述段2}. + +[Camera] +{镜头类型}, {运镜方式}, {全程单一连贯镜头描述}. + +[Audio] +{Xs-Ys}: "{台词内容}" — {说话者名} ({dialogue / inner monologue OS / voiceover VO}), {lip-sync active / silent lips}. +{音效描述}. + +[Narrative] +{情节点概述}, {叙事位置}. +``` + +--- + +## 生成规则 + +1. **提示词输出全部用英文** +2. **不使用任何 `@图N ` 引用**:全部内容用纯文本描述 +3. **主体用文字描述**:在 [Visual] 中简要描述主体外观特征(如服饰、发型等关键辨识特征) +4. **每个主体必须标注说话状态**:`speaking` / `silent` / `speaking simultaneously` +5. **台词不可缺失**:videoDesc 中有台词的分镜,必须在 `[Audio]` 中完整输出台词内容(保持原始语言,不翻译) +6. **台词类型标注**: + - 普通对白 → `dialogue, lip-sync active` + - 内心独白 → `inner monologue (OS), silent lips` + - 画外音 → `voiceover (VO), silent lips` +7. **不说话的主体标注 `silent`**:防止误生口型 +8. **Motion 时间轴**:每段最低 1 秒,不超过总时长 +9. **全程单一连贯镜头**:Camera 段落描述从头到尾一个镜头,绝不切镜 +10. **镜头类型**从以下选取:`Wide establishing shot / Over-the-shoulder / Medium shot / Close-up / Wide shot / POV / Dutch angle / Crane up / Dolly right / Whip pan / Handheld / Slow motion` + +--- + +## 完整示例 + +**输入:** + +资产信息[A001, role, 沈辞], [A002, role, 苏锦], [A003, scene, 城楼] + +```xml + + +``` + +**输出:** + +``` +[Visual] +Shen Ci: male, dark flowing robes, hair tied up, standing alone atop city wall, hands clasped behind back, robes billowing, silent. +Su Jin: female, light-colored dress, hair partially down, ascending steps toward Shen Ci, expression worried, silent. +Ancient city wall, vast open land beyond, dusk sky fading. +Cinematic, photorealistic, 4K, high contrast, desaturated tones, shallow depth of field. + +[Motion] +0s-4s: Shen Ci stands still on city wall edge, robes flutter in wind, hair sways gently. Gaze fixed on distant horizon. +4s-8s: Su Jin climbs the last few steps onto the wall, walks toward Shen Ci. Shen Ci remains still, unaware. Su Jin slows as she approaches. + +[Camera] +Wide establishing shot, static for first 4 seconds capturing the lone figure. Then smooth transition to medium tracking shot following the woman ascending steps, single continuous take throughout, no cuts. + +[Audio] +0s-4s: Wind howling across wall, fabric flapping rhythmically. No dialogue. +4s-8s: Footsteps on stone, robes rustling. No dialogue. +Shen Ci — silent. Su Jin — silent. + +[Narrative] +Lone figure on city wall, then arrival of a companion. Tension between determination and concern. Single continuous take. +``` \ No newline at end of file diff --git a/data/vendor/atlascloud.ts b/data/vendor/atlascloud.ts new file mode 100644 index 0000000..8b87a2e --- /dev/null +++ b/data/vendor/atlascloud.ts @@ -0,0 +1,589 @@ +/** + * Toonflow AI供应商模板 - AtlasCloud MASS + * @version 0.8 + * + * 说明: + * 1) 文本接口使用 OpenAI 兼容基地址:https://api.atlascloud.ai/v1 + * 2) 图片/视频使用 Atlas Cloud 媒体接口:https://api.atlascloud.ai/api/v1 + * 3) 图片/视频为异步任务:提交后轮询 /api/v1/model/prediction/{id} + */ + +// ============================================================ +// 类型定义 +// ============================================================ + +type VideoMode = + | "singleImage" + | "startEndRequired" + | "endFrameOptional" + | "startFrameOptional" + | "text" + | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[]; + +interface TextModel { + name: string; + modelName: string; + type: "text"; + think: boolean; +} + +interface ImageModel { + name: string; + modelName: string; + type: "image"; + mode: ("text" | "singleImage" | "multiReference")[]; + associationSkills?: string; +} + +interface VideoModel { + name: string; + modelName: string; + type: "video"; + mode: VideoMode[]; + associationSkills?: string; + audio: "optional" | false | true; + durationResolutionMap: { duration: number[]; resolution: string[] }[]; +} + +interface TTSModel { + name: string; + modelName: string; + type: "tts"; + voices: { title: string; voice: string }[]; +} + +interface VendorConfig { + id: string; + version: string; + name: string; + author: string; + description?: string; + icon?: string; + inputs: { key: string; label: string; type: "text" | "password" | "url"; required: boolean; placeholder?: string; disabled?: boolean }[]; + inputValues: Record; + models: (TextModel | ImageModel | VideoModel | TTSModel)[]; +} + +type ReferenceList = + | { type: "image"; sourceType: "base64"; base64: string } + | { type: "audio"; sourceType: "base64"; base64: string } + | { type: "video"; sourceType: "base64"; base64: string }; + +interface ImageConfig { + prompt: string; + referenceList?: Extract[]; + size: "1K" | "2K" | "4K"; + aspectRatio: `${number}:${number}`; +} + +interface VideoConfig { + duration: number; + resolution: string; + aspectRatio: "16:9" | "9:16"; + prompt: string; + referenceList?: ReferenceList[]; + audio?: boolean; + mode: VideoMode[]; +} + +interface TTSConfig { + text: string; + voice: string; + speechRate: number; + pitchRate: number; + volume: number; + referenceList?: Extract[]; +} + +interface PollResult { + completed: boolean; + data?: string; + error?: string; +} + +type AtlasVideoModelKind = + | "seedanceTextToVideo" + | "seedanceReferenceToVideo" + | "seedanceImageToVideo" + | "wanReferenceToVideo" + | "generic"; + +// ============================================================ +// 全局声明 +// ============================================================ + +declare const axios: any; +declare const logger: (msg: string) => void; +declare const urlToBase64: (url: string) => Promise; +declare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise; +declare const createOpenAICompatible: any; +declare const exports: { + vendor: VendorConfig; + textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any; + imageRequest: (c: ImageConfig, m: ImageModel) => Promise; + videoRequest: (c: VideoConfig, m: VideoModel) => Promise; + ttsRequest: (c: TTSConfig, m: TTSModel) => Promise; + checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>; + updateVendor?: () => Promise; +}; + +// ============================================================ +// 供应商配置 +// ============================================================ + +const vendor: VendorConfig = { + id: "atlascloud", + version: "1.0", + author: "AtlasCloud", + name: "AtlasCloud MASS", + description: "AtlasCloud 全模态平台接入 Toonflow。默认按官方文档填写文本、图片、视频与任务轮询路径。", + inputs: [ + { key: "apiKey", label: "API密钥", type: "password", required: true, placeholder: "AtlasCloud API Key" }, + { key: "chatBaseUrl", label: "文本基地址", type: "url", required: true, placeholder: "https://api.atlascloud.ai/v1", disabled: true }, + { key: "mediaBaseUrl", label: "媒体基地址", type: "url", required: true, placeholder: "https://api.atlascloud.ai/api/v1", disabled: true }, + ], + inputValues: { + apiKey: "", + chatBaseUrl: "https://api.atlascloud.ai/v1", + mediaBaseUrl: "https://api.atlascloud.ai/api/v1", + }, + models: [ + { name: "DeepSeek V4 Pro", modelName: "deepseek-ai/deepseek-v4-pro", type: "text", think: false }, + { name: "DeepSeek V4 Flash", modelName: "deepseek-ai/deepseek-v4-flash", type: "text", think: false }, + { name: "Kimi K2.6", modelName: "moonshotai/kimi-k2.6", type: "text", think: false }, + { name: "GLM 5.1", modelName: "zai-org/glm-5.1", type: "text", think: false }, + { name: "MiniMax M2.7", modelName: "minimaxai/minimax-m2.7", type: "text", think: false }, + { name: "GPT Image 2", modelName: "openai/gpt-image-2/text-to-image", type: "image", mode: ["text", "singleImage"] }, + { name: "Nano Banana Pro", modelName: "google/nano-banana-pro/text-to-image", type: "image", mode: ["text", "singleImage", "multiReference"] }, + { name: "Nano Banana 2", modelName: "google/nano-banana-2/text-to-image", type: "image", mode: ["text", "singleImage", "multiReference"] }, + { name: "Seedream v5", modelName: "bytedance/seedream-v5.0-lite/sequential", type: "image", mode: ["text"] }, + { name: "Qwen Image 2 Pro", modelName: "qwen/qwen-image-2.0-pro/text-to-image", type: "image", mode: ["text"] }, + { + name: "Seedance 2.0 Audio-Visual", + modelName: "bytedance/seedance-2.0/text-to-video", + type: "video", + mode: ["text", "startFrameOptional", ["imageReference:9", "videoReference:3", "audioReference:3"]], + audio: "optional", + durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: ["480p", "720p"] }], + }, + { + name: "Seedance 2.0 Reference-to-Video", + modelName: "bytedance/seedance-2.0/reference-to-video", + type: "video", + mode: ["singleImage"], + audio: "optional", + durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: ["480p", "720p", "1080p"] }], + }, + { + name: "Seedance 2.0 Multi-Image-to-Video", + modelName: "bytedance/seedance-2.0/image-to-video", + type: "video", + mode: ["startFrameOptional", ["imageReference:4"]], + audio: "optional", + durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: ["480p", "720p", "1080p"] }], + }, + { + name: "Seedance 2.0 Fast Audio-Visual", + modelName: "bytedance/seedance-2.0-fast/text-to-video", + type: "video", + mode: ["text", "startFrameOptional", ["imageReference:9", "videoReference:3", "audioReference:3"]], + audio: "optional", + durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: ["480p", "720p"] }], + }, + { + name: "Seedance 2.0 Fast Reference-to-Video", + modelName: "bytedance/seedance-2.0-fast/reference-to-video", + type: "video", + mode: ["singleImage"], + audio: "optional", + durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: ["480p", "720p"] }], + }, + { + name: "Wan-2.7 Reference-to-video", + modelName: "alibaba/wan-2.7/reference-to-video", + type: "video", + mode: ["singleImage"], + audio: "optional", + durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: ["720p", "1080p"] }], + }, + ], +}; + +// ============================================================ +// 辅助工具 +// ============================================================ + +const getChatBaseUrl = () => vendor.inputValues.chatBaseUrl.replace(/\/+$/, ""); + +const getMediaBaseUrl = () => vendor.inputValues.mediaBaseUrl.replace(/\/+$/, ""); + +const joinUrl = (base: string, path: string) => `${base}${path.startsWith("/") ? "" : "/"}${path}`; + +const getHeaders = () => { + if (!vendor.inputValues.apiKey) throw new Error("缺少 API Key"); + return { + "Content-Type": "application/json", + Authorization: `Bearer ${vendor.inputValues.apiKey.replace(/^Bearer\s+/i, "")}`, + }; +}; + +const readByPath = (obj: any, path: string): any => { + if (!obj || !path) return undefined; + const normalizedPath = path.replace(/\[(\d+)\]/g, ".$1"); + return normalizedPath.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); +}; + +const pickFirstPath = (obj: any, paths: string[]): any => { + for (const path of paths) { + const value = readByPath(obj, path); + if (value !== undefined && value !== null && value !== "") return value; + } + return undefined; +}; + +const extractTaskId = (data: any): string | undefined => { + return pickFirstPath(data, ["id", "taskId", "task_id", "data.id", "data.taskId", "data.task_id"]); +}; + +const extractUrl = (data: any): string | undefined => { + return ( + (Array.isArray(readByPath(data, "data.outputs")) ? readByPath(data, "data.outputs")[0] : undefined) || + (Array.isArray(readByPath(data, "outputs")) ? readByPath(data, "outputs")[0] : undefined) || + readByPath(data, "url") || + readByPath(data, "video_url") || + readByPath(data, "image_url") || + readByPath(data, "data.url") || + readByPath(data, "data.video_url") || + readByPath(data, "data.image_url") || + readByPath(data, "data.output.url") || + readByPath(data, "data.output.video_url") || + readByPath(data, "output.url") + ); +}; + +const extractB64 = (data: any): string | undefined => { + return pickFirstPath(data, ["b64_json", "data.b64_json", "data.0.b64_json", "data[0].b64_json"]); +}; + +const extractStatus = (data: any): string => { + const statusRaw = pickFirstPath(data, ["status", "data.status", "data.state", "state"]); + return String(statusRaw || "").toLowerCase(); +}; + +const extractError = (data: any): string | undefined => { + return pickFirstPath(data, ["error.message", "message", "msg", "data.error.message", "data.message"]); +}; + +const isDnsOrNetworkError = (err: any): boolean => { + const msg = String(err?.message || err || ""); + return /ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|timeout/i.test(msg); +}; + +const withNetworkRetry = async (fn: () => Promise, maxRetry = 3, waitMs = 1500): Promise => { + let lastErr: any; + for (let i = 0; i < maxRetry; i += 1) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (!isDnsOrNetworkError(err) || i === maxRetry - 1) throw err; + await new Promise((resolve) => setTimeout(resolve, waitMs * (i + 1))); + } + } + throw lastErr; +}; + +const resolveAtlasImageModelName = (modelName: string, hasImageRefs: boolean): string => { + if (!hasImageRefs) return modelName; + + switch (modelName) { + case "google/nano-banana-pro/text-to-image": + return "google/nano-banana-pro/edit"; + case "google/nano-banana-2/text-to-image": + return "google/nano-banana-2/edit"; + default: + return modelName; + } +}; + +const resolveAtlasVideoModelKind = (modelName: string): AtlasVideoModelKind => { + if (modelName === "alibaba/wan-2.7/reference-to-video") return "wanReferenceToVideo"; + if (/^bytedance\/seedance-2\.0(?:-fast)?\/reference-to-video$/.test(modelName)) return "seedanceReferenceToVideo"; + if (/^bytedance\/seedance-2\.0(?:-fast)?\/image-to-video$/.test(modelName)) return "seedanceImageToVideo"; + if (/^bytedance\/seedance-2\.0(?:-fast)?\/text-to-video$/.test(modelName)) return "seedanceTextToVideo"; + return "generic"; +}; + +const clampNumber = (value: unknown, min: number, max: number, fallback: number): number => { + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + return Math.max(min, Math.min(max, num)); +}; + +const normalizeResolution = (value: unknown, allowed: string[], fallback: string): string => { + const lower = String(value || "").toLowerCase(); + const matched = allowed.find((item) => item.toLowerCase() === lower); + if (matched) return matched; + if (/1080/.test(lower)) return allowed.find((item) => /1080/i.test(item)) || fallback; + if (/720/.test(lower)) return allowed.find((item) => /720/i.test(item)) || fallback; + if (/480/.test(lower)) return allowed.find((item) => /480/i.test(item)) || fallback; + return fallback; +}; + +const getReferenceLimit = ( + modes: VideoMode[], + prefix: "imageReference" | "videoReference" | "audioReference", +): number | undefined => { + for (const mode of modes) { + if (!Array.isArray(mode)) continue; + for (const entry of mode) { + if (!entry.startsWith(`${prefix}:`)) continue; + const limit = Number(entry.split(":")[1]); + if (Number.isFinite(limit) && limit > 0) return limit; + } + } + return undefined; +}; + +const limitReferences = (refs: string[], maxCount?: number): string[] => { + if (!maxCount || maxCount < 1) return refs; + return refs.slice(0, maxCount); +}; + +const summarizeRefCount = (usedCount: number, rawCount: number): string => { + return usedCount === rawCount ? String(usedCount) : `${usedCount}/${rawCount}`; +}; + +const buildAtlasVideoPayload = (config: VideoConfig, model: VideoModel) => { + const rawImageRefs = (config.referenceList || []).filter((r) => r.type === "image").map((r) => r.base64).filter(Boolean); + const rawVideoRefs = (config.referenceList || []).filter((r) => r.type === "video").map((r) => r.base64).filter(Boolean); + const rawAudioRefs = (config.referenceList || []).filter((r) => r.type === "audio").map((r) => r.base64).filter(Boolean); + + const imageRefs = limitReferences(rawImageRefs, getReferenceLimit(model.mode, "imageReference")); + const videoRefs = limitReferences(rawVideoRefs, getReferenceLimit(model.mode, "videoReference")); + const audioRefs = limitReferences(rawAudioRefs, getReferenceLimit(model.mode, "audioReference")); + const kind = resolveAtlasVideoModelKind(model.modelName); + const ratio = config.aspectRatio || "16:9"; + const shouldGenerateAudio = model.audio === true || (model.audio === "optional" && config.audio !== false); + const body: any = { + model: model.modelName, + prompt: config.prompt || "", + }; + + if (kind === "wanReferenceToVideo") { + if (imageRefs.length < 1) { + throw new Error(`${model.name} 需要至少 1 张参考图`); + } + body.images = [imageRefs[0]]; + body.ratio = ratio; + body.duration = clampNumber(config.duration, 2, 10, 5); + body.resolution = normalizeResolution(config.resolution, ["720P", "1080P"], "720P"); + body.prompt_extend = false; + body.seed = -1; + } else if (kind === "seedanceReferenceToVideo") { + if (imageRefs.length < 1) { + throw new Error(`${model.name} 需要至少 1 张参考图`); + } + if (shouldGenerateAudio) body.generate_audio = true; + body.images = [imageRefs[0]]; + body.ratio = ratio; + body.duration = clampNumber(config.duration, 4, 15, 5); + body.resolution = normalizeResolution(config.resolution, ["480p", "720p", "1080p"], "720p"); + body.watermark = false; + } else if (kind === "seedanceImageToVideo") { + if (imageRefs.length < 1) { + throw new Error(`${model.name} 需要至少 1 张参考图`); + } + if (shouldGenerateAudio) body.generate_audio = true; + body.images = imageRefs; + body.ratio = ratio; + body.duration = clampNumber(config.duration, 4, 15, 5); + body.resolution = normalizeResolution(config.resolution, ["480p", "720p", "1080p"], "720p"); + body.watermark = false; + } else { + if (shouldGenerateAudio) body.generate_audio = true; + if (imageRefs.length > 0) body.reference_images = imageRefs; + if (videoRefs.length > 0) body.reference_videos = videoRefs; + if (audioRefs.length > 0) body.reference_audios = audioRefs; + body.ratio = ratio; + body.duration = clampNumber(config.duration, 4, 15, 5); + body.resolution = normalizeResolution(config.resolution, ["480p", "720p"], "720p"); + body.watermark = false; + } + + return { + body, + summary: `kind=${kind} imageRefs=${summarizeRefCount(imageRefs.length, rawImageRefs.length)} videoRefs=${summarizeRefCount(videoRefs.length, rawVideoRefs.length)} audioRefs=${summarizeRefCount(audioRefs.length, rawAudioRefs.length)} resolution=${body.resolution} duration=${body.duration}${shouldGenerateAudio ? " audio=on" : " audio=off"}`, + }; +}; + +// ============================================================ +// 适配器函数 +// ============================================================ + +const textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => { + if (!vendor.inputValues.apiKey) throw new Error("缺少 API Key"); + const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\s+/i, ""); + const effortMap: Record = { 0: "minimal", 1: "low", 2: "medium", 3: "high" }; + + return createOpenAICompatible({ + name: "atlascloud", + baseURL: getChatBaseUrl(), + apiKey, + fetch: async (url: string, options?: RequestInit) => { + const rawBody = JSON.parse((options?.body as string) ?? "{}"); + const body = think + ? { + ...rawBody, + thinking: { type: "enabled" }, + reasoning_effort: effortMap[thinkLevel], + } + : rawBody; + return await fetch(url, { ...options, body: JSON.stringify(body) }); + }, + }).chatModel(model.modelName); +}; + +const imageRequest = async (config: ImageConfig, model: ImageModel): Promise => { + const headers = getHeaders(); + const url = joinUrl(getMediaBaseUrl(), "/model/generateImage"); + const sizeToResolution: Record = { + "1K": "1k", + "2K": "2k", + "4K": "4k", + }; + const imageRefs = (config.referenceList || []).map((ref) => ref.base64).filter(Boolean); + const resolvedModelName = resolveAtlasImageModelName(model.modelName, imageRefs.length > 0); + const isNanoModel = /^google\/nano-banana-(pro|2)\//.test(resolvedModelName); + const supportsImageConditioning = /^(openai\/gpt-image-2\/text-to-image|google\/nano-banana-(pro|2)\/edit)$/.test(resolvedModelName); + + const body: any = { + model: resolvedModelName, + prompt: config.prompt || "", + }; + if (supportsImageConditioning && imageRefs.length > 0) { + body.images = imageRefs; + } + if (isNanoModel) { + body.aspect_ratio = config.aspectRatio || "16:9"; + body.resolution = sizeToResolution[config.size || "1K"] || "1k"; + } + + logger(`[AtlasCloud 图片] 提交任务: ${model.modelName} -> ${resolvedModelName}, refs=${imageRefs.length}`); + const submitResp = await axios.post(url, body, { headers }); + const submitData = submitResp.data; + + // 同步返回(直接拿图) + const syncB64 = extractB64(submitData); + if (syncB64) return syncB64; + const syncUrl = extractUrl(submitData); + if (syncUrl) return await urlToBase64(syncUrl); + + // 异步返回(拿 taskId 再轮询) + const taskId = extractTaskId(submitData); + if (!taskId) { + throw new Error(`图片任务提交失败:未获取到任务ID。原始响应:${JSON.stringify(submitData).slice(0, 500)}`); + } + + const pollResult = await pollTask( + async (): Promise => { + const resultUrl = joinUrl(getMediaBaseUrl(), `/model/prediction/${taskId}`); + const resultResp = await axios.get(resultUrl, { headers }); + const data = resultResp.data; + const status = extractStatus(data); + + if (["succeeded", "success", "done", "completed"].includes(status)) { + const b64 = extractB64(data); + if (b64) return { completed: true, data: b64 }; + const mediaUrl = extractUrl(data); + if (mediaUrl) return { completed: true, data: mediaUrl }; + return { completed: true, error: "任务成功但未返回结果地址" }; + } + if (["failed", "error", "cancelled", "canceled", "expired"].includes(status)) { + return { completed: true, error: extractError(data) || "图片生成失败" }; + } + return { completed: false }; + }, + 3000, + 600000, + ); + + if (pollResult.error) throw new Error(pollResult.error); + if (!pollResult.data) throw new Error("图片生成失败:轮询未返回数据"); + if (pollResult.data.startsWith("data:")) return pollResult.data; + if (pollResult.data.startsWith("http")) return await urlToBase64(pollResult.data); + return pollResult.data; +}; + +const videoRequest = async (config: VideoConfig, model: VideoModel): Promise => { + const headers = getHeaders(); + const url = joinUrl(getMediaBaseUrl(), "/model/generateVideo"); + const { body, summary } = buildAtlasVideoPayload(config, model); + + logger(`[AtlasCloud 视频] 提交任务: ${model.modelName}, ${summary}`); + const submitResp: any = await withNetworkRetry(() => axios.post(url, body, { headers }), 3, 1500); + const submitData = submitResp.data; + + const taskId = extractTaskId(submitData); + if (!taskId) { + const syncUrl = extractUrl(submitData); + if (syncUrl) return await urlToBase64(syncUrl); + throw new Error(`视频任务提交失败:未获取到任务ID。原始响应:${JSON.stringify(submitData).slice(0, 500)}`); + } + + const pollResult = await pollTask( + async (): Promise => { + const resultUrl = joinUrl(getMediaBaseUrl(), `/model/prediction/${taskId}`); + const resultResp: any = await withNetworkRetry(() => axios.get(resultUrl, { headers }), 3, 1200); + const data = resultResp.data; + const status = extractStatus(data); + + if (["succeeded", "success", "done", "completed"].includes(status)) { + const mediaUrl = extractUrl(data); + if (mediaUrl) return { completed: true, data: mediaUrl }; + return { completed: true, error: "任务成功但未返回视频地址" }; + } + if (["failed", "error", "cancelled", "canceled", "expired"].includes(status)) { + return { completed: true, error: extractError(data) || "视频生成失败" }; + } + return { completed: false }; + }, + 5000, + 1800000, + ); + + if (pollResult.error) throw new Error(pollResult.error); + if (!pollResult.data) throw new Error("视频生成失败:轮询未返回数据"); + return await urlToBase64(pollResult.data); +}; + +const ttsRequest = async (_config: TTSConfig, _model: TTSModel): Promise => { + // AtlasCloud 当前版本先不接 TTS。 + return ""; +}; + +const checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => { + return { + hasUpdate: false, + latestVersion: vendor.version, + notice: "AtlasCloud MASS 初稿。", + }; +}; + +const updateVendor = async (): Promise => { + return ""; +}; + +// ============================================================ +// 导出 +// ============================================================ + +exports.vendor = vendor; +exports.textRequest = textRequest; +exports.imageRequest = imageRequest; +exports.videoRequest = videoRequest; +exports.ttsRequest = ttsRequest; +exports.checkForUpdates = checkForUpdates; +exports.updateVendor = updateVendor; + +export { }; diff --git a/data/vendor/deepseek.ts b/data/vendor/deepseek.ts new file mode 100644 index 0000000..0a03062 --- /dev/null +++ b/data/vendor/deepseek.ts @@ -0,0 +1,214 @@ +/** + * Toonflow AI供应商模板 - DeepSeek + * @version 2.0 + */ + +// ============================================================ +// 类型定义 +// ============================================================ + +type VideoMode = + | "singleImage" + | "startEndRequired" + | "endFrameOptional" + | "startFrameOptional" + | "text" + | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[]; + +interface TextModel { + name: string; + modelName: string; + type: "text"; + think: boolean; +} + +interface ImageModel { + name: string; + modelName: string; + type: "image"; + mode: ("text" | "singleImage" | "multiReference")[]; + associationSkills?: string; +} + +interface VideoModel { + name: string; + modelName: string; + type: "video"; + mode: VideoMode[]; + associationSkills?: string; + audio: "optional" | false | true; + durationResolutionMap: { duration: number[]; resolution: string[] }[]; +} + +interface TTSModel { + name: string; + modelName: string; + type: "tts"; + voices: { title: string; voice: string }[]; +} + +interface VendorConfig { + id: string; + version: string; + name: string; + author: string; + description?: string; + icon?: string; + inputs: { key: string; label: string; type: "text" | "password" | "url"; required: boolean; placeholder?: string }[]; + inputValues: Record; + models: (TextModel | ImageModel | VideoModel | TTSModel)[]; +} + +interface ImageConfig { + prompt: string; + imageBase64: string[]; + size: "1K" | "2K" | "4K"; + aspectRatio: `${number}:${number}`; +} + +interface VideoConfig { + duration: number; + resolution: string; + aspectRatio: "16:9" | "9:16"; + prompt: string; + imageBase64?: string[]; + audio?: boolean; + mode: VideoMode[]; +} + +interface TTSConfig { + text: string; + voice: string; + speechRate: number; + pitchRate: number; + volume: number; +} + +interface PollResult { + completed: boolean; + data?: string; + error?: string; +} + +// ============================================================ +// 全局声明 +// ============================================================ + +declare const axios: any; +declare const logger: (msg: string) => void; +declare const jsonwebtoken: any; +declare const zipImage: (base64: string, size: number) => Promise; +declare const zipImageResolution: (base64: string, w: number, h: number) => Promise; +declare const mergeImages: (base64Arr: string[], maxSize?: string) => Promise; +declare const urlToBase64: (url: string) => Promise; +declare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise; +declare const createOpenAI: any; +declare const createDeepSeek: any; +declare const createZhipu: any; +declare const createQwen: any; +declare const createAnthropic: any; +declare const createOpenAICompatible: any; +declare const createXai: any; +declare const createMinimax: any; +declare const createGoogleGenerativeAI: any; +declare const exports: { + vendor: VendorConfig; + textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any; + imageRequest: (c: ImageConfig, m: ImageModel) => Promise; + videoRequest: (c: VideoConfig, m: VideoModel) => Promise; + ttsRequest: (c: TTSConfig, m: TTSModel) => Promise; + checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>; + updateVendor?: () => Promise; +}; + +// ============================================================ +// 供应商配置 +// ============================================================ + +const vendor: VendorConfig = { + id: "deepseek", + version: "2.0", + author: "Toonflow", + name: "DeepSeek", + description: + "DeepSeek 官方接口适配,支持 V4 系列模型与思考模式(思维链输出)。\n\n[前往平台](https://platform.deepseek.com/)", + icon: "", + inputs: [ + { key: "apiKey", label: "API密钥", type: "password", required: true }, + { key: "baseUrl", label: "请求地址", type: "url", required: true, placeholder: "示例:https://api.deepseek.com" }, + ], + inputValues: { + apiKey: "", + baseUrl: "https://api.deepseek.com/v1", + }, + models: [ + { name: "DeepSeek V4 Pro", modelName: "deepseek-v4-pro", type: "text", think: true }, + { name: "DeepSeek V4 Flash", modelName: "deepseek-v4-flash", type: "text", think: true }, + ], +}; + +// ============================================================ +// 适配器函数 +// ============================================================ + +const textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => { + if (!vendor.inputValues.apiKey) throw new Error("缺少API Key"); + const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\s+/i, ""); + + // DeepSeek 思考强度仅支持 high / max(low、medium 会被映射为 high,xhigh 会被映射为 max) + // thinkLevel: 0/1/2 → high, 3 → max + const effortMap: Record<0 | 1 | 2 | 3, "high" | "max"> = { + 0: "high", + 1: "high", + 2: "high", + 3: "max", + }; + + const enableThinking = model.think && think; + const extraBody: Record = { + thinking: { type: enableThinking ? "enabled" : "disabled" }, + }; + if (enableThinking) { + extraBody.reasoning_effort = effortMap[thinkLevel]; + } + + return createDeepSeek({ + baseURL: vendor.inputValues.baseUrl, + apiKey, + extraBody, + }).chat(model.modelName); +}; + +const imageRequest = async (config: ImageConfig, model: ImageModel): Promise => { + return ""; +}; + +const videoRequest = async (config: VideoConfig, model: VideoModel): Promise => { + return ""; +}; + +const ttsRequest = async (config: TTSConfig, model: TTSModel): Promise => { + return ""; +}; + +const checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => { + return { hasUpdate: false, latestVersion: "2.0", notice: "" }; +}; + +const updateVendor = async (): Promise => { + return ""; +}; + +// ============================================================ +// 导出 +// ============================================================ + +exports.vendor = vendor; +exports.textRequest = textRequest; +exports.imageRequest = imageRequest; +exports.videoRequest = videoRequest; +exports.ttsRequest = ttsRequest; +exports.checkForUpdates = checkForUpdates; +exports.updateVendor = updateVendor; + +export { }; \ No newline at end of file diff --git a/src/agents/scriptAgent/index.ts b/src/agents/scriptAgent/index.ts index a86f37a..f925287 100644 --- a/src/agents/scriptAgent/index.ts +++ b/src/agents/scriptAgent/index.ts @@ -40,7 +40,6 @@ function buildMemPrompt(mem: Awaited>): string { export async function runDecisionAI(ctx: AgentContext) { const { isolationKey, text, userMessageTime, abortSignal, resTool } = ctx; - const memory = new Memory("scriptAgent", isolationKey); await memory.add("user", text, { createTime: userMessageTime }); @@ -181,7 +180,7 @@ function createSubAgent(parentCtx: AgentContext) { const run_sub_agent_script = tool({ description: "运行执行subAgent来完成剧本相关任务", - inputSchema: promptInput, + inputSchema: jsonSchema<{ prompt: string }>(promptInput), execute: async ({ prompt }) => { const skill = path.join(u.getPath("skills"), "script_execution_script.md"); const systemPrompt = await fs.promises.readFile(skill, "utf-8"); @@ -211,7 +210,7 @@ function createSubAgent(parentCtx: AgentContext) { const run_supervision_agent = tool({ description: "运行监督层subAgent执行独立任务,完成后返回结果", - inputSchema: promptInput, + inputSchema: jsonSchema<{ prompt: string }>(promptInput), execute: async ({ prompt }) => { const skill = path.join(u.getPath("skills"), "script_agent_supervision.md"); const systemPrompt = await fs.promises.readFile(skill, "utf-8"); diff --git a/src/lib/fixDB.ts b/src/lib/fixDB.ts index 9c0307c..c5d9bcd 100644 --- a/src/lib/fixDB.ts +++ b/src/lib/fixDB.ts @@ -66,7 +66,46 @@ export default async (knex: Knex): Promise => { // 添加新字段 await addColumn("o_agentDeploy", "maxOutputTokens", "integer"); await addColumn("o_assets", "audioBindState", "integer"); + const vendorDataSelect = await u.db("o_vendorConfig").whereIn("id", ["deepseek", "atlascloud"]).select("*"); + if (!vendorDataSelect.find((i) => i.id == "deepseek")) { + await u.db("o_vendorConfig").insert({ + id: "deepseek", + inputValues: "{}", + models: "[]", + enable: 0, + }); + } + if (!vendorDataSelect.find((i) => i.id == "atlascloud")) { + await u.db("o_vendorConfig").insert({ + id: "atlascloud", + inputValues: "{}", + models: "[]", + enable: 0, + }); + } + //检测是否包含新增音色绑定提示词 + const existAudioPrompt = await db("o_prompt").where("type", "audioBindPrompt").first(); + if (!existAudioPrompt) + await db("o_prompt").insert({ + name: "音色绑定", + type: "audioBindPrompt", + data: `你是一个音色匹配助手。\n你的任务是:根据给定角色资产的名称与描述,从候选音频列表中选出最合适的音色。\n匹配规则:\n1. 优先根据角色性别、年龄、性格等特征与音色描述进行语义匹配;\n2. 同一角色仅可匹配一个音色;\n3. 若候选列表中没有合适的音色,则无需返回 audioId;`, + }); + //检测o_setting是否有agentUseMode + const agentUserMode = await u.db("o_setting").where("key", "agentUseMode").first(); + if (!agentUserMode) { + const allDeployData = await u + .db("o_agentDeploy") + .leftJoin("o_vendorConfig", "o_vendorConfig.id", "o_agentDeploy.vendorId") + .select("o_agentDeploy.*"); + const advancedData = allDeployData.filter((item: any) => item.key?.includes(":")); + const notValModelData = advancedData.filter((item) => !item.modelName); + await u.db("o_setting").insert({ + key: "agentUseMode", + value: notValModelData.length ? "0" : "1", + }); + } //添加数据高级配置 const advancedAgentList = [ { key: "scriptAgent:decisionAgent", name: "剧本Agent:决策层", desc: "决策层" }, diff --git a/src/lib/initDB.ts b/src/lib/initDB.ts index aa47b6f..a1239aa 100644 --- a/src/lib/initDB.ts +++ b/src/lib/initDB.ts @@ -362,6 +362,11 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => type: "videoPromptGeneration", data: `# 视频提示词生成 Skill\n\n你是**视频提示词生成 Agent**,专门负责根据指定的 AI 视频模型,读取分镜信息并输出该模型对应格式的视频提示词。\n\n---\n\n## 输入格式\n\n### 1. 模型与模式(必选)\n\n\n#### 模式路由规则\n\n| 条件 | 匹配模式 | 说明 |\n|------|----------|------|\n| 模型名为 \`Seedance2.0\` / \`seedance 2.0\` / \`即梦2.0\` | **Seedance 2.0** | 固定模式,无论多参标志如何 |\n| 模型名为 \`Wan2.6\` / \`wan 2.6\` / \`万象2.6\` | **Wan 2.6** | 固定模式,单图(首帧)+ 叙事文本,无尾帧 |\n| 其他任何模型 + \`多参:是\` | **通用多参模式** | 支持角色/场景/分镜图多参引用 |\n| 其他任何模型 + \`多参:否\` | **通用首尾帧模式** | 首帧/首尾帧 + 纯文本描述 |\n\n> 模型名仅用于记录,实际提示词格式由匹配到的模式决定。Seedance 2.0 和 Wan 2.6 是指定模型名即确定模式的特例。\n\n### 2. 资产信息\n\n\`\`\`\n资产信息[id, type, name], [id, type, name], ...\n\`\`\`\n\n- \`id\`:资产唯一标识(如 \`A001\`)\n- \`type\`:资产类型,取值 \`character\`(角色)/ \`scene\`(场景)/ \`prop\`(道具)\n- \`name\`:资产名称(如 \`沈辞\`、\`城楼\`、\`长剑\`)\n\n### 3. 分镜信息\n\n分镜以 \`\` XML 标签列表的形式传入,每条分镜结构如下:\n\n\`\`\`xml\n\n\`\`\`\n\n#### 输入字段说明\n\n| 属性 | 说明 | 来源 |\n|------|------|------|\n| \`videoDesc\` | **核心输入**:分镜的结构化画面描述,包含画面描述、场景、关联资产名称、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效、关联资产ID | 用户/上游系统填写 |\n| \`prompt\` | **已有字段**:上游生成的分镜图提示词,作为辅助参考上下文,**不修改** | 上游系统已填写 |\n| \`track\` | 分镜分组标识 | 用户/上游系统填写 |\n| \`duration\` | 视频推荐时长(秒) | 用户/上游系统填写 |\n| \`associateAssetsIds\` | 该分镜关联的资产ID列表 | 用户/上游系统填写 |\n| \`shouldGenerateImage\` | 是否需要生成分镜图片,默认 \`true\` | 用户/上游系统填写 |\n\n---\n\n## 任务目标\n\n读取所有 \`\` 的属性,结合资产信息,根据指定模型的提示词格式,将全部分镜整合为一个完整的视频提示词。\n\n---\n\n## 输出格式\n\n将所有分镜整合为**一个完整的视频提示词**输出(非逐条独立):\n\n| 模式 | 整合方式 |\n|------|----------|\n| **通用多参模式** | \`[References]\` 汇总所有 \`@图N \` 引用;\`[Instruction]\` 按时间顺序描述完整叙事 |\n| **通用首尾帧模式** | 纯文本五维度(Visual / Motion / Camera / Audio / Narrative),不使用任何 \`@图N \` 引用,按时间轴连续编排(\`[Motion]\` 0s → 总时长,每段最低 1 秒),全程单一连贯镜头,不切镜 |\n| **Seedance 2.0** | \`生成一个由以下 N 个分镜组成的视频\`,每条对应 \`分镜N\` 段落 |\n| **Wan 2.6** | 单图首帧模式,每次仅输入一条分镜,输出一段叙事式英文提示词(三段式:风格基调 → 主体动作+场景环境+光线氛围 → 镜头收尾),不使用 \`@图N \` 引用 |\n\n- 仅输出视频提示词文本,不输出 XML 标签,不附加解释\n\n---\n\n## videoDesc 解析规则\n\n从 \`videoDesc\` 括号内按顿号分隔提取以下结构化字段:\n\n\`\`\`\n({画面描述}、{场景}、{关联资产名称}、{时长}、{景别}、{运镜}、{角色动作}、{情绪}、{光影氛围}、{台词}、{音效}、{关联资产ID})\n\`\`\`\n\n| 序号 | 字段 | 用途 | 示例 |\n|------|------|------|------|\n| 1 | 画面描述 | prompt 的叙事主干 | 沈辞独立城楼远眺苍茫大地 |\n| 2 | 场景 | 匹配场景资产 | 城楼 |\n| 3 | 关联资产名称 | 匹配角色/道具资产 | 沈辞/城楼 |\n| 4 | 时长 | 控制时长参数 | 4s |\n| 5 | 景别 | 控制镜头景别 | 全景 |\n| 6 | 运镜 | 控制运镜方式 | 静止 |\n| 7 | 角色动作 | prompt 动作描写 | 负手而立衣袂随风飘扬 |\n| 8 | 情绪 | prompt 情绪氛围 | 坚定决绝 |\n| 9 | 光影氛围 | prompt 光影描写 | 黄昏冷调侧逆光 |\n| 10 | 台词 | prompt 台词/音频段 | 无台词 / 具体台词内容 |\n| 11 | 音效 | prompt 音效描写 | 风声衣袂声 |\n| 12 | 关联资产ID | 用于资产ID↔角色标签映射 | A001/A002 |\n\n---\n\n## 资产引用编号规则\n\n所有模型统一使用 \`@图N \` 格式引用资产和分镜图,编号按输入顺序连续递增:\n\n1. **资产**:按资产信息中 \`[id, type, name]\` 的出现顺序,从 \`@图1 \` 开始编号(不区分 character / scene / prop)\n2. **分镜图**:每条 \`\` 对应一张分镜图,编号接续资产之后\n3. **跳过无分镜图的条目**:当 \`shouldGenerateImage="false"\` 时,该分镜未生成图片,**不分配**分镜图编号,后续编号顺延\n\n#### 示例\n\n输入 3 个资产 + 2 条分镜:\n\`\`\`\n资产信息[A001, character, 沈辞], [A002, character, 苏锦], [A003, scene, 城楼]\n\`\`\`\n\`\`\`xml\n \n \n\`\`\`\n\n编号结果:\n\n| 输入项 | 引用标签 | 说明 |\n|--------|----------|------|\n| [A001, character, 沈辞] | \`@图1 \` | 角色·沈辞 参考图 |\n| [A002, character, 苏锦] | \`@图2 \` | 角色·苏锦 参考图 |\n| [A003, scene, 城楼] | \`@图3 \` | 场景·城楼 参考图 |\n| storyboardItem 第1条 | \`@图4 \` | 分镜图1 |\n| storyboardItem 第2条 | \`@图5 \` | 分镜图2 |\n\n---\n\n## 模型提示词生成规则\n\n### 一、通用多参模式\n\n#### 核心原则\n- MVL 多模态融合:自然语言 + 图像引用在同一语义空间\n- 分镜图序列负责动作/时间轴/构图,场景参考图负责环境一致性\n- 所有资产和分镜图统一用 \`@图N \` 引用\n- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段生成,不编造额外内容\n- **台词不可缺失**:videoDesc 中有台词的分镜,必须在 Instruction 中体现台词相关描述\n- **台词类型标注**:区分普通对白(dialogue)、内心独白(inner monologue OS)、画外音(voiceover VO),在 Instruction 中用括号标注\n\n#### prompt 生成模板\n\n\`\`\`\n[References]\n@图1 : [{角色A名}参考图]\n@图2 : [{角色B名}参考图]\n@图3 : [{场景名}参考图]\n@图4 : [分镜图1]\n\n[Instruction]\nBased on the storyboard @图4 :\n@图1 {动作/状态描述(英文)},\n@图2 {动作/状态描述(英文)},\nset in the {场景描述(英文)} of @图3 ,\n{镜头/运镜描述(英文)},\n{情感基调(英文)},\n{台词描述(英文,含 dialogue/OS/VO 标注)/ No dialogue},\n{音效描述(英文)}.\n\`\`\`\n\n#### 生成约束\n1. **Instruction 必须用英文**\n2. **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段,不编造额外信息\n3. **角色动作**从 videoDesc 的「角色动作」字段提取,翻译为简洁英文动作描述\n4. **台词不可缺失**:videoDesc 中有台词的分镜,必须在 Instruction 中体现台词内容(保持原始语言,不翻译)\n5. **台词类型标注**:普通对白标注 \`(dialogue)\`;内心独白标注 \`(inner monologue, OS)\`;画外音标注 \`(voiceover, VO)\`\n6. **镜头风格**使用标准标签:\`cinematic\` / \`wide-angle\` / \`close-up\` / \`slow motion\` / \`surround shooting\` / \`handheld\`\n7. **空间关系**使用标准动词:\`wearing\` / \`holding\` / \`standing on\` / \`following behind\` / \`sitting in\`\n8. 单条分镜对应单个 \`@图N \`,不做多帧跨镜描述\n9. 无需描述角色外观(由参考图负责)\n10. 无时长标注(由模型推断)\n11. **无分镜图时**:当 \`shouldGenerateImage="false"\` 时,该分镜无分镜图,\`[References]\` 中不列出该分镜图,\`[Instruction]\` 中不使用 \`@图N \` 引用该分镜图,改为纯文本描述画面内容\n\n#### KlingOmni 完整示例\n\n输入:\n\`\`\`\n模型:KlingOmni\n资产信息[A001, character, 沈辞], [A002, character, 苏锦], [A003, scene, 城楼]\n\`\`\`\n\`\`\`xml\n\n\n\`\`\`\n\n输出:\n\`\`\`\n[References]\n@图1 : [沈辞参考图]\n@图2 : [苏锦参考图]\n@图3 : [城楼参考图]\n@图4 : [分镜图1]\n@图5 : [分镜图2]\n\n[Instruction]\nBased on the storyboard from @图4 to @图5 :\n@图1 standing alone atop the city wall, hands clasped behind back, robes billowing in the wind, gazing across the vast land,\n@图2 ascending the steps toward @图1 , expression worried,\nset in the ancient city wall environment of @图3 ,\nwide shot transitioning to medium tracking shot, cinematic,\nresolute determination shifting to concerned anticipation, dusk cold-toned side-backlit atmosphere fading,\nno dialogue,\nwind howling, fabric flapping, footsteps on stone.\n\`\`\`\n\n---\n\n### 二、通用首尾帧模式\n\n#### 核心原则\n- **纯文本提示词**:提示词内**不使用任何 \`@图N \` 引用**(不引用角色资产、场景资产、也不引用分镜图),全部内容用纯文本描述\n- **五维度结构**:Visual / Motion / Camera / Audio / Narrative\n- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段生成,不编造额外内容\n- **台词不可缺失**:videoDesc 中有台词的分镜,必须在 \`[Audio]\` 中完整输出台词内容\n- **台词类型标注**:区分普通对白(dialogue, lip-sync active)、内心独白(inner monologue OS, silent lips)、画外音(voiceover VO, silent lips),并在 \`[Audio]\` 中明确标注\n- **不说话的主体标注 \`silent\`** — 防止误生口型\n- **全程单一连贯镜头**:从头到尾一个镜头,不存在切镜\n- **时间轴分段**:每段最低 1 秒,用 \`0s-Xs\` 标注\n\n#### prompt 生成模板\n\n\`\`\`\n[Visual]\n{主体A名}: {外观简述}, {站位/姿态}, {说话状态 speaking/silent}.\n{主体B名}: {外观简述}, {站位/姿态}, {说话状态}.\n{场景描述}, {道具描述}.\n{视觉风格标签}.\n\n[Motion]\n0s-{X}s: {主体A名} {动作描述段1}.\n{X}s-{Y}s: {主体B名} {动作描述段2}.\n\n[Camera]\n{镜头类型}, {运镜方式}, {全程单一连贯镜头描述}.\n\n[Audio]\n{Xs-Ys}: "{台词内容}" — {说话者名} ({dialogue / inner monologue OS / voiceover VO}), {lip-sync active / silent lips}.\n{音效描述}.\n\n[Narrative]\n{情节点概述}, {叙事位置}.\n\`\`\`\n\n#### 生成约束\n1. **全部用英文**\n2. **不使用任何 \`@图N \` 引用**:提示词内不引用角色资产、场景资产、分镜图,全部内容用纯文本描述\n3. **主体用文字描述**:在 [Visual] 中简要描述主体外观特征(如服饰、发型等关键辨识特征)\n4. **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段,不编造额外信息\n5. **每个主体必须标注说话状态**:\`speaking\` / \`silent\` / \`speaking simultaneously\`\n6. **台词不可缺失**:videoDesc 中有台词的分镜,必须在 \`[Audio]\` 中完整输出台词内容(保持原始语言,不翻译)\n7. **台词类型标注**:普通对白标注 \`dialogue, lip-sync active\`;内心独白标注 \`inner monologue (OS), silent lips\`;画外音标注 \`voiceover (VO), silent lips\`\n8. **Motion 时间轴**每段最低 1 秒,不超过总时长\n9. **全程单一连贯镜头**:Camera 段落描述从头到尾的一个镜头,绝不切镜\n10. **视觉风格**参考 Assistant 中的「视觉风格约束」部分内容\n11. **镜头类型**从以下选取:\`Wide establishing shot / Over-the-shoulder / Medium shot / Close-up / Wide shot / POV / Dutch angle / Crane up / Dolly right / Whip pan / Handheld / Slow motion\`\n\n#### Seedance 1.5 Pro 完整示例\n\n输入:\n\`\`\`\n模型:Seedance1.5\n资产信息[A001, character, 沈辞], [A002, character, 苏锦], [A003, scene, 城楼]\n\`\`\`\n\`\`\`xml\n\n\n\`\`\`\n\n输出:\n\`\`\`\n[Visual]\nShen Ci: male, dark flowing robes, hair tied up, standing alone atop city wall, hands clasped behind back, robes billowing, silent.\nSu Jin: female, light-colored dress, hair partially down, ascending steps toward Shen Ci, expression worried, silent.\nAncient city wall, vast open land beyond, dusk sky fading.\nCinematic, photorealistic, 4K, high contrast, desaturated tones, shallow depth of field.\n\n[Motion]\n0s-4s: Shen Ci stands still on city wall edge, robes flutter in wind, hair sways gently. Gaze fixed on distant horizon.\n4s-8s: Su Jin climbs the last few steps onto the wall, walks toward Shen Ci. Shen Ci remains still, unaware. Su Jin slows as she approaches.\n\n[Camera]\nWide establishing shot, static for first 4 seconds capturing the lone figure. Then smooth transition to medium tracking shot following the woman ascending steps, single continuous take throughout, no cuts.\n\n[Audio]\n0s-4s: Wind howling across wall, fabric flapping rhythmically. No dialogue.\n4s-8s: Footsteps on stone, robes rustling. No dialogue.\nShen Ci — silent. Su Jin — silent.\n\n[Narrative]\nLone figure on city wall, then arrival of a companion. Tension between determination and concern. Single continuous take.\n\`\`\`\n\n---\n\n### 三、Seedance 2.0\n\n#### 核心原则\n- **结构化12维编码**:统一用 \`@图N \` 引用资产和分镜图,时长 \`\`\n- **音色参数9维度精细描述**(有台词时必填)\n- **毫秒级时长控制**:单分镜时长最低 1000ms(1 秒)\n- **中文提示词**\n- **严格遵循 videoDesc**:每条分镜的描述内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段生成,不编造额外内容\n- **台词不可缺失**:videoDesc 中有台词的分镜,必须完整输出台词和音色描述\n- **台词类型标注**:区分普通对白(直接使用「说:」)、内心独白(使用「内心OS:」)、画外音(使用「画外音VO:」),并匹配对应的嘴型状态描述\n\n#### prompt 生成模板\n\n**单分镜模板:**\n\`\`\`\n画面风格和类型: {风格}, {色调}, {类型}\n\n生成一个由以下 1 个分镜组成的视频:\n\n场景:\n分镜过渡: 无\n\n分镜1{毫秒数}: 时间:{日/夜/晨/黄昏},场景图片:@图{场景编号} ,镜头:{景别},{角度},{运镜},@图{角色编号} {动作/表情/视线朝向/站位描述}。{台词与音色描述(如有)}。{背景环境补充}。{光影氛围}。{运镜补充}。\n\`\`\`\n\n**多分镜模板:**\n\`\`\`\n画面风格和类型: {风格}, {色调}, {类型}\n\n生成一个由以下 {N} 个分镜组成的视频:\n\n场景:\n分镜过渡: {全局过渡描述}\n\n分镜1{毫秒数}: 时间:{...},场景图片:@图{场景编号} ,镜头:{...},@图{角色编号} {...}。{...}。\n分镜2{毫秒数}: ...\n...\n\`\`\`\n\n#### 音色生成规则(有台词时必填)\n\n台词格式:\`@图{角色编号} 说:「{台词内容}」音色:{9维度描述}\`\n\n9维度按顺序填写:\n\`\`\`\n{性别},{年龄音色},{音调},{音色质感},{声音厚度},{发音方式},{气息},{语速},{特殊质感}\n\`\`\`\n\n> 当 desc 中未明确音色信息时,根据角色类型从以下参考表推断:\n\n| 角色类型特征 | 默认音色 |\n|------------|---------|\n| 男性权威/霸气角色 | 男声,中年音色,音调低沉,音色浑厚有力,声音厚重,发音标准,气息极其沉稳,语速偏慢 |\n| 女性温柔/甜美角色 | 女声,青年音色,音调中等偏高,音色质感明亮清脆,声音清亮柔和,气息充沛平稳,带温婉真诚感 |\n| 男性年轻/普通角色 | 男声,青年音色,音调中等,音色干净,声音厚度适中,发音清晰,气息平稳,语速适中 |\n| 女性活泼/外向角色 | 女声,青年音色,音调偏高,音色清脆活泼,声音轻盈,气息充沛,语速偏快,带笑意和感染力 |\n| 反派/冷酷角色 | 男声,中年音色,音调低沉,音色质感干燥偏暗,声音带沙砾感,气息平稳,语速极慢,有威胁感 |\n\n#### 无台词分镜处理\n- 不写 \`说:\` 和音色段落\n- 在动作描述后标注 \`无台词\`\n\n#### 台词类型格式\n\n| 台词类型 | 格式 | 嘴型描述 |\n|----------|------|----------|\n| 普通对白 | \`@图{角色编号} 说:「{台词}」音色:{9维度}\` | 角色嘴部开合说话 |\n| 内心独白 | \`@图{角色编号} 内心OS:「{台词}」音色:{9维度}\` | 角色嘴部紧闭不动 |\n| 画外音 | \`@图{角色编号} 画外音VO:「{台词}」音色:{9维度}\` | 角色嘴部紧闭不动(或角色不在画面中) |\n\n#### 生成约束\n1. **中文提示词**\n2. **严格遵循 videoDesc**:每条分镜内容严格基于 videoDesc 的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段,不编造额外信息\n3. **台词不可缺失**:videoDesc 中有台词的分镜,必须完整输出台词和音色\n4. **台词类型正确标注**:普通对白用「说:」,内心独白用「内心OS:」,画外音用「画外音VO:」\n5. **单分镜时长最低 1000ms(1 秒)**\n6. **时长单位**:将 videoDesc 中的秒 × 1000 转为毫秒填入 \`\`\n\n#### Seedance 2.0 完整示例\n\n输入:\n\`\`\`\n模型:Seedance2.0\n资产信息[A001, character, 沈辞], [A002, character, 苏锦], [A003, scene, 城楼]\n\`\`\`\n\`\`\`xml\n\n\n\`\`\`\n\n输出:\n\`\`\`\n画面风格和类型: 真人写实, 电影风格, 冷调, 古风\n\n生成一个由以下 2 个分镜组成的视频:\n\n场景:\n分镜过渡: 镜头平滑切换,从全景过渡到中景跟踪,焦点从沈辞独处转向苏锦到来。\n\n分镜14000: 时间:黄昏,场景图片:@图3 ,镜头:全景,平视略仰,静止镜头,@图1 独立城楼之上,负手而立,衣袂随风飘扬,目光远眺苍茫大地,神情肃然面容沉着,眼神坚定目光清冽,眉眼沉静气质凛然。无台词。背景是古城楼砖石纹理清晰,远方大地苍茫辽阔,天际线冷暖交替。黄昏斜射余晖侧逆光,冷调为主,长影拉伸,轮廓光微勾勒人物边缘,光感诗意。镜头静止。\n\n分镜24000: 时间:黄昏,场景图片:@图3 ,镜头:中景,平视,跟踪拍摄,@图2 拾级而上,走向城楼上的@图1 ,面部朝向@图1 方向,神情微愣面色微变,眼神中带着担忧,@图2 说:「你又一个人在这里。」音色:女声,青年音色,音调中等偏高,音色质感明亮清脆,声音清亮柔和,发音方式干净,气息充沛平稳,语速适中,带温婉真诚感。背景城楼台阶纹理清晰,余晖渐暗,天际线冷暖交替加深。镜头跟踪苏锦移动。\n\`\`\`\n\n---\n\n### 四、Wan 2.6\n\n#### 核心原则\n- **单图首帧模式**:归类为首尾帧模式,但仅有首帧(分镜图),无尾帧\n- **单条分镜输入/输出**:每次仅输入一条 \`\` 及其关联资产信息,输出也仅为一段完整的叙事式提示词\n- **叙事式英文提示词**:像写小说一样描写画面,不使用标签罗列(不写 \`4K, cinematic, high quality\` 这类堆砌)\n- **三段式结构**:风格基调 → 主体动作 + 场景环境 + 光线氛围 → 镜头收尾\n- **纯文本提示词**:提示词内**不使用任何 \`@图N \` 引用**,全部内容用纯文本描述\n- **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段生成,不编造额外内容\n- **台词不可缺失**:videoDesc 中有台词的分镜,必须在提示词中体现台词相关描述\n- **台词类型标注**:区分普通对白(dialogue)、内心独白(inner monologue OS)、画外音(voiceover VO),在提示词中用括号标注\n\n#### prompt 生成模板\n\n每次输入一条分镜,输出一段完整提示词(无编号前缀),格式如下:\n\n\`\`\`\n{风格基调一句话定性},\n{主体名} {外观简述}, {具体动作/姿态描述}, {情绪/表情用动作暗示}.\n{场景背景主体}, {具体环境物件}, {空间感}, {时间/天气}.\n{光线方向/色温} {质感描述}, {情绪暗示光影}.\n{台词描述(如有,含 dialogue/OS/VO 标注)/ No dialogue}.\n{音效描述}.\n{拍摄方式}, {景别}, {视角}, {运镜方式}.\n\`\`\`\n\n#### 叙事式写法要点\n\n| 原则 | 说明 | 示例 |\n|------|------|------|\n| 风格基调放最前 | 一句话定性整体气质 | \`A cinematic epic scene\` / \`A melancholic cinematic scene\` |\n| 主体+动作紧密绑定 | 主体后面直接跟动作,外观细节嵌入主体描述 | \`A young man in dark flowing robes stands alone atop the city wall, hands clasped behind back\` |\n| 情绪用动作暗示 | 不直接陈述「他很悲伤」 | ❌ \`He is sad.\` → ✅ \`head drops slowly, shoulders slumped\` |\n| 环境融入叙事 | 不罗列环境属性 | ❌ \`The sky is blue. The grass is green.\` → ✅ \`hazy blue sky stretches over the emerald valley\` |\n| 光线单独成句 | 光线方向+色温+质感+情绪 | \`Warm golden hour light streams from behind, casting long shadows across the stone floor\` |\n| 镜头语言收尾 | 一句话点睛 | \`Captured in a wide establishing shot from a low-angle perspective, static camera\` |\n| 禁止标签堆砌 | 不写 \`4K, cinematic, high quality\` | \`cinematic\` 融入风格基调即可 |\n\n#### 生成约束\n1. **全部用英文**\n2. **不使用任何 \`@图N \` 引用**:提示词内不引用角色资产、场景资产、分镜图,全部内容用纯文本描述\n3. **叙事式描写**:像写小说一样构建画面,禁止标签罗列和配置清单式写法\n4. **主体用文字描述**:简要描述主体外观特征(如服饰、发型等关键辨识特征),嵌入主体描述中\n5. **严格遵循 videoDesc**:提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段,不编造额外信息\n6. **台词不可缺失**:videoDesc 中有台词的分镜,必须在提示词中完整输出台词内容(保持原始语言,不翻译)\n7. **台词类型标注**:普通对白标注 \`(dialogue)\`;内心独白标注 \`(inner monologue, OS)\`;画外音标注 \`(voiceover, VO)\`\n8. **单条输入/输出**:每次仅处理一条分镜,输出一段提示词,无编号前缀\n9. **无需标注时长**:时长由模型侧控制,提示词中不写时长参数\n10. **镜头描述融入叙事**:不用方括号标签,用完整句子描述镜头\n11. **视觉风格**参考 Assistant 中的「视觉风格约束」部分内容\n\n#### Wan 2.6 完整示例\n\n**示例1:无台词分镜**\n\n输入:\n\`\`\`\n模型:Wan2.6\n资产信息[A001, character, 沈辞], [A003, scene, 城楼]\n\`\`\`\n\`\`\`xml\n\n\`\`\`\n\n输出:\n\`\`\`\nA cinematic epic scene with a cold, desaturated palette,\nA lone man in dark flowing robes stands atop an ancient city wall, hands clasped behind his back, robes and hair billowing in the wind, gaze fixed on the vast land stretching to the horizon, jaw set firm, eyes unwavering.\nThe weathered stone battlements frame the endless expanse below, rolling terrain fading into haze beneath a heavy dusk sky, clouds layered in muted golds and slate greys.\nCold side-backlight from the setting sun carves a sharp silhouette, long shadows stretching across the stone floor, a faint warm rim outlining the figure against the cool atmosphere.\nNo dialogue.\nWind howling across the open wall, fabric flapping rhythmically.\nCaptured in a wide establishing shot from a slightly low angle, static camera, single continuous take.\n\`\`\`\n\n**示例2:有台词分镜**\n\n输入:\n\`\`\`\n模型:Wan2.6\n资产信息[A001, character, 沈辞], [A002, character, 苏锦], [A003, scene, 城楼]\n\`\`\`\n\`\`\`xml\n\n\`\`\`\n\n输出:\n\`\`\`\nA melancholic cinematic scene, dusk tones deepening,\nA young woman in a light-colored dress ascends the final stone steps onto the city wall, her gaze locked on the lone figure ahead, brow slightly furrowed, pace slowing as she approaches, lips parting softly.\nThe ancient city wall stretches behind her, weathered stairs leading up from below, the distant skyline dimming as the last traces of golden hour fade into twilight.\nFading warm light mingles with rising cool blue tones, the contrast between the two figures softened by the diffused remnants of sunset.\n"你又一个人在这里。" — Su Jin (dialogue).\nFootsteps on stone, wind sweeping across the battlements, fabric rustling.\nA medium tracking shot follows the woman from behind as she ascends and approaches, handheld camera with subtle movement, single continuous take.\n\`\`\`\n\n---\n\n## 景别 → 镜头标签映射\n\n| videoDesc 中的景别 | KlingOmni(英文标签) | Seedance 1.5(英文标签) | Seedance 2.0(中文描述) | Wan 2.6(英文叙事式) |\n|------|------|------|------|------|\n| 远景 | extreme wide shot | Extreme wide shot | 远景 | an extreme wide shot capturing the vast expanse |\n| 全景 | wide shot | Wide establishing shot | 全景 | a wide establishing shot |\n| 中景 | medium shot | Medium shot | 中景 | a medium shot |\n| 近景 | close-up | Close-up | 近景 | a close-up shot |\n| 特写 | close-up | Close-up | 特写 | a close-up capturing fine detail |\n| 大特写 | extreme close-up | Extreme close-up | 大特写 | an extreme close-up |\n\n## 运镜 → 镜头标签映射\n\n| videoDesc 中的运镜 | KlingOmni(英文标签) | Seedance 1.5(英文标签) | Seedance 2.0(中文描述) | Wan 2.6(英文叙事式) |\n|------|------|------|------|------|\n| 静止 | static camera | Static, no camera movement | 镜头静止 | static camera, locked off |\n| 推进 | dolly in / push in | Slow dolly forward | 镜头缓慢向前推进 | camera slowly pushing in |\n| 拉远 | dolly out / pull back | Slow dolly backward pull | 镜头缓慢向后拉远 | camera gently pulling back |\n| 跟踪 | tracking shot | Tracking shot, handheld | 跟踪拍摄 | tracking shot following the subject |\n| 摇镜 | pan left/right | Slow pan | 镜头缓慢摇移 | smooth pan across the scene |\n| 甩镜 | whip pan | Whip pan | 快速甩镜 | whip pan |\n| 升降 | crane up/down | Crane up/down | 镜头升降 | crane rising / descending |\n| 环绕 | surround shooting | Orbiting shot | 环绕拍摄 | orbiting around the subject |\n\n---\n\n## 执行流程\n\n1. **解析输入**:提取模型名和多参标志,按路由规则匹配模式;提取资产列表\n2. **构建 @图N 编号表**:资产按输入顺序从 \`@图1 \` 起编号,分镜图接续编号;\`shouldGenerateImage="false"\` 的分镜不分配分镜图编号\n3. **逐条解析 \`\`**:按 videoDesc 解析规则提取12个字段,结合 \`duration\`、\`associateAssetsIds\` 建立标签映射\n4. **整合为一个完整的视频提示词**:按目标模型格式编排全部分镜\n5. **输出视频提示词**\n\n---\n\n## 约束\n\n- **仅输出视频提示词**:不附加任何解释、注释或额外说明,只输出视频提示词文本\n- **严格遵循 videoDesc**(全模式通用):提示词内容严格基于 videoDesc 中的画面描述、时长、景别、运镜、角色动作、情绪、光影氛围、台词、音效字段生成,不编造额外内容\n- **台词不可缺失**(全模式通用):videoDesc 中有台词的分镜,必须在提示词中完整体现台词内容,不得遗漏\n- **台词保持原始输入**(全模式通用):台词内容严禁翻译,必须保持 videoDesc 中的原始语言原样输出\n- **台词类型标注**(全模式通用):必须区分普通对白(dialogue / 说)、内心独白(OS / 内心OS)、画外音(VO / 画外音VO),并在提示词中正确标注\n- **时间跨度最低 1 秒**(全模式通用):所有模式中涉及时间分段(Motion 时间轴 / duration-ms)的最小粒度为 1 秒(1000ms),禁止出现 0.5 秒等低于 1 秒的间隔\n- **视觉风格**:风格相关描述参考 Assistant 中的「视觉风格约束」部分内容,不在本 Skill 内自行定义风格\n- **严格按匹配到的模式格式**,不混用不同模式的格式\n- **不修改原始输入**:不改写 \`\` 的任何字段;\`prompt\` 已有的分镜图提示词仅作画面参考\n- **不编造资产或台词**:只使用输入中的资产信息;无台词则标注「无台词」/ \`No dialogue\`\n- **时长单位转换**:Seedance 2.0 的 \`\` 需将秒 × 1000 转为毫秒\n`, }, + { + name: "音色绑定", + type: "audioBindPrompt", + data: `你是一个音色匹配助手。\n你的任务是:根据给定角色资产的名称与描述,从候选音频列表中选出最合适的音色。\n匹配规则:\n1. 优先根据角色性别、年龄、性格等特征与音色描述进行语义匹配;\n2. 同一角色仅可匹配一个音色;\n3. 若候选列表中没有合适的音色,则无需返回 audioId;`, + }, ]); }, }, @@ -372,7 +377,8 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => table.integer("id").notNullable(); table.string("vendorId"); table.string("model"); - table.text("prompt"); + table.text("fileName"); + table.text("path"); table.primary(["id"]); table.unique(["id"]); }, @@ -564,6 +570,18 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => models: "[]", enable: 0, }, + { + id: "deepseek", + inputValues: "{}", + models: "[]", + enable: 0, + }, + { + id: "atlascloud", + inputValues: "{}", + models: "[]", + enable: 0, + }, { id: "volcengine", inputValues: "{}", diff --git a/src/lib/vendor.json b/src/lib/vendor.json index 1795cd6..42dca9c 100644 --- a/src/lib/vendor.json +++ b/src/lib/vendor.json @@ -6,5 +6,7 @@ "openai.ts": "/**\r\n * Toonflow AI供应商模板\r\n * @version 2.0\r\n */\r\n// ============================================================\r\n// 类型定义\r\n// ============================================================\r\ntype VideoMode =\r\n | \"singleImage\"\r\n | \"startEndRequired\"\r\n | \"endFrameOptional\"\r\n | \"startFrameOptional\"\r\n | \"text\"\r\n | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[];\r\ninterface TextModel {\r\n name: string;\r\n modelName: string;\r\n type: \"text\";\r\n think: boolean;\r\n}\r\ninterface ImageModel {\r\n name: string;\r\n modelName: string;\r\n type: \"image\";\r\n mode: (\"text\" | \"singleImage\" | \"multiReference\")[];\r\n associationSkills?: string;\r\n}\r\ninterface VideoModel {\r\n name: string;\r\n modelName: string;\r\n type: \"video\";\r\n mode: VideoMode[];\r\n associationSkills?: string;\r\n audio: \"optional\" | false | true;\r\n durationResolutionMap: { duration: number[]; resolution: string[] }[];\r\n}\r\ninterface TTSModel {\r\n name: string;\r\n modelName: string;\r\n type: \"tts\";\r\n voices: { title: string; voice: string }[];\r\n}\r\ninterface VendorConfig {\r\n id: string;\r\n version: string;\r\n name: string;\r\n author: string;\r\n description?: string;\r\n icon?: string;\r\n inputs: { key: string; label: string; type: \"text\" | \"password\" | \"url\"; required: boolean; placeholder?: string }[];\r\n inputValues: Record;\r\n models: (TextModel | ImageModel | VideoModel | TTSModel)[];\r\n}\r\ninterface ImageConfig {\r\n prompt: string;\r\n imageBase64: string[];\r\n size: \"1K\" | \"2K\" | \"4K\";\r\n aspectRatio: `${number}:${number}`;\r\n}\r\ninterface VideoConfig {\r\n duration: number;\r\n resolution: string;\r\n aspectRatio: \"16:9\" | \"9:16\";\r\n prompt: string;\r\n imageBase64?: string[];\r\n audio?: boolean;\r\n mode: VideoMode[];\r\n}\r\ninterface TTSConfig {\r\n text: string;\r\n voice: string;\r\n speechRate: number;\r\n pitchRate: number;\r\n volume: number;\r\n}\r\ninterface PollResult {\r\n completed: boolean;\r\n data?: string;\r\n error?: string;\r\n}\r\n// ============================================================\r\n// 全局声明\r\n// ============================================================\r\ndeclare const axios: any;\r\ndeclare const logger: (msg: string) => void;\r\ndeclare const jsonwebtoken: any;\r\ndeclare const zipImage: (base64: string, size: number) => Promise;\r\ndeclare const zipImageResolution: (base64: string, w: number, h: number) => Promise;\r\ndeclare const mergeImages: (base64Arr: string[], maxSize?: string) => Promise;\r\ndeclare const urlToBase64: (url: string) => Promise;\r\ndeclare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise;\r\ndeclare const createOpenAI: any;\r\ndeclare const createDeepSeek: any;\r\ndeclare const createZhipu: any;\r\ndeclare const createQwen: any;\r\ndeclare const createAnthropic: any;\r\ndeclare const createOpenAICompatible: any;\r\ndeclare const createXai: any;\r\ndeclare const createMinimax: any;\r\ndeclare const createGoogleGenerativeAI: any;\r\ndeclare const exports: {\r\n vendor: VendorConfig;\r\n textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any;\r\n imageRequest: (c: ImageConfig, m: ImageModel) => Promise;\r\n videoRequest: (c: VideoConfig, m: VideoModel) => Promise;\r\n ttsRequest: (c: TTSConfig, m: TTSModel) => Promise;\r\n checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>;\r\n updateVendor?: () => Promise;\r\n};\r\n// ============================================================\r\n// 供应商配置\r\n// ============================================================\r\nconst vendor: VendorConfig = {\r\n id: \"openai\",\r\n version: \"2.0\",\r\n author: \"Toonflow\",\r\n name: \"OpenAI标准接口\",\r\n description: \"OpenAI标准格式接口,可修改请求地址并手动添加模型。\",\r\n icon: \"\",\r\n inputs: [\r\n { key: \"apiKey\", label: \"API密钥\", type: \"password\", required: true },\r\n { key: \"baseUrl\", label: \"请求地址\", type: \"url\", required: true, placeholder: \"以v1结束,示例:https://api.openai.com/v1\" },\r\n ],\r\n inputValues: {\r\n apiKey: \"\",\r\n baseUrl: \"https://api.openai.com/v1\",\r\n },\r\n models: [\r\n { name: \"GPT-4o\", modelName: \"gpt-4o\", type: \"text\", think: false },\r\n { name: \"GPT-4.1\", modelName: \"gpt-4.1\", type: \"text\", think: false },\r\n { name: \"GPT-5.1\", modelName: \"gpt-5.1\", type: \"text\", think: false },\r\n { name: \"GPT-5.2\", modelName: \"gpt-5.2\", type: \"text\", think: false },\r\n { name: \"GPT-5.4\", modelName: \"gpt-5.4\", type: \"text\", think: false },\r\n ],\r\n};\r\n// ============================================================\r\n// 适配器函数\r\n// ============================================================\r\nconst textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n return createOpenAI({ baseURL: vendor.inputValues.baseUrl, apiKey }).chat(model.modelName);\r\n};\r\nconst imageRequest = async (config: ImageConfig, model: ImageModel): Promise => {\r\n return \"\";\r\n};\r\nconst videoRequest = async (config: VideoConfig, model: VideoModel): Promise => {\r\n return \"\";\r\n};\r\nconst ttsRequest = async (config: TTSConfig, model: TTSModel): Promise => {\r\n return \"\";\r\n};\r\nconst checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => {\r\n return { hasUpdate: false, latestVersion: \"2.0\", notice: \"\" };\r\n};\r\nconst updateVendor = async (): Promise => {\r\n return \"\";\r\n};\r\n// ============================================================\r\n// 导出\r\n// ============================================================\r\nexports.vendor = vendor;\r\nexports.textRequest = textRequest;\r\nexports.imageRequest = imageRequest;\r\nexports.videoRequest = videoRequest;\r\nexports.ttsRequest = ttsRequest;\r\nexports.checkForUpdates = checkForUpdates;\r\nexports.updateVendor = updateVendor;\r\nexport {};", "toonflow.ts": "/**\r\n * Toonflow官方中转平台 供应商适配\r\n * @version 2.0\r\n */\r\n\r\n// ============================================================\r\n// 类型定义\r\n// ============================================================\r\n\r\ntype VideoMode =\r\n | \"singleImage\"\r\n | \"startEndRequired\"\r\n | \"endFrameOptional\"\r\n | \"startFrameOptional\"\r\n | \"text\"\r\n | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[];\r\n\r\ninterface TextModel {\r\n name: string;\r\n modelName: string;\r\n type: \"text\";\r\n think: boolean;\r\n}\r\n\r\ninterface ImageModel {\r\n name: string;\r\n modelName: string;\r\n type: \"image\";\r\n mode: (\"text\" | \"singleImage\" | \"multiReference\")[];\r\n associationSkills?: string;\r\n}\r\n\r\ninterface VideoModel {\r\n name: string;\r\n modelName: string;\r\n type: \"video\";\r\n mode: VideoMode[];\r\n associationSkills?: string;\r\n audio: \"optional\" | false | true;\r\n durationResolutionMap: { duration: number[]; resolution: string[] }[];\r\n}\r\n\r\ninterface TTSModel {\r\n name: string;\r\n modelName: string;\r\n type: \"tts\";\r\n voices: { title: string; voice: string }[];\r\n}\r\n\r\ninterface VendorConfig {\r\n id: string;\r\n version: string;\r\n name: string;\r\n author: string;\r\n description?: string;\r\n icon?: string;\r\n inputs: { key: string; label: string; type: \"text\" | \"password\" | \"url\"; required: boolean; placeholder?: string }[];\r\n inputValues: Record;\r\n models: (TextModel | ImageModel | VideoModel | TTSModel)[];\r\n}\r\n\r\ntype ReferenceList =\r\n | { type: \"image\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"audio\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"video\"; sourceType: \"base64\"; base64: string };\r\n\r\ninterface ImageConfig {\r\n prompt: string;\r\n referenceList?: Extract[];\r\n size: \"1K\" | \"2K\" | \"4K\";\r\n aspectRatio: `${number}:${number}`;\r\n}\r\n\r\ninterface VideoConfig {\r\n duration: number;\r\n resolution: string;\r\n aspectRatio: \"16:9\" | \"9:16\";\r\n prompt: string;\r\n referenceList?: ReferenceList[];\r\n audio?: boolean;\r\n mode: VideoMode[];\r\n}\r\n\r\ninterface TTSConfig {\r\n text: string;\r\n voice: string;\r\n speechRate: number;\r\n pitchRate: number;\r\n volume: number;\r\n referenceList?: Extract[];\r\n}\r\n\r\ninterface PollResult {\r\n completed: boolean;\r\n data?: string;\r\n error?: string;\r\n}\r\n\r\n// ============================================================\r\n// 全局声明\r\n// ============================================================\r\n\r\ndeclare const axios: any;\r\ndeclare const logger: (msg: string) => void;\r\ndeclare const jsonwebtoken: any;\r\ndeclare const zipImage: (base64: string, size: number) => Promise;\r\ndeclare const zipImageResolution: (base64: string, w: number, h: number) => Promise;\r\ndeclare const mergeImages: (base64Arr: string[], maxSize?: string) => Promise;\r\ndeclare const urlToBase64: (url: string) => Promise;\r\ndeclare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise;\r\ndeclare const createOpenAI: any;\r\ndeclare const createDeepSeek: any;\r\ndeclare const createZhipu: any;\r\ndeclare const createQwen: any;\r\ndeclare const createAnthropic: any;\r\ndeclare const createOpenAICompatible: any;\r\ndeclare const createXai: any;\r\ndeclare const createMinimax: any;\r\ndeclare const createGoogleGenerativeAI: any;\r\ndeclare const exports: {\r\n vendor: VendorConfig;\r\n textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any;\r\n imageRequest: (c: ImageConfig, m: ImageModel) => Promise;\r\n videoRequest: (c: VideoConfig, m: VideoModel) => Promise;\r\n ttsRequest: (c: TTSConfig, m: TTSModel) => Promise;\r\n checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>;\r\n updateVendor?: () => Promise;\r\n};\r\n\r\n// ============================================================\r\n// 供应商配置\r\n// ============================================================\r\n\r\nconst vendor: VendorConfig = {\r\n id: \"toonflow\",\r\n version: \"2.0\",\r\n author: \"Toonflow\",\r\n name: \"Toonflow官方中转平台\",\r\n description:\r\n \"## Toonflow官方中转平台\\n\\nToonflow官方中转平台,提供**文本、图像、视频、音频**等多模态生成能力的中转服务,支持接入多个大模型供应商,方便用户统一管理和调用不同供应商的生成能力。\\n\\n🔗 [前往中转平台](https://api.toonflow.net/)\\n\\n如果这个项目对你有帮助,可以考虑支持一下我们的开发工作 ☕\",\r\n icon: \"\",\r\n inputs: [{ key: \"apiKey\", label: \"API密钥\", type: \"password\", required: true }],\r\n inputValues: {\r\n apiKey: \"\",\r\n baseUrl: \"https://api.toonflow.net/v1\",\r\n },\r\n models: [\r\n { name: \"claude-sonnet-4-6\", type: \"text\", modelName: \"claude-sonnet-4-6\", think: false },\r\n { name: \"claude-opus-4-6\", type: \"text\", modelName: \"claude-opus-4-6\", think: false },\r\n { name: \"claude-sonnet-4-5-20250929\", type: \"text\", modelName: \"claude-sonnet-4-5-20250929\", think: false },\r\n { name: \"claude-opus-4-5-20251101\", type: \"text\", modelName: \"claude-opus-4-5-20251101\", think: false },\r\n { name: \"claude-haiku-4-5-20251001\", type: \"text\", modelName: \"claude-haiku-4-5-20251001\", think: false },\r\n { name: \"gpt-5.4\", type: \"text\", modelName: \"gpt-5.4\", think: false },\r\n { name: \"gpt-5.2\", type: \"text\", modelName: \"gpt-5.2\", think: false },\r\n { name: \"MiniMax-M2.7\", type: \"text\", modelName: \"MiniMax-M2.7\", think: true },\r\n { name: \"MiniMax-M2.5\", type: \"text\", modelName: \"MiniMax-M2.5\", think: true },\r\n {\r\n name: \"Wan2.6 I2V 1080P (支持真人)\",\r\n type: \"video\",\r\n modelName: \"Wan2.6-I2V-1080P\",\r\n mode: [\"text\", \"startEndRequired\"],\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"1080p\"] }],\r\n audio: true,\r\n },\r\n {\r\n name: \"Wan2.6 I2V 720P (支持真人)\",\r\n type: \"video\",\r\n modelName: \"Wan2.6-I2V-720P\",\r\n mode: [\"text\", \"startEndRequired\"],\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"720p\"] }],\r\n audio: true,\r\n },\r\n {\r\n name: \"Seedance 1.5 Pro\",\r\n type: \"video\",\r\n modelName: \"doubao-seedance-1-5-pro-251215\",\r\n mode: [\"text\", \"endFrameOptional\"],\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n audio: true,\r\n },\r\n {\r\n name: \"vidu2 turbo\",\r\n type: \"video\",\r\n modelName: \"ViduQ2-turbo\",\r\n mode: [\"singleImage\", \"startEndRequired\"],\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: [\"540p\", \"720p\", \"1080p\"] }],\r\n audio: false,\r\n },\r\n {\r\n name: \"ViduQ3 pro\",\r\n type: \"video\",\r\n modelName: \"ViduQ3-pro\",\r\n mode: [\"singleImage\", \"startEndRequired\"],\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], resolution: [\"540p\", \"720p\", \"1080p\"] }],\r\n audio: false,\r\n },\r\n {\r\n name: \"ViduQ2 pro\",\r\n type: \"video\",\r\n modelName: \"ViduQ2-pro\",\r\n mode: [\"singleImage\", \"startEndRequired\"],\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: [\"540p\", \"720p\", \"1080p\"] }],\r\n audio: false,\r\n },\r\n {\r\n name: \"Doubao Seedream 5.0 Lite\",\r\n type: \"image\",\r\n modelName: \"Doubao-Seedream-5.0-Lite\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Doubao Seedream 4.5\",\r\n type: \"image\",\r\n modelName: \"doubao-seedream-4-5-251128\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n ],\r\n};\r\n\r\n// ============================================================\r\n// 辅助工具\r\n// ============================================================\r\n\r\n// 从 markdown 内容中提取第一张图片\r\nfunction extractFirstImageFromMd(content: string) {\r\n const regex = /!\\[([^\\]]*)\\]\\((data:image\\/[^;]+;base64,[A-Za-z0-9+/=]+|https?:\\/\\/[^\\s)]+|\\/\\/[^\\s)]+|[^\\s)]+)\\)/;\r\n const match = content.match(regex);\r\n if (!match) return null;\r\n const raw = match[2].trim();\r\n const url = raw.startsWith(\"data:\") ? raw : raw.split(/\\s+/)[0];\r\n return { alt: match[1], url, type: url.startsWith(\"data:image\") ? \"base64\" : \"url\" };\r\n}\r\n\r\n// ============================================================\r\n// 适配器函数\r\n// ============================================================\r\n\r\nconst textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n return createOpenAI({ baseURL: vendor.inputValues.baseUrl, apiKey }).chat(model.modelName);\r\n};\r\n\r\nconst imageRequest = async (config: ImageConfig, model: ImageModel): Promise => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n const baseUrl = vendor.inputValues.baseUrl;\r\n const lowerName = model.modelName.toLowerCase();\r\n const imageBase64List = (config.referenceList ?? []).map((r) => r.base64);\r\n\r\n // Gemini / nano 系模型:走 chat/completions 接口,从返回的 markdown 中提取图片\r\n if (lowerName.includes(\"gemini\") || lowerName.includes(\"nano\")) {\r\n const imageConfigGoogle: Record = {\r\n aspect_ratio: config.aspectRatio,\r\n image_size: config.size,\r\n };\r\n const messages: any[] = [];\r\n if (imageBase64List.length) {\r\n messages.push({\r\n role: \"user\",\r\n content: imageBase64List.map((b) => ({ type: \"image_url\", image_url: { url: b } })),\r\n });\r\n }\r\n messages.push({ role: \"user\", content: config.prompt + \"请直接输出图片\" });\r\n const body = {\r\n model: model.modelName,\r\n messages,\r\n extra_body: { google: { image_config: imageConfigGoogle } },\r\n };\r\n logger(`[imageRequest] 使用 gemini 适配器,模型: ${model.modelName}`);\r\n const response = await fetch(`${baseUrl}/chat/completions`, {\r\n method: \"POST\",\r\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify(body),\r\n });\r\n if (!response.ok) {\r\n const errorText = await response.text();\r\n throw new Error(`请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);\r\n }\r\n const data = await response.json();\r\n const imageResult = extractFirstImageFromMd(data.choices[0].message.content);\r\n if (!imageResult) throw new Error(\"未能从响应中提取图片\");\r\n if (imageResult.type === \"base64\") return imageResult.url;\r\n return await urlToBase64(imageResult.url);\r\n }\r\n\r\n // 豆包 / seedream 系模型:走 images/generations 接口\r\n if (lowerName.includes(\"doubao\") || lowerName.includes(\"seedream\")) {\r\n const effectiveSize = config.size === \"1K\" ? \"2K\" : config.size;\r\n const sizeMap: Record> = {\r\n \"16:9\": { \"2K\": \"2848x1600\", \"4K\": \"4096x2304\" },\r\n \"9:16\": { \"2K\": \"1600x2848\", \"4K\": \"2304x4096\" },\r\n };\r\n const resolvedSize = sizeMap[config.aspectRatio]?.[effectiveSize];\r\n const body: Record = {\r\n model: model.modelName,\r\n prompt: config.prompt,\r\n size: resolvedSize,\r\n response_format: \"url\",\r\n sequential_image_generation: \"disabled\",\r\n stream: false,\r\n watermark: false,\r\n ...(imageBase64List.length && { image: imageBase64List }),\r\n };\r\n logger(`[imageRequest] 使用 doubao 适配器,模型: ${model.modelName}`);\r\n const response = await fetch(`${baseUrl}/images/generations`, {\r\n method: \"POST\",\r\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify(body),\r\n });\r\n if (!response.ok) {\r\n const errorText = await response.text();\r\n throw new Error(`请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);\r\n }\r\n const data = await response.json();\r\n const resultUrl = data.data[0].url;\r\n return await urlToBase64(resultUrl);\r\n }\r\n\r\n throw new Error(`不支持的图像模型: ${model.modelName}`);\r\n};\r\n\r\nconst videoRequest = async (config: VideoConfig, model: VideoModel): Promise => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n const baseUrl = vendor.inputValues.baseUrl;\r\n const lowerName = model.modelName.toLowerCase();\r\n\r\n // 当前激活的单一 VideoMode(取第一个非数组模式,或数组模式)\r\n const activeMode = config.mode[0];\r\n const imageRefs = (config.referenceList ?? []).filter((r) => r.type === \"image\").map((r) => r.base64);\r\n const videoRefs = (config.referenceList ?? []).filter((r) => r.type === \"video\").map((r) => r.base64);\r\n const audioRefs = (config.referenceList ?? []).filter((r) => r.type === \"audio\").map((r) => r.base64);\r\n\r\n // 构建模型专属 metadata\r\n let metadata: Record = {};\r\n\r\n if (lowerName.includes(\"wan\")) {\r\n // 万象系列\r\n if (\r\n (activeMode === \"startEndRequired\" || activeMode === \"endFrameOptional\" || activeMode === \"startFrameOptional\") &&\r\n imageRefs.length >= 2\r\n ) {\r\n if (imageRefs[0]) metadata.first_frame_url = imageRefs[0];\r\n if (imageRefs[1]) metadata.last_frame_url = imageRefs[1];\r\n } else if (imageRefs.length) {\r\n metadata.img_url = imageRefs[0];\r\n }\r\n if (typeof config.audio === \"boolean\") metadata.audio = config.audio;\r\n\r\n // 万象需要额外传 size 字段\r\n const wanSizeMap: Record> = {\r\n \"480p\": { \"16:9\": \"832*480\", \"9:16\": \"480*832\" },\r\n \"720p\": { \"16:9\": \"1280*720\", \"9:16\": \"720*1280\" },\r\n \"1080p\": { \"16:9\": \"1920*1080\", \"9:16\": \"1080*1920\" },\r\n };\r\n const wanSize = wanSizeMap[config.resolution]?.[config.aspectRatio];\r\n const body: Record = {\r\n model: model.modelName,\r\n prompt: config.prompt,\r\n duration: config.duration,\r\n size: wanSize,\r\n metadata,\r\n };\r\n logger(`[videoRequest] 提交万象视频任务,模型: ${model.modelName}`);\r\n const response = await fetch(`${baseUrl}/video/generations`, {\r\n method: \"POST\",\r\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify(body),\r\n });\r\n if (!response.ok) {\r\n const errorText = await response.text();\r\n throw new Error(`请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);\r\n }\r\n const data = await response.json();\r\n const taskId = data.id;\r\n logger(`[videoRequest] 万象任务ID: ${taskId}`);\r\n const res = await pollTask(async () => {\r\n const queryResponse = await fetch(`${baseUrl}/video/generations/${taskId}`, {\r\n method: \"GET\",\r\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n });\r\n if (!queryResponse.ok) {\r\n const errorText = await queryResponse.text();\r\n throw new Error(`轮询失败,状态码: ${queryResponse.status}, 错误信息: ${errorText}`);\r\n }\r\n const queryData = await queryResponse.json();\r\n const status = queryData?.status ?? queryData?.data?.status;\r\n switch (status) {\r\n case \"completed\":\r\n case \"SUCCESS\":\r\n case \"success\":\r\n return { completed: true, data: queryData.data.result_url };\r\n case \"FAILURE\":\r\n case \"failed\":\r\n return { completed: true, error: queryData?.data?.fail_reason ?? \"视频生成失败\" };\r\n default:\r\n return { completed: false };\r\n }\r\n });\r\n if (res.error) throw new Error(res.error);\r\n return await urlToBase64(res.data!);\r\n }\r\n\r\n if (lowerName.includes(\"doubao\") || lowerName.includes(\"seedance\")) {\r\n // 豆包/Seedance 系列\r\n metadata = {\r\n ...(typeof config.audio === \"boolean\" && { generate_audio: config.audio }),\r\n ratio: config.aspectRatio,\r\n image_roles: [] as string[],\r\n references: [] as string[],\r\n };\r\n if (Array.isArray(activeMode)) {\r\n // 多参考模式\r\n imageRefs.forEach((b) => metadata.references.push(b));\r\n videoRefs.forEach((b) => metadata.references.push(b));\r\n audioRefs.forEach((b) => metadata.references.push(b));\r\n } else if (activeMode === \"startEndRequired\" || activeMode === \"endFrameOptional\" || activeMode === \"startFrameOptional\") {\r\n imageRefs.forEach((_, i) => (metadata.image_roles as string[]).push(i === 0 ? \"first_frame\" : \"last_frame\"));\r\n } else if (activeMode === \"singleImage\") {\r\n imageRefs.forEach(() => (metadata.image_roles as string[]).push(\"reference_image\"));\r\n }\r\n } else if (lowerName.includes(\"vidu\")) {\r\n // Vidu 系列\r\n metadata = {\r\n aspect_ratio: config.aspectRatio,\r\n audio: config.audio ?? false,\r\n off_peak: false,\r\n };\r\n } else if (lowerName.includes(\"kling\")) {\r\n // 可灵系列\r\n metadata = { aspect_ratio: config.aspectRatio };\r\n if (Array.isArray(activeMode)) {\r\n metadata.reference = [...imageRefs, ...videoRefs, ...audioRefs];\r\n } else if (activeMode === \"endFrameOptional\" && imageRefs.length) {\r\n metadata.image_tail = imageRefs[0];\r\n } else if (activeMode === \"startEndRequired\" && imageRefs.length >= 2) {\r\n metadata.image_list = [\r\n { image_url: imageRefs[0], type: \"first_frame\" },\r\n { image_url: imageRefs[1], type: \"last_frame\" },\r\n ];\r\n } else if (activeMode === \"singleImage\" && imageRefs.length) {\r\n metadata.image = imageRefs[0];\r\n }\r\n }\r\n\r\n // 公共请求体(非万象通用路径)\r\n const publicBody: Record = {\r\n model: model.modelName,\r\n ...(!Array.isArray(activeMode) && imageRefs.length ? { images: imageRefs } : {}),\r\n prompt: config.prompt,\r\n duration: config.duration,\r\n metadata,\r\n };\r\n\r\n logger(`[videoRequest] 提交视频任务,模型: ${model.modelName}`);\r\n const response = await fetch(`${baseUrl}/video/generations`, {\r\n method: \"POST\",\r\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify(publicBody),\r\n });\r\n if (!response.ok) {\r\n const errorText = await response.text();\r\n throw new Error(`请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);\r\n }\r\n const data = await response.json();\r\n const taskId = data.id;\r\n logger(`[videoRequest] 任务ID: ${taskId}`);\r\n\r\n const res = await pollTask(async () => {\r\n const queryResponse = await fetch(`${baseUrl}/video/generations/${taskId}`, {\r\n method: \"GET\",\r\n headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n });\r\n if (!queryResponse.ok) {\r\n const errorText = await queryResponse.text();\r\n throw new Error(`轮询失败,状态码: ${queryResponse.status}, 错误信息: ${errorText}`);\r\n }\r\n const queryData = await queryResponse.json();\r\n const status = queryData?.status ?? queryData?.data?.status;\r\n switch (status) {\r\n case \"completed\":\r\n case \"SUCCESS\":\r\n case \"success\":\r\n return { completed: true, data: queryData.data.result_url };\r\n case \"FAILURE\":\r\n case \"failed\":\r\n return { completed: true, error: queryData?.data?.fail_reason ?? \"视频生成失败\" };\r\n default:\r\n return { completed: false };\r\n }\r\n });\r\n\r\n if (res.error) throw new Error(res.error);\r\n return await urlToBase64(res.data!);\r\n};\r\n\r\nconst ttsRequest = async (config: TTSConfig, model: TTSModel): Promise => {\r\n return \"\";\r\n};\r\n\r\nconst checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => {\r\n return { hasUpdate: false, latestVersion: \"2.0\", notice: \"\" };\r\n};\r\n\r\nconst updateVendor = async (): Promise => {\r\n return \"\";\r\n};\r\n\r\n// ============================================================\r\n// 导出\r\n// ============================================================\r\n\r\nexports.vendor = vendor;\r\nexports.textRequest = textRequest;\r\nexports.imageRequest = imageRequest;\r\nexports.videoRequest = videoRequest;\r\nexports.ttsRequest = ttsRequest;\r\nexports.checkForUpdates = checkForUpdates;\r\nexports.updateVendor = updateVendor;\r\n\r\nexport {};", "vidu.ts": "//如需遥测AI请使用在toonflow安装目录运行npx @ai-sdk/devtools (要求在其他设置中打开遥测功能,且toonflow有权限在安装目录创建.devtools文件夹)\r\n// ==================== 类型定义 ====================\r\n// 文本模型\r\ninterface TextModel {\r\n name: string; // 显示名称\r\n modelName: string;\r\n type: \"text\";\r\n think: boolean; // 前端显示用\r\n}\r\n\r\n// 图像模型\r\ninterface ImageModel {\r\n name: string; // 显示名称\r\n modelName: string;\r\n type: \"image\";\r\n mode: (\"text\" | \"singleImage\" | \"multiReference\")[];\r\n associationSkills?: string; // 关联技能,多个技能用逗号分隔\r\n}\r\n// 视频模型\r\ninterface VideoModel {\r\n name: string; // 显示名称\r\n modelName: string; //全局唯一\r\n type: \"video\";\r\n mode: (\r\n | \"singleImage\" // 单图\r\n | \"startEndRequired\" // 首尾帧(两张都得有)\r\n | \"endFrameOptional\" // 首尾帧(尾帧可选)\r\n | \"startFrameOptional\" // 首尾帧(首帧可选)\r\n | \"text\" // 文本生视频\r\n | (\"videoReference\" | \"imageReference\" | \"audioReference\" | \"textReference\")[] // 混合参考\r\n )[];\r\n associationSkills?: string; // 关联技能,多个技能用逗号分隔\r\n audio: \"optional\" | false | true; // 音频配置\r\n durationResolutionMap: { duration: number[]; resolution: string[] }[];\r\n}\r\n\r\ninterface TTSModel {\r\n name: string; // 显示名称\r\n modelName: string;\r\n type: \"tts\";\r\n voices: {\r\n title: string; //显示名称\r\n voice: string; //说话人\r\n }[];\r\n}\r\n// 供应商配置\r\ninterface VendorConfig {\r\n id: string; //供应商唯一标识,必须全局唯一\r\n author: string;\r\n description?: string; //md5格式\r\n name: string;\r\n icon?: string; //仅支持base64格式\r\n inputs: {\r\n key: string;\r\n label: string;\r\n type: \"text\" | \"password\" | \"url\";\r\n required: boolean;\r\n placeholder?: string;\r\n }[];\r\n inputValues: Record;\r\n models: (TextModel | ImageModel | VideoModel)[];\r\n}\r\n// ==================== 全局工具函数 ====================\r\n//Axios实例\r\n//压缩图片大小(1MB = 1 * 1024 * 1024)\r\ndeclare const zipImage: (completeBase64: string, size: number) => Promise;\r\n//压缩图片分辨率\r\ndeclare const zipImageResolution: (completeBase64: string, width: number, height: number) => Promise;\r\n//多图拼接乘单图 maxSize 最大输出大小,默认为 10mb\r\ndeclare const mergeImages: (completeBase64: string[], maxSize?: string) => Promise;\r\n//Url转Base64\r\ndeclare const urlToBase64: (url: string) => Promise;\r\n//轮询函数\r\ndeclare const pollTask: (\r\n fn: () => Promise<{ completed: boolean; data?: string; error?: string }>,\r\n interval?: number,\r\n timeout?: number,\r\n) => Promise<{ completed: boolean; data?: string; error?: string }>;\r\ndeclare const axios: any;\r\ndeclare const createOpenAI: any;\r\ndeclare const createDeepSeek: any;\r\ndeclare const createZhipu: any;\r\ndeclare const createQwen: any;\r\ndeclare const createAnthropic: any;\r\ndeclare const createOpenAICompatible: any;\r\ndeclare const createXai: any;\r\ndeclare const createMinimax: any;\r\ndeclare const createGoogleGenerativeAI: any;\r\ndeclare const logger: (logstring: string) => void;\r\ndeclare const jsonwebtoken: any;\r\n// ==================== 供应商数据 ====================\r\nconst vendor: VendorConfig = {\r\n id: \"vidu\",\r\n author: \"搬砖的Coder\",\r\n description:\r\n \"Vidu 官方视频生成平台。 [前往平台](https://platform.vidu.cn/login/)\",\r\n name: \"Vidu 开放平台\",\r\n inputs: [\r\n { key: \"apiKey\", label: \"API密钥\", type: \"password\", required: true, placeholder: \"请到Vidu官方申请\" },\r\n { key: \"baseUrl\", label: \"接口路径\", type: \"url\", required: true, placeholder: \"https://api.vidu.cn/ent/v2\" },\r\n ],\r\n inputValues: {\r\n apiKey: \"\",\r\n baseUrl: \"https://api.vidu.cn/ent/v2\",\r\n },\r\n models: [\r\n {\r\n name: \"ViduQ3 turbo\",\r\n type: \"video\",\r\n modelName: \"ViduQ3-turbo\",\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], resolution: [\"540p\", \"720p\", \"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\", \"text\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"ViduQ3 pro\",\r\n type: \"video\",\r\n modelName: \"ViduQ3-pro\",\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], resolution: [\"540p\", \"720p\", \"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\", \"text\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"ViduQ2 pro fast\",\r\n type: \"video\",\r\n modelName: \"ViduQ2-pro-fast\",\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: [\"720p\", \"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"viduQ2 turbo\",\r\n type: \"video\",\r\n modelName: \"ViduQ2-turbo\",\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: [\"540p\", \"720p\", \"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"ViduQ2 pro\",\r\n type: \"video\",\r\n modelName: \"ViduQ2-pro\",\r\n durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: [\"540p\", \"720p\", \"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\"], //参考生视频无有效设置值\r\n audio: true,\r\n },\r\n {\r\n name: \"ViduQ2\",\r\n type: \"video\",\r\n modelName: \"ViduQ2\",\r\n durationResolutionMap: [{ duration: [5], resolution: [\"1080p\"] }],\r\n mode: [\"text\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"ViduQ1\",\r\n type: \"video\",\r\n modelName: \"ViduQ1\",\r\n durationResolutionMap: [{ duration: [5], resolution: [\"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\", \"text\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"ViduQ1 classic\",\r\n type: \"video\",\r\n modelName: \"viduQ1-classic\",\r\n durationResolutionMap: [{ duration: [5], resolution: [\"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"Vidu2.0\",\r\n type: \"video\",\r\n modelName: \"vidu2.0\",\r\n durationResolutionMap: [{ duration: [4, 8], resolution: [\"360p\", \"720p\", \"1080p\"] }],\r\n mode: [\"singleImage\", \"startEndRequired\"],\r\n audio: true,\r\n },\r\n {\r\n name: \"viduq1 for image\",\r\n type: \"image\",\r\n modelName: \"viduq1\",\r\n mode: [\"text\"],\r\n },\r\n {\r\n name: \"viduq2 for image\",\r\n type: \"image\",\r\n modelName: \"viduq2\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n ],\r\n};\r\nexports.vendor = vendor;\r\n\r\n// ==================== 适配器函数 ====================\r\n\r\n// 文本请求函数\r\nconst textRequest: (textModel: TextModel) => { url: string; model: string } = (textModel) => {\r\n throw new Error(\"当前供应商仅支持视频大模型,谢谢!\");\r\n};\r\nexports.textRequest = textRequest;\r\n\r\n//图片请求函数\r\ninterface ImageConfig {\r\n prompt: string; //图片提示词\r\n imageBase64: string[]; //输入的图片提示词\r\n size: \"1K\" | \"2K\" | \"4K\"; // 图片尺寸\r\n aspectRatio: `${number}:${number}`; // 长宽比\r\n}\r\nconst imageRequest = async (imageConfig: ImageConfig, imageModel: ImageModel) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(\"Token \", \"\");\r\n\r\n const size = imageConfig.size === \"1K\" ? \"2K\" : imageConfig.size;\r\n const sizeMap: Record> = {\r\n \"16:9\": {\r\n \"1k\": \"1920x1080\",\r\n \"2K\": \"2848x1600\",\r\n \"4K\": \"4096x2304\",\r\n },\r\n \"9:16\": {\r\n \"1k\": \"1920x1080\",\r\n \"2K\": \"1600x2848\",\r\n \"4K\": \"2304x4096\",\r\n },\r\n };\r\n\r\n const body: Record = {\r\n model: imageModel.modelName,\r\n prompt: imageConfig.prompt,\r\n aspect_ratio: sizeMap[imageConfig.aspectRatio][size],\r\n seed: 0,\r\n resolution: size,\r\n ...(imageConfig.imageBase64 && { image: imageConfig.imageBase64 }),\r\n };\r\n\r\n const createImageUrl = vendor.inputValues.baseUrl + \"/reference2image\";\r\n const response = await fetch(createImageUrl, {\r\n method: \"POST\",\r\n headers: { Authorization: `Token ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify(body),\r\n });\r\n if (!response.ok) {\r\n const errorText = await response.text(); // 获取错误信息\r\n console.error(\"请求失败,状态码:\", response.status, \", 错误信息:\", errorText);\r\n throw new Error(`请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);\r\n }\r\n const data = await response.json();\r\n const res = await checkTaskResult(data.task_id);\r\n if (!res.data) {\r\n throw new Error(\"图片未能生成\");\r\n }\r\n const list = JSON.parse(JSON.stringify(res.data));\r\n return list[0].url;\r\n};\r\nexports.imageRequest = imageRequest;\r\n\r\ninterface VideoConfig {\r\n duration: number;\r\n resolution: string;\r\n aspectRatio: \"16:9\" | \"9:16\";\r\n prompt: string;\r\n imageBase64?: string[];\r\n audio?: boolean;\r\n mode:\r\n | \"singleImage\" // 单图\r\n | \"multiImage\" // 多图模式\r\n | \"gridImage\" // 网格单图(传入一张图片,但该图片是网格图)\r\n | \"startEndRequired\" // 首尾帧(两张都得有)\r\n | \"endFrameOptional\" // 首尾帧(尾帧可选)\r\n | \"startFrameOptional\" // 首尾帧(首帧可选)\r\n | \"text\" // 文本生视频\r\n | (\"video\" | \"image\" | \"audio\" | \"text\")[]; // 混合参考\r\n}\r\n\r\n// 构建 各个平台的metadata参数\r\n\r\nconst buildViduMetadata = (videoConfig: VideoConfig) => ({\r\n aspect_ratio: videoConfig.aspectRatio,\r\n audio: videoConfig.audio ?? false,\r\n off_peak: false,\r\n});\r\n\r\ntype MetadataBuilder = (config: VideoConfig) => Record;\r\nconst METADATA_BUILDERS: Array<[string, MetadataBuilder]> = [[\"vidu\", buildViduMetadata]];\r\nconst buildModelMetadata = (modelName: string, videoConfig: VideoConfig) => {\r\n const lowerName = modelName.toLowerCase();\r\n const match = METADATA_BUILDERS.find(([key]) => lowerName.includes(key));\r\n return match ? match[1](videoConfig) : {};\r\n};\r\n// 检查生成物结果\r\nconst checkTaskResult = async (taskId: string) => {\r\n const queryUrl = vendor.inputValues.baseUrl + \"/tasks/{id}/creations\";\r\n const apiKey = vendor.inputValues.apiKey;\r\n const res = await pollTask(async () => {\r\n const queryResponse = await fetch(queryUrl.replace(\"{id}\", taskId), {\r\n method: \"GET\",\r\n headers: { Authorization: `Token ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n });\r\n if (!queryResponse.ok) {\r\n const errorText = await queryResponse.text(); // 获取错误信息\r\n console.error(\"请求失败,状态码:\", queryResponse.status, \", 错误信息:\", errorText);\r\n throw new Error(`请求失败,状态码: ${queryResponse.status}, 错误信息: ${errorText}`);\r\n }\r\n const queryData = await queryResponse.json();\r\n const status = queryData?.state ?? queryData?.data?.state;\r\n const fail_reason = queryData?.data?.err_code ?? queryData?.data;\r\n switch (status) {\r\n case \"completed\":\r\n case \"SUCCESS\":\r\n case \"success\":\r\n return { completed: true, data: queryData.creations };\r\n case \"FAILURE\":\r\n case \"failed\":\r\n return { completed: false, error: fail_reason || \"生成失败\" };\r\n default:\r\n return { completed: false };\r\n }\r\n });\r\n if (res.error) throw new Error(res.error);\r\n return res;\r\n};\r\n\r\nconst videoRequest = async (videoConfig: VideoConfig, videoModel: VideoModel) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(\"Token \", \"\");\r\n\r\n // 构建每个模型对应的附加参数\r\n const metadata = buildModelMetadata(videoModel.modelName, videoConfig);\r\n\r\n //公共请求参数\r\n const publicBody = {\r\n model: videoModel.modelName,\r\n ...(videoConfig.imageBase64 && videoConfig.imageBase64.length ? { images: videoConfig.imageBase64 } : {}),\r\n prompt: videoConfig.prompt,\r\n size: videoConfig.resolution,\r\n duration: videoConfig.duration,\r\n metadata: metadata,\r\n };\r\n\r\n const requestUrl = vendor.inputValues.baseUrl + \"/start-end2video\";\r\n const response = await fetch(requestUrl, {\r\n method: \"POST\",\r\n headers: { Authorization: `Token ${apiKey}`, \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify(publicBody),\r\n });\r\n if (!response.ok) {\r\n const errorText = await response.text(); // 获取错误信息\r\n console.error(\"请求失败,状态码:\", response.status, \", 错误信息:\", errorText);\r\n throw new Error(`请求失败,状态码: ${response.status}, 错误信息: ${errorText}`);\r\n }\r\n const data = await response.json();\r\n const taskId = data.id;\r\n const result = await checkTaskResult(taskId);\r\n return result.data;\r\n};\r\nexports.videoRequest = videoRequest;\r\n\r\ninterface TTSConfig {\r\n text: string;\r\n voice: string;\r\n speechRate: number;\r\n pitchRate: number;\r\n volume: number;\r\n}\r\nconst ttsRequest = async (ttsConfig: TTSConfig, ttsModel: TTSModel) => {\r\n throw new Error(\"Vidu 暂不支持语音合成(TTS)\");\r\n};\r\n", - "volcengine.ts": "/**\r\n * Toonflow AI供应商模板 - 火山引擎(豆包)\r\n * @version 2.0\r\n */\r\n\r\n// ============================================================\r\n// 类型定义\r\n// ============================================================\r\n\r\ntype VideoMode =\r\n | \"singleImage\"\r\n | \"startEndRequired\"\r\n | \"endFrameOptional\"\r\n | \"startFrameOptional\"\r\n | \"text\"\r\n | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[];\r\n\r\ninterface TextModel {\r\n name: string;\r\n modelName: string;\r\n type: \"text\";\r\n think: boolean;\r\n}\r\n\r\ninterface ImageModel {\r\n name: string;\r\n modelName: string;\r\n type: \"image\";\r\n mode: (\"text\" | \"singleImage\" | \"multiReference\")[];\r\n associationSkills?: string;\r\n}\r\n\r\ninterface VideoModel {\r\n name: string;\r\n modelName: string;\r\n type: \"video\";\r\n mode: VideoMode[];\r\n associationSkills?: string;\r\n audio: \"optional\" | false | true;\r\n durationResolutionMap: { duration: number[]; resolution: string[] }[];\r\n}\r\n\r\ninterface TTSModel {\r\n name: string;\r\n modelName: string;\r\n type: \"tts\";\r\n voices: { title: string; voice: string }[];\r\n}\r\n\r\ninterface VendorConfig {\r\n id: string;\r\n version: string;\r\n name: string;\r\n author: string;\r\n description?: string;\r\n icon?: string;\r\n inputs: { key: string; label: string; type: \"text\" | \"password\" | \"url\"; required: boolean; placeholder?: string }[];\r\n inputValues: Record;\r\n models: (TextModel | ImageModel | VideoModel | TTSModel)[];\r\n}\r\n\r\ntype ReferenceList =\r\n | { type: \"image\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"audio\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"video\"; sourceType: \"base64\"; base64: string };\r\n\r\ninterface ImageConfig {\r\n prompt: string;\r\n referenceList?: Extract[];\r\n size: \"1K\" | \"2K\" | \"4K\";\r\n aspectRatio: `${number}:${number}`;\r\n}\r\n\r\ninterface VideoConfig {\r\n duration: number;\r\n resolution: string;\r\n aspectRatio: \"16:9\" | \"9:16\";\r\n prompt: string;\r\n referenceList?: ReferenceList[];\r\n audio?: boolean;\r\n mode: VideoMode[];\r\n}\r\n\r\ninterface TTSConfig {\r\n text: string;\r\n voice: string;\r\n speechRate: number;\r\n pitchRate: number;\r\n volume: number;\r\n referenceList?: Extract[];\r\n}\r\n\r\ninterface PollResult {\r\n completed: boolean;\r\n data?: string;\r\n error?: string;\r\n}\r\n\r\n// ============================================================\r\n// 全局声明\r\n// ============================================================\r\n\r\ndeclare const axios: any;\r\ndeclare const logger: (msg: string) => void;\r\ndeclare const jsonwebtoken: any;\r\ndeclare const zipImage: (base64: string, size: number) => Promise;\r\ndeclare const zipImageResolution: (base64: string, w: number, h: number) => Promise;\r\ndeclare const mergeImages: (base64Arr: string[], maxSize?: string) => Promise;\r\ndeclare const urlToBase64: (url: string) => Promise;\r\ndeclare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise;\r\ndeclare const createOpenAI: any;\r\ndeclare const createDeepSeek: any;\r\ndeclare const createZhipu: any;\r\ndeclare const createQwen: any;\r\ndeclare const createAnthropic: any;\r\ndeclare const createOpenAICompatible: any;\r\ndeclare const createXai: any;\r\ndeclare const createMinimax: any;\r\ndeclare const createGoogleGenerativeAI: any;\r\ndeclare const exports: {\r\n vendor: VendorConfig;\r\n textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any;\r\n imageRequest: (c: ImageConfig, m: ImageModel) => Promise;\r\n videoRequest: (c: VideoConfig, m: VideoModel) => Promise;\r\n ttsRequest: (c: TTSConfig, m: TTSModel) => Promise;\r\n checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>;\r\n updateVendor?: () => Promise;\r\n};\r\n\r\n// ============================================================\r\n// 供应商配置\r\n// ============================================================\r\n\r\nconst vendor: VendorConfig = {\r\n id: \"volcengine\",\r\n version: \"2.3\",\r\n author: \"leeqi\",\r\n name: \"火山引擎(豆包)\",\r\n description: \"火山引擎豆包大模型,支持文本、图片生成、视频生成等能力。\\n\\n需要在[火山引擎控制台](https://console.volcengine.com/ark)获取API密钥。\",\r\n icon: \"\",\r\n inputs: [\r\n { key: \"apiKey\", label: \"API密钥\", type: \"password\", required: true, placeholder: \"火山引擎API Key\" },\r\n { key: \"baseUrl\", label: \"请求地址\", type: \"url\", required: true, placeholder: \"以v3结束,示例:https://ark.cn-beijing.volces.com/api/v3\" },\r\n ],\r\n inputValues: {\r\n apiKey: \"\",\r\n baseUrl: \"https://ark.cn-beijing.volces.com/api/v3\",\r\n },\r\n models: [\r\n // ===================== 文本模型 - 推荐 =====================\r\n { name: \"Doubao-Seed-2.0-Pro\", modelName: \"doubao-seed-2-0-pro-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-2.0-Lite\", modelName: \"doubao-seed-2-0-lite-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-2.0-Mini\", modelName: \"doubao-seed-2-0-mini-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-2.0-Code-Preview\", modelName: \"doubao-seed-2-0-code-preview-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-Character\", modelName: \"doubao-seed-character-251128\", type: \"text\", think: false },\r\n // ===================== 文本模型 - 往期 =====================\r\n { name: \"Doubao-Seed-1.8\", modelName: \"doubao-seed-1-8-251228\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-Code-Preview\", modelName: \"doubao-seed-code-preview-251028\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Lite\", modelName: \"doubao-seed-1-6-lite-251015\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Flash(0828)\", modelName: \"doubao-seed-1-6-flash-250828\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Vision\", modelName: \"doubao-seed-1-6-vision-250815\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6(1015)\", modelName: \"doubao-seed-1-6-251015\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6(0615)\", modelName: \"doubao-seed-1-6-250615\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Flash(0615)\", modelName: \"doubao-seed-1-6-flash-250615\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-Translation\", modelName: \"doubao-seed-translation-250915\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Pro-32K\", modelName: \"doubao-1-5-pro-32k-250115\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Pro-32K-Character(0715)\", modelName: \"doubao-1-5-pro-32k-character-250715\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Pro-32K-Character(0228)\", modelName: \"doubao-1-5-pro-32k-character-250228\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Lite-32K\", modelName: \"doubao-1-5-lite-32k-250115\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Vision-Pro-32K\", modelName: \"doubao-1-5-vision-pro-32k-250115\", type: \"text\", think: false },\r\n // ===================== 文本模型 - 第三方(火山引擎托管) =====================\r\n { name: \"GLM-4-7\", modelName: \"glm-4-7-251222\", type: \"text\", think: true },\r\n { name: \"DeepSeek-V3-2\", modelName: \"deepseek-v3-2-251201\", type: \"text\", think: true },\r\n { name: \"DeepSeek-V3-1-Terminus\", modelName: \"deepseek-v3-1-terminus\", type: \"text\", think: true },\r\n { name: \"DeepSeek-V3(0324)\", modelName: \"deepseek-v3-250324\", type: \"text\", think: false },\r\n { name: \"DeepSeek-R1(0528)\", modelName: \"deepseek-r1-250528\", type: \"text\", think: true },\r\n { name: \"Qwen3-32B\", modelName: \"qwen3-32b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen3-14B\", modelName: \"qwen3-14b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen3-8B\", modelName: \"qwen3-8b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen3-0.6B\", modelName: \"qwen3-0-6b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen2.5-72B\", modelName: \"qwen2-5-72b-20240919\", type: \"text\", think: false },\r\n { name: \"GLM-4.5-Air\", modelName: \"glm-4-5-air\", type: \"text\", think: false },\r\n // ===================== 图片生成模型 =====================\r\n {\r\n name: \"Seedream-5.0\",\r\n modelName: \"doubao-seedream-5-0-260128\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-5.0-Lite\",\r\n modelName: \"doubao-seedream-5-0-lite-260128\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-4.5\",\r\n modelName: \"doubao-seedream-4-5-251128\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-4.0\",\r\n modelName: \"doubao-seedream-4-0-250828\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-3.0-T2I\",\r\n modelName: \"doubao-seedream-3-0-t2i-250415\",\r\n type: \"image\",\r\n mode: [\"text\"],\r\n },\r\n // ===================== 视频生成模型 =====================\r\n {\r\n name: \"Seedance-2.0(音画同生)\",\r\n modelName: \"doubao-seedance-2-0-260128\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\", [\"imageReference:9\", \"videoReference:3\", \"audioReference:3\"]],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\"] }],\r\n },\r\n {\r\n name: \"Seedance-2.0-Fast(音画同生)\",\r\n modelName: \"doubao-seedance-2-0-fast-260128\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\", [\"imageReference:9\", \"videoReference:3\", \"audioReference:3\"]],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.5-Pro(音画同生)\",\r\n modelName: \"doubao-seedance-1-5-pro-251215\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\"],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Pro\",\r\n modelName: \"doubao-seedance-1-0-pro-250528\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\"],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Pro-Fast\",\r\n modelName: \"doubao-seedance-1-0-pro-fast-251015\",\r\n type: \"video\",\r\n mode: [\"text\", \"singleImage\"],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Lite-T2V\",\r\n modelName: \"doubao-seedance-1-0-lite-t2v-250428\",\r\n type: \"video\",\r\n mode: [\"text\"],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Lite-I2V\",\r\n modelName: \"doubao-seedance-1-0-lite-i2v-250428\",\r\n type: \"video\",\r\n mode: [\"startFrameOptional\", [\"imageReference:4\"]],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n ],\r\n};\r\n\r\n// ============================================================\r\n// 辅助工具\r\n// ============================================================\r\n\r\nconst getHeaders = () => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n return {\r\n \"Content-Type\": \"application/json\",\r\n Authorization: `Bearer ${vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\")}`,\r\n };\r\n};\r\n\r\nconst getBaseUrl = () => vendor.inputValues.baseUrl.replace(/\\/+$/, \"\");\r\n\r\n// ============================================================\r\n// 适配器函数\r\n// ============================================================\r\n\r\nconst textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n\r\n const effortMap: Record = {\r\n 0: \"minimal\",\r\n 1: \"low\",\r\n 2: \"medium\",\r\n 3: \"high\",\r\n };\r\n\r\n return createOpenAICompatible({\r\n name: \"volcengine\",\r\n baseURL: getBaseUrl(),\r\n apiKey,\r\n fetch: async (url: string, options?: RequestInit) => {\r\n const rawBody = JSON.parse((options?.body as string) ?? \"{}\");\r\n const modifiedBody = {\r\n ...rawBody,\r\n thinking: {\r\n type: \"enabled\",\r\n },\r\n reasoning_effort: effortMap[thinkLevel],\r\n };\r\n return await fetch(url, {\r\n ...options,\r\n body: JSON.stringify(modifiedBody),\r\n });\r\n },\r\n }).chatModel(model.modelName);\r\n};\r\n\r\nconst imageRequest = async (config: ImageConfig, model: ImageModel): Promise => {\r\n const baseUrl = getBaseUrl();\r\n const headers = getHeaders();\r\n\r\n const body: any = {\r\n model: model.modelName,\r\n prompt: config.prompt || \"\",\r\n response_format: \"url\",\r\n watermark: false,\r\n };\r\n\r\n const isOldModel = model.modelName.includes(\"seedream-3-0\");\r\n const is5Lite = model.modelName.includes(\"seedream-5-0-lite\");\r\n\r\n // sequential_image_generation 仅 seedream 5.0-lite/4.5/4.0 支持\r\n if (!isOldModel) {\r\n body.sequential_image_generation = \"disabled\";\r\n }\r\n\r\n // 参考图片:单图为 string,多图为 array(seedream-3.0-t2i 不支持 image 参数)\r\n if (!isOldModel && config.referenceList && config.referenceList.length > 0) {\r\n const images = config.referenceList.map((ref) => ref.base64);\r\n body.image = images.length === 1 ? images[0] : images;\r\n }\r\n\r\n // 尺寸处理:优先使用推荐像素值,未匹配则直接传分辨率字符串让模型自行决定\r\n const [w, h] = config.aspectRatio.split(\":\").map(Number);\r\n const sizeTable: Record> = {\r\n \"1K\": {\r\n \"1:1\": \"1024x1024\",\r\n \"4:3\": \"1152x864\",\r\n \"3:4\": \"864x1152\",\r\n \"16:9\": \"1280x720\",\r\n \"9:16\": \"720x1280\",\r\n \"3:2\": \"1248x832\",\r\n \"2:3\": \"832x1248\",\r\n \"21:9\": \"1512x648\",\r\n },\r\n \"2K\": {\r\n \"1:1\": \"2048x2048\",\r\n \"4:3\": \"2304x1728\",\r\n \"3:4\": \"1728x2304\",\r\n \"16:9\": \"2848x1600\",\r\n \"9:16\": \"1600x2848\",\r\n \"3:2\": \"2496x1664\",\r\n \"2:3\": \"1664x2496\",\r\n \"21:9\": \"3136x1344\",\r\n },\r\n \"4K\": {\r\n \"1:1\": \"4096x4096\",\r\n \"4:3\": \"4704x3520\",\r\n \"3:4\": \"3520x4704\",\r\n \"16:9\": \"5504x3040\",\r\n \"9:16\": \"3040x5504\",\r\n \"3:2\": \"4992x3328\",\r\n \"2:3\": \"3328x4992\",\r\n \"21:9\": \"6240x2656\",\r\n },\r\n };\r\n\r\n const sizeKey = config.size || \"2K\";\r\n const ratioKey = config.aspectRatio;\r\n const table = sizeTable[sizeKey];\r\n\r\n if (table && table[ratioKey]) {\r\n // 推荐像素值匹配到了,但需要检查是否满足模型最低像素要求\r\n const [pw, ph] = table[ratioKey].split(\"x\").map(Number);\r\n const totalPixels = pw * ph;\r\n if (isOldModel) {\r\n // seedream-3.0-t2i: 像素范围 [512x512, 2048x2048]\r\n body.size = table[ratioKey];\r\n } else if (totalPixels < 3686400) {\r\n // 1K 像素值不满足新模型最低要求,直接传 \"2K\" 让模型自行决定\r\n body.size = \"2K\";\r\n } else if (is5Lite && totalPixels > 10404496) {\r\n // seedream-5.0-lite 最高 10404496,4K 超限,回退传 \"2K\"\r\n body.size = \"2K\";\r\n } else {\r\n body.size = table[ratioKey];\r\n }\r\n } else if (isOldModel) {\r\n // seedream-3.0-t2i: 像素范围 [512x512, 2048x2048],直接按比例计算\r\n const base = sizeKey === \"1K\" ? 1024 : 2048;\r\n const calcW = Math.min(2048, Math.round(base * Math.sqrt(w / h)));\r\n const calcH = Math.min(2048, Math.round(base * Math.sqrt(h / w)));\r\n body.size = `${Math.max(512, calcW)}x${Math.max(512, calcH)}`;\r\n } else {\r\n // 新模型未匹配推荐值时,直接传分辨率字符串(方式1),由模型根据 prompt 自行决定尺寸\r\n // seedream 5.0-lite 支持 \"2K\"/\"3K\",seedream 4.5 支持 \"2K\"/\"4K\",seedream 4.0 支持 \"1K\"/\"2K\"/\"4K\"\r\n if (is5Lite) {\r\n body.size = sizeKey === \"4K\" ? \"3K\" : sizeKey === \"1K\" ? \"2K\" : sizeKey;\r\n } else {\r\n body.size = sizeKey === \"1K\" ? \"2K\" : sizeKey;\r\n }\r\n }\r\n\r\n logger(`[图片生成] 请求模型: ${model.modelName}, 尺寸: ${body.size}`);\r\n\r\n const response = await axios.post(`${baseUrl}/images/generations`, body, { headers });\r\n const data = response.data;\r\n\r\n if (data?.error) {\r\n throw new Error(`图片生成失败:${data.error.message || data.error.code}`);\r\n }\r\n\r\n // 从 data 数组中提取第一张成功的图片\r\n if (data?.data && data.data.length > 0) {\r\n for (const item of data.data) {\r\n if (item.url) {\r\n return await urlToBase64(item.url);\r\n }\r\n if (item.b64_json) {\r\n return item.b64_json;\r\n }\r\n if (item.error) {\r\n throw new Error(`图片生成失败:${item.error.message || item.error.code}`);\r\n }\r\n }\r\n }\r\n\r\n throw new Error(\"图片生成失败:未返回有效结果\");\r\n};\r\n\r\nconst videoRequest = async (config: VideoConfig, model: VideoModel): Promise => {\r\n const baseUrl = getBaseUrl();\r\n const headers = getHeaders();\r\n\r\n const content: any[] = [];\r\n\r\n if (config.prompt) {\r\n content.push({ type: \"text\", text: config.prompt });\r\n }\r\n\r\n if (typeof config.mode === \"string\") {\r\n switch (config.mode) {\r\n case \"singleImage\": {\r\n const firstImage = config.referenceList?.find((r) => r.type === \"image\");\r\n if (firstImage) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: firstImage.base64 },\r\n role: \"first_frame\",\r\n });\r\n }\r\n break;\r\n }\r\n case \"startFrameOptional\": {\r\n const images = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n if (images.length > 0) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[0].base64 },\r\n role: \"first_frame\",\r\n });\r\n if (images.length > 1) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[1].base64 },\r\n role: \"last_frame\",\r\n });\r\n }\r\n }\r\n break;\r\n }\r\n case \"startEndRequired\": {\r\n const images = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n if (images.length >= 2) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[0].base64 },\r\n role: \"first_frame\",\r\n });\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[1].base64 },\r\n role: \"last_frame\",\r\n });\r\n }\r\n break;\r\n }\r\n case \"endFrameOptional\": {\r\n const images = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n if (images.length > 0) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[0].base64 },\r\n role: \"first_frame\",\r\n });\r\n if (images.length > 1) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[1].base64 },\r\n role: \"last_frame\",\r\n });\r\n }\r\n }\r\n break;\r\n }\r\n case \"text\":\r\n default:\r\n break;\r\n }\r\n } else if (Array.isArray(config.mode)) {\r\n // 多模态参考模式:按类型分别提取并添加\r\n const imageRefs = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n const videoRefs = config.referenceList?.filter((r) => r.type === \"video\") ?? [];\r\n const audioRefs = config.referenceList?.filter((r) => r.type === \"audio\") ?? [];\r\n\r\n for (const refDef of config.mode) {\r\n if (typeof refDef === \"string\") {\r\n if (refDef.startsWith(\"imageReference:\")) {\r\n const maxCount = parseInt(refDef.split(\":\")[1], 10);\r\n for (const ref of imageRefs.slice(0, maxCount)) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: ref.base64 },\r\n role: \"reference_image\",\r\n });\r\n }\r\n } else if (refDef.startsWith(\"videoReference:\")) {\r\n const maxCount = parseInt(refDef.split(\":\")[1], 10);\r\n for (const ref of videoRefs.slice(0, maxCount)) {\r\n content.push({\r\n type: \"video_url\",\r\n video_url: { url: ref.base64 },\r\n role: \"reference_video\",\r\n });\r\n }\r\n } else if (refDef.startsWith(\"audioReference:\")) {\r\n const maxCount = parseInt(refDef.split(\":\")[1], 10);\r\n for (const ref of audioRefs.slice(0, maxCount)) {\r\n content.push({\r\n type: \"audio_url\",\r\n audio_url: { url: ref.base64 },\r\n role: \"reference_audio\",\r\n });\r\n }\r\n }\r\n }\r\n }\r\n }\r\n\r\n const body: any = {\r\n model: model.modelName,\r\n content,\r\n ratio: config.aspectRatio,\r\n duration: config.duration,\r\n resolution: config.resolution || \"720p\",\r\n watermark: false,\r\n };\r\n\r\n if (model.audio === \"optional\") {\r\n body.generate_audio = config.audio !== false;\r\n } else if (model.audio === true) {\r\n body.generate_audio = true;\r\n } else {\r\n body.generate_audio = false;\r\n }\r\n\r\n logger(`[视频生成] 提交任务, 模型: ${model.modelName}, 时长: ${config.duration}s, 分辨率: ${config.resolution}`);\r\n\r\n const createResponse = await axios.post(`${baseUrl}/contents/generations/tasks`, body, { headers });\r\n const taskId = createResponse.data?.id;\r\n\r\n if (!taskId) {\r\n throw new Error(\"视频生成任务创建失败:未返回任务ID\");\r\n }\r\n\r\n logger(`[视频生成] 任务已创建, ID: ${taskId}`);\r\n\r\n const result = await pollTask(\r\n async (): Promise => {\r\n const queryResponse = await axios.get(`${baseUrl}/contents/generations/tasks/${taskId}`, { headers });\r\n const task = queryResponse.data;\r\n\r\n logger(`[视频生成] 任务状态: ${task.status}`);\r\n\r\n switch (task.status) {\r\n case \"succeeded\":\r\n if (task.content?.video_url) {\r\n return { completed: true, data: task.content.video_url };\r\n }\r\n return { completed: true, error: \"任务成功但未返回视频URL\" };\r\n case \"failed\":\r\n return { completed: true, error: task.error?.message || \"视频生成失败\" };\r\n case \"expired\":\r\n return { completed: true, error: \"视频生成任务超时\" };\r\n case \"cancelled\":\r\n return { completed: true, error: \"视频生成任务已取消\" };\r\n default:\r\n return { completed: false };\r\n }\r\n },\r\n 10000,\r\n 600000,\r\n );\r\n\r\n if (result.error) {\r\n throw new Error(result.error);\r\n }\r\n\r\n return await urlToBase64(result.data!);\r\n};\r\n\r\nconst ttsRequest = async (config: TTSConfig, model: TTSModel): Promise => {\r\n return \"\";\r\n};\r\n\r\nconst checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => {\r\n return { hasUpdate: false, latestVersion: \"2.0\", notice: \"\" };\r\n};\r\n\r\nconst updateVendor = async (): Promise => {\r\n return \"\";\r\n};\r\n\r\n// ============================================================\r\n// 导出\r\n// ============================================================\r\n\r\nexports.vendor = vendor;\r\nexports.textRequest = textRequest;\r\nexports.imageRequest = imageRequest;\r\nexports.videoRequest = videoRequest;\r\nexports.ttsRequest = ttsRequest;\r\nexports.checkForUpdates = checkForUpdates;\r\nexports.updateVendor = updateVendor;\r\n\r\nexport {};\r\n" -} \ No newline at end of file + "volcengine.ts": "/**\r\n * Toonflow AI供应商模板 - 火山引擎(豆包)\r\n * @version 2.0\r\n */\r\n\r\n// ============================================================\r\n// 类型定义\r\n// ============================================================\r\n\r\ntype VideoMode =\r\n | \"singleImage\"\r\n | \"startEndRequired\"\r\n | \"endFrameOptional\"\r\n | \"startFrameOptional\"\r\n | \"text\"\r\n | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[];\r\n\r\ninterface TextModel {\r\n name: string;\r\n modelName: string;\r\n type: \"text\";\r\n think: boolean;\r\n}\r\n\r\ninterface ImageModel {\r\n name: string;\r\n modelName: string;\r\n type: \"image\";\r\n mode: (\"text\" | \"singleImage\" | \"multiReference\")[];\r\n associationSkills?: string;\r\n}\r\n\r\ninterface VideoModel {\r\n name: string;\r\n modelName: string;\r\n type: \"video\";\r\n mode: VideoMode[];\r\n associationSkills?: string;\r\n audio: \"optional\" | false | true;\r\n durationResolutionMap: { duration: number[]; resolution: string[] }[];\r\n}\r\n\r\ninterface TTSModel {\r\n name: string;\r\n modelName: string;\r\n type: \"tts\";\r\n voices: { title: string; voice: string }[];\r\n}\r\n\r\ninterface VendorConfig {\r\n id: string;\r\n version: string;\r\n name: string;\r\n author: string;\r\n description?: string;\r\n icon?: string;\r\n inputs: { key: string; label: string; type: \"text\" | \"password\" | \"url\"; required: boolean; placeholder?: string }[];\r\n inputValues: Record;\r\n models: (TextModel | ImageModel | VideoModel | TTSModel)[];\r\n}\r\n\r\ntype ReferenceList =\r\n | { type: \"image\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"audio\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"video\"; sourceType: \"base64\"; base64: string };\r\n\r\ninterface ImageConfig {\r\n prompt: string;\r\n referenceList?: Extract[];\r\n size: \"1K\" | \"2K\" | \"4K\";\r\n aspectRatio: `${number}:${number}`;\r\n}\r\n\r\ninterface VideoConfig {\r\n duration: number;\r\n resolution: string;\r\n aspectRatio: \"16:9\" | \"9:16\";\r\n prompt: string;\r\n referenceList?: ReferenceList[];\r\n audio?: boolean;\r\n mode: VideoMode[];\r\n}\r\n\r\ninterface TTSConfig {\r\n text: string;\r\n voice: string;\r\n speechRate: number;\r\n pitchRate: number;\r\n volume: number;\r\n referenceList?: Extract[];\r\n}\r\n\r\ninterface PollResult {\r\n completed: boolean;\r\n data?: string;\r\n error?: string;\r\n}\r\n\r\n// ============================================================\r\n// 全局声明\r\n// ============================================================\r\n\r\ndeclare const axios: any;\r\ndeclare const logger: (msg: string) => void;\r\ndeclare const jsonwebtoken: any;\r\ndeclare const zipImage: (base64: string, size: number) => Promise;\r\ndeclare const zipImageResolution: (base64: string, w: number, h: number) => Promise;\r\ndeclare const mergeImages: (base64Arr: string[], maxSize?: string) => Promise;\r\ndeclare const urlToBase64: (url: string) => Promise;\r\ndeclare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise;\r\ndeclare const createOpenAI: any;\r\ndeclare const createDeepSeek: any;\r\ndeclare const createZhipu: any;\r\ndeclare const createQwen: any;\r\ndeclare const createAnthropic: any;\r\ndeclare const createOpenAICompatible: any;\r\ndeclare const createXai: any;\r\ndeclare const createMinimax: any;\r\ndeclare const createGoogleGenerativeAI: any;\r\ndeclare const exports: {\r\n vendor: VendorConfig;\r\n textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any;\r\n imageRequest: (c: ImageConfig, m: ImageModel) => Promise;\r\n videoRequest: (c: VideoConfig, m: VideoModel) => Promise;\r\n ttsRequest: (c: TTSConfig, m: TTSModel) => Promise;\r\n checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>;\r\n updateVendor?: () => Promise;\r\n};\r\n\r\n// ============================================================\r\n// 供应商配置\r\n// ============================================================\r\n\r\nconst vendor: VendorConfig = {\r\n id: \"volcengine\",\r\n version: \"2.3\",\r\n author: \"leeqi\",\r\n name: \"火山引擎(豆包)\",\r\n description: \"火山引擎豆包大模型,支持文本、图片生成、视频生成等能力。\\n\\n需要在[火山引擎控制台](https://console.volcengine.com/ark)获取API密钥。\",\r\n icon: \"\",\r\n inputs: [\r\n { key: \"apiKey\", label: \"API密钥\", type: \"password\", required: true, placeholder: \"火山引擎API Key\" },\r\n { key: \"baseUrl\", label: \"请求地址\", type: \"url\", required: true, placeholder: \"以v3结束,示例:https://ark.cn-beijing.volces.com/api/v3\" },\r\n ],\r\n inputValues: {\r\n apiKey: \"\",\r\n baseUrl: \"https://ark.cn-beijing.volces.com/api/v3\",\r\n },\r\n models: [\r\n // ===================== 文本模型 - 推荐 =====================\r\n { name: \"Doubao-Seed-2.0-Pro\", modelName: \"doubao-seed-2-0-pro-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-2.0-Lite\", modelName: \"doubao-seed-2-0-lite-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-2.0-Mini\", modelName: \"doubao-seed-2-0-mini-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-2.0-Code-Preview\", modelName: \"doubao-seed-2-0-code-preview-260215\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-Character\", modelName: \"doubao-seed-character-251128\", type: \"text\", think: false },\r\n // ===================== 文本模型 - 往期 =====================\r\n { name: \"Doubao-Seed-1.8\", modelName: \"doubao-seed-1-8-251228\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-Code-Preview\", modelName: \"doubao-seed-code-preview-251028\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Lite\", modelName: \"doubao-seed-1-6-lite-251015\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Flash(0828)\", modelName: \"doubao-seed-1-6-flash-250828\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Vision\", modelName: \"doubao-seed-1-6-vision-250815\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6(1015)\", modelName: \"doubao-seed-1-6-251015\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6(0615)\", modelName: \"doubao-seed-1-6-250615\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-1.6-Flash(0615)\", modelName: \"doubao-seed-1-6-flash-250615\", type: \"text\", think: true },\r\n { name: \"Doubao-Seed-Translation\", modelName: \"doubao-seed-translation-250915\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Pro-32K\", modelName: \"doubao-1-5-pro-32k-250115\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Pro-32K-Character(0715)\", modelName: \"doubao-1-5-pro-32k-character-250715\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Pro-32K-Character(0228)\", modelName: \"doubao-1-5-pro-32k-character-250228\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Lite-32K\", modelName: \"doubao-1-5-lite-32k-250115\", type: \"text\", think: false },\r\n { name: \"Doubao-1.5-Vision-Pro-32K\", modelName: \"doubao-1-5-vision-pro-32k-250115\", type: \"text\", think: false },\r\n // ===================== 文本模型 - 第三方(火山引擎托管) =====================\r\n { name: \"GLM-4-7\", modelName: \"glm-4-7-251222\", type: \"text\", think: true },\r\n { name: \"DeepSeek-V3-2\", modelName: \"deepseek-v3-2-251201\", type: \"text\", think: true },\r\n { name: \"DeepSeek-V3-1-Terminus\", modelName: \"deepseek-v3-1-terminus\", type: \"text\", think: true },\r\n { name: \"DeepSeek-V3(0324)\", modelName: \"deepseek-v3-250324\", type: \"text\", think: false },\r\n { name: \"DeepSeek-R1(0528)\", modelName: \"deepseek-r1-250528\", type: \"text\", think: true },\r\n { name: \"Qwen3-32B\", modelName: \"qwen3-32b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen3-14B\", modelName: \"qwen3-14b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen3-8B\", modelName: \"qwen3-8b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen3-0.6B\", modelName: \"qwen3-0-6b-20250429\", type: \"text\", think: false },\r\n { name: \"Qwen2.5-72B\", modelName: \"qwen2-5-72b-20240919\", type: \"text\", think: false },\r\n { name: \"GLM-4.5-Air\", modelName: \"glm-4-5-air\", type: \"text\", think: false },\r\n // ===================== 图片生成模型 =====================\r\n {\r\n name: \"Seedream-5.0\",\r\n modelName: \"doubao-seedream-5-0-260128\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-5.0-Lite\",\r\n modelName: \"doubao-seedream-5-0-lite-260128\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-4.5\",\r\n modelName: \"doubao-seedream-4-5-251128\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-4.0\",\r\n modelName: \"doubao-seedream-4-0-250828\",\r\n type: \"image\",\r\n mode: [\"text\", \"singleImage\", \"multiReference\"],\r\n },\r\n {\r\n name: \"Seedream-3.0-T2I\",\r\n modelName: \"doubao-seedream-3-0-t2i-250415\",\r\n type: \"image\",\r\n mode: [\"text\"],\r\n },\r\n // ===================== 视频生成模型 =====================\r\n {\r\n name: \"Seedance-2.0(音画同生)\",\r\n modelName: \"doubao-seedance-2-0-260128\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\", [\"imageReference:9\", \"videoReference:3\", \"audioReference:3\"]],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\"] }],\r\n },\r\n {\r\n name: \"Seedance-2.0-Fast(音画同生)\",\r\n modelName: \"doubao-seedance-2-0-fast-260128\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\", [\"imageReference:9\", \"videoReference:3\", \"audioReference:3\"]],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.5-Pro(音画同生)\",\r\n modelName: \"doubao-seedance-1-5-pro-251215\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\"],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Pro\",\r\n modelName: \"doubao-seedance-1-0-pro-250528\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\"],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Pro-Fast\",\r\n modelName: \"doubao-seedance-1-0-pro-fast-251015\",\r\n type: \"video\",\r\n mode: [\"text\", \"singleImage\"],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Lite-T2V\",\r\n modelName: \"doubao-seedance-1-0-lite-t2v-250428\",\r\n type: \"video\",\r\n mode: [\"text\"],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance-1.0-Lite-I2V\",\r\n modelName: \"doubao-seedance-1-0-lite-i2v-250428\",\r\n type: \"video\",\r\n mode: [\"startFrameOptional\", [\"imageReference:4\"]],\r\n audio: false,\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n ],\r\n};\r\n\r\n// ============================================================\r\n// 辅助工具\r\n// ============================================================\r\n\r\nconst getHeaders = () => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n return {\r\n \"Content-Type\": \"application/json\",\r\n Authorization: `Bearer ${vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\")}`,\r\n };\r\n};\r\n\r\nconst getBaseUrl = () => vendor.inputValues.baseUrl.replace(/\\/+$/, \"\");\r\n\r\n// ============================================================\r\n// 适配器函数\r\n// ============================================================\r\n\r\nconst textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n\r\n const effortMap: Record = {\r\n 0: \"minimal\",\r\n 1: \"low\",\r\n 2: \"medium\",\r\n 3: \"high\",\r\n };\r\n\r\n return createOpenAICompatible({\r\n name: \"volcengine\",\r\n baseURL: getBaseUrl(),\r\n apiKey,\r\n fetch: async (url: string, options?: RequestInit) => {\r\n const rawBody = JSON.parse((options?.body as string) ?? \"{}\");\r\n const modifiedBody = {\r\n ...rawBody,\r\n thinking: {\r\n type: \"enabled\",\r\n },\r\n reasoning_effort: effortMap[thinkLevel],\r\n };\r\n return await fetch(url, {\r\n ...options,\r\n body: JSON.stringify(modifiedBody),\r\n });\r\n },\r\n }).chatModel(model.modelName);\r\n};\r\n\r\nconst imageRequest = async (config: ImageConfig, model: ImageModel): Promise => {\r\n const baseUrl = getBaseUrl();\r\n const headers = getHeaders();\r\n\r\n const body: any = {\r\n model: model.modelName,\r\n prompt: config.prompt || \"\",\r\n response_format: \"url\",\r\n watermark: false,\r\n };\r\n\r\n const isOldModel = model.modelName.includes(\"seedream-3-0\");\r\n const is5Lite = model.modelName.includes(\"seedream-5-0-lite\");\r\n\r\n // sequential_image_generation 仅 seedream 5.0-lite/4.5/4.0 支持\r\n if (!isOldModel) {\r\n body.sequential_image_generation = \"disabled\";\r\n }\r\n\r\n // 参考图片:单图为 string,多图为 array(seedream-3.0-t2i 不支持 image 参数)\r\n if (!isOldModel && config.referenceList && config.referenceList.length > 0) {\r\n const images = config.referenceList.map((ref) => ref.base64);\r\n body.image = images.length === 1 ? images[0] : images;\r\n }\r\n\r\n // 尺寸处理:优先使用推荐像素值,未匹配则直接传分辨率字符串让模型自行决定\r\n const [w, h] = config.aspectRatio.split(\":\").map(Number);\r\n const sizeTable: Record> = {\r\n \"1K\": {\r\n \"1:1\": \"1024x1024\",\r\n \"4:3\": \"1152x864\",\r\n \"3:4\": \"864x1152\",\r\n \"16:9\": \"1280x720\",\r\n \"9:16\": \"720x1280\",\r\n \"3:2\": \"1248x832\",\r\n \"2:3\": \"832x1248\",\r\n \"21:9\": \"1512x648\",\r\n },\r\n \"2K\": {\r\n \"1:1\": \"2048x2048\",\r\n \"4:3\": \"2304x1728\",\r\n \"3:4\": \"1728x2304\",\r\n \"16:9\": \"2848x1600\",\r\n \"9:16\": \"1600x2848\",\r\n \"3:2\": \"2496x1664\",\r\n \"2:3\": \"1664x2496\",\r\n \"21:9\": \"3136x1344\",\r\n },\r\n \"4K\": {\r\n \"1:1\": \"4096x4096\",\r\n \"4:3\": \"4704x3520\",\r\n \"3:4\": \"3520x4704\",\r\n \"16:9\": \"5504x3040\",\r\n \"9:16\": \"3040x5504\",\r\n \"3:2\": \"4992x3328\",\r\n \"2:3\": \"3328x4992\",\r\n \"21:9\": \"6240x2656\",\r\n },\r\n };\r\n\r\n const sizeKey = config.size || \"2K\";\r\n const ratioKey = config.aspectRatio;\r\n const table = sizeTable[sizeKey];\r\n\r\n if (table && table[ratioKey]) {\r\n // 推荐像素值匹配到了,但需要检查是否满足模型最低像素要求\r\n const [pw, ph] = table[ratioKey].split(\"x\").map(Number);\r\n const totalPixels = pw * ph;\r\n if (isOldModel) {\r\n // seedream-3.0-t2i: 像素范围 [512x512, 2048x2048]\r\n body.size = table[ratioKey];\r\n } else if (totalPixels < 3686400) {\r\n // 1K 像素值不满足新模型最低要求,直接传 \"2K\" 让模型自行决定\r\n body.size = \"2K\";\r\n } else if (is5Lite && totalPixels > 10404496) {\r\n // seedream-5.0-lite 最高 10404496,4K 超限,回退传 \"2K\"\r\n body.size = \"2K\";\r\n } else {\r\n body.size = table[ratioKey];\r\n }\r\n } else if (isOldModel) {\r\n // seedream-3.0-t2i: 像素范围 [512x512, 2048x2048],直接按比例计算\r\n const base = sizeKey === \"1K\" ? 1024 : 2048;\r\n const calcW = Math.min(2048, Math.round(base * Math.sqrt(w / h)));\r\n const calcH = Math.min(2048, Math.round(base * Math.sqrt(h / w)));\r\n body.size = `${Math.max(512, calcW)}x${Math.max(512, calcH)}`;\r\n } else {\r\n // 新模型未匹配推荐值时,直接传分辨率字符串(方式1),由模型根据 prompt 自行决定尺寸\r\n // seedream 5.0-lite 支持 \"2K\"/\"3K\",seedream 4.5 支持 \"2K\"/\"4K\",seedream 4.0 支持 \"1K\"/\"2K\"/\"4K\"\r\n if (is5Lite) {\r\n body.size = sizeKey === \"4K\" ? \"3K\" : sizeKey === \"1K\" ? \"2K\" : sizeKey;\r\n } else {\r\n body.size = sizeKey === \"1K\" ? \"2K\" : sizeKey;\r\n }\r\n }\r\n\r\n logger(`[图片生成] 请求模型: ${model.modelName}, 尺寸: ${body.size}`);\r\n\r\n const response = await axios.post(`${baseUrl}/images/generations`, body, { headers });\r\n const data = response.data;\r\n\r\n if (data?.error) {\r\n throw new Error(`图片生成失败:${data.error.message || data.error.code}`);\r\n }\r\n\r\n // 从 data 数组中提取第一张成功的图片\r\n if (data?.data && data.data.length > 0) {\r\n for (const item of data.data) {\r\n if (item.url) {\r\n return await urlToBase64(item.url);\r\n }\r\n if (item.b64_json) {\r\n return item.b64_json;\r\n }\r\n if (item.error) {\r\n throw new Error(`图片生成失败:${item.error.message || item.error.code}`);\r\n }\r\n }\r\n }\r\n\r\n throw new Error(\"图片生成失败:未返回有效结果\");\r\n};\r\n\r\nconst videoRequest = async (config: VideoConfig, model: VideoModel): Promise => {\r\n const baseUrl = getBaseUrl();\r\n const headers = getHeaders();\r\n\r\n const content: any[] = [];\r\n\r\n if (config.prompt) {\r\n content.push({ type: \"text\", text: config.prompt });\r\n }\r\n\r\n if (typeof config.mode === \"string\") {\r\n switch (config.mode) {\r\n case \"singleImage\": {\r\n const firstImage = config.referenceList?.find((r) => r.type === \"image\");\r\n if (firstImage) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: firstImage.base64 },\r\n role: \"first_frame\",\r\n });\r\n }\r\n break;\r\n }\r\n case \"startFrameOptional\": {\r\n const images = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n if (images.length > 0) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[0].base64 },\r\n role: \"first_frame\",\r\n });\r\n if (images.length > 1) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[1].base64 },\r\n role: \"last_frame\",\r\n });\r\n }\r\n }\r\n break;\r\n }\r\n case \"startEndRequired\": {\r\n const images = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n if (images.length >= 2) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[0].base64 },\r\n role: \"first_frame\",\r\n });\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[1].base64 },\r\n role: \"last_frame\",\r\n });\r\n }\r\n break;\r\n }\r\n case \"endFrameOptional\": {\r\n const images = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n if (images.length > 0) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[0].base64 },\r\n role: \"first_frame\",\r\n });\r\n if (images.length > 1) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: images[1].base64 },\r\n role: \"last_frame\",\r\n });\r\n }\r\n }\r\n break;\r\n }\r\n case \"text\":\r\n default:\r\n break;\r\n }\r\n } else if (Array.isArray(config.mode)) {\r\n // 多模态参考模式:按类型分别提取并添加\r\n const imageRefs = config.referenceList?.filter((r) => r.type === \"image\") ?? [];\r\n const videoRefs = config.referenceList?.filter((r) => r.type === \"video\") ?? [];\r\n const audioRefs = config.referenceList?.filter((r) => r.type === \"audio\") ?? [];\r\n\r\n for (const refDef of config.mode) {\r\n if (typeof refDef === \"string\") {\r\n if (refDef.startsWith(\"imageReference:\")) {\r\n const maxCount = parseInt(refDef.split(\":\")[1], 10);\r\n for (const ref of imageRefs.slice(0, maxCount)) {\r\n content.push({\r\n type: \"image_url\",\r\n image_url: { url: ref.base64 },\r\n role: \"reference_image\",\r\n });\r\n }\r\n } else if (refDef.startsWith(\"videoReference:\")) {\r\n const maxCount = parseInt(refDef.split(\":\")[1], 10);\r\n for (const ref of videoRefs.slice(0, maxCount)) {\r\n content.push({\r\n type: \"video_url\",\r\n video_url: { url: ref.base64 },\r\n role: \"reference_video\",\r\n });\r\n }\r\n } else if (refDef.startsWith(\"audioReference:\")) {\r\n const maxCount = parseInt(refDef.split(\":\")[1], 10);\r\n for (const ref of audioRefs.slice(0, maxCount)) {\r\n content.push({\r\n type: \"audio_url\",\r\n audio_url: { url: ref.base64 },\r\n role: \"reference_audio\",\r\n });\r\n }\r\n }\r\n }\r\n }\r\n }\r\n\r\n const body: any = {\r\n model: model.modelName,\r\n content,\r\n ratio: config.aspectRatio,\r\n duration: config.duration,\r\n resolution: config.resolution || \"720p\",\r\n watermark: false,\r\n };\r\n\r\n if (model.audio === \"optional\") {\r\n body.generate_audio = config.audio !== false;\r\n } else if (model.audio === true) {\r\n body.generate_audio = true;\r\n } else {\r\n body.generate_audio = false;\r\n }\r\n\r\n logger(`[视频生成] 提交任务, 模型: ${model.modelName}, 时长: ${config.duration}s, 分辨率: ${config.resolution}`);\r\n\r\n const createResponse = await axios.post(`${baseUrl}/contents/generations/tasks`, body, { headers });\r\n const taskId = createResponse.data?.id;\r\n\r\n if (!taskId) {\r\n throw new Error(\"视频生成任务创建失败:未返回任务ID\");\r\n }\r\n\r\n logger(`[视频生成] 任务已创建, ID: ${taskId}`);\r\n\r\n const result = await pollTask(\r\n async (): Promise => {\r\n const queryResponse = await axios.get(`${baseUrl}/contents/generations/tasks/${taskId}`, { headers });\r\n const task = queryResponse.data;\r\n\r\n logger(`[视频生成] 任务状态: ${task.status}`);\r\n\r\n switch (task.status) {\r\n case \"succeeded\":\r\n if (task.content?.video_url) {\r\n return { completed: true, data: task.content.video_url };\r\n }\r\n return { completed: true, error: \"任务成功但未返回视频URL\" };\r\n case \"failed\":\r\n return { completed: true, error: task.error?.message || \"视频生成失败\" };\r\n case \"expired\":\r\n return { completed: true, error: \"视频生成任务超时\" };\r\n case \"cancelled\":\r\n return { completed: true, error: \"视频生成任务已取消\" };\r\n default:\r\n return { completed: false };\r\n }\r\n },\r\n 10000,\r\n 600000,\r\n );\r\n\r\n if (result.error) {\r\n throw new Error(result.error);\r\n }\r\n\r\n return await urlToBase64(result.data!);\r\n};\r\n\r\nconst ttsRequest = async (config: TTSConfig, model: TTSModel): Promise => {\r\n return \"\";\r\n};\r\n\r\nconst checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => {\r\n return { hasUpdate: false, latestVersion: \"2.0\", notice: \"\" };\r\n};\r\n\r\nconst updateVendor = async (): Promise => {\r\n return \"\";\r\n};\r\n\r\n// ============================================================\r\n// 导出\r\n// ============================================================\r\n\r\nexports.vendor = vendor;\r\nexports.textRequest = textRequest;\r\nexports.imageRequest = imageRequest;\r\nexports.videoRequest = videoRequest;\r\nexports.ttsRequest = ttsRequest;\r\nexports.checkForUpdates = checkForUpdates;\r\nexports.updateVendor = updateVendor;\r\n\r\nexport {};\r\n", + "deepseek.ts": "/**\r\n * Toonflow AI供应商模板 - DeepSeek\r\n * @version 2.0\r\n */\r\n\r\n// ============================================================\r\n// 类型定义\r\n// ============================================================\r\n\r\ntype VideoMode =\r\n | \"singleImage\"\r\n | \"startEndRequired\"\r\n | \"endFrameOptional\"\r\n | \"startFrameOptional\"\r\n | \"text\"\r\n | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[];\r\n\r\ninterface TextModel {\r\n name: string;\r\n modelName: string;\r\n type: \"text\";\r\n think: boolean;\r\n}\r\n\r\ninterface ImageModel {\r\n name: string;\r\n modelName: string;\r\n type: \"image\";\r\n mode: (\"text\" | \"singleImage\" | \"multiReference\")[];\r\n associationSkills?: string;\r\n}\r\n\r\ninterface VideoModel {\r\n name: string;\r\n modelName: string;\r\n type: \"video\";\r\n mode: VideoMode[];\r\n associationSkills?: string;\r\n audio: \"optional\" | false | true;\r\n durationResolutionMap: { duration: number[]; resolution: string[] }[];\r\n}\r\n\r\ninterface TTSModel {\r\n name: string;\r\n modelName: string;\r\n type: \"tts\";\r\n voices: { title: string; voice: string }[];\r\n}\r\n\r\ninterface VendorConfig {\r\n id: string;\r\n version: string;\r\n name: string;\r\n author: string;\r\n description?: string;\r\n icon?: string;\r\n inputs: { key: string; label: string; type: \"text\" | \"password\" | \"url\"; required: boolean; placeholder?: string }[];\r\n inputValues: Record;\r\n models: (TextModel | ImageModel | VideoModel | TTSModel)[];\r\n}\r\n\r\ninterface ImageConfig {\r\n prompt: string;\r\n imageBase64: string[];\r\n size: \"1K\" | \"2K\" | \"4K\";\r\n aspectRatio: `${number}:${number}`;\r\n}\r\n\r\ninterface VideoConfig {\r\n duration: number;\r\n resolution: string;\r\n aspectRatio: \"16:9\" | \"9:16\";\r\n prompt: string;\r\n imageBase64?: string[];\r\n audio?: boolean;\r\n mode: VideoMode[];\r\n}\r\n\r\ninterface TTSConfig {\r\n text: string;\r\n voice: string;\r\n speechRate: number;\r\n pitchRate: number;\r\n volume: number;\r\n}\r\n\r\ninterface PollResult {\r\n completed: boolean;\r\n data?: string;\r\n error?: string;\r\n}\r\n\r\n// ============================================================\r\n// 全局声明\r\n// ============================================================\r\n\r\ndeclare const axios: any;\r\ndeclare const logger: (msg: string) => void;\r\ndeclare const jsonwebtoken: any;\r\ndeclare const zipImage: (base64: string, size: number) => Promise;\r\ndeclare const zipImageResolution: (base64: string, w: number, h: number) => Promise;\r\ndeclare const mergeImages: (base64Arr: string[], maxSize?: string) => Promise;\r\ndeclare const urlToBase64: (url: string) => Promise;\r\ndeclare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise;\r\ndeclare const createOpenAI: any;\r\ndeclare const createDeepSeek: any;\r\ndeclare const createZhipu: any;\r\ndeclare const createQwen: any;\r\ndeclare const createAnthropic: any;\r\ndeclare const createOpenAICompatible: any;\r\ndeclare const createXai: any;\r\ndeclare const createMinimax: any;\r\ndeclare const createGoogleGenerativeAI: any;\r\ndeclare const exports: {\r\n vendor: VendorConfig;\r\n textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any;\r\n imageRequest: (c: ImageConfig, m: ImageModel) => Promise;\r\n videoRequest: (c: VideoConfig, m: VideoModel) => Promise;\r\n ttsRequest: (c: TTSConfig, m: TTSModel) => Promise;\r\n checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>;\r\n updateVendor?: () => Promise;\r\n};\r\n\r\n// ============================================================\r\n// 供应商配置\r\n// ============================================================\r\n\r\nconst vendor: VendorConfig = {\r\n id: \"deepseek\",\r\n version: \"2.0\",\r\n author: \"Toonflow\",\r\n name: \"DeepSeek\",\r\n description:\r\n \"DeepSeek 官方接口适配,支持 V4 系列模型与思考模式(思维链输出)。\\n\\n[前往平台](https://platform.deepseek.com/)\",\r\n icon: \"\",\r\n inputs: [\r\n { key: \"apiKey\", label: \"API密钥\", type: \"password\", required: true },\r\n { key: \"baseUrl\", label: \"请求地址\", type: \"url\", required: true, placeholder: \"示例:https://api.deepseek.com\" },\r\n ],\r\n inputValues: {\r\n apiKey: \"\",\r\n baseUrl: \"https://api.deepseek.com/v1\",\r\n },\r\n models: [\r\n { name: \"DeepSeek V4 Pro\", modelName: \"deepseek-v4-pro\", type: \"text\", think: true },\r\n { name: \"DeepSeek V4 Flash\", modelName: \"deepseek-v4-flash\", type: \"text\", think: true },\r\n ],\r\n};\r\n\r\n// ============================================================\r\n// 适配器函数\r\n// ============================================================\r\n\r\nconst textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n\r\n // DeepSeek 思考强度仅支持 high / max(low、medium 会被映射为 high,xhigh 会被映射为 max)\r\n // thinkLevel: 0/1/2 → high, 3 → max\r\n const effortMap: Record<0 | 1 | 2 | 3, \"high\" | \"max\"> = {\r\n 0: \"high\",\r\n 1: \"high\",\r\n 2: \"high\",\r\n 3: \"max\",\r\n };\r\n\r\n const enableThinking = model.think && think;\r\n const extraBody: Record = {\r\n thinking: { type: enableThinking ? \"enabled\" : \"disabled\" },\r\n };\r\n if (enableThinking) {\r\n extraBody.reasoning_effort = effortMap[thinkLevel];\r\n }\r\n\r\n return createDeepSeek({\r\n baseURL: vendor.inputValues.baseUrl,\r\n apiKey,\r\n extraBody,\r\n }).chat(model.modelName);\r\n};\r\n\r\nconst imageRequest = async (config: ImageConfig, model: ImageModel): Promise => {\r\n return \"\";\r\n};\r\n\r\nconst videoRequest = async (config: VideoConfig, model: VideoModel): Promise => {\r\n return \"\";\r\n};\r\n\r\nconst ttsRequest = async (config: TTSConfig, model: TTSModel): Promise => {\r\n return \"\";\r\n};\r\n\r\nconst checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => {\r\n return { hasUpdate: false, latestVersion: \"2.0\", notice: \"\" };\r\n};\r\n\r\nconst updateVendor = async (): Promise => {\r\n return \"\";\r\n};\r\n\r\n// ============================================================\r\n// 导出\r\n// ============================================================\r\n\r\nexports.vendor = vendor;\r\nexports.textRequest = textRequest;\r\nexports.imageRequest = imageRequest;\r\nexports.videoRequest = videoRequest;\r\nexports.ttsRequest = ttsRequest;\r\nexports.checkForUpdates = checkForUpdates;\r\nexports.updateVendor = updateVendor;\r\n\r\nexport { };", + "atlascloud.ts": "/**\r\n * Toonflow AI供应商模板 - AtlasCloud MASS\r\n * @version 0.8\r\n *\r\n * 说明:\r\n * 1) 文本接口使用 OpenAI 兼容基地址:https://api.atlascloud.ai/v1\r\n * 2) 图片/视频使用 Atlas Cloud 媒体接口:https://api.atlascloud.ai/api/v1\r\n * 3) 图片/视频为异步任务:提交后轮询 /api/v1/model/prediction/{id}\r\n */\r\n\r\n// ============================================================\r\n// 类型定义\r\n// ============================================================\r\n\r\ntype VideoMode =\r\n | \"singleImage\"\r\n | \"startEndRequired\"\r\n | \"endFrameOptional\"\r\n | \"startFrameOptional\"\r\n | \"text\"\r\n | (`videoReference:${number}` | `imageReference:${number}` | `audioReference:${number}`)[];\r\n\r\ninterface TextModel {\r\n name: string;\r\n modelName: string;\r\n type: \"text\";\r\n think: boolean;\r\n}\r\n\r\ninterface ImageModel {\r\n name: string;\r\n modelName: string;\r\n type: \"image\";\r\n mode: (\"text\" | \"singleImage\" | \"multiReference\")[];\r\n associationSkills?: string;\r\n}\r\n\r\ninterface VideoModel {\r\n name: string;\r\n modelName: string;\r\n type: \"video\";\r\n mode: VideoMode[];\r\n associationSkills?: string;\r\n audio: \"optional\" | false | true;\r\n durationResolutionMap: { duration: number[]; resolution: string[] }[];\r\n}\r\n\r\ninterface TTSModel {\r\n name: string;\r\n modelName: string;\r\n type: \"tts\";\r\n voices: { title: string; voice: string }[];\r\n}\r\n\r\ninterface VendorConfig {\r\n id: string;\r\n version: string;\r\n name: string;\r\n author: string;\r\n description?: string;\r\n icon?: string;\r\n inputs: { key: string; label: string; type: \"text\" | \"password\" | \"url\"; required: boolean; placeholder?: string; disabled?: boolean }[];\r\n inputValues: Record;\r\n models: (TextModel | ImageModel | VideoModel | TTSModel)[];\r\n}\r\n\r\ntype ReferenceList =\r\n | { type: \"image\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"audio\"; sourceType: \"base64\"; base64: string }\r\n | { type: \"video\"; sourceType: \"base64\"; base64: string };\r\n\r\ninterface ImageConfig {\r\n prompt: string;\r\n referenceList?: Extract[];\r\n size: \"1K\" | \"2K\" | \"4K\";\r\n aspectRatio: `${number}:${number}`;\r\n}\r\n\r\ninterface VideoConfig {\r\n duration: number;\r\n resolution: string;\r\n aspectRatio: \"16:9\" | \"9:16\";\r\n prompt: string;\r\n referenceList?: ReferenceList[];\r\n audio?: boolean;\r\n mode: VideoMode[];\r\n}\r\n\r\ninterface TTSConfig {\r\n text: string;\r\n voice: string;\r\n speechRate: number;\r\n pitchRate: number;\r\n volume: number;\r\n referenceList?: Extract[];\r\n}\r\n\r\ninterface PollResult {\r\n completed: boolean;\r\n data?: string;\r\n error?: string;\r\n}\r\n\r\ntype AtlasVideoModelKind =\r\n | \"seedanceTextToVideo\"\r\n | \"seedanceReferenceToVideo\"\r\n | \"seedanceImageToVideo\"\r\n | \"wanReferenceToVideo\"\r\n | \"generic\";\r\n\r\n// ============================================================\r\n// 全局声明\r\n// ============================================================\r\n\r\ndeclare const axios: any;\r\ndeclare const logger: (msg: string) => void;\r\ndeclare const urlToBase64: (url: string) => Promise;\r\ndeclare const pollTask: (fn: () => Promise, interval?: number, timeout?: number) => Promise;\r\ndeclare const createOpenAICompatible: any;\r\ndeclare const exports: {\r\n vendor: VendorConfig;\r\n textRequest: (m: TextModel, t: boolean, tl: 0 | 1 | 2 | 3) => any;\r\n imageRequest: (c: ImageConfig, m: ImageModel) => Promise;\r\n videoRequest: (c: VideoConfig, m: VideoModel) => Promise;\r\n ttsRequest: (c: TTSConfig, m: TTSModel) => Promise;\r\n checkForUpdates?: () => Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }>;\r\n updateVendor?: () => Promise;\r\n};\r\n\r\n// ============================================================\r\n// 供应商配置\r\n// ============================================================\r\n\r\nconst vendor: VendorConfig = {\r\n id: \"atlascloud\",\r\n version: \"1.0\",\r\n author: \"AtlasCloud\",\r\n name: \"AtlasCloud MASS\",\r\n description: \"AtlasCloud 全模态平台接入 Toonflow。默认按官方文档填写文本、图片、视频与任务轮询路径。\",\r\n inputs: [\r\n { key: \"apiKey\", label: \"API密钥\", type: \"password\", required: true, placeholder: \"AtlasCloud API Key\" },\r\n { key: \"chatBaseUrl\", label: \"文本基地址\", type: \"url\", required: true, placeholder: \"https://api.atlascloud.ai/v1\", disabled: true },\r\n { key: \"mediaBaseUrl\", label: \"媒体基地址\", type: \"url\", required: true, placeholder: \"https://api.atlascloud.ai/api/v1\", disabled: true },\r\n ],\r\n inputValues: {\r\n apiKey: \"\",\r\n chatBaseUrl: \"https://api.atlascloud.ai/v1\",\r\n mediaBaseUrl: \"https://api.atlascloud.ai/api/v1\",\r\n },\r\n models: [\r\n { name: \"DeepSeek V4 Pro\", modelName: \"deepseek-ai/deepseek-v4-pro\", type: \"text\", think: false },\r\n { name: \"DeepSeek V4 Flash\", modelName: \"deepseek-ai/deepseek-v4-flash\", type: \"text\", think: false },\r\n { name: \"Kimi K2.6\", modelName: \"moonshotai/kimi-k2.6\", type: \"text\", think: false },\r\n { name: \"GLM 5.1\", modelName: \"zai-org/glm-5.1\", type: \"text\", think: false },\r\n { name: \"MiniMax M2.7\", modelName: \"minimaxai/minimax-m2.7\", type: \"text\", think: false },\r\n { name: \"GPT Image 2\", modelName: \"openai/gpt-image-2/text-to-image\", type: \"image\", mode: [\"text\", \"singleImage\"] },\r\n { name: \"Nano Banana Pro\", modelName: \"google/nano-banana-pro/text-to-image\", type: \"image\", mode: [\"text\", \"singleImage\", \"multiReference\"] },\r\n { name: \"Nano Banana 2\", modelName: \"google/nano-banana-2/text-to-image\", type: \"image\", mode: [\"text\", \"singleImage\", \"multiReference\"] },\r\n { name: \"Seedream v5\", modelName: \"bytedance/seedream-v5.0-lite/sequential\", type: \"image\", mode: [\"text\"] },\r\n { name: \"Qwen Image 2 Pro\", modelName: \"qwen/qwen-image-2.0-pro/text-to-image\", type: \"image\", mode: [\"text\"] },\r\n {\r\n name: \"Seedance 2.0 Audio-Visual\",\r\n modelName: \"bytedance/seedance-2.0/text-to-video\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\", [\"imageReference:9\", \"videoReference:3\", \"audioReference:3\"]],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\"] }],\r\n },\r\n {\r\n name: \"Seedance 2.0 Reference-to-Video\",\r\n modelName: \"bytedance/seedance-2.0/reference-to-video\",\r\n type: \"video\",\r\n mode: [\"singleImage\"],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance 2.0 Multi-Image-to-Video\",\r\n modelName: \"bytedance/seedance-2.0/image-to-video\",\r\n type: \"video\",\r\n mode: [\"startFrameOptional\", [\"imageReference:4\"]],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\", \"1080p\"] }],\r\n },\r\n {\r\n name: \"Seedance 2.0 Fast Audio-Visual\",\r\n modelName: \"bytedance/seedance-2.0-fast/text-to-video\",\r\n type: \"video\",\r\n mode: [\"text\", \"startFrameOptional\", [\"imageReference:9\", \"videoReference:3\", \"audioReference:3\"]],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\"] }],\r\n },\r\n {\r\n name: \"Seedance 2.0 Fast Reference-to-Video\",\r\n modelName: \"bytedance/seedance-2.0-fast/reference-to-video\",\r\n type: \"video\",\r\n mode: [\"singleImage\"],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], resolution: [\"480p\", \"720p\"] }],\r\n },\r\n {\r\n name: \"Wan-2.7 Reference-to-video\",\r\n modelName: \"alibaba/wan-2.7/reference-to-video\",\r\n type: \"video\",\r\n mode: [\"singleImage\"],\r\n audio: \"optional\",\r\n durationResolutionMap: [{ duration: [2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: [\"720p\", \"1080p\"] }],\r\n },\r\n ],\r\n};\r\n\r\n// ============================================================\r\n// 辅助工具\r\n// ============================================================\r\n\r\nconst getChatBaseUrl = () => vendor.inputValues.chatBaseUrl.replace(/\\/+$/, \"\");\r\n\r\nconst getMediaBaseUrl = () => vendor.inputValues.mediaBaseUrl.replace(/\\/+$/, \"\");\r\n\r\nconst joinUrl = (base: string, path: string) => `${base}${path.startsWith(\"/\") ? \"\" : \"/\"}${path}`;\r\n\r\nconst getHeaders = () => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少 API Key\");\r\n return {\r\n \"Content-Type\": \"application/json\",\r\n Authorization: `Bearer ${vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\")}`,\r\n };\r\n};\r\n\r\nconst readByPath = (obj: any, path: string): any => {\r\n if (!obj || !path) return undefined;\r\n const normalizedPath = path.replace(/\\[(\\d+)\\]/g, \".$1\");\r\n return normalizedPath.split(\".\").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);\r\n};\r\n\r\nconst pickFirstPath = (obj: any, paths: string[]): any => {\r\n for (const path of paths) {\r\n const value = readByPath(obj, path);\r\n if (value !== undefined && value !== null && value !== \"\") return value;\r\n }\r\n return undefined;\r\n};\r\n\r\nconst extractTaskId = (data: any): string | undefined => {\r\n return pickFirstPath(data, [\"id\", \"taskId\", \"task_id\", \"data.id\", \"data.taskId\", \"data.task_id\"]);\r\n};\r\n\r\nconst extractUrl = (data: any): string | undefined => {\r\n return (\r\n (Array.isArray(readByPath(data, \"data.outputs\")) ? readByPath(data, \"data.outputs\")[0] : undefined) ||\r\n (Array.isArray(readByPath(data, \"outputs\")) ? readByPath(data, \"outputs\")[0] : undefined) ||\r\n readByPath(data, \"url\") ||\r\n readByPath(data, \"video_url\") ||\r\n readByPath(data, \"image_url\") ||\r\n readByPath(data, \"data.url\") ||\r\n readByPath(data, \"data.video_url\") ||\r\n readByPath(data, \"data.image_url\") ||\r\n readByPath(data, \"data.output.url\") ||\r\n readByPath(data, \"data.output.video_url\") ||\r\n readByPath(data, \"output.url\")\r\n );\r\n};\r\n\r\nconst extractB64 = (data: any): string | undefined => {\r\n return pickFirstPath(data, [\"b64_json\", \"data.b64_json\", \"data.0.b64_json\", \"data[0].b64_json\"]);\r\n};\r\n\r\nconst extractStatus = (data: any): string => {\r\n const statusRaw = pickFirstPath(data, [\"status\", \"data.status\", \"data.state\", \"state\"]);\r\n return String(statusRaw || \"\").toLowerCase();\r\n};\r\n\r\nconst extractError = (data: any): string | undefined => {\r\n return pickFirstPath(data, [\"error.message\", \"message\", \"msg\", \"data.error.message\", \"data.message\"]);\r\n};\r\n\r\nconst isDnsOrNetworkError = (err: any): boolean => {\r\n const msg = String(err?.message || err || \"\");\r\n return /ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|timeout/i.test(msg);\r\n};\r\n\r\nconst withNetworkRetry = async (fn: () => Promise, maxRetry = 3, waitMs = 1500): Promise => {\r\n let lastErr: any;\r\n for (let i = 0; i < maxRetry; i += 1) {\r\n try {\r\n return await fn();\r\n } catch (err) {\r\n lastErr = err;\r\n if (!isDnsOrNetworkError(err) || i === maxRetry - 1) throw err;\r\n await new Promise((resolve) => setTimeout(resolve, waitMs * (i + 1)));\r\n }\r\n }\r\n throw lastErr;\r\n};\r\n\r\nconst resolveAtlasImageModelName = (modelName: string, hasImageRefs: boolean): string => {\r\n if (!hasImageRefs) return modelName;\r\n\r\n switch (modelName) {\r\n case \"google/nano-banana-pro/text-to-image\":\r\n return \"google/nano-banana-pro/edit\";\r\n case \"google/nano-banana-2/text-to-image\":\r\n return \"google/nano-banana-2/edit\";\r\n default:\r\n return modelName;\r\n }\r\n};\r\n\r\nconst resolveAtlasVideoModelKind = (modelName: string): AtlasVideoModelKind => {\r\n if (modelName === \"alibaba/wan-2.7/reference-to-video\") return \"wanReferenceToVideo\";\r\n if (/^bytedance\\/seedance-2\\.0(?:-fast)?\\/reference-to-video$/.test(modelName)) return \"seedanceReferenceToVideo\";\r\n if (/^bytedance\\/seedance-2\\.0(?:-fast)?\\/image-to-video$/.test(modelName)) return \"seedanceImageToVideo\";\r\n if (/^bytedance\\/seedance-2\\.0(?:-fast)?\\/text-to-video$/.test(modelName)) return \"seedanceTextToVideo\";\r\n return \"generic\";\r\n};\r\n\r\nconst clampNumber = (value: unknown, min: number, max: number, fallback: number): number => {\r\n const num = Number(value);\r\n if (!Number.isFinite(num)) return fallback;\r\n return Math.max(min, Math.min(max, num));\r\n};\r\n\r\nconst normalizeResolution = (value: unknown, allowed: string[], fallback: string): string => {\r\n const lower = String(value || \"\").toLowerCase();\r\n const matched = allowed.find((item) => item.toLowerCase() === lower);\r\n if (matched) return matched;\r\n if (/1080/.test(lower)) return allowed.find((item) => /1080/i.test(item)) || fallback;\r\n if (/720/.test(lower)) return allowed.find((item) => /720/i.test(item)) || fallback;\r\n if (/480/.test(lower)) return allowed.find((item) => /480/i.test(item)) || fallback;\r\n return fallback;\r\n};\r\n\r\nconst getReferenceLimit = (\r\n modes: VideoMode[],\r\n prefix: \"imageReference\" | \"videoReference\" | \"audioReference\",\r\n): number | undefined => {\r\n for (const mode of modes) {\r\n if (!Array.isArray(mode)) continue;\r\n for (const entry of mode) {\r\n if (!entry.startsWith(`${prefix}:`)) continue;\r\n const limit = Number(entry.split(\":\")[1]);\r\n if (Number.isFinite(limit) && limit > 0) return limit;\r\n }\r\n }\r\n return undefined;\r\n};\r\n\r\nconst limitReferences = (refs: string[], maxCount?: number): string[] => {\r\n if (!maxCount || maxCount < 1) return refs;\r\n return refs.slice(0, maxCount);\r\n};\r\n\r\nconst summarizeRefCount = (usedCount: number, rawCount: number): string => {\r\n return usedCount === rawCount ? String(usedCount) : `${usedCount}/${rawCount}`;\r\n};\r\n\r\nconst buildAtlasVideoPayload = (config: VideoConfig, model: VideoModel) => {\r\n const rawImageRefs = (config.referenceList || []).filter((r) => r.type === \"image\").map((r) => r.base64).filter(Boolean);\r\n const rawVideoRefs = (config.referenceList || []).filter((r) => r.type === \"video\").map((r) => r.base64).filter(Boolean);\r\n const rawAudioRefs = (config.referenceList || []).filter((r) => r.type === \"audio\").map((r) => r.base64).filter(Boolean);\r\n\r\n const imageRefs = limitReferences(rawImageRefs, getReferenceLimit(model.mode, \"imageReference\"));\r\n const videoRefs = limitReferences(rawVideoRefs, getReferenceLimit(model.mode, \"videoReference\"));\r\n const audioRefs = limitReferences(rawAudioRefs, getReferenceLimit(model.mode, \"audioReference\"));\r\n const kind = resolveAtlasVideoModelKind(model.modelName);\r\n const ratio = config.aspectRatio || \"16:9\";\r\n const shouldGenerateAudio = model.audio === true || (model.audio === \"optional\" && config.audio !== false);\r\n const body: any = {\r\n model: model.modelName,\r\n prompt: config.prompt || \"\",\r\n };\r\n\r\n if (kind === \"wanReferenceToVideo\") {\r\n if (imageRefs.length < 1) {\r\n throw new Error(`${model.name} 需要至少 1 张参考图`);\r\n }\r\n body.images = [imageRefs[0]];\r\n body.ratio = ratio;\r\n body.duration = clampNumber(config.duration, 2, 10, 5);\r\n body.resolution = normalizeResolution(config.resolution, [\"720P\", \"1080P\"], \"720P\");\r\n body.prompt_extend = false;\r\n body.seed = -1;\r\n } else if (kind === \"seedanceReferenceToVideo\") {\r\n if (imageRefs.length < 1) {\r\n throw new Error(`${model.name} 需要至少 1 张参考图`);\r\n }\r\n if (shouldGenerateAudio) body.generate_audio = true;\r\n body.images = [imageRefs[0]];\r\n body.ratio = ratio;\r\n body.duration = clampNumber(config.duration, 4, 15, 5);\r\n body.resolution = normalizeResolution(config.resolution, [\"480p\", \"720p\", \"1080p\"], \"720p\");\r\n body.watermark = false;\r\n } else if (kind === \"seedanceImageToVideo\") {\r\n if (imageRefs.length < 1) {\r\n throw new Error(`${model.name} 需要至少 1 张参考图`);\r\n }\r\n if (shouldGenerateAudio) body.generate_audio = true;\r\n body.images = imageRefs;\r\n body.ratio = ratio;\r\n body.duration = clampNumber(config.duration, 4, 15, 5);\r\n body.resolution = normalizeResolution(config.resolution, [\"480p\", \"720p\", \"1080p\"], \"720p\");\r\n body.watermark = false;\r\n } else {\r\n if (shouldGenerateAudio) body.generate_audio = true;\r\n if (imageRefs.length > 0) body.reference_images = imageRefs;\r\n if (videoRefs.length > 0) body.reference_videos = videoRefs;\r\n if (audioRefs.length > 0) body.reference_audios = audioRefs;\r\n body.ratio = ratio;\r\n body.duration = clampNumber(config.duration, 4, 15, 5);\r\n body.resolution = normalizeResolution(config.resolution, [\"480p\", \"720p\"], \"720p\");\r\n body.watermark = false;\r\n }\r\n\r\n return {\r\n body,\r\n summary: `kind=${kind} imageRefs=${summarizeRefCount(imageRefs.length, rawImageRefs.length)} videoRefs=${summarizeRefCount(videoRefs.length, rawVideoRefs.length)} audioRefs=${summarizeRefCount(audioRefs.length, rawAudioRefs.length)} resolution=${body.resolution} duration=${body.duration}${shouldGenerateAudio ? \" audio=on\" : \" audio=off\"}`,\r\n };\r\n};\r\n\r\n// ============================================================\r\n// 适配器函数\r\n// ============================================================\r\n\r\nconst textRequest = (model: TextModel, think: boolean, thinkLevel: 0 | 1 | 2 | 3) => {\r\n if (!vendor.inputValues.apiKey) throw new Error(\"缺少 API Key\");\r\n const apiKey = vendor.inputValues.apiKey.replace(/^Bearer\\s+/i, \"\");\r\n const effortMap: Record = { 0: \"minimal\", 1: \"low\", 2: \"medium\", 3: \"high\" };\r\n\r\n return createOpenAICompatible({\r\n name: \"atlascloud\",\r\n baseURL: getChatBaseUrl(),\r\n apiKey,\r\n fetch: async (url: string, options?: RequestInit) => {\r\n const rawBody = JSON.parse((options?.body as string) ?? \"{}\");\r\n const body = think\r\n ? {\r\n ...rawBody,\r\n thinking: { type: \"enabled\" },\r\n reasoning_effort: effortMap[thinkLevel],\r\n }\r\n : rawBody;\r\n return await fetch(url, { ...options, body: JSON.stringify(body) });\r\n },\r\n }).chatModel(model.modelName);\r\n};\r\n\r\nconst imageRequest = async (config: ImageConfig, model: ImageModel): Promise => {\r\n const headers = getHeaders();\r\n const url = joinUrl(getMediaBaseUrl(), \"/model/generateImage\");\r\n const sizeToResolution: Record = {\r\n \"1K\": \"1k\",\r\n \"2K\": \"2k\",\r\n \"4K\": \"4k\",\r\n };\r\n const imageRefs = (config.referenceList || []).map((ref) => ref.base64).filter(Boolean);\r\n const resolvedModelName = resolveAtlasImageModelName(model.modelName, imageRefs.length > 0);\r\n const isNanoModel = /^google\\/nano-banana-(pro|2)\\//.test(resolvedModelName);\r\n const supportsImageConditioning = /^(openai\\/gpt-image-2\\/text-to-image|google\\/nano-banana-(pro|2)\\/edit)$/.test(resolvedModelName);\r\n\r\n const body: any = {\r\n model: resolvedModelName,\r\n prompt: config.prompt || \"\",\r\n };\r\n if (supportsImageConditioning && imageRefs.length > 0) {\r\n body.images = imageRefs;\r\n }\r\n if (isNanoModel) {\r\n body.aspect_ratio = config.aspectRatio || \"16:9\";\r\n body.resolution = sizeToResolution[config.size || \"1K\"] || \"1k\";\r\n }\r\n\r\n logger(`[AtlasCloud 图片] 提交任务: ${model.modelName} -> ${resolvedModelName}, refs=${imageRefs.length}`);\r\n const submitResp = await axios.post(url, body, { headers });\r\n const submitData = submitResp.data;\r\n\r\n // 同步返回(直接拿图)\r\n const syncB64 = extractB64(submitData);\r\n if (syncB64) return syncB64;\r\n const syncUrl = extractUrl(submitData);\r\n if (syncUrl) return await urlToBase64(syncUrl);\r\n\r\n // 异步返回(拿 taskId 再轮询)\r\n const taskId = extractTaskId(submitData);\r\n if (!taskId) {\r\n throw new Error(`图片任务提交失败:未获取到任务ID。原始响应:${JSON.stringify(submitData).slice(0, 500)}`);\r\n }\r\n\r\n const pollResult = await pollTask(\r\n async (): Promise => {\r\n const resultUrl = joinUrl(getMediaBaseUrl(), `/model/prediction/${taskId}`);\r\n const resultResp = await axios.get(resultUrl, { headers });\r\n const data = resultResp.data;\r\n const status = extractStatus(data);\r\n\r\n if ([\"succeeded\", \"success\", \"done\", \"completed\"].includes(status)) {\r\n const b64 = extractB64(data);\r\n if (b64) return { completed: true, data: b64 };\r\n const mediaUrl = extractUrl(data);\r\n if (mediaUrl) return { completed: true, data: mediaUrl };\r\n return { completed: true, error: \"任务成功但未返回结果地址\" };\r\n }\r\n if ([\"failed\", \"error\", \"cancelled\", \"canceled\", \"expired\"].includes(status)) {\r\n return { completed: true, error: extractError(data) || \"图片生成失败\" };\r\n }\r\n return { completed: false };\r\n },\r\n 3000,\r\n 600000,\r\n );\r\n\r\n if (pollResult.error) throw new Error(pollResult.error);\r\n if (!pollResult.data) throw new Error(\"图片生成失败:轮询未返回数据\");\r\n if (pollResult.data.startsWith(\"data:\")) return pollResult.data;\r\n if (pollResult.data.startsWith(\"http\")) return await urlToBase64(pollResult.data);\r\n return pollResult.data;\r\n};\r\n\r\nconst videoRequest = async (config: VideoConfig, model: VideoModel): Promise => {\r\n const headers = getHeaders();\r\n const url = joinUrl(getMediaBaseUrl(), \"/model/generateVideo\");\r\n const { body, summary } = buildAtlasVideoPayload(config, model);\r\n\r\n logger(`[AtlasCloud 视频] 提交任务: ${model.modelName}, ${summary}`);\r\n const submitResp: any = await withNetworkRetry(() => axios.post(url, body, { headers }), 3, 1500);\r\n const submitData = submitResp.data;\r\n\r\n const taskId = extractTaskId(submitData);\r\n if (!taskId) {\r\n const syncUrl = extractUrl(submitData);\r\n if (syncUrl) return await urlToBase64(syncUrl);\r\n throw new Error(`视频任务提交失败:未获取到任务ID。原始响应:${JSON.stringify(submitData).slice(0, 500)}`);\r\n }\r\n\r\n const pollResult = await pollTask(\r\n async (): Promise => {\r\n const resultUrl = joinUrl(getMediaBaseUrl(), `/model/prediction/${taskId}`);\r\n const resultResp: any = await withNetworkRetry(() => axios.get(resultUrl, { headers }), 3, 1200);\r\n const data = resultResp.data;\r\n const status = extractStatus(data);\r\n\r\n if ([\"succeeded\", \"success\", \"done\", \"completed\"].includes(status)) {\r\n const mediaUrl = extractUrl(data);\r\n if (mediaUrl) return { completed: true, data: mediaUrl };\r\n return { completed: true, error: \"任务成功但未返回视频地址\" };\r\n }\r\n if ([\"failed\", \"error\", \"cancelled\", \"canceled\", \"expired\"].includes(status)) {\r\n return { completed: true, error: extractError(data) || \"视频生成失败\" };\r\n }\r\n return { completed: false };\r\n },\r\n 5000,\r\n 1800000,\r\n );\r\n\r\n if (pollResult.error) throw new Error(pollResult.error);\r\n if (!pollResult.data) throw new Error(\"视频生成失败:轮询未返回数据\");\r\n return await urlToBase64(pollResult.data);\r\n};\r\n\r\nconst ttsRequest = async (_config: TTSConfig, _model: TTSModel): Promise => {\r\n // AtlasCloud 当前版本先不接 TTS。\r\n return \"\";\r\n};\r\n\r\nconst checkForUpdates = async (): Promise<{ hasUpdate: boolean; latestVersion: string; notice: string }> => {\r\n return {\r\n hasUpdate: false,\r\n latestVersion: vendor.version,\r\n notice: \"AtlasCloud MASS 初稿。\",\r\n };\r\n};\r\n\r\nconst updateVendor = async (): Promise => {\r\n return \"\";\r\n};\r\n\r\n// ============================================================\r\n// 导出\r\n// ============================================================\r\n\r\nexports.vendor = vendor;\r\nexports.textRequest = textRequest;\r\nexports.imageRequest = imageRequest;\r\nexports.videoRequest = videoRequest;\r\nexports.ttsRequest = ttsRequest;\r\nexports.checkForUpdates = checkForUpdates;\r\nexports.updateVendor = updateVendor;\r\n\r\nexport { };\r\n" +} diff --git a/src/router.ts b/src/router.ts index 513cb0a..8f3b70b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,4 @@ -// @routes-hash 902e3917f4bdb484fea4ea9219071a06 +// @routes-hash 490361155cc6fa7ac97f72f183a52eb3 import { Express } from "express"; import route1 from "./routes/agents/clearMemory"; @@ -121,43 +121,52 @@ import route117 from "./routes/setting/about/downloadApp"; import route118 from "./routes/setting/agentDeploy/agentSetKey"; import route119 from "./routes/setting/agentDeploy/deployAgentModel"; import route120 from "./routes/setting/agentDeploy/getAgentDeploy"; -import route121 from "./routes/setting/dbConfig/clearData"; -import route122 from "./routes/setting/dbConfig/clearTable"; -import route123 from "./routes/setting/dbConfig/dbInfo"; -import route124 from "./routes/setting/dbConfig/exportData"; -import route125 from "./routes/setting/dbConfig/importData"; -import route126 from "./routes/setting/dev/getSwitchAiDevTool"; -import route127 from "./routes/setting/dev/updateSwitchAiDevTool"; -import route128 from "./routes/setting/fileManagement/openFolder"; -import route129 from "./routes/setting/getTextModel"; -import route130 from "./routes/setting/loginConfig/getUser"; -import route131 from "./routes/setting/loginConfig/updateUserPwd"; -import route132 from "./routes/setting/memoryConfig/delAllMemory"; -import route133 from "./routes/setting/memoryConfig/getMemory"; -import route134 from "./routes/setting/memoryConfig/sureMemory"; -import route135 from "./routes/setting/modelMap/bindingPrompt"; -import route136 from "./routes/setting/modelMap/getImageAndVideoModel"; -import route137 from "./routes/setting/promptManage/getPrompt"; -import route138 from "./routes/setting/promptManage/updatePrompt"; -import route139 from "./routes/setting/skillManagement/getSkillContent"; -import route140 from "./routes/setting/skillManagement/getSkillList"; -import route141 from "./routes/setting/skillManagement/saveSkillContent"; -import route142 from "./routes/setting/vendorConfig/addVendor"; -import route143 from "./routes/setting/vendorConfig/addVendorModel"; -import route144 from "./routes/setting/vendorConfig/deleteVendor"; -import route145 from "./routes/setting/vendorConfig/delVendorModel"; -import route146 from "./routes/setting/vendorConfig/enableVendor"; -import route147 from "./routes/setting/vendorConfig/getCodeByLink"; -import route148 from "./routes/setting/vendorConfig/getVendorList"; -import route149 from "./routes/setting/vendorConfig/modelTest"; -import route150 from "./routes/setting/vendorConfig/updateCode"; -import route151 from "./routes/setting/vendorConfig/updateVendorInputs"; -import route152 from "./routes/setting/vendorConfig/upVendorModel"; -import route153 from "./routes/task/getProject"; -import route154 from "./routes/task/getTaskApi"; -import route155 from "./routes/task/getTaskCategories"; -import route156 from "./routes/task/taskDetails"; -import route157 from "./routes/test/test"; +import route121 from "./routes/setting/agentDeploy/getAgentUseMode"; +import route122 from "./routes/setting/agentDeploy/updateUseMode"; +import route123 from "./routes/setting/dbConfig/clearData"; +import route124 from "./routes/setting/dbConfig/clearTable"; +import route125 from "./routes/setting/dbConfig/dbInfo"; +import route126 from "./routes/setting/dbConfig/exportData"; +import route127 from "./routes/setting/dbConfig/importData"; +import route128 from "./routes/setting/dev/getSwitchAiDevTool"; +import route129 from "./routes/setting/dev/updateSwitchAiDevTool"; +import route130 from "./routes/setting/fileManagement/openFolder"; +import route131 from "./routes/setting/getTextModel"; +import route132 from "./routes/setting/loginConfig/getUser"; +import route133 from "./routes/setting/loginConfig/updateUserPwd"; +import route134 from "./routes/setting/memoryConfig/delAllMemory"; +import route135 from "./routes/setting/memoryConfig/getMemory"; +import route136 from "./routes/setting/memoryConfig/sureMemory"; +import route137 from "./routes/setting/modelMap/bindingPrompt"; +import route138 from "./routes/setting/modelMap/deletePrompt"; +import route139 from "./routes/setting/modelMap/getImageAndVideoModel"; +import route140 from "./routes/setting/modelMap/getPromptList"; +import route141 from "./routes/setting/modelMap/savePrompt"; +import route142 from "./routes/setting/modelMap/updatePrompt"; +import route143 from "./routes/setting/promptManage/getPrompt"; +import route144 from "./routes/setting/promptManage/updatePrompt"; +import route145 from "./routes/setting/skillManagement/getSkillContent"; +import route146 from "./routes/setting/skillManagement/getSkillList"; +import route147 from "./routes/setting/skillManagement/saveSkillContent"; +import route148 from "./routes/setting/vendorConfig/addVendor"; +import route149 from "./routes/setting/vendorConfig/addVendorModel"; +import route150 from "./routes/setting/vendorConfig/deleteVendor"; +import route151 from "./routes/setting/vendorConfig/delVendorModel"; +import route152 from "./routes/setting/vendorConfig/enableVendor"; +import route153 from "./routes/setting/vendorConfig/getCodeByLink"; +import route154 from "./routes/setting/vendorConfig/getVendorList"; +import route155 from "./routes/setting/vendorConfig/modelTest"; +import route156 from "./routes/setting/vendorConfig/modelTest/imageTest"; +import route157 from "./routes/setting/vendorConfig/modelTest/textTest"; +import route158 from "./routes/setting/vendorConfig/modelTest/videoTest"; +import route159 from "./routes/setting/vendorConfig/updateCode"; +import route160 from "./routes/setting/vendorConfig/updateVendorInputs"; +import route161 from "./routes/setting/vendorConfig/upVendorModel"; +import route162 from "./routes/task/getProject"; +import route163 from "./routes/task/getTaskApi"; +import route164 from "./routes/task/getTaskCategories"; +import route165 from "./routes/task/taskDetails"; +import route166 from "./routes/test/test"; export default async (app: Express) => { app.use("/api/agents/clearMemory", route1); @@ -280,41 +289,50 @@ export default async (app: Express) => { app.use("/api/setting/agentDeploy/agentSetKey", route118); app.use("/api/setting/agentDeploy/deployAgentModel", route119); app.use("/api/setting/agentDeploy/getAgentDeploy", route120); - app.use("/api/setting/dbConfig/clearData", route121); - app.use("/api/setting/dbConfig/clearTable", route122); - app.use("/api/setting/dbConfig/dbInfo", route123); - app.use("/api/setting/dbConfig/exportData", route124); - app.use("/api/setting/dbConfig/importData", route125); - app.use("/api/setting/dev/getSwitchAiDevTool", route126); - app.use("/api/setting/dev/updateSwitchAiDevTool", route127); - app.use("/api/setting/fileManagement/openFolder", route128); - app.use("/api/setting/getTextModel", route129); - app.use("/api/setting/loginConfig/getUser", route130); - app.use("/api/setting/loginConfig/updateUserPwd", route131); - app.use("/api/setting/memoryConfig/delAllMemory", route132); - app.use("/api/setting/memoryConfig/getMemory", route133); - app.use("/api/setting/memoryConfig/sureMemory", route134); - app.use("/api/setting/modelMap/bindingPrompt", route135); - app.use("/api/setting/modelMap/getImageAndVideoModel", route136); - app.use("/api/setting/promptManage/getPrompt", route137); - app.use("/api/setting/promptManage/updatePrompt", route138); - app.use("/api/setting/skillManagement/getSkillContent", route139); - app.use("/api/setting/skillManagement/getSkillList", route140); - app.use("/api/setting/skillManagement/saveSkillContent", route141); - app.use("/api/setting/vendorConfig/addVendor", route142); - app.use("/api/setting/vendorConfig/addVendorModel", route143); - app.use("/api/setting/vendorConfig/deleteVendor", route144); - app.use("/api/setting/vendorConfig/delVendorModel", route145); - app.use("/api/setting/vendorConfig/enableVendor", route146); - app.use("/api/setting/vendorConfig/getCodeByLink", route147); - app.use("/api/setting/vendorConfig/getVendorList", route148); - app.use("/api/setting/vendorConfig/modelTest", route149); - app.use("/api/setting/vendorConfig/updateCode", route150); - app.use("/api/setting/vendorConfig/updateVendorInputs", route151); - app.use("/api/setting/vendorConfig/upVendorModel", route152); - app.use("/api/task/getProject", route153); - app.use("/api/task/getTaskApi", route154); - app.use("/api/task/getTaskCategories", route155); - app.use("/api/task/taskDetails", route156); - app.use("/api/test/test", route157); + app.use("/api/setting/agentDeploy/getAgentUseMode", route121); + app.use("/api/setting/agentDeploy/updateUseMode", route122); + app.use("/api/setting/dbConfig/clearData", route123); + app.use("/api/setting/dbConfig/clearTable", route124); + app.use("/api/setting/dbConfig/dbInfo", route125); + app.use("/api/setting/dbConfig/exportData", route126); + app.use("/api/setting/dbConfig/importData", route127); + app.use("/api/setting/dev/getSwitchAiDevTool", route128); + app.use("/api/setting/dev/updateSwitchAiDevTool", route129); + app.use("/api/setting/fileManagement/openFolder", route130); + app.use("/api/setting/getTextModel", route131); + app.use("/api/setting/loginConfig/getUser", route132); + app.use("/api/setting/loginConfig/updateUserPwd", route133); + app.use("/api/setting/memoryConfig/delAllMemory", route134); + app.use("/api/setting/memoryConfig/getMemory", route135); + app.use("/api/setting/memoryConfig/sureMemory", route136); + app.use("/api/setting/modelMap/bindingPrompt", route137); + app.use("/api/setting/modelMap/deletePrompt", route138); + app.use("/api/setting/modelMap/getImageAndVideoModel", route139); + app.use("/api/setting/modelMap/getPromptList", route140); + app.use("/api/setting/modelMap/savePrompt", route141); + app.use("/api/setting/modelMap/updatePrompt", route142); + app.use("/api/setting/promptManage/getPrompt", route143); + app.use("/api/setting/promptManage/updatePrompt", route144); + app.use("/api/setting/skillManagement/getSkillContent", route145); + app.use("/api/setting/skillManagement/getSkillList", route146); + app.use("/api/setting/skillManagement/saveSkillContent", route147); + app.use("/api/setting/vendorConfig/addVendor", route148); + app.use("/api/setting/vendorConfig/addVendorModel", route149); + app.use("/api/setting/vendorConfig/deleteVendor", route150); + app.use("/api/setting/vendorConfig/delVendorModel", route151); + app.use("/api/setting/vendorConfig/enableVendor", route152); + app.use("/api/setting/vendorConfig/getCodeByLink", route153); + app.use("/api/setting/vendorConfig/getVendorList", route154); + app.use("/api/setting/vendorConfig/modelTest", route155); + app.use("/api/setting/vendorConfig/modelTest/imageTest", route156); + app.use("/api/setting/vendorConfig/modelTest/textTest", route157); + app.use("/api/setting/vendorConfig/modelTest/videoTest", route158); + app.use("/api/setting/vendorConfig/updateCode", route159); + app.use("/api/setting/vendorConfig/updateVendorInputs", route160); + app.use("/api/setting/vendorConfig/upVendorModel", route161); + app.use("/api/task/getProject", route162); + app.use("/api/task/getTaskApi", route163); + app.use("/api/task/getTaskCategories", route164); + app.use("/api/task/taskDetails", route165); + app.use("/api/test/test", route166); } diff --git a/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts b/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts index 8187f66..41dd036 100644 --- a/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts +++ b/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts @@ -41,9 +41,10 @@ export default router.post( ), projectId: zod.number(), concurrentCount: zod.number().int().min(1).optional(), + otherTextPrompt: zod.string(), }), async (req, res) => { - const { projectId, items, concurrentCount } = req.body; + const { projectId, items, concurrentCount, otherTextPrompt } = req.body; //获取风格 const project = await u.db("o_project").where("id", projectId).select("artStyle", "type", "intro").first(); //如果没有找到对应的项目,返回错误 @@ -102,7 +103,7 @@ export default router.post( const systemPrompt = visualManual; try { const { _output } = (await u.Ai.Text("universalAi").invoke({ - system: systemPrompt, + system: systemPrompt + "\n" + otherTextPrompt, messages: [ { role: "user", diff --git a/src/routes/cornerScape/batchBindAudio.ts b/src/routes/cornerScape/batchBindAudio.ts index 44b3e4f..80cca6a 100644 --- a/src/routes/cornerScape/batchBindAudio.ts +++ b/src/routes/cornerScape/batchBindAudio.ts @@ -16,7 +16,7 @@ export default router.post( }), async (req, res) => { const { projectId, assetsIds, concurrentCount } = req.body; - const assetsData = await u.db("o_assets").whereIn("id", assetsIds).andWhere("projectId", projectId).select("id", "name", "describe"); + const assetsData = await u.db("o_assets").whereIn("id", assetsIds).andWhere("projectId", projectId).select("id", "name", "describe", "type"); const audioData = await u .db("o_assets") @@ -36,7 +36,6 @@ export default router.post( inputSchema: jsonSchema<{ id: number; audioId: number }>( z .object({ - id: z.number().describe("资产ID"), audioId: z.number().nullable().optional().describe("与该资产匹配的音频ID列表,若无合适匹配则返回空数组"), }) .toJSONSchema(), @@ -50,19 +49,19 @@ export default router.post( }); const audioList = audioData.map((i) => `- ID:${i.id} | 名称:${i.name} | 描述:${i.describe ?? "无"}`).join("\n"); - + const promptData = await u.db("o_prompt").where("type", "audioBindPrompt").first(); + let audioBindPrompt = "" as string | undefined; + if (promptData && promptData.useData) { + audioBindPrompt = promptData.useData; + } else { + audioBindPrompt = promptData?.data ?? undefined; + } const { text } = await u.Ai.Text("universalAi").invoke({ messages: [ { role: "system", content: ` - 你是一个音色匹配助手。 - 你的任务是:根据给定角色资产的名称与描述,从候选音频列表中选出最合适的音色。 - 匹配规则: - 1. 优先根据角色性别、年龄、性格等特征与音色描述进行语义匹配; - 2. 可以为同一角色匹配多个音色(例如主备选); - 3. 若候选列表中没有合适的音色,则返回空数组; - 4. 匹配完成后必须调用 resultTool 工具提交结果,无需额外回复用户。 + ${audioBindPrompt} `, }, { @@ -71,7 +70,7 @@ export default router.post( ## 候选音频列表 ${audioList} ## 待匹配资产 - - ID:${asset.id} | 名称:${asset.name} | 描述:${asset.describe ?? "无"} + - ID:${asset.id} | 名称:${asset.name} | 描述:${asset.describe ?? "无"} | 类型:${asset.type} 请从候选音频列表中为该资产选出来一个最符合该角色设定的音色,并调用 resultTool 提交结果。 `, }, diff --git a/src/routes/production/storyboard/batchGenerateImage.ts b/src/routes/production/storyboard/batchGenerateImage.ts index 6e7da9e..80ee1df 100644 --- a/src/routes/production/storyboard/batchGenerateImage.ts +++ b/src/routes/production/storyboard/batchGenerateImage.ts @@ -16,6 +16,7 @@ export default router.post( projectId: z.number(), scriptId: z.number(), concurrentCount: z.number().min(1).optional(), + compulsory: z.boolean().optional(), }), async (req, res) => { const { @@ -23,36 +24,34 @@ export default router.post( projectId, scriptId, concurrentCount = 5, + compulsory = false, }: { storyboardIds: number[]; projectId: number; scriptId: number; concurrentCount: number; + compulsory: boolean; } = req.body; if (!storyboardIds || storyboardIds.length === 0) return res.status(400).send(error("storyboardIds不能为空")); // 当没有 storyboardIds 时,通过 AI 生成新的分镜面板数据 let finalStoryboardIds: number[] = storyboardIds || []; // shouldGenerateImage === 0 的分镜标记为「未生成」,其余标记为「生成中」 - await u - .db("o_storyboard") - .whereIn("id", finalStoryboardIds) - .where("scriptId", scriptId) - .where("shouldGenerateImage", 0) - .update({ state: "未生成" }); - await u - .db("o_storyboard") - .whereIn("id", finalStoryboardIds) - .where("scriptId", scriptId) - .where("shouldGenerateImage", 1) - .update({ state: "生成中" }); + const storyboardData = await u.db("o_storyboard").where("scriptId", scriptId).where("projectId", projectId).whereIn("id", finalStoryboardIds); + if (!storyboardData.length) return res.status(500).send(error("未查到分镜数据")); + const storyIds = storyboardData.map((i) => i.id); + if (compulsory) { + await u.db("o_storyboard").whereIn("id", storyIds).where("scriptId", scriptId).update({ state: "生成中", shouldGenerateImage: 1 }); + } else { + await u.db("o_storyboard").whereIn("id", storyIds).where("scriptId", scriptId).where("shouldGenerateImage", 0).update({ state: "未生成" }); + await u.db("o_storyboard").whereIn("id", storyIds).where("scriptId", scriptId).where("shouldGenerateImage", 1).update({ state: "生成中" }); + } const projectSettingData = await u.db("o_project").where("id", projectId).select("imageModel", "imageQuality", "artStyle", "videoRatio").first(); - const storyboardData = await u.db("o_storyboard").where("scriptId", scriptId).whereIn("id", finalStoryboardIds); // 按 rowid 顺序查出每个 storyboard 关联的 assetId 有序列表 const assets2StoryboardRows = await u .db("o_assets2Storyboard") - .whereIn("storyboardId", finalStoryboardIds) + .whereIn("storyboardId", storyIds) .orderBy("rowid") .select("storyboardId", "assetId"); @@ -77,10 +76,10 @@ export default router.post( assetRecord[item.storyboardId].push(imageId); } }); - + const realStoryData = await u.db("o_storyboard").where("scriptId", scriptId).where("projectId", projectId).whereIn("id", storyIds); res.status(200).send( success( - storyboardData.map((i) => ({ + realStoryData.map((i) => ({ id: i.id, prompt: i.prompt, associateAssetsIds: assetRecord[i.id!], @@ -130,9 +129,13 @@ export default router.post( }); }); }; - // 按 concurrentCount 控制并发数,分批执行;跳过 shouldGenerateImage === 0 的分镜 - const generateList = storyboardData.filter((item) => item.shouldGenerateImage !== 0); + let generateList = []; + if (compulsory) { + generateList = storyboardData; + } else { + generateList = storyboardData.filter((item) => item.shouldGenerateImage !== 0); + } for (let i = 0; i < generateList.length; i += concurrentCount) { const batch = generateList.slice(i, i + concurrentCount); await Promise.all(batch.map(generateTask)); diff --git a/src/routes/production/workbench/generateVideo.ts b/src/routes/production/workbench/generateVideo.ts index 53432b1..192451f 100644 --- a/src/routes/production/workbench/generateVideo.ts +++ b/src/routes/production/workbench/generateVideo.ts @@ -93,6 +93,8 @@ export default router.post( type: "视频", }; const aiVideo = u.Ai.Video(model); + + console.log("%c Line:47 🍩 modeData", "background:#93c0a4", modeData); await aiVideo.run( { prompt, diff --git a/src/routes/production/workbench/generateVideoPrompt.ts b/src/routes/production/workbench/generateVideoPrompt.ts index f05761e..c199e0e 100644 --- a/src/routes/production/workbench/generateVideoPrompt.ts +++ b/src/routes/production/workbench/generateVideoPrompt.ts @@ -3,7 +3,8 @@ import u from "@/utils"; import { z } from "zod"; import { success, error } from "@/lib/responseFormat"; import { validateFields } from "@/middleware/middleware"; -import { info } from "node:console"; +import fs from "fs/promises"; +import path from "path"; const router = express.Router(); export default router.post( @@ -18,9 +19,11 @@ export default router.post( }), ), model: z.string(), + mode: z.string(), }), async (req, res) => { - const { trackId, projectId, info, model } = req.body; + const { trackId, projectId, info, model, mode } = req.body; + //查询参数 const images = await Promise.all( info.map(async (item: { id: number; sources: string }) => { @@ -83,25 +86,72 @@ export default router.post( const projectData = await u.db("o_project").select("*").where({ id: projectId }).first(); const videoPrompt = await u.db("o_prompt").where("type", "videoPromptGeneration").first(); let videoPromptGeneration = "" as string | undefined; - if (videoPrompt && videoPrompt.useData) { - videoPromptGeneration = videoPrompt.useData; - } else { - videoPromptGeneration = videoPrompt?.data ?? undefined; + + const modelPromptData = await u.db("o_modelPrompt").where("vendorId", id).where("model", modelData).first(); + //查询到 有绑定对应视频提示词 + if (modelPromptData) { + const modelPromptRoot = u.getPath(["modelPrompt"]); + try { + const fullPath = path.join(modelPromptRoot, modelPromptData?.path!); + const content = await fs.readFile(fullPath, "utf-8"); + videoPromptGeneration = content ?? ""; + } catch {} } + + // 未查询到绑定,根据模型名称 + mode 自动匹配 modelPrompt/video/ 下的文件 + if (!videoPromptGeneration) { + const modelPromptRoot = u.getPath(["modelPrompt"]); + const videoPromptDir = path.join(modelPromptRoot, "video"); + const modelLower = (modelData ?? "").toLowerCase(); + + let fileName: string | null = null; + + if (modelLower.includes("wan") && modelLower.includes("2.6")) { + // wan2.6 系列 => 单图首尾帧模式 + fileName = "wan2.6单图首帧模式.md"; + } else if (/seedance.*2[.\-]0/i.test(modelData)) { + // seedance 2.0 / 2-0 系列 + fileName = "seedance2多参模式.md"; + } else if (mode === "startEndRequired" || mode === "endFrameOptional" || mode === "startFrameOptional") { + // body.mode 为首尾帧相关 => 通用首尾帧模式 + fileName = "通用首尾帧模式.md"; + } else if (typeof mode === "string" && mode.startsWith('["') && mode.endsWith('"]')) { + // 其他 => 通用多参模式 + fileName = "通用多参模式.md"; + } + if (fileName) { + try { + const fullPath = path.join(videoPromptDir, fileName); + videoPromptGeneration = await fs.readFile(fullPath, "utf-8"); + } catch { + // 文件不存在则忽略,继续用备选 + } + } + } + + //备选 + if (!videoPromptGeneration) { + if (videoPrompt && videoPrompt.useData) { + videoPromptGeneration = videoPrompt.useData; + } else { + videoPromptGeneration = videoPrompt?.data ?? undefined; + } + } + const artStyle = projectData?.artStyle || "无"; const visualManual = u.getArtPrompt(artStyle, "art_skills", "art_storyboard_video"); const content = ` **模型名称**:${modelData}, **资产信息**(角色、场景、道具、音频):${assets - .filter((i) => i.filePath) - .map((i) => `[${i.id},${i.type},${i.name}]`) - .join(",")}, + .filter((i) => i.filePath) + .map((i) => `[${i.id},${i.type},${i.name}]`) + .join(",")}, **分镜信息**:${storyboard.map( - (i) => ` ``, - )}, + )}, `; try { diff --git a/src/routes/project/delProject.ts b/src/routes/project/delProject.ts index e9360ca..f3ac07c 100644 --- a/src/routes/project/delProject.ts +++ b/src/routes/project/delProject.ts @@ -16,8 +16,6 @@ export default router.post( //删除项目 await u.db("o_project").where("id", id).delete(); await u.db("o_agentWorkData").where("projectId", id).delete(); - const novelData = await u.db("o_novel").where("projectId", id).select("id"); - const novelId = novelData.map((item: any) => item.id); //删除项目下的原文 await u.db("o_novel").where("projectId", id).delete(); // 删除项目下的剧本信息 @@ -27,7 +25,6 @@ export default router.post( await u.db("o_scriptAssets").whereIn("scriptId", scriptIds).delete(); } await u.db("o_script").where("projectId", id).delete(); - await u.db("o_outline").where("projectId", id).delete(); // 删除项目下的任务 await u.db("o_tasks").where("projectId", id).delete(); // 删除项目下的分镜 diff --git a/src/routes/script/extractAssets.ts b/src/routes/script/extractAssets.ts index fe2f7f1..4129e52 100644 --- a/src/routes/script/extractAssets.ts +++ b/src/routes/script/extractAssets.ts @@ -81,6 +81,7 @@ export default router.post( /** 一组剧本提取完成后统一入库并建立关联 */ async function persistGroupResult(result: GroupResult) { + console.log("%c Line:84 🍪 result", "background:#6ec1c2", result); if (!result) return; const { batchScriptIds, newAssets, existingRefs } = result; if (!newAssets.length && !existingRefs.length) return; diff --git a/src/routes/setting/agentDeploy/getAgentUseMode.ts b/src/routes/setting/agentDeploy/getAgentUseMode.ts new file mode 100644 index 0000000..a7f01d0 --- /dev/null +++ b/src/routes/setting/agentDeploy/getAgentUseMode.ts @@ -0,0 +1,11 @@ +import express from "express"; +import { success, error } from "@/lib/responseFormat"; +import u from "@/utils"; + +const router = express.Router(); + +export default router.get("/", async (req, res) => { + const useMode = await u.db("o_setting").where("key", "agentUseMode").first(); + console.log("%c Line:9 🍓 useMode", "background:#33a5ff", useMode); + res.status(200).send(success(useMode?.value || "0")); +}); diff --git a/src/routes/setting/agentDeploy/updateUseMode.ts b/src/routes/setting/agentDeploy/updateUseMode.ts new file mode 100644 index 0000000..3c6c1a3 --- /dev/null +++ b/src/routes/setting/agentDeploy/updateUseMode.ts @@ -0,0 +1,20 @@ +import express from "express"; +import u from "@/utils"; +import { z } from "zod"; +import { success } from "@/lib/responseFormat"; +import { validateFields } from "@/middleware/middleware"; +const router = express.Router(); + +export default router.post( + "/", + validateFields({ + agentUseMode: z.string(), + }), + async (req, res) => { + const { agentUseMode } = req.body; + await u.db("o_setting").where("key", "agentUseMode").update({ + value: agentUseMode, + }); + res.status(200).send(success("保存设置成功")); + }, +); diff --git a/src/routes/setting/modelMap/bindingPrompt.ts b/src/routes/setting/modelMap/bindingPrompt.ts index 82ba5ea..5df0f92 100644 --- a/src/routes/setting/modelMap/bindingPrompt.ts +++ b/src/routes/setting/modelMap/bindingPrompt.ts @@ -10,16 +10,17 @@ export default router.post( validateFields({ vendorId: z.string(), model: z.string(), - prompt: z.string(), + path: z.string(), + fileName: z.string(), }), async (req, res) => { - const { vendorId, model, prompt } = req.body; + const { vendorId, model, path, fileName } = req.body; const data = await u.db("o_modelPrompt").where("model", model).andWhere("vendorId", vendorId).select("*").first(); if (data) { - await u.db("o_modelPrompt").where("model", model).andWhere("vendorId", vendorId).update({ prompt }); + await u.db("o_modelPrompt").where("model", model).andWhere("vendorId", vendorId).update({ fileName, path }); res.status(200).send(success("绑定成功")); } else { - await u.db("o_modelPrompt").insert({ vendorId, model, prompt }); + await u.db("o_modelPrompt").insert({ vendorId, model, path, fileName }); res.status(200).send(success("绑定成功")); } }, diff --git a/src/routes/setting/modelMap/deletePrompt.ts b/src/routes/setting/modelMap/deletePrompt.ts new file mode 100644 index 0000000..d4dee00 --- /dev/null +++ b/src/routes/setting/modelMap/deletePrompt.ts @@ -0,0 +1,38 @@ +import express from "express"; +import { error, success } from "@/lib/responseFormat"; +import u from "@/utils"; +import { z } from "zod"; +import { validateFields } from "@/middleware/middleware"; +import fs from "fs/promises"; +import path from "path"; + +const router = express.Router(); + +export default router.post( + "/", + validateFields({ + path: z.string(), + }), + async (req, res) => { + const { path: filePath } = req.body; + + const modelPromptRoot = u.getPath(["modelPrompt"]); + + // 路径隧穿检测 + const resolvedRoot = path.resolve(modelPromptRoot); + const resolvedFile = path.resolve(modelPromptRoot, filePath); + if (!resolvedFile.startsWith(resolvedRoot + path.sep)) { + return res.status(400).send(error("非法路径")); + } + + // 文件不存在则报错 + try { + await fs.access(resolvedFile); + } catch { + return res.status(404).send(error("文件不存在")); + } + + await fs.unlink(resolvedFile); + res.status(200).send(success("删除成功")); + }, +); diff --git a/src/routes/setting/modelMap/getImageAndVideoModel.ts b/src/routes/setting/modelMap/getImageAndVideoModel.ts index fc40e7a..8e69d1b 100644 --- a/src/routes/setting/modelMap/getImageAndVideoModel.ts +++ b/src/routes/setting/modelMap/getImageAndVideoModel.ts @@ -12,7 +12,7 @@ export default router.post("/", async (req, res) => { dataList.map(async (item) => { const vendor = u.vendor.getVendor(item.id!); const promptList = await u.db("o_modelPrompt").andWhere("vendorId", vendor.id).select("*"); - const promptMap = new Map(promptList.map((p) => [p.model, p.prompt])); + const promptMap = new Map(promptList.map((p) => [p.model, { fileName: p.fileName, path: p.path }])); const models = await u.vendor.getModelList(item.id!); const filteredModels = models .filter((m: any) => m.type === "video") @@ -20,7 +20,7 @@ export default router.post("/", async (req, res) => { name: m.name, type: m.type as "image" | "video", model: m.modelName, - prompt: promptMap.get(m.modelName) ?? "", + ...(promptMap.get(m.modelName) ? { ...promptMap.get(m.modelName) } : {}), })); return { id: item.id, diff --git a/src/routes/setting/modelMap/getPromptList.ts b/src/routes/setting/modelMap/getPromptList.ts new file mode 100644 index 0000000..a35de5a --- /dev/null +++ b/src/routes/setting/modelMap/getPromptList.ts @@ -0,0 +1,28 @@ +import express from "express"; +import { error, success } from "@/lib/responseFormat"; +import u from "@/utils"; +import fg from "fast-glob"; +import fs from "fs/promises"; +import path from "path"; +const router = express.Router(); + +export default router.get("/", async (req, res) => { + const modelPromptRoot = u.getPath(["modelPrompt"]); + + const entries = await fg("**/*.md", { + cwd: modelPromptRoot.replace(/\\/g, "/"), + onlyFiles: true, + }); + + const result = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(modelPromptRoot, entry); + const content = await fs.readFile(fullPath, "utf-8"); + const name = path.basename(entry, ".md"); + const type = entry.includes("/") ? entry.split("/")[0] : ""; + return { path: entry, name, type, data: content }; + }), + ); + + res.status(200).send(success(result)); +}); diff --git a/src/routes/setting/modelMap/savePrompt.ts b/src/routes/setting/modelMap/savePrompt.ts new file mode 100644 index 0000000..efed89d --- /dev/null +++ b/src/routes/setting/modelMap/savePrompt.ts @@ -0,0 +1,31 @@ +import express from "express"; +import { error, success } from "@/lib/responseFormat"; +import u from "@/utils"; +import { z } from "zod"; +import { validateFields } from "@/middleware/middleware"; +import fs from "fs/promises"; +import path from "path"; + +const router = express.Router(); + +export default router.post( + "/", + validateFields({ + name: z.string().min(1), + data: z.string(), + type: z.enum(["image", "video"]), + }), + async (req, res) => { + const { name, data, type } = req.body; + + const modelPromptRoot = u.getPath(["modelPrompt"]); + const dir = path.join(modelPromptRoot, type); + + await fs.mkdir(dir, { recursive: true }); + + const filePath = path.join(dir, `${name}.md`); + await fs.writeFile(filePath, data, "utf-8"); + + res.status(200).send(success("保存成功")); + }, +); diff --git a/src/routes/setting/modelMap/updatePrompt.ts b/src/routes/setting/modelMap/updatePrompt.ts new file mode 100644 index 0000000..61c795a --- /dev/null +++ b/src/routes/setting/modelMap/updatePrompt.ts @@ -0,0 +1,41 @@ +import express from "express"; +import { error, success } from "@/lib/responseFormat"; +import u from "@/utils"; +import { z } from "zod"; +import { validateFields } from "@/middleware/middleware"; +import fs from "fs/promises"; +import path from "path"; + +const router = express.Router(); + +export default router.post( + "/", + validateFields({ + name: z.string().min(1), + data: z.string(), + type: z.enum(["image", "video"]), + }), + async (req, res) => { + const { name, data, type } = req.body; + + const modelPromptRoot = u.getPath(["modelPrompt"]); + const filePath = path.join(modelPromptRoot, type, `${name}.md`); + + // 路径隧穿检测 + const resolvedRoot = path.resolve(modelPromptRoot); + const resolvedFile = path.resolve(filePath); + if (!resolvedFile.startsWith(resolvedRoot + path.sep)) { + return res.status(400).send(error("非法路径")); + } + + // 文件不存在则报错 + try { + await fs.access(resolvedFile); + } catch { + return res.status(404).send(error("文件不存在")); + } + + await fs.writeFile(resolvedFile, data, "utf-8"); + res.status(200).send(success("更新成功")); + }, +); diff --git a/src/routes/setting/vendorConfig/modelTest/imageTest.ts b/src/routes/setting/vendorConfig/modelTest/imageTest.ts new file mode 100644 index 0000000..98d71ec --- /dev/null +++ b/src/routes/setting/vendorConfig/modelTest/imageTest.ts @@ -0,0 +1,43 @@ +import express from "express"; +import { success, error } from "@/lib/responseFormat"; +import { validateFields } from "@/middleware/middleware"; +import u from "@/utils"; +import { z } from "zod"; +import { tool, jsonSchema } from "ai"; +const router = express.Router(); + +// 检查语言模型 +export default router.post( + "/", + validateFields({ + modelName: z.string(), + id: z.string(), + imageBase64: z.string().optional(), + prompt: z.string(), + }), + async (req, res) => { + const { modelName, imageBase64, id, prompt } = req.body; + + try { + const vendorConfigData = await u.db("o_vendorConfig").where("id", id).first(); + + if (!vendorConfigData) return res.status(500).send(error("未找到该供应商配置")); + if (!vendorConfigData.models) return res.status(500).send(error("未找到模型列表")); + + const reqFn = await u.Ai.Image(`${id}:${modelName}`).run({ + prompt: prompt, + referenceList: [{type:"image",base64:imageBase64}], //输入的图片提示词 + size: "1K", // 图片尺寸 + aspectRatio: "16:9", + }); + await reqFn.save("testImage.jpg"); + const resultUrl = await u.oss.getFileUrl("testImage.jpg"); + res.status(200).send(success(resultUrl)); + } catch (err) { + console.error(err); + const msg = u.error(err).message; + console.error(msg); + res.status(500).send(error(msg)); + } + }, +); diff --git a/src/routes/setting/vendorConfig/modelTest/textTest.ts b/src/routes/setting/vendorConfig/modelTest/textTest.ts new file mode 100644 index 0000000..67b8cb8 --- /dev/null +++ b/src/routes/setting/vendorConfig/modelTest/textTest.ts @@ -0,0 +1,64 @@ +import express from "express"; +import { success, error } from "@/lib/responseFormat"; +import { validateFields } from "@/middleware/middleware"; +import u from "@/utils"; +import { z } from "zod"; +import { tool, jsonSchema } from "ai"; +const router = express.Router(); + +// 检查语言模型 +export default router.post( + "/", + validateFields({ + modelName: z.string(), + id: z.string(), + messages: z.array( + z.object({ + role: z.enum(["user", "assistant"]), + content: z.string(), + }), + ), + }), + async (req, res) => { + const { modelName, messages, id } = req.body; + + try { + const vendorConfigData = await u.db("o_vendorConfig").where("id", id).first(); + + if (!vendorConfigData) return res.status(500).send(error("未找到该供应商配置")); + if (!vendorConfigData.models) return res.status(500).send(error("未找到模型列表")); + + const modelList = await u.vendor.getModelList(vendorConfigData.id!); + + const getWeatherTool = tool({ + description: "Get the weather in a location", + inputSchema: jsonSchema<{ location: string }>( + z + .object({ + location: z.string().describe("The location to get the weather for"), + }) + .toJSONSchema(), + ), + execute: async ({ location }) => { + return { + location, + temperature: 72 + Math.floor(Math.random() * 21) - 10, + }; + }, + }); + + const data = await u.Ai.Text(`${id}:${modelName}`).invoke({ + messages, + tools: { getWeatherTool }, + }); + console.log("%c Line:46 🍐 data", "background:#6ec1c2", data); + if (!data) return res.status(500).send(error("模型未返回结果")); + res.status(200).send(success({ thinking: data.reasoningText, content: data.text })); + } catch (err) { + console.error(err); + const msg = u.error(err).message; + console.error(msg); + res.status(500).send(error(msg)); + } + }, +); diff --git a/src/routes/setting/vendorConfig/modelTest/videoTest.ts b/src/routes/setting/vendorConfig/modelTest/videoTest.ts new file mode 100644 index 0000000..71bcaee --- /dev/null +++ b/src/routes/setting/vendorConfig/modelTest/videoTest.ts @@ -0,0 +1,74 @@ +import express from "express"; +import { success, error } from "@/lib/responseFormat"; +import { validateFields } from "@/middleware/middleware"; +import u from "@/utils"; +import { z } from "zod"; +import { tool, jsonSchema } from "ai"; +const router = express.Router(); + +// 检查语言模型 +export default router.post( + "/", + validateFields({ + modelName: z.string(), + id: z.string(), + mode: z.string(), + prompt: z.string(), + videos: z.array( + z.object({ + type: z.string(), + base64: z.string(), + }), + ), + audios: z.array( + z.object({ + type: z.string(), + base64: z.string(), + }), + ), + images: z.array( + z.object({ + type: z.string(), + base64: z.string(), + }), + ), + }), + async (req, res) => { + const { modelName, id, mode, prompt, images, videos, audios } = req.body; + + try { + const vendorConfigData = await u.db("o_vendorConfig").where("id", id).first(); + + if (!vendorConfigData) return res.status(500).send(error("未找到该供应商配置")); + if (!vendorConfigData.models) return res.status(500).send(error("未找到模型列表")); + const modelList = await u.vendor.getModelList(vendorConfigData.id!); + + const selectedModel = modelList.find((i: any) => i.modelName == modelName); + + let modeData = []; + if (Array.isArray(mode)) { + } else if (typeof mode === "string" && mode.startsWith('["') && mode.endsWith('"]')) { + try { + modeData = JSON.parse(mode); + } catch (e) {} + } + const reqFn = await u.Ai.Video(`${id}:${modelName}`).run({ + duration: selectedModel.durationResolutionMap[0].duration[0], + resolution: selectedModel.durationResolutionMap[0].resolution[0], + aspectRatio: "16:9", + prompt: prompt, + referenceList: [...images, ...videos, ...audios], + audio: typeof selectedModel.audio == "boolean" ? selectedModel.audio : true, + mode: modeData.length > 0 ? modeData : mode, + }); + await reqFn.save("test.mp4"); + const resultUrl = await u.oss.getFileUrl("test.mp4"); + res.status(200).send(success(resultUrl)); + } catch (err) { + console.error(err); + const msg = u.error(err).message; + console.error(msg); + res.status(500).send(error(msg)); + } + }, +); diff --git a/src/socket/routes/scriptAgent.ts b/src/socket/routes/scriptAgent.ts index 44654ee..ec71a70 100644 --- a/src/socket/routes/scriptAgent.ts +++ b/src/socket/routes/scriptAgent.ts @@ -68,6 +68,7 @@ export default (nsp: Namespace) => { } catch (err: any) { if (err.name !== "AbortError" && !currentController.signal.aborted) { console.error("[scriptAgent] chat error:", u.error(err).message); + msg.error(u.error(err).message) } } finally { if (abortController === currentController) { diff --git a/src/types/database.d.ts b/src/types/database.d.ts index 4ece20a..52cf62a 100644 --- a/src/types/database.d.ts +++ b/src/types/database.d.ts @@ -1,4 +1,4 @@ -// @db-hash ef0c3cdd7111f4f5d87b82df06bdd72a +// @db-hash 17b50430f27f3b720ad137e6c30cc477 //该文件由脚本自动生成,请勿手动修改 export interface _o_assets_old_20260428 { @@ -109,8 +109,10 @@ export interface o_imageFlow { 'id'?: number; } export interface o_modelPrompt { + 'fileName'?: string | null; 'id'?: number; 'model'?: string | null; + 'path'?: string | null; 'prompt'?: string | null; 'vendorId'?: string | null; } diff --git a/src/utils/ai.ts b/src/utils/ai.ts index eee8137..6ff0548 100644 --- a/src/utils/ai.ts +++ b/src/utils/ai.ts @@ -45,8 +45,27 @@ const AiTypeValues: AiType[] = [ ]; async function resolveModelName(value: AiType | `${string}:${string}`): Promise<`${string}:${string}`> { if (AiTypeValues.includes(value as AiType)) { + const agentUseModeVal = await u.db("o_setting").where("key", "agentUseMode").first(); + + //正常流程 + //高级配置 + if (agentUseModeVal?.value == "1") { + const agentDeployData = await u.db("o_agentDeploy").where("key", value).first(); + if (!agentDeployData?.modelName) throw new Error(`高级配置模式下,未找到对应的模型配置 ${value}`); + return agentDeployData?.modelName as `${number}:${string}`; + } + //简易配置 + if (agentUseModeVal?.value == "0") { + const [mainly] = value!.split(/:(.+)/); + const mainlyData = await u.db("o_agentDeploy").where("key", mainly).first(); + if (!mainlyData?.modelName) throw new Error(`简易配置模式下,未找到部署配置 ${value}`); + return mainlyData?.modelName as `${number}:${string}`; + } + + //未查到agentUseModeVal 维持原判断 const agentDeployData = await u.db("o_agentDeploy").where("key", value).first(); let modelName = null; + if (!agentDeployData?.modelName) { const [mainly] = agentDeployData!.key!.split(/:(.+)/); const mainlyData = await u.db("o_agentDeploy").where("key", mainly).first(); @@ -61,7 +80,25 @@ async function resolveModelName(value: AiType | `${string}:${string}`): Promise< async function getModelConfig(value: AiType | `${string}:${string}`) { if (AiTypeValues.includes(value as AiType)) { + const agentUseModeVal = await u.db("o_setting").where("key", "agentUseMode").first(); + //正常流程 + //高级配置 + if (agentUseModeVal?.value == "1") { + const agentDeployData = await u.db("o_agentDeploy").where("key", value).first(); + if (!agentDeployData?.modelName) throw new Error(`高级配置模式下,未找到对应的模型配置 ${value}`); + return agentDeployData; + } + //简易配置 + if (agentUseModeVal?.value == "0") { + const [mainly] = value!.split(/:(.+)/); + const mainlyData = await u.db("o_agentDeploy").where("key", mainly).first(); + if (!mainlyData?.modelName) throw new Error(`简易配置模式下,未找到部署配置 ${value}`); + return mainlyData; + } + + //未查到 agentUseModelVal 维持原流程 const agentDeployData = await u.db("o_agentDeploy").where("key", value).first(); + if (!agentDeployData?.modelName) { const [mainly] = agentDeployData!.key!.split(/:(.+)/); const mainlyData = await u.db("o_agentDeploy").where("key", mainly).first(); diff --git a/src/utils/oss.ts b/src/utils/oss.ts index d34ed05..5748274 100644 --- a/src/utils/oss.ts +++ b/src/utils/oss.ts @@ -53,7 +53,7 @@ class OSS { // URL 始终使用 /,所以这里需要将系统分隔符转回 / let url = `/${prefix}/`; if (process.env.ossURL && process.env.ossURL !== "") url = process.env.ossURL + `/${prefix}/`; - if (process.env.NODE_ENV == "dev") url = `http://localhost:10588/${prefix}/`; + if (process.env.NODE_ENV == "dev") url = `http://192.168.0.116:10588/${prefix}/`; if (isEletron()) url = `http://localhost:${process.env.PORT}/${prefix}/`; return `${url}${safePath.split(path.sep).join("/")}`; }