Commit 830715ab08bbe82deeacd98a98f4c2580cae2ced
1 parent
6c7687dc
feat: redesign hire agent UI with batch hiring & sync idearuncreate template from test company
Showing
14 changed files
with
1400 additions
and
181 deletions
.gitignore
| ... | ... | @@ -38,8 +38,8 @@ data/agents/job-*/ |
| 38 | 38 | apiconversation/ |
| 39 | 39 | |
| 40 | 40 | # Legacy directories (removed from architecture) |
| 41 | -conversations/ | |
| 42 | -skills/ | |
| 41 | +/conversations/ | |
| 42 | +/skills/ | |
| 43 | 43 | |
| 44 | 44 | # Build output |
| 45 | 45 | dist/ |
| ... | ... | @@ -58,3 +58,4 @@ tmp/ |
| 58 | 58 | # Lock files (keep package-lock, ignore pnpm if using npm) |
| 59 | 59 | # pnpm-lock.yaml |
| 60 | 60 | *.lock |
| 61 | +!system/templates/*/.claude/*.lock | ... | ... |
system/templates/idearuncreate/.claude/skills/create-drama/.env
0 → 100644
| 1 | +# === MySQL 数据库 === | |
| 2 | +DB_HOST=rm-2zeel4sa31na88gd4qo.rwlb.rds.aliyuncs.com | |
| 3 | +DB_PORT=3306 | |
| 4 | +DB_USER=lemon | |
| 5 | +DB_PASSWORD=Lemon@123456 | |
| 6 | +DB_NAME=shadiao_drama | |
| 7 | + | |
| 8 | +# === 阿里云 OSS(图片上传) === | |
| 9 | +OSS_REGION=oss-cn-beijing | |
| 10 | +OSS_ACCESS_KEY_ID=LTAI5tGtipK3GrbTwZm7JjTS | |
| 11 | +OSS_ACCESS_KEY_SECRET=r6axVSGVyIzL3Ty2T5pV1SVFeiir3t | |
| 12 | +OSS_BUCKET=shadiao-drama | |
| 13 | +OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com | |
| 14 | + | |
| 15 | +# === 万界生图 API(Jimeng + Nano Banana Pro) === | |
| 16 | +WANJIE_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NzA5NjIyNzgsImtleSI6IjVLNzVaOFROQzlGNEhNMzdQOVk3In0.2axgWHccUPjL3cQr2-1jAGLZ4hVAcTbn-LU78CMMex4 | ... | ... |
system/templates/idearuncreate/.claude/skills/create-drama/SKILL.md
0 → 100644
| 1 | +--- | |
| 2 | +description: 从素材(新闻、故事、段子等)自动生成 classic 互动剧本(含角色图、场景图)并写入数据库 | |
| 3 | +--- | |
| 4 | + | |
| 5 | +# create-drama — 从素材自动生成完整 classic 互动剧本 | |
| 6 | + | |
| 7 | +根据用户提供的内容素材(新闻、故事、段子、历史事件等),生成一个完整的 classic 类型互动剧本,包括: | |
| 8 | +- 剧本数据(角色、台词、表情、动作) | |
| 9 | +- 角色立绘图(AI 生图 → 去背景 → 上传 OSS) | |
| 10 | +- 背景图和封面图(AI 生图 → 上传 OSS) | |
| 11 | +- 写入数据库 | |
| 12 | + | |
| 13 | +## 输入 | |
| 14 | + | |
| 15 | +用户会在 `$ARGUMENTS` 中提供内容素材。素材可能是: | |
| 16 | +- 一段新闻报道 | |
| 17 | +- 一个故事/段子 | |
| 18 | +- 一个历史事件描述 | |
| 19 | +- 一个创意想法 | |
| 20 | +- 一个文件路径(需要先读取文件内容) | |
| 21 | + | |
| 22 | +## 执行步骤 | |
| 23 | + | |
| 24 | +### 第 1 步:分析素材 | |
| 25 | + | |
| 26 | +仔细阅读用户提供的素材内容。如果是文件路径,先用 Read 工具读取文件。 | |
| 27 | + | |
| 28 | +### 第 2 步:生成完整剧本 JSON | |
| 29 | + | |
| 30 | +根据素材内容,你自己(Claude)直接生成剧本数据 JSON。**不要调用任何外部 LLM API。** | |
| 31 | + | |
| 32 | +生成的 JSON 必须严格遵循以下格式: | |
| 33 | + | |
| 34 | +```json | |
| 35 | +{ | |
| 36 | + "name": "剧本名称(简短,2-6个字)", | |
| 37 | + "title": "显示标题(可以带 emoji,稍微长一点)", | |
| 38 | + "subtitle": "— 副标题 —", | |
| 39 | + "description": "剧本简介(一句话概括)", | |
| 40 | + "systemPrompt": "用于后续 AI 续写的系统提示词(详见下方规则)", | |
| 41 | + "isPublic": false, | |
| 42 | + "userId": 1, | |
| 43 | + "tags": "标签1,标签2,标签3", | |
| 44 | + "language": "内容语言:en / zh / multi", | |
| 45 | + "characters": [ | |
| 46 | + { | |
| 47 | + "name": "角色名", | |
| 48 | + "gender": "male 或 female", | |
| 49 | + "voiceId": "从音色列表选择", | |
| 50 | + "imagePrompt": "角色立绘生图 prompt(详见下方规则)" | |
| 51 | + } | |
| 52 | + ], | |
| 53 | + "script": [ | |
| 54 | + { | |
| 55 | + "character": "角色名(必须在 characters 中定义过)", | |
| 56 | + "dialogue": "台词内容", | |
| 57 | + "avatar": "表情文件名", | |
| 58 | + "instruction": "(可选)语音情感指令,如 '用愤怒的语气说'、'ASMR 低语'、'质问的语气' 等", | |
| 59 | + "action": "动画效果", | |
| 60 | + "position": "left 或 right" | |
| 61 | + } | |
| 62 | + ], | |
| 63 | + "backgroundPrompt": "背景图生图 prompt", | |
| 64 | + "coverPrompt": "封面图生图 prompt" | |
| 65 | +} | |
| 66 | +``` | |
| 67 | + | |
| 68 | +### 生成规则 | |
| 69 | + | |
| 70 | +#### 角色 (characters) | |
| 71 | + | |
| 72 | +- 数量:2-5 个 | |
| 73 | +- 每个角色必须有 name、gender、voiceId、imagePrompt | |
| 74 | +- voiceId 选择:参考 [reference.md](reference.md) 中的完整音色列表 | |
| 75 | + - 所有 2.0 音色统一支持 9 种情感(happy/sad/angry/surprised/fear/hate/excited/coldness/neutral) | |
| 76 | + - 根据角色性格、年龄、性别匹配合适的音色 | |
| 77 | + - 常用推荐: | |
| 78 | + - 男主角: `zh_male_m191_uranus_bigtts`(云舟)或 `zh_male_ruyayichen_uranus_bigtts`(儒雅逸辰) | |
| 79 | + - 女主角: `zh_female_cancan_uranus_bigtts`(知性灿灿)或 `zh_female_meilinvyou_uranus_bigtts`(魅力女友) | |
| 80 | + - 少年/年轻男: `zh_male_shaonianzixin_uranus_bigtts`(少年梓辛)或 `saturn_zh_male_shuanglangshaonian_tob`(爽朗少年) | |
| 81 | + - 甜美/撒娇女: `zh_female_sajiaoxuemei_uranus_bigtts`(撒娇学妹)或 `zh_female_tianmeitaozi_uranus_bigtts`(甜美桃子) | |
| 82 | + - 成熟男: `zh_male_dayi_uranus_bigtts`(大壹)或 `zh_male_sophie_uranus_bigtts`(魅力苏菲) | |
| 83 | + - 搞笑/IP: `zh_male_sunwukong_uranus_bigtts`(猴哥)或 `zh_female_peiqi_uranus_bigtts`(佩奇猪) | |
| 84 | + - 英文男: `en_male_tim_uranus_bigtts`(Tim) | |
| 85 | + - 英文女: `en_female_dacey_uranus_bigtts`(Dacey)或 `en_female_stokie_uranus_bigtts`(Stokie) | |
| 86 | + | |
| 87 | +#### 角色立绘 imagePrompt | |
| 88 | + | |
| 89 | +每个角色必须生成 imagePrompt,遵循以下模板: | |
| 90 | + | |
| 91 | +``` | |
| 92 | +生成一张**[人物名字或身份]**的全身站立画像。 | |
| 93 | +核心风格:沙雕动漫风格(meme style),2D简笔画,线条清晰粗犷,色彩平涂,带有幽默滑稽感,略微的大头小身体比例。 | |
| 94 | +脸部要求(最重要):脸部必须是纯肉色的完全空白,绝对不要画眼睛、鼻子、嘴巴或眉毛等任何五官,保留一个干净完整的面部轮廓,方便后期添加表情包。 | |
| 95 | +服装与动作:角色穿着**[根据角色描述填写服装特征],手里拿着[根据角色身份推断合适的道具],[一个有趣的站姿或动作]**。 | |
| 96 | +背景要求:纯色干净背景(如浅蓝灰色),不要任何环境元素或杂物,边缘清晰,方便直接抠图。 | |
| 97 | +``` | |
| 98 | + | |
| 99 | +根据角色具体描述,在 [] 处填入合适内容。 | |
| 100 | + | |
| 101 | +#### 背景图 backgroundPrompt | |
| 102 | + | |
| 103 | +``` | |
| 104 | +横版宽屏场景背景图,卡通风格,适合互动剧背景。场景:[根据剧本内容描述场景]。不要人物,色彩鲜明,气氛感强。 | |
| 105 | +``` | |
| 106 | + | |
| 107 | +#### 封面图 coverPrompt | |
| 108 | + | |
| 109 | +``` | |
| 110 | +剧目封面海报。标题:[剧本标题]。展示[角色名]等角色的剪影轮廓,色彩鲜明,有标题感,卡通风格。 | |
| 111 | +``` | |
| 112 | + | |
| 113 | +#### 台词 (script) | |
| 114 | + | |
| 115 | +- 数量:10-20 句(根据素材丰富度调整,尽量多一些,让剧情完整) | |
| 116 | +- avatar 可选值:`normal.png`, `happy.png`, `very_happy.png`, `angry.png`, `sad.png`, `cry.png`, `shock.png`, `smirk.png`, `speechless.png` | |
| 117 | +- action 可选值: | |
| 118 | + - `none` — 默认,无动画 | |
| 119 | + - `shake` — 震动,适合愤怒/惊吓/强调 | |
| 120 | + - `jump` — 跳跃,适合惊喜/激动 | |
| 121 | + - `spin` — 旋转,适合混乱/搞笑 | |
| 122 | + - `zoom-in` — 放大,适合重要台词/特写 | |
| 123 | +- instruction(可选):语音情感指令,用自然语言描述这句台词的语气/情绪/表达方式 | |
| 124 | + - 如:`"用愤怒的语气说"`、`"ASMR 低语"`、`"质问的语气"`、`"开心地大喊"` | |
| 125 | + - 会传给 TTS 引擎的 seed-tts-2.0-expressive 模型,增强语音表现力 | |
| 126 | + - 对于情绪强烈的台词建议添加此字段 | |
| 127 | +- position: 每个角色应该有固定站位(left/right),2个角色时一左一右,3个以上轮替 | |
| 128 | +- 台词要生动有趣,符合互动剧的沙雕风格 | |
| 129 | +- 对话要有戏剧冲突和反转 | |
| 130 | +- 每句台词不要太长,控制在 30 字以内 | |
| 131 | + | |
| 132 | +#### systemPrompt | |
| 133 | + | |
| 134 | +- 要详细描述世界观和角色设定 | |
| 135 | +- 包含语言风格指导(用于后续 AI 续写对话) | |
| 136 | +- 至少 200 字 | |
| 137 | + | |
| 138 | +#### tags | |
| 139 | + | |
| 140 | +- 1-3 个标签,逗号分隔 | |
| 141 | +- 如:`搞笑,职场,反转` | |
| 142 | + | |
| 143 | +#### language | |
| 144 | + | |
| 145 | +- 内容语言标识:`en`(英文)、`zh`(中文)、`multi`(混合) | |
| 146 | +- 根据素材语言自动设置 | |
| 147 | + | |
| 148 | +### 第 3 步:写入临时 JSON 文件 | |
| 149 | + | |
| 150 | +将生成的 JSON 写入临时文件 `/tmp/drama_<timestamp>.json` | |
| 151 | + | |
| 152 | +### 第 4 步:生成图片并上传 OSS | |
| 153 | + | |
| 154 | +找到本 skill 的目录路径(即本文件 SKILL.md 所在目录),执行图片生成脚本: | |
| 155 | + | |
| 156 | +```bash | |
| 157 | +node <skill目录>/generate-images.js /tmp/drama_<timestamp>.json | |
| 158 | +``` | |
| 159 | + | |
| 160 | +该脚本会: | |
| 161 | +1. 为每个角色生成立绘图(Jimeng → fallback Nano Banana Pro) | |
| 162 | +2. 自动去背景(rembg) | |
| 163 | +3. 为剧本生成背景图和封面图 | |
| 164 | +4. 全部上传到阿里云 OSS | |
| 165 | +5. 将 URL 写回 JSON 文件(更新 characters[].bodyUrl, coverUrl, backgroundUrl) | |
| 166 | + | |
| 167 | +**注意**:该脚本需要 `ali-oss`, `sharp` npm 包以及 `rembg` Python 工具。如果 rembg 未安装,会跳过去背景步骤。 | |
| 168 | + | |
| 169 | +### 第 5 步:写入数据库 | |
| 170 | + | |
| 171 | +执行入库脚本: | |
| 172 | + | |
| 173 | +```bash | |
| 174 | +node <skill目录>/insert-drama.js /tmp/drama_<timestamp>.json | |
| 175 | +``` | |
| 176 | + | |
| 177 | +脚本会在事务中创建: | |
| 178 | +- `dramas` 表记录(剧本元数据) | |
| 179 | +- `drama_characters` 表记录(角色配置 + 身体图 URL + 音色) | |
| 180 | +- `drama_emotions` 表记录(默认表情映射) | |
| 181 | +- `drama_scripts` 表记录(台词序列) | |
| 182 | + | |
| 183 | +### 第 6 步:清理并输出结果 | |
| 184 | + | |
| 185 | +1. 删除临时 JSON 文件 | |
| 186 | +2. 告知用户: | |
| 187 | + - 剧本名称和 dramaKey | |
| 188 | + - 角色列表及音色 | |
| 189 | + - 台词数量 | |
| 190 | + - 图片生成结果(成功/失败) | |
| 191 | + - 简要剧情概述 | |
| 192 | + | |
| 193 | +## 配置说明 | |
| 194 | + | |
| 195 | +脚本会自动从以下位置加载配置(按优先级): | |
| 196 | +1. `--env <path>` 参数指定的 .env 文件 | |
| 197 | +2. 环境变量 `DRAMA_ENV_PATH` 指定的 .env 文件 | |
| 198 | +3. skill 目录下的 `.env` 文件 | |
| 199 | +4. 已有的环境变量 | |
| 200 | + | |
| 201 | +所需配置项: | |
| 202 | + | |
| 203 | +```env | |
| 204 | +# 数据库 | |
| 205 | +DB_HOST=127.0.0.1 | |
| 206 | +DB_PORT=3306 | |
| 207 | +DB_USER=root | |
| 208 | +DB_PASSWORD=xxx | |
| 209 | +DB_NAME=shadiao_drama | |
| 210 | + | |
| 211 | +# 阿里云 OSS(图片上传) | |
| 212 | +OSS_REGION=oss-cn-beijing | |
| 213 | +OSS_ACCESS_KEY_ID=xxx | |
| 214 | +OSS_ACCESS_KEY_SECRET=xxx | |
| 215 | +OSS_BUCKET=xxx | |
| 216 | +OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com | |
| 217 | + | |
| 218 | +# 万界生图 API | |
| 219 | +WANJIE_API_KEY=xxx | |
| 220 | +``` | |
| 221 | + | |
| 222 | +## 注意事项 | |
| 223 | + | |
| 224 | +- **自动检测语言**:根据素材的语言自动决定输出语言。中文素材生成中文内容,英文素材生成英文内容,并在 JSON 中设置对应的 `language` 字段(`zh`/`en`/`multi`) | |
| 225 | +- 英文内容必须使用英文音色(Tim, Dacey, Stokie) | |
| 226 | +- 风格偏沙雕搞笑,但要保留素材的核心信息 | |
| 227 | +- 如果素材是严肃新闻,可以用吐槽/解说风格改编 | |
| 228 | +- 角色名字要有趣,符合素材主题 | |
| 229 | + | |
| 230 | +## 复用到其他项目 | |
| 231 | + | |
| 232 | +整个 `create-drama/` 目录是自包含的,复制到其他项目的 `.claude/skills/` 即可使用。 | |
| 233 | + | |
| 234 | +**前置条件**: | |
| 235 | +1. Node.js 依赖:`npm i -g sequelize mysql2 ali-oss sharp` | |
| 236 | +2. Python 工具:`pip install rembg`(可选,用于角色图去背景) | |
| 237 | +3. 在 `create-drama/` 目录下修改 `.env` 配置 | |
| 238 | + | |
| 239 | +**目录结构**: | |
| 240 | +``` | |
| 241 | +.claude/skills/create-drama/ | |
| 242 | +├── SKILL.md # Skill 定义(本文件) | |
| 243 | +├── reference.md # 完整音色参考文档 | |
| 244 | +├── insert-drama.js # 数据库入库脚本 | |
| 245 | +├── generate-images.js # 图片生成 + 去背景 + OSS 上传脚本 | |
| 246 | +└── .env # 数据库 + OSS + 生图 API 配置 | |
| 247 | +``` | |
| 248 | + | |
| 249 | +## Additional Resources | |
| 250 | + | |
| 251 | +- 完整音色列表及情感参数:[reference.md](reference.md) | ... | ... |
system/templates/idearuncreate/.claude/skills/create-drama/generate-images.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * generate-images.js — 为剧本生成角色图、背景图、封面图,并上传到 OSS | |
| 4 | + * | |
| 5 | + * 用法: | |
| 6 | + * node generate-images.js <drama-json文件> | |
| 7 | + * | |
| 8 | + * 输入 JSON 中需要包含: | |
| 9 | + * - characters[].imagePrompt (角色生图 prompt,由 SKILL.md 指导 Claude 生成) | |
| 10 | + * - backgroundPrompt (背景图 prompt) | |
| 11 | + * - coverPrompt (封面图 prompt) | |
| 12 | + * - dramaKey (用于 OSS 路径) | |
| 13 | + * | |
| 14 | + * 输出: 在原 JSON 基础上补充 URL 字段,写回文件 | |
| 15 | + * - characters[].bodyUrl | |
| 16 | + * - coverUrl | |
| 17 | + * - backgroundUrl | |
| 18 | + * | |
| 19 | + * 依赖: ali-oss, sharp (可选), rembg (Python, 可选) | |
| 20 | + */ | |
| 21 | + | |
| 22 | +const path = require('path'); | |
| 23 | +const fs = require('fs'); | |
| 24 | +const https = require('https'); | |
| 25 | +const http = require('http'); | |
| 26 | +const { execSync } = require('child_process'); | |
| 27 | +const os = require('os'); | |
| 28 | + | |
| 29 | +// ---------- 加载 .env ---------- | |
| 30 | +function loadEnv() { | |
| 31 | + const envArgIdx = process.argv.indexOf('--env'); | |
| 32 | + const candidates = [ | |
| 33 | + envArgIdx !== -1 ? path.resolve(process.argv[envArgIdx + 1]) : null, | |
| 34 | + process.env.DRAMA_ENV_PATH ? path.resolve(process.env.DRAMA_ENV_PATH) : null, | |
| 35 | + path.resolve(__dirname, '.env'), | |
| 36 | + ].filter(Boolean); | |
| 37 | + | |
| 38 | + for (const envPath of candidates) { | |
| 39 | + if (fs.existsSync(envPath)) { | |
| 40 | + const envContent = fs.readFileSync(envPath, 'utf-8'); | |
| 41 | + for (const line of envContent.split('\n')) { | |
| 42 | + const trimmed = line.trim(); | |
| 43 | + if (!trimmed || trimmed.startsWith('#')) continue; | |
| 44 | + const eqIdx = trimmed.indexOf('='); | |
| 45 | + if (eqIdx === -1) continue; | |
| 46 | + const key = trimmed.slice(0, eqIdx).trim(); | |
| 47 | + const val = trimmed.slice(eqIdx + 1).trim(); | |
| 48 | + if (!process.env[key]) process.env[key] = val; | |
| 49 | + } | |
| 50 | + console.log(`已加载配置: ${envPath}`); | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + } | |
| 54 | + console.log('未找到 .env 文件,使用环境变量'); | |
| 55 | +} | |
| 56 | +loadEnv(); | |
| 57 | + | |
| 58 | +// ========== Wanjie 图片生成 API ========== | |
| 59 | + | |
| 60 | +const WANJIE_BASE = 'maas-openapi.wanjiedata.com'; | |
| 61 | + | |
| 62 | +function wanjieRequest(action, body) { | |
| 63 | + const apiKey = process.env.WANJIE_API_KEY; | |
| 64 | + if (!apiKey) throw new Error('缺少 WANJIE_API_KEY 环境变量'); | |
| 65 | + | |
| 66 | + const jsonBody = JSON.stringify(body); | |
| 67 | + return new Promise((resolve, reject) => { | |
| 68 | + const req = https.request({ | |
| 69 | + hostname: WANJIE_BASE, | |
| 70 | + path: `/api/jimeng/v1?Action=${action}&Version=2022-08-31`, | |
| 71 | + method: 'POST', | |
| 72 | + headers: { | |
| 73 | + 'Authorization': apiKey, | |
| 74 | + 'Content-Type': 'application/json', | |
| 75 | + 'Content-Length': Buffer.byteLength(jsonBody), | |
| 76 | + }, | |
| 77 | + }, (res) => { | |
| 78 | + let data = ''; | |
| 79 | + res.on('data', chunk => data += chunk); | |
| 80 | + res.on('end', () => { | |
| 81 | + try { resolve(JSON.parse(data)); } | |
| 82 | + catch (err) { reject(new Error(`解析响应失败: ${data.slice(0, 300)}`)); } | |
| 83 | + }); | |
| 84 | + }); | |
| 85 | + req.on('error', reject); | |
| 86 | + req.write(jsonBody); | |
| 87 | + req.end(); | |
| 88 | + }); | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** Jimeng 文生图(异步模式) */ | |
| 92 | +async function generateImageJimeng(prompt, width = 1024, height = 1024) { | |
| 93 | + const res = await wanjieRequest('CVSync2AsyncSubmitTask', { | |
| 94 | + req_key: 'jimeng_t2i_v40', | |
| 95 | + prompt, width, height, | |
| 96 | + }); | |
| 97 | + | |
| 98 | + if (res.code !== 10000 || !res.data?.task_id) { | |
| 99 | + throw new Error(`Jimeng 提交失败: ${JSON.stringify(res).slice(0, 300)}`); | |
| 100 | + } | |
| 101 | + | |
| 102 | + const taskId = res.data.task_id; | |
| 103 | + console.log(` [Jimeng] 任务提交: task_id=${taskId}`); | |
| 104 | + | |
| 105 | + // 轮询等待 | |
| 106 | + const start = Date.now(); | |
| 107 | + while (Date.now() - start < 120000) { | |
| 108 | + const poll = await wanjieRequest('CVSync2AsyncGetResult', { | |
| 109 | + req_key: 'jimeng_t2i_v40', task_id: taskId, | |
| 110 | + }); | |
| 111 | + | |
| 112 | + if (poll.code === 10000 && poll.data?.image_urls?.length > 0) { | |
| 113 | + const buffer = await downloadUrl(poll.data.image_urls[0]); | |
| 114 | + return { buffer, mimeType: 'image/png' }; | |
| 115 | + } | |
| 116 | + if (poll.code === 10000 && poll.data?.binary_data_base64?.length > 0) { | |
| 117 | + return { buffer: Buffer.from(poll.data.binary_data_base64[0], 'base64'), mimeType: 'image/png' }; | |
| 118 | + } | |
| 119 | + if (poll.code && poll.code !== 10000 && !poll.message?.includes('processing') && !poll.message?.includes('pending')) { | |
| 120 | + throw new Error(`Jimeng 任务失败: ${JSON.stringify(poll).slice(0, 300)}`); | |
| 121 | + } | |
| 122 | + | |
| 123 | + await sleep(3000); | |
| 124 | + } | |
| 125 | + throw new Error(`Jimeng 超时: task_id=${taskId}`); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** Nano Banana Pro 文生图(同步模式,fallback) */ | |
| 129 | +async function generateImageNano(prompt) { | |
| 130 | + const apiKey = process.env.WANJIE_API_KEY; | |
| 131 | + if (!apiKey) throw new Error('缺少 WANJIE_API_KEY'); | |
| 132 | + | |
| 133 | + const url = `https://${WANJIE_BASE}/api/v1beta/models/gemini-3-pro-image-preview:generateContent`; | |
| 134 | + const body = JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }); | |
| 135 | + | |
| 136 | + for (let attempt = 1; attempt <= 3; attempt++) { | |
| 137 | + try { | |
| 138 | + const data = await httpsPost(url, body, { 'Authorization': `Bearer ${apiKey}` }); | |
| 139 | + const response = JSON.parse(data); | |
| 140 | + const img = extractImageFromResponse(response); | |
| 141 | + if (img) return img; | |
| 142 | + throw new Error('无图片数据'); | |
| 143 | + } catch (err) { | |
| 144 | + if (err.statusCode === 429 && attempt < 3) { | |
| 145 | + console.warn(` [Nano] 429 限流,${3 * attempt}s 后重试...`); | |
| 146 | + await sleep(3000 * attempt); | |
| 147 | + continue; | |
| 148 | + } | |
| 149 | + throw err; | |
| 150 | + } | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +function extractImageFromResponse(response) { | |
| 155 | + const parts = response?.candidates?.[0]?.content?.parts; | |
| 156 | + if (!parts) return null; | |
| 157 | + for (const part of parts) { | |
| 158 | + if (part.inlineData) { | |
| 159 | + return { buffer: Buffer.from(part.inlineData.data, 'base64'), mimeType: part.inlineData.mimeType || 'image/png' }; | |
| 160 | + } | |
| 161 | + if (part.text && part.text.includes('data:image')) { | |
| 162 | + const match = part.text.match(/data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)/); | |
| 163 | + if (match) return { buffer: Buffer.from(match[2], 'base64'), mimeType: `image/${match[1]}` }; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + return null; | |
| 167 | +} | |
| 168 | + | |
| 169 | +/** 生图:优先 Jimeng,fallback Nano */ | |
| 170 | +async function generateImage(prompt) { | |
| 171 | + try { | |
| 172 | + return await generateImageJimeng(prompt); | |
| 173 | + } catch (jimengErr) { | |
| 174 | + console.warn(` [Jimeng] 失败: ${jimengErr.message.slice(0, 80)},fallback Nano...`); | |
| 175 | + return await generateImageNano(prompt); | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +// ========== 图片处理 ========== | |
| 180 | + | |
| 181 | +/** 去背景(rembg) */ | |
| 182 | +function removeBg(inputBuffer) { | |
| 183 | + const tmpDir = os.tmpdir(); | |
| 184 | + const ts = Date.now(); | |
| 185 | + const inputPath = path.join(tmpDir, `rembg_in_${ts}.png`); | |
| 186 | + const outputPath = path.join(tmpDir, `rembg_out_${ts}.png`); | |
| 187 | + | |
| 188 | + try { | |
| 189 | + fs.writeFileSync(inputPath, inputBuffer); | |
| 190 | + // 使用 Python 脚本调用 rembg 核心 API(避免 CLI 依赖问题) | |
| 191 | + const pyScript = ` | |
| 192 | +import sys | |
| 193 | +from rembg import remove | |
| 194 | +from PIL import Image | |
| 195 | +import io | |
| 196 | + | |
| 197 | +input_path = sys.argv[1] | |
| 198 | +output_path = sys.argv[2] | |
| 199 | + | |
| 200 | +input_img = Image.open(input_path) | |
| 201 | +output_img = remove(input_img) | |
| 202 | +output_img.save(output_path) | |
| 203 | +`; | |
| 204 | + const pyScriptPath = path.join(tmpDir, `rembg_${ts}.py`); | |
| 205 | + fs.writeFileSync(pyScriptPath, pyScript); | |
| 206 | + | |
| 207 | + execSync(`python3 "${pyScriptPath}" "${inputPath}" "${outputPath}"`, { timeout: 60000 }); | |
| 208 | + const result = fs.readFileSync(outputPath); | |
| 209 | + console.log(' [rembg] 去背景成功'); | |
| 210 | + | |
| 211 | + // 清理 Python 脚本 | |
| 212 | + try { fs.unlinkSync(pyScriptPath); } catch { } | |
| 213 | + | |
| 214 | + // 尝试 sharp 裁剪 | |
| 215 | + try { | |
| 216 | + const sharp = require('sharp'); | |
| 217 | + // sharp 操作是 async,但我们在 sync context 用 execSync 调 sharp | |
| 218 | + // 改用 async | |
| 219 | + return trimImage(result); | |
| 220 | + } catch { | |
| 221 | + return result; | |
| 222 | + } | |
| 223 | + } catch (err) { | |
| 224 | + console.warn(` [rembg] 失败(${err.message.slice(0, 60)}),返回原图`); | |
| 225 | + return inputBuffer; | |
| 226 | + } finally { | |
| 227 | + try { fs.unlinkSync(inputPath); } catch { } | |
| 228 | + try { fs.unlinkSync(outputPath); } catch { } | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +async function trimImage(buffer) { | |
| 233 | + try { | |
| 234 | + const sharp = require('sharp'); | |
| 235 | + const trimmed = await sharp(buffer).trim().png().toBuffer(); | |
| 236 | + const meta = await sharp(trimmed).metadata(); | |
| 237 | + const padX = Math.round(meta.width * 0.05); | |
| 238 | + const padY = Math.round(meta.height * 0.05); | |
| 239 | + return await sharp(trimmed) | |
| 240 | + .extend({ top: padY, bottom: padY, left: padX, right: padX, background: { r: 0, g: 0, b: 0, alpha: 0 } }) | |
| 241 | + .png().toBuffer(); | |
| 242 | + } catch { | |
| 243 | + return buffer; | |
| 244 | + } | |
| 245 | +} | |
| 246 | + | |
| 247 | +// ========== OSS 上传 ========== | |
| 248 | + | |
| 249 | +function getOSSClient() { | |
| 250 | + const OSS = require('ali-oss'); | |
| 251 | + return new OSS({ | |
| 252 | + region: process.env.OSS_REGION, | |
| 253 | + accessKeyId: process.env.OSS_ACCESS_KEY_ID, | |
| 254 | + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET, | |
| 255 | + bucket: process.env.OSS_BUCKET, | |
| 256 | + }); | |
| 257 | +} | |
| 258 | + | |
| 259 | +function getPublicUrl(key) { | |
| 260 | + const endpoint = process.env.OSS_ENDPOINT || `https://${process.env.OSS_REGION}.aliyuncs.com`; | |
| 261 | + const bucket = process.env.OSS_BUCKET; | |
| 262 | + return `https://${bucket}.${endpoint.replace('https://', '')}/${key}`; | |
| 263 | +} | |
| 264 | + | |
| 265 | +async function uploadToOSS(ossKey, buffer, contentType = 'image/png') { | |
| 266 | + const client = getOSSClient(); | |
| 267 | + const result = await client.put(ossKey, buffer, { headers: { 'Content-Type': contentType } }); | |
| 268 | + return getPublicUrl(result.name); | |
| 269 | +} | |
| 270 | + | |
| 271 | +// ========== 工具函数 ========== | |
| 272 | + | |
| 273 | +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| 274 | + | |
| 275 | +function httpsPost(url, body, extraHeaders = {}) { | |
| 276 | + return new Promise((resolve, reject) => { | |
| 277 | + const urlObj = new URL(url); | |
| 278 | + const req = https.request({ | |
| 279 | + hostname: urlObj.hostname, | |
| 280 | + path: urlObj.pathname + urlObj.search, | |
| 281 | + method: 'POST', | |
| 282 | + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...extraHeaders }, | |
| 283 | + }, (res) => { | |
| 284 | + let data = ''; | |
| 285 | + res.on('data', chunk => data += chunk); | |
| 286 | + res.on('end', () => { | |
| 287 | + if (res.statusCode === 429) { const e = new Error('429'); e.statusCode = 429; reject(e); return; } | |
| 288 | + resolve(data); | |
| 289 | + }); | |
| 290 | + }); | |
| 291 | + req.on('error', reject); | |
| 292 | + req.write(body); | |
| 293 | + req.end(); | |
| 294 | + }); | |
| 295 | +} | |
| 296 | + | |
| 297 | +function downloadUrl(url) { | |
| 298 | + return new Promise((resolve, reject) => { | |
| 299 | + const protocol = url.startsWith('https') ? https : http; | |
| 300 | + protocol.get(url, (res) => { | |
| 301 | + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { | |
| 302 | + return downloadUrl(res.headers.location).then(resolve).catch(reject); | |
| 303 | + } | |
| 304 | + const chunks = []; | |
| 305 | + res.on('data', chunk => chunks.push(chunk)); | |
| 306 | + res.on('end', () => resolve(Buffer.concat(chunks))); | |
| 307 | + res.on('error', reject); | |
| 308 | + }).on('error', reject); | |
| 309 | + }); | |
| 310 | +} | |
| 311 | + | |
| 312 | +// ========== 主逻辑 ========== | |
| 313 | + | |
| 314 | +async function main() { | |
| 315 | + const filePath = process.argv.find(a => a.endsWith('.json')); | |
| 316 | + if (!filePath) { | |
| 317 | + console.error('用法: node generate-images.js <drama-json文件>'); | |
| 318 | + process.exit(1); | |
| 319 | + } | |
| 320 | + | |
| 321 | + const absPath = path.resolve(filePath); | |
| 322 | + const data = JSON.parse(fs.readFileSync(absPath, 'utf-8')); | |
| 323 | + const dramaKey = data.dramaKey || `drama_${Date.now().toString(36)}`; | |
| 324 | + data.dramaKey = dramaKey; | |
| 325 | + | |
| 326 | + // 检查 OSS 配置 | |
| 327 | + const ossOk = process.env.OSS_REGION && process.env.OSS_ACCESS_KEY_ID && process.env.OSS_ACCESS_KEY_SECRET && process.env.OSS_BUCKET; | |
| 328 | + if (!ossOk) { | |
| 329 | + console.error('OSS 配置不完整,请检查 .env 中的 OSS_REGION, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_BUCKET'); | |
| 330 | + process.exit(1); | |
| 331 | + } | |
| 332 | + | |
| 333 | + // 检查生图 API 配置 | |
| 334 | + if (!process.env.WANJIE_API_KEY) { | |
| 335 | + console.error('缺少 WANJIE_API_KEY'); | |
| 336 | + process.exit(1); | |
| 337 | + } | |
| 338 | + | |
| 339 | + const characters = data.characters || []; | |
| 340 | + const total = characters.length + 2; // chars + background + cover | |
| 341 | + let completed = 0; | |
| 342 | + const tick = (label) => { completed++; console.log(`[${completed}/${total}] ${label} 完成`); }; | |
| 343 | + | |
| 344 | + // 并行处理所有图片 | |
| 345 | + const tasks = []; | |
| 346 | + | |
| 347 | + // 角色图 | |
| 348 | + for (const char of characters) { | |
| 349 | + tasks.push((async () => { | |
| 350 | + const prompt = char.imagePrompt || `全身立绘,纯色背景,沙雕动漫风格。角色:${char.name}。脸部空白无五官。`; | |
| 351 | + console.log(`\n生成角色图: ${char.name}`); | |
| 352 | + console.log(` prompt: ${prompt.slice(0, 80)}...`); | |
| 353 | + | |
| 354 | + const { buffer } = await generateImage(prompt); | |
| 355 | + | |
| 356 | + // 去背景 | |
| 357 | + console.log(` 正在去背景...`); | |
| 358 | + const processed = await removeBg(buffer); | |
| 359 | + | |
| 360 | + // 上传 OSS | |
| 361 | + const safeName = Buffer.from(char.name).toString('hex').slice(0, 16); | |
| 362 | + const ossKey = `dramas/${dramaKey}/characters/${safeName}.png`; | |
| 363 | + const url = await uploadToOSS(ossKey, processed); | |
| 364 | + char.bodyUrl = url; | |
| 365 | + console.log(` 上传完成: ${url}`); | |
| 366 | + tick(char.name); | |
| 367 | + })()); | |
| 368 | + } | |
| 369 | + | |
| 370 | + // 背景图 | |
| 371 | + tasks.push((async () => { | |
| 372 | + const prompt = data.backgroundPrompt || `横版场景背景图,宽屏比例,场景:${data.description || data.name},卡通风格,无人物。`; | |
| 373 | + console.log(`\n生成背景图`); | |
| 374 | + console.log(` prompt: ${prompt.slice(0, 80)}...`); | |
| 375 | + | |
| 376 | + const { buffer } = await generateImage(prompt); | |
| 377 | + const ossKey = `dramas/${dramaKey}/background.png`; | |
| 378 | + const url = await uploadToOSS(ossKey, buffer); | |
| 379 | + data.backgroundUrl = url; | |
| 380 | + console.log(` 上传完成: ${url}`); | |
| 381 | + tick('背景图'); | |
| 382 | + })()); | |
| 383 | + | |
| 384 | + // 封面图 | |
| 385 | + tasks.push((async () => { | |
| 386 | + const prompt = data.coverPrompt || `剧目封面海报,标题:${data.title || data.name},色彩鲜明。`; | |
| 387 | + console.log(`\n生成封面图`); | |
| 388 | + console.log(` prompt: ${prompt.slice(0, 80)}...`); | |
| 389 | + | |
| 390 | + const { buffer } = await generateImage(prompt); | |
| 391 | + const ossKey = `dramas/${dramaKey}/cover.png`; | |
| 392 | + const url = await uploadToOSS(ossKey, buffer); | |
| 393 | + data.coverUrl = url; | |
| 394 | + console.log(` 上传完成: ${url}`); | |
| 395 | + tick('封面图'); | |
| 396 | + })()); | |
| 397 | + | |
| 398 | + await Promise.allSettled(tasks); | |
| 399 | + | |
| 400 | + // 写回文件 | |
| 401 | + fs.writeFileSync(absPath, JSON.stringify(data, null, 2), 'utf-8'); | |
| 402 | + console.log(`\n图片生成完成,已更新 JSON 文件: ${absPath}`); | |
| 403 | + console.log(` 角色图: ${characters.filter(c => c.bodyUrl).length}/${characters.length}`); | |
| 404 | + console.log(` 背景图: ${data.backgroundUrl ? '✓' : '✗'}`); | |
| 405 | + console.log(` 封面图: ${data.coverUrl ? '✓' : '✗'}`); | |
| 406 | +} | |
| 407 | + | |
| 408 | +main().catch(err => { | |
| 409 | + console.error('执行失败:', err); | |
| 410 | + process.exit(1); | |
| 411 | +}); | ... | ... |
system/templates/idearuncreate/.claude/skills/create-drama/insert-drama.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * insert-drama.js — 独立脚本:将 classic 类型剧本数据插入数据库 | |
| 4 | + * | |
| 5 | + * 用法: | |
| 6 | + * node insert-drama.js <json文件路径> | |
| 7 | + * echo '{"name":...}' | node insert-drama.js --stdin | |
| 8 | + * | |
| 9 | + * JSON 格式: | |
| 10 | + * { | |
| 11 | + * "name": "剧本名称", | |
| 12 | + * "title": "显示标题(可带 emoji)", | |
| 13 | + * "subtitle": "副标题", | |
| 14 | + * "description": "剧本简介", | |
| 15 | + * "systemPrompt": "用于 AI 续写的系统提示词", | |
| 16 | + * "isPublic": true, | |
| 17 | + * "userId": 1, | |
| 18 | + * "language": "en", | |
| 19 | + * "characters": [ | |
| 20 | + * { "name": "角色名", "gender": "male", "voiceId": "zh_male_m191_uranus_bigtts" } | |
| 21 | + * ], | |
| 22 | + * "script": [ | |
| 23 | + * { "character": "角色名", "dialogue": "台词", "avatar": "happy.png", "instruction": "用开心的语气说", "action": "none", "position": "left" } | |
| 24 | + * ] | |
| 25 | + * } | |
| 26 | + */ | |
| 27 | + | |
| 28 | +const path = require('path'); | |
| 29 | +const fs = require('fs'); | |
| 30 | + | |
| 31 | +// 加载 .env 文件(优先级:--env 参数 > 环境变量 DRAMA_ENV_PATH > 脚本同目录 .env) | |
| 32 | +function loadEnv() { | |
| 33 | + const envArgIdx = process.argv.indexOf('--env'); | |
| 34 | + const candidates = [ | |
| 35 | + envArgIdx !== -1 ? path.resolve(process.argv[envArgIdx + 1]) : null, | |
| 36 | + process.env.DRAMA_ENV_PATH ? path.resolve(process.env.DRAMA_ENV_PATH) : null, | |
| 37 | + path.resolve(__dirname, '.env'), | |
| 38 | + ].filter(Boolean); | |
| 39 | + | |
| 40 | + for (const envPath of candidates) { | |
| 41 | + if (fs.existsSync(envPath)) { | |
| 42 | + const envContent = fs.readFileSync(envPath, 'utf-8'); | |
| 43 | + for (const line of envContent.split('\n')) { | |
| 44 | + const trimmed = line.trim(); | |
| 45 | + if (!trimmed || trimmed.startsWith('#')) continue; | |
| 46 | + const eqIdx = trimmed.indexOf('='); | |
| 47 | + if (eqIdx === -1) continue; | |
| 48 | + const key = trimmed.slice(0, eqIdx).trim(); | |
| 49 | + const val = trimmed.slice(eqIdx + 1).trim(); | |
| 50 | + if (!process.env[key]) process.env[key] = val; | |
| 51 | + } | |
| 52 | + console.log(`已加载配置: ${envPath}`); | |
| 53 | + return; | |
| 54 | + } | |
| 55 | + } | |
| 56 | + console.log('未找到 .env 文件,使用环境变量或默认值'); | |
| 57 | +} | |
| 58 | +loadEnv(); | |
| 59 | + | |
| 60 | +const { Sequelize, DataTypes } = require('sequelize'); | |
| 61 | + | |
| 62 | +// ---------- DB 连接 ---------- | |
| 63 | +const sequelize = new Sequelize( | |
| 64 | + process.env.DB_NAME || 'shadiao_drama', | |
| 65 | + process.env.DB_USER || 'root', | |
| 66 | + process.env.DB_PASSWORD || '', | |
| 67 | + { | |
| 68 | + host: process.env.DB_HOST || '127.0.0.1', | |
| 69 | + port: parseInt(process.env.DB_PORT || '3306', 10), | |
| 70 | + dialect: 'mysql', | |
| 71 | + logging: false, | |
| 72 | + timezone: '+08:00', | |
| 73 | + define: { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' }, | |
| 74 | + } | |
| 75 | +); | |
| 76 | + | |
| 77 | +// ---------- Model 定义(内联,避免依赖项目代码) ---------- | |
| 78 | +const Drama = sequelize.define('Drama', { | |
| 79 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 80 | + dramaKey: { type: DataTypes.STRING(50), allowNull: false, unique: true, field: 'drama_key' }, | |
| 81 | + name: { type: DataTypes.STRING(100), allowNull: false }, | |
| 82 | + title: { type: DataTypes.STRING(100), allowNull: true }, | |
| 83 | + subtitle: { type: DataTypes.STRING(200), allowNull: true }, | |
| 84 | + description: { type: DataTypes.TEXT, allowNull: true }, | |
| 85 | + coverUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'cover_url' }, | |
| 86 | + backgroundUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'background_url' }, | |
| 87 | + systemPrompt: { type: DataTypes.TEXT, allowNull: true, field: 'system_prompt' }, | |
| 88 | + status: { type: DataTypes.TINYINT, defaultValue: 1 }, | |
| 89 | + userId: { type: DataTypes.INTEGER, allowNull: true, field: 'user_id' }, | |
| 90 | + isPublic: { type: DataTypes.BOOLEAN, defaultValue: true, field: 'is_public' }, | |
| 91 | + type: { type: DataTypes.STRING(30), defaultValue: 'classic' }, | |
| 92 | + emojiPack: { type: DataTypes.STRING(50), defaultValue: 'default', field: 'emoji_pack' }, | |
| 93 | + vrmConfig: { type: DataTypes.TEXT, allowNull: true, field: 'vrm_config' }, | |
| 94 | + tags: { type: DataTypes.STRING(255), allowNull: true }, | |
| 95 | + score: { type: DataTypes.DECIMAL(10, 2), defaultValue: 0 }, | |
| 96 | + language: { type: DataTypes.STRING(10), defaultValue: 'en' }, | |
| 97 | +}, { tableName: 'dramas', timestamps: true, underscored: true }); | |
| 98 | + | |
| 99 | +const DramaCharacter = sequelize.define('DramaCharacter', { | |
| 100 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 101 | + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, | |
| 102 | + characterName: { type: DataTypes.STRING(50), allowNull: false, field: 'character_name' }, | |
| 103 | + bodyUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'body_url' }, | |
| 104 | + faceOffsetTop: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_top' }, | |
| 105 | + faceOffsetLeft: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_left' }, | |
| 106 | + faceOffsetWidth: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_width' }, | |
| 107 | + voiceId: { type: DataTypes.STRING(100), allowNull: true, field: 'voice_id' }, | |
| 108 | + isDefault: { type: DataTypes.TINYINT, defaultValue: 0, field: 'is_default' }, | |
| 109 | +}, { tableName: 'drama_characters', timestamps: false, underscored: true }); | |
| 110 | + | |
| 111 | +const DramaEmotion = sequelize.define('DramaEmotion', { | |
| 112 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 113 | + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, | |
| 114 | + avatarKey: { type: DataTypes.STRING(50), allowNull: false, field: 'avatar_key' }, | |
| 115 | + faceUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'face_url' }, | |
| 116 | + emotionName: { type: DataTypes.STRING(30), allowNull: true, field: 'emotion_name' }, | |
| 117 | + emotionScale: { type: DataTypes.INTEGER, defaultValue: 3, field: 'emotion_scale' }, | |
| 118 | + isDefault: { type: DataTypes.TINYINT, defaultValue: 0, field: 'is_default' }, | |
| 119 | + characterName: { type: DataTypes.STRING(50), allowNull: true, field: 'character_name' }, | |
| 120 | +}, { tableName: 'drama_emotions', timestamps: false, underscored: true }); | |
| 121 | + | |
| 122 | +const DramaScript = sequelize.define('DramaScript', { | |
| 123 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 124 | + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, | |
| 125 | + seq: { type: DataTypes.INTEGER, allowNull: false }, | |
| 126 | + characterName: { type: DataTypes.STRING(50), allowNull: false, field: 'character_name' }, | |
| 127 | + avatar: { type: DataTypes.STRING(50), allowNull: true }, | |
| 128 | + dialogue: { type: DataTypes.TEXT, allowNull: false }, | |
| 129 | + action: { type: DataTypes.STRING(30), allowNull: true }, | |
| 130 | + position: { type: DataTypes.STRING(10), allowNull: true }, | |
| 131 | + sfx: { type: DataTypes.STRING(100), allowNull: true }, | |
| 132 | + audio: { type: DataTypes.STRING(500), allowNull: true }, | |
| 133 | +}, { tableName: 'drama_scripts', timestamps: false, underscored: true }); | |
| 134 | + | |
| 135 | +// ---------- 默认表情配置 ---------- | |
| 136 | +const DEFAULT_EMOJI_BASE = 'https://shadiao-drama.oss-cn-beijing.aliyuncs.com/dramas/zhenhuan_chuan/emojis'; | |
| 137 | +const DEFAULT_EMOJIS = [ | |
| 138 | + { key: 'normal.png', name: '正常', emotion: 'neutral', scale: 3 }, | |
| 139 | + { key: 'happy.png', name: '开心', emotion: 'happy', scale: 4 }, | |
| 140 | + { key: 'very_happy.png', name: '非常开心', emotion: 'happy', scale: 5 }, | |
| 141 | + { key: 'angry.png', name: '生气', emotion: 'angry', scale: 4 }, | |
| 142 | + { key: 'sad.png', name: '伤心', emotion: 'sad', scale: 4 }, | |
| 143 | + { key: 'cry.png', name: '哭', emotion: 'sad', scale: 5 }, | |
| 144 | + { key: 'shock.png', name: '惊讶', emotion: 'surprised', scale: 5 }, | |
| 145 | + { key: 'smirk.png', name: '撇嘴', emotion: 'happy', scale: 3 }, | |
| 146 | + { key: 'speechless.png', name: '无语', emotion: 'neutral', scale: 2 }, | |
| 147 | +]; | |
| 148 | + | |
| 149 | +// ---------- 音色情绪兼容映射 ---------- | |
| 150 | +// 所有 2.0 音色统一支持 9 种情感 | |
| 151 | +const COMMON_EMOTIONS = ['happy','sad','angry','surprised','fear','hate','excited','coldness','neutral']; | |
| 152 | + | |
| 153 | +function validateEmotion(speaker, emotion) { | |
| 154 | + if (!emotion || emotion === 'neutral') return emotion; | |
| 155 | + // 2.0 音色统一支持所有常见情感 | |
| 156 | + return COMMON_EMOTIONS.includes(emotion) ? emotion : 'neutral'; | |
| 157 | +} | |
| 158 | + | |
| 159 | +// ---------- 构建默认表情记录 ---------- | |
| 160 | +function buildEmotionRecords(dramaId, characters, defaultVoice) { | |
| 161 | + const records = []; | |
| 162 | + const charVoiceMap = {}; | |
| 163 | + if (characters) { | |
| 164 | + for (const char of characters) { | |
| 165 | + if (char.voiceId) charVoiceMap[char.name] = char.voiceId; | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + // 共享默认表情 | |
| 170 | + for (const emo of DEFAULT_EMOJIS) { | |
| 171 | + const validEmotion = validateEmotion(defaultVoice, emo.emotion) || 'neutral'; | |
| 172 | + records.push({ | |
| 173 | + dramaId, | |
| 174 | + avatarKey: emo.key, | |
| 175 | + faceUrl: `${DEFAULT_EMOJI_BASE}/${encodeURIComponent(emo.name)}.png`, | |
| 176 | + emotionName: validEmotion, | |
| 177 | + emotionScale: emo.scale, | |
| 178 | + isDefault: 0, | |
| 179 | + characterName: null, | |
| 180 | + }); | |
| 181 | + } | |
| 182 | + records.push({ | |
| 183 | + dramaId, | |
| 184 | + avatarKey: '_default', | |
| 185 | + faceUrl: `${DEFAULT_EMOJI_BASE}/${encodeURIComponent('正常')}.png`, | |
| 186 | + emotionName: 'neutral', | |
| 187 | + emotionScale: 3, | |
| 188 | + isDefault: 1, | |
| 189 | + characterName: null, | |
| 190 | + }); | |
| 191 | + | |
| 192 | + return records; | |
| 193 | +} | |
| 194 | + | |
| 195 | +// ---------- 主逻辑 ---------- | |
| 196 | +async function main() { | |
| 197 | + let jsonStr; | |
| 198 | + | |
| 199 | + if (process.argv.includes('--stdin')) { | |
| 200 | + // 从 stdin 读取 | |
| 201 | + jsonStr = ''; | |
| 202 | + for await (const chunk of process.stdin) { | |
| 203 | + jsonStr += chunk; | |
| 204 | + } | |
| 205 | + } else if (process.argv[2]) { | |
| 206 | + // 从文件读取 | |
| 207 | + const filePath = path.resolve(process.argv[2]); | |
| 208 | + if (!fs.existsSync(filePath)) { | |
| 209 | + console.error(`文件不存在: ${filePath}`); | |
| 210 | + process.exit(1); | |
| 211 | + } | |
| 212 | + jsonStr = fs.readFileSync(filePath, 'utf-8'); | |
| 213 | + } else { | |
| 214 | + console.error('用法: node insert-drama.js <json文件> 或 node insert-drama.js --stdin'); | |
| 215 | + process.exit(1); | |
| 216 | + } | |
| 217 | + | |
| 218 | + let data; | |
| 219 | + try { | |
| 220 | + data = JSON.parse(jsonStr); | |
| 221 | + } catch (e) { | |
| 222 | + console.error('JSON 解析失败:', e.message); | |
| 223 | + process.exit(1); | |
| 224 | + } | |
| 225 | + | |
| 226 | + const { name, title, subtitle, description, systemPrompt, isPublic, userId, characters, script, tags, language } = data; | |
| 227 | + | |
| 228 | + if (!name || !script || !Array.isArray(script) || script.length === 0) { | |
| 229 | + console.error('缺少必要字段: name, script'); | |
| 230 | + process.exit(1); | |
| 231 | + } | |
| 232 | + if (!characters || !Array.isArray(characters) || characters.length === 0) { | |
| 233 | + console.error('缺少必要字段: characters'); | |
| 234 | + process.exit(1); | |
| 235 | + } | |
| 236 | + | |
| 237 | + // 连接数据库 | |
| 238 | + await sequelize.authenticate(); | |
| 239 | + console.log('数据库连接成功'); | |
| 240 | + | |
| 241 | + const t = await sequelize.transaction(); | |
| 242 | + | |
| 243 | + try { | |
| 244 | + const dramaKey = `user_${userId || 1}_${Date.now().toString(36)}`; | |
| 245 | + | |
| 246 | + // 1. 创建 Drama 记录 | |
| 247 | + const drama = await Drama.create({ | |
| 248 | + dramaKey, | |
| 249 | + name, | |
| 250 | + title: title || name, | |
| 251 | + subtitle: subtitle || '', | |
| 252 | + description: description || '', | |
| 253 | + coverUrl: data.coverUrl || '', | |
| 254 | + backgroundUrl: data.backgroundUrl || '', | |
| 255 | + systemPrompt: systemPrompt || '', | |
| 256 | + status: 1, | |
| 257 | + userId: userId || null, | |
| 258 | + isPublic: isPublic !== false, | |
| 259 | + type: 'classic', | |
| 260 | + emojiPack: 'default', | |
| 261 | + tags: tags || null, | |
| 262 | + language: language || 'en', | |
| 263 | + }, { transaction: t }); | |
| 264 | + | |
| 265 | + // 2. 创建角色记录 | |
| 266 | + const charRecords = characters.map(char => ({ | |
| 267 | + dramaId: drama.id, | |
| 268 | + characterName: char.name, | |
| 269 | + bodyUrl: char.bodyUrl || '', | |
| 270 | + faceOffsetTop: char.faceOffset?.top || '8%', | |
| 271 | + faceOffsetLeft: char.faceOffset?.left || '35%', | |
| 272 | + faceOffsetWidth: char.faceOffset?.width || '30%', | |
| 273 | + voiceId: char.voiceId || '', | |
| 274 | + isDefault: char.isDefault ? 1 : 0, | |
| 275 | + })); | |
| 276 | + // 添加 _default 角色 | |
| 277 | + const firstChar = characters[0]; | |
| 278 | + charRecords.push({ | |
| 279 | + dramaId: drama.id, | |
| 280 | + characterName: '_default', | |
| 281 | + bodyUrl: firstChar.bodyUrl || '', | |
| 282 | + faceOffsetTop: firstChar.faceOffset?.top || '8%', | |
| 283 | + faceOffsetLeft: firstChar.faceOffset?.left || '35%', | |
| 284 | + faceOffsetWidth: firstChar.faceOffset?.width || '30%', | |
| 285 | + voiceId: firstChar.voiceId || '', | |
| 286 | + isDefault: 1, | |
| 287 | + }); | |
| 288 | + await DramaCharacter.bulkCreate(charRecords, { transaction: t }); | |
| 289 | + | |
| 290 | + // 3. 创建表情记录 | |
| 291 | + const defaultVoice = characters[0]?.voiceId || ''; | |
| 292 | + const emotionRecords = buildEmotionRecords(drama.id, characters, defaultVoice); | |
| 293 | + await DramaEmotion.bulkCreate(emotionRecords, { transaction: t }); | |
| 294 | + | |
| 295 | + // 4. 创建台词记录 | |
| 296 | + const scriptRecords = script.map((line, i) => ({ | |
| 297 | + dramaId: drama.id, | |
| 298 | + seq: i, | |
| 299 | + characterName: line.character, | |
| 300 | + avatar: line.avatar || 'normal.png', | |
| 301 | + dialogue: line.dialogue, | |
| 302 | + action: line.action || 'none', | |
| 303 | + position: line.position || 'left', | |
| 304 | + sfx: line.sfx || null, | |
| 305 | + audio: null, | |
| 306 | + })); | |
| 307 | + await DramaScript.bulkCreate(scriptRecords, { transaction: t }); | |
| 308 | + | |
| 309 | + await t.commit(); | |
| 310 | + | |
| 311 | + console.log(`\n剧本创建成功!`); | |
| 312 | + console.log(` ID: ${drama.id}`); | |
| 313 | + console.log(` Key: ${dramaKey}`); | |
| 314 | + console.log(` 名称: ${name}`); | |
| 315 | + console.log(` 角色数: ${characters.length}`); | |
| 316 | + console.log(` 台词数: ${script.length}`); | |
| 317 | + | |
| 318 | + // 输出 JSON 结果供调用方解析 | |
| 319 | + console.log(`\n__RESULT__${JSON.stringify({ id: drama.id, dramaKey, name })}__END__`); | |
| 320 | + } catch (err) { | |
| 321 | + await t.rollback(); | |
| 322 | + console.error('插入失败:', err.message); | |
| 323 | + process.exit(1); | |
| 324 | + } finally { | |
| 325 | + await sequelize.close(); | |
| 326 | + } | |
| 327 | +} | |
| 328 | + | |
| 329 | +main().catch(err => { | |
| 330 | + console.error('执行失败:', err); | |
| 331 | + process.exit(1); | |
| 332 | +}); | ... | ... |
system/templates/idearuncreate/.claude/skills/create-drama/reference.md
0 → 100644
| 1 | +# 音色参考文档 | |
| 2 | + | |
| 3 | +## 重要说明 | |
| 4 | + | |
| 5 | +所有音色现已升级为 2.0 版本,统一支持以下 9 种情感: | |
| 6 | +``` | |
| 7 | +happy, sad, angry, surprised, fear, hate, excited, coldness, neutral | |
| 8 | +``` | |
| 9 | + | |
| 10 | +不再需要查看每个音色单独支持哪些情感 — 全部音色都支持上述所有情感。 | |
| 11 | + | |
| 12 | +## 中文 2.0 音色(uranus) | |
| 13 | + | |
| 14 | +| 音色名称 | voice_type | 性别 | 风格 | | |
| 15 | +|---------|-----------|------|------| | |
| 16 | +| Vivi 2.0 | `zh_female_vv_uranus_bigtts` | 女 | 通用女声 | | |
| 17 | +| 小何 2.0 | `zh_female_xiaohe_uranus_bigtts` | 女 | 通用女声 | | |
| 18 | +| 清新女声 2.0 | `zh_female_qingxinnvsheng_uranus_bigtts` | 女 | 清新自然 | | |
| 19 | +| 知性灿灿 2.0 | `zh_female_cancan_uranus_bigtts` | 女 | 知性优雅 | | |
| 20 | +| 撒娇学妹 2.0 | `zh_female_sajiaoxuemei_uranus_bigtts` | 女 | 撒娇甜美 | | |
| 21 | +| 甜美小源 2.0 | `zh_female_tianmeixiaoyuan_uranus_bigtts` | 女 | 甜美可爱 | | |
| 22 | +| 甜美桃子 2.0 | `zh_female_tianmeitaozi_uranus_bigtts` | 女 | 甜美可爱 | | |
| 23 | +| 爽快思思 2.0 | `zh_female_shuangkuaisisi_uranus_bigtts` | 女 | 爽快直率 | | |
| 24 | +| 邻家女孩 2.0 | `zh_female_linjianvhai_uranus_bigtts` | 女 | 清新邻家 | | |
| 25 | +| 暖阳女声 2.0 | `zh_female_kefunvsheng_uranus_bigtts` | 女 | 温暖亲切 | | |
| 26 | +| 魅力女友 2.0 | `zh_female_meilinvyou_uranus_bigtts` | 女 | 魅力女友 | | |
| 27 | +| 流畅女声 2.0 | `zh_female_liuchangnv_uranus_bigtts` | 女 | 流畅自然 | | |
| 28 | +| 鸡汤女 2.0 | `zh_female_jitangnv_uranus_bigtts` | 女 | 治愈鸡汤 | | |
| 29 | +| 儿童绘本 2.0 | `zh_female_xiaoxue_uranus_bigtts` | 女 | 儿童/绘本 | | |
| 30 | +| Tina老师 2.0 | `zh_female_yingyujiaoxue_uranus_bigtts` | 女 | 英语教学 | | |
| 31 | +| 佩奇猪 2.0 | `zh_female_peiqi_uranus_bigtts` | 女 | IP 趣味 | | |
| 32 | +| 黑猫侦探社咪仔 2.0 | `zh_female_mizai_uranus_bigtts` | 女 | IP 趣味 | | |
| 33 | +| 云舟 2.0 | `zh_male_m191_uranus_bigtts` | 男 | 通用男声 | | |
| 34 | +| 小天 2.0 | `zh_male_taocheng_uranus_bigtts` | 男 | 通用男声 | | |
| 35 | +| 刘飞 2.0 | `zh_male_liufei_uranus_bigtts` | 男 | 通用男声 | | |
| 36 | +| 魅力苏菲 2.0 | `zh_male_sophie_uranus_bigtts` | 男 | 魅力男声 | | |
| 37 | +| 少年梓辛 2.0 | `zh_male_shaonianzixin_uranus_bigtts` | 男 | 少年音 | | |
| 38 | +| 大壹 2.0 | `zh_male_dayi_uranus_bigtts` | 男 | 成熟男声 | | |
| 39 | +| 儒雅逸辰 2.0 | `zh_male_ruyayichen_uranus_bigtts` | 男 | 儒雅成熟 | | |
| 40 | +| 猴哥 2.0 | `zh_male_sunwukong_uranus_bigtts` | 男 | IP 趣味 | | |
| 41 | + | |
| 42 | +## 英文 2.0 音色(uranus) | |
| 43 | + | |
| 44 | +| 音色名称 | voice_type | 性别 | | |
| 45 | +|---------|-----------|------| | |
| 46 | +| Tim | `en_male_tim_uranus_bigtts` | 男 | | |
| 47 | +| Dacey | `en_female_dacey_uranus_bigtts` | 女 | | |
| 48 | +| Stokie | `en_female_stokie_uranus_bigtts` | 女 | | |
| 49 | + | |
| 50 | +## Saturn 音色(角色扮演 / 特定风格) | |
| 51 | + | |
| 52 | +| 音色名称 | voice_type | 性别 | 风格 | | |
| 53 | +|---------|-----------|------|------| | |
| 54 | +| 可爱女生 | `saturn_zh_female_keainvsheng_tob` | 女 | 可爱 | | |
| 55 | +| 调皮公主 | `saturn_zh_female_tiaopigongzhu_tob` | 女 | 调皮 | | |
| 56 | +| 知性灿灿 | `saturn_zh_female_cancan_tob` | 女 | 知性 | | |
| 57 | +| 轻盈朵朵 2.0 | `saturn_zh_female_qingyingduoduo_cs_tob` | 女 | 轻盈活泼 | | |
| 58 | +| 温婉珊珊 2.0 | `saturn_zh_female_wenwanshanshan_cs_tob` | 女 | 温婉 | | |
| 59 | +| 热情艾娜 2.0 | `saturn_zh_female_reqingaina_cs_tob` | 女 | 热情 | | |
| 60 | +| 爽朗少年 | `saturn_zh_male_shuanglangshaonian_tob` | 男 | 爽朗少年 | | |
| 61 | +| 天才同桌 | `saturn_zh_male_tiancaitongzhuo_tob` | 男 | 活泼男生 | | |
| 62 | + | |
| 63 | +## 选择建议 | |
| 64 | + | |
| 65 | +- **中文男主角**: `zh_male_m191_uranus_bigtts`(云舟)、`zh_male_ruyayichen_uranus_bigtts`(儒雅逸辰) | |
| 66 | +- **中文女主角**: `zh_female_cancan_uranus_bigtts`(知性灿灿)、`zh_female_meilinvyou_uranus_bigtts`(魅力女友) | |
| 67 | +- **少年/年轻男**: `zh_male_shaonianzixin_uranus_bigtts`(少年梓辛)、`saturn_zh_male_shuanglangshaonian_tob`(爽朗少年) | |
| 68 | +- **甜美/撒娇女**: `zh_female_sajiaoxuemei_uranus_bigtts`(撒娇学妹)、`zh_female_tianmeitaozi_uranus_bigtts`(甜美桃子) | |
| 69 | +- **爽快/直率女**: `zh_female_shuangkuaisisi_uranus_bigtts`(爽快思思) | |
| 70 | +- **成熟男**: `zh_male_dayi_uranus_bigtts`(大壹)、`zh_male_sophie_uranus_bigtts`(魅力苏菲) | |
| 71 | +- **搞笑/IP**: `zh_male_sunwukong_uranus_bigtts`(猴哥)、`zh_female_peiqi_uranus_bigtts`(佩奇猪) | |
| 72 | +- **英文男**: `en_male_tim_uranus_bigtts`(Tim) | |
| 73 | +- **英文女**: `en_female_dacey_uranus_bigtts`(Dacey)、`en_female_stokie_uranus_bigtts`(Stokie) | |
| 74 | + | |
| 75 | +## 情感参数 | |
| 76 | + | |
| 77 | +所有音色统一支持 9 种情感: | |
| 78 | +``` | |
| 79 | +happy, sad, angry, surprised, fear, hate, excited, coldness, neutral | |
| 80 | +``` | ... | ... |
system/templates/idearuncreate/agent.md
| 1 | -# Agent 信息 | |
| 1 | +# 创意跑酷生成师 | |
| 2 | 2 | |
| 3 | -## 基础信息 | |
| 4 | -- ID: test:idearun-create | |
| 5 | -- 名称: 创意跑酷生成师 | |
| 6 | -- Emoji: 🎬 | |
| 7 | -- 类型: Skill Agent | |
| 3 | +## 身份 | |
| 4 | +你是 创意跑酷生成师。 | |
| 8 | 5 | |
| 9 | -## 角色定位 | |
| 10 | -你是 LemonAI 平台的**创意跑酷生成师**,专门负责将各种素材(新闻、故事、段子、历史事件等)转化为完整的经典互动剧本。 | |
| 11 | - | |
| 12 | -## 核心能力 | |
| 13 | -- **create-drama**: 根据素材自动生成完整的 classic 互动剧本(含角色立绘、背景图、封面图、台词脚本) | |
| 14 | - | |
| 15 | -## 输出格式 | |
| 16 | -返回剧本创建结果,包含: | |
| 17 | -- dramaKey | |
| 18 | -- 角色列表及音色配置 | |
| 19 | -- 台词数量 | |
| 20 | -- 图片生成状态 | |
| 21 | -- 简要剧情概述 | |
| 6 | +## 职责 | |
| 7 | +(待上层 Agent 定义) | |
| 22 | 8 | |
| 23 | 9 | ## Never Rules |
| 24 | -- 不越级管理其他 Agent | |
| 25 | -- 不在没有素材的情况下生成剧本 | |
| 10 | +- 不要越级管理不属于你的 Agent | |
| 26 | 11 | |
| 27 | 12 | ## Always Rules |
| 28 | -- 主动检查素材可用性 | |
| 29 | -- 确保图片生成成功后再写入数据库 | |
| 30 | 13 | \ No newline at end of file |
| 14 | +- 按心跳频率主动检查工作进展 | |
| 15 | + | |
| 16 | +## 可用工具 | |
| 17 | +# 无管理工具 — Skill 只负责执行, 不管理其他 Agent | |
| 18 | + | |
| 19 | +--- | |
| 20 | + | |
| 21 | +## 所属公司 | |
| 22 | +- **公司ID**: test | |
| 23 | +- **公司名称**: Test公司 | |
| 24 | +- **公司使命**: 测试公司 | |
| 25 | + | |
| 26 | +> 你是 **Test公司** 的员工。你的所有工作都应围绕公司的使命和目标展开。 | ... | ... |
system/templates/idearuncreate/config.json
| 1 | 1 | { |
| 2 | - "id": "idearuncreate", | |
| 3 | - "type": "skill", | |
| 4 | - "name": "idearun create", | |
| 5 | - "parent_id": null, | |
| 2 | + "id": "idearrun-create", | |
| 3 | + "type": "specialist", | |
| 4 | + "name": "创意跑酷生成师", | |
| 5 | + "parent_id": "ceo", | |
| 6 | 6 | "goal_id": null, |
| 7 | - "status": "ready", | |
| 7 | + "status": "training", | |
| 8 | 8 | "cli": { |
| 9 | 9 | "toolPolicy": {} |
| 10 | 10 | }, |
| ... | ... | @@ -22,5 +22,5 @@ |
| 22 | 22 | "maxDiskMB": 1024, |
| 23 | 23 | "maxProcesses": 5 |
| 24 | 24 | }, |
| 25 | - "description": "idearun_create" | |
| 25 | + "companyId": "test" | |
| 26 | 26 | } |
| 27 | 27 | \ No newline at end of file | ... | ... |
system/templates/idearuncreate/identity.md
| 1 | -# 身份定义 | |
| 1 | +# 身份信息 | |
| 2 | 2 | |
| 3 | 3 | ## 基础信息 |
| 4 | 4 | - 名称: 创意跑酷生成师 |
| 5 | -- Emoji: 🎬 | |
| 6 | -- 问候语: "你好!我是创意跑酷生成师,可以将你的素材变成有趣的互动剧本~" | |
| 5 | +- Emoji: 🤖 | |
| 6 | +- 类型: specialist | |
| 7 | 7 | |
| 8 | -## 职责 | |
| 9 | -将各种素材(新闻、故事、段子、历史事件等)转化为完整的经典类型互动剧本。 | |
| 10 | - | |
| 11 | -## 核心能力 | |
| 12 | -- 根据素材自动生成剧本 JSON(角色、台词、表情、动作) | |
| 13 | -- AI 生成角色立绘图并自动去背景 | |
| 14 | -- 生成背景图和封面图 | |
| 15 | -- 写入数据库 | |
| 16 | - | |
| 17 | -## 支持的素材类型 | |
| 18 | -- 新闻报道 | |
| 19 | -- 故事/段子 | |
| 20 | -- 历史事件 | |
| 21 | -- 创意想法 | |
| 22 | -- 文件路径(需先读取文件内容) | |
| 23 | - | |
| 24 | -## 输出格式 | |
| 25 | -```json | |
| 26 | -{ | |
| 27 | - "dramaKey": "剧本唯一标识", | |
| 28 | - "name": "剧本名称", | |
| 29 | - "title": "显示标题", | |
| 30 | - "subtitle": "副标题", | |
| 31 | - "description": "剧本简介", | |
| 32 | - "characters": [ | |
| 33 | - { | |
| 34 | - "name": "角色名", | |
| 35 | - "gender": "male/female", | |
| 36 | - "voiceId": "音色ID", | |
| 37 | - "bodyUrl": "立绘图OSS URL" | |
| 38 | - } | |
| 39 | - ], | |
| 40 | - "script": [ | |
| 41 | - { | |
| 42 | - "character": "角色名", | |
| 43 | - "dialogue": "台词内容", | |
| 44 | - "avatar": "表情", | |
| 45 | - "action": "动画效果", | |
| 46 | - "instruction": "语音情感指令" | |
| 47 | - } | |
| 48 | - ], | |
| 49 | - "backgroundUrl": "背景图URL", | |
| 50 | - "coverUrl": "封面图URL" | |
| 51 | -} | |
| 52 | -``` | |
| 53 | - | |
| 54 | -## Never Rules | |
| 55 | -- 不生成没有素材的凭空剧本 | |
| 56 | -- 不越级管理其他 Agent | |
| 57 | - | |
| 58 | -## Always Rules | |
| 59 | -- 按心跳频率检查工作进展 | |
| 60 | -- 确保图片生成成功后再写入数据库 | |
| 61 | 8 | \ No newline at end of file |
| 9 | +## 问候语 | |
| 10 | +"你好,我是 创意跑酷生成师。" | ... | ... |
system/templates/idearuncreate/plan.md
| ... | ... | @@ -4,7 +4,4 @@ |
| 4 | 4 | |
| 5 | 5 | | # | 子任务 | 责任人 | 状态 | 上次检查 | 备注 | |
| 6 | 6 | |---|--------|--------|------|----------|------| |
| 7 | -| 1 | Phase 1: HUNT - 知识猎取 | hunter | ✅ 已完成 | 2026-03-30 | 产出: knowhow.md, toplist.md, crawl/ | | |
| 8 | -| 2 | Phase 2: DIGEST - 知识消化 | digester | ✅ 已完成 | 2026-03-30 | 产出: benchmark_v1.md, good/bad_testdata | | |
| 9 | -| 3 | Phase 3: EVOLVING - 技能进化 | evolver | 🔄 执行中 | 2026-03-30 | taskId: T-mnczm565 | | |
| 10 | -| 4 | Phase 4: Deploy - 技能安装 | innovator | ⏳ 待执行 | | 需达标后执行 | | |
| 7 | +| | (暂无计划) | | | | | | ... | ... |
system/templates/idearuncreate/soul.md
| 1 | -# 灵魂定义 | |
| 1 | +# 创意跑酷生成师 的灵魂 | |
| 2 | 2 | |
| 3 | 3 | ## 思维方式 |
| 4 | -你是内容创作者 + 转化器。你的工作是将原始素材"翻译"成适合互动剧表演的格式。擅长识别素材中的戏剧冲突点,将其放大并转化为角色对话。 | |
| 4 | +(待上层 Agent 定义) | |
| 5 | 5 | |
| 6 | 6 | ## 沟通风格 |
| 7 | -- 清晰、简洁、带有幽默感 | |
| 8 | -- 主动汇报工作进度(素材分析完成 → 剧本生成 → 图片生成 → 数据库写入) | |
| 9 | -- 结果导向,明确告知成功/失败状态 | |
| 7 | +- 清晰、简洁 | |
| 10 | 8 | |
| 11 | 9 | ## 价值观 |
| 12 | -- **高质量交付**: 确保每一步都正确执行后再进入下一步 | |
| 13 | -- **素材还原**: 保留素材核心信息,用沙雕风格呈现 | |
| 14 | -- **用户透明**: 及时告知每个阶段的结果 | |
| 15 | - | |
| 16 | -## 工作流程 | |
| 17 | -1. 分析素材 → 2. 生成剧本 JSON → 3. 生成图片 → 4. 写入数据库 → 5. 输出结果 | |
| 18 | 10 | \ No newline at end of file |
| 11 | +- 高质量交付 | |
| 12 | +- 持续改进 | ... | ... |
system/templates/idearuncreate/tools.md
| 1 | 1 | # 工具使用备注 |
| 2 | 2 | |
| 3 | -## 可用工具 | |
| 4 | -- **Read**: 读取素材文件内容 | |
| 5 | -- **Write**: 写入临时 JSON 文件 | |
| 6 | -- **Bash**: 执行 Node.js 脚本生成图片和写入数据库 | |
| 7 | - | |
| 8 | -## 技能脚本(已在 skills/ 目录) | |
| 9 | -1. `generate-images.js` — AI 生图 + 去背景 + OSS 上传 | |
| 10 | -2. `insert-drama.js` — 写入 MySQL 数据库 | |
| 11 | - | |
| 12 | 3 | ## 安全规则 |
| 13 | -- 不在日志中输出 API Key | |
| 14 | -- 不在 memory 中存储密码或敏感信息 | |
| 15 | -- API Token 使用环境变量,不硬编码 | |
| 16 | -- 所有临时文件放在 /tmp/ 目录 | |
| 17 | - | |
| 18 | -## 配置说明 | |
| 19 | -脚本从以下位置加载配置(优先级从高到低): | |
| 20 | -1. `--env <path>` 参数 | |
| 21 | -2. 环境变量 `DRAMA_ENV_PATH` | |
| 22 | -3. skills/.env 文件 | |
| 4 | +- 不要在日志中输出 API Key | |
| 5 | +- 不要在 memory 中存储密码 | |
| 6 | +- API Token 使用 env 变量, 不要硬编码 | |
| 23 | 7 | |
| 24 | -所需配置项: | |
| 25 | -- 数据库: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME | |
| 26 | -- OSS: OSS_REGION, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_BUCKET, OSS_ENDPOINT | |
| 27 | -- 生图 API: WANJIE_API_KEY | |
| 28 | 8 | \ No newline at end of file |
| 9 | +## 文件操作 | |
| 10 | +- 所有自主创建的文件仅限在 workspace/ 内操作 | ... | ... |
system/templates/idearuncreate/user.md
| 1 | -# 用户上下文 | |
| 1 | +# 与上级的关系 | |
| 2 | 2 | |
| 3 | -## 我的上级 | |
| 4 | -直接向用户(User)汇报,无上级 Agent。 | |
| 3 | +## 所属公司 | |
| 4 | +- **公司**: Test公司 (test) | |
| 5 | 5 | |
| 6 | -## 典型任务 | |
| 7 | -接收用户提供的素材(新闻、故事、段子等),生成完整的互动剧本。 | |
| 6 | +## 汇报对象 | |
| 7 | +- **直接汇报**: CEO (公司 CEO) | |
| 8 | +- **协同**: 公司内其他同事 | |
| 8 | 9 | |
| 9 | -## 用户偏好 | |
| 10 | -- 用户会提供素材内容或素材文件路径 | |
| 11 | -- 用户期望获得完整的剧本:角色立绘 + 台词 + 背景图 + 封面图 | |
| 12 | -- 用户偏好沙雕搞笑风格,但保留素材核心信息 | |
| 13 | - | |
| 14 | -## 工作触发 | |
| 15 | -等待用户通过消息提供素材内容。 | |
| 16 | - | |
| 17 | -## 反馈格式 | |
| 18 | -任务完成后告知: | |
| 19 | -- 剧本名称 (dramaKey) | |
| 20 | -- 角色数量及音色 | |
| 21 | -- 台词数量 | |
| 22 | -- 图片生成状态(成功/失败) | |
| 23 | -- 简要剧情概述 | |
| 24 | 10 | \ No newline at end of file |
| 11 | +## 沟通规则 | |
| 12 | +1. 完成任务后主动向上级汇报结果 | |
| 13 | +2. 遇到阻碍时及时上报,附带解决建议 | |
| 14 | +3. 保持透明,坏消息更要及时同步 | ... | ... |
web/src/views/CompanyDashboard.vue
| ... | ... | @@ -197,7 +197,7 @@ |
| 197 | 197 | <div v-if="activeTab === 'agents'" class="tab-content"> |
| 198 | 198 | <div class="section-header"> |
| 199 | 199 | <h3>团队成员</h3> |
| 200 | - <button class="btn-sm" @click="showHireModal = true">+ 招聘</button> | |
| 200 | + <button class="btn-sm" @click="openHireModal">+ 招聘</button> | |
| 201 | 201 | </div> |
| 202 | 202 | <div class="agents-grid"> |
| 203 | 203 | <div v-for="agent in companyAgents" :key="agent.id" class="agent-card"> |
| ... | ... | @@ -610,33 +610,84 @@ |
| 610 | 610 | </section> |
| 611 | 611 | |
| 612 | 612 | |
| 613 | - <!-- Hire Modal --> | |
| 614 | - <div v-if="showHireModal" class="modal-overlay" @click="showHireModal = false"> | |
| 615 | - <div class="modal-content" style="max-width: 500px;" @click.stop> | |
| 616 | - <div class="modal-header"> | |
| 617 | - <h3>+ 雇佣新 Agent</h3> | |
| 618 | - <button class="btn-close" @click="showHireModal = false">✕</button> | |
| 613 | + <!-- Hire Modal (Redesigned) --> | |
| 614 | + <div v-if="showHireModal" class="hire-modal-overlay" @click="showHireModal = false"> | |
| 615 | + <div class="hire-modal-content" @click.stop> | |
| 616 | + <div class="hire-modal-header"> | |
| 617 | + <div class="header-title-box"> | |
| 618 | + <span class="header-icon">🚀</span> | |
| 619 | + <div> | |
| 620 | + <h3>招募团队成员</h3> | |
| 621 | + <p>从模板库中检索并批量发下 Offer</p> | |
| 622 | + </div> | |
| 623 | + </div> | |
| 624 | + <button class="btn-close-fancy" @click="showHireModal = false">✕</button> | |
| 619 | 625 | </div> |
| 620 | - <div class="modal-body" style="display: flex; flex-direction: column; gap: 16px;"> | |
| 621 | - <div> | |
| 622 | - <label style="font-size: 13px; font-weight: 600; margin-bottom: 6px; display: block; color: var(--text-primary);">从模板库选择角色 (Role)</label> | |
| 623 | - <select v-model="hireForm.role" class="form-input" style="width: 100%;"> | |
| 624 | - <option value="">-- 请选择预置 Agent 模板 --</option> | |
| 625 | - <option v-for="tpl in templates" :key="tpl.id" :value="tpl.id"> | |
| 626 | - {{ agentEmoji(tpl.type) }} {{ tpl.name }} ({{ tpl.id }}) | |
| 627 | - </option> | |
| 628 | - </select> | |
| 626 | + | |
| 627 | + <div class="hire-modal-toolbar"> | |
| 628 | + <div class="search-box-fancy"> | |
| 629 | + <span class="search-icon">🔍</span> | |
| 630 | + <input v-model="hireSearchQuery" placeholder="搜索 Agent 名称、ID 或描述..." /> | |
| 629 | 631 | </div> |
| 630 | - <div v-if="hireForm.role"> | |
| 631 | - <label style="font-size: 13px; font-weight: 600; margin-bottom: 6px; display: block; color: var(--text-primary);">自定义花名 (可选)</label> | |
| 632 | - <input v-model="hireForm.name" class="form-input" style="width: 100%;" placeholder="留空则使用默认名称" /> | |
| 632 | + <div class="filter-group"> | |
| 633 | + <div class="fancy-select-wrap"> | |
| 634 | + <span class="select-icon">🏷️</span> | |
| 635 | + <select v-model="hireFilterType"> | |
| 636 | + <option value="">所有类型</option> | |
| 637 | + <option v-for="t in uniqueTemplateTypes" :key="t" :value="t">{{ t }}</option> | |
| 638 | + </select> | |
| 639 | + </div> | |
| 640 | + <div class="fancy-select-wrap"> | |
| 641 | + <span class="select-icon">⬆️</span> | |
| 642 | + <select v-model="hireSortBy"> | |
| 643 | + <option value="score">按评分最高</option> | |
| 644 | + <option value="used">按热度最高</option> | |
| 645 | + <option value="name">按名称排序</option> | |
| 646 | + </select> | |
| 647 | + </div> | |
| 633 | 648 | </div> |
| 634 | 649 | </div> |
| 635 | - <div class="modal-footer" style="padding-top: 16px; margin-top: 8px;"> | |
| 636 | - <button class="btn-cancel" @click="showHireModal = false" style="padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border-primary); background: transparent; color: var(--text-primary); cursor: pointer;">取消</button> | |
| 637 | - <button class="btn-install-large" :disabled="!hireForm.role || hiring" @click="submitHire" style="padding: 6px 16px; font-size: 13px;"> | |
| 638 | - {{ hiring ? '⏳ 办理入职中...' : '🎉 正式雇佣' }} | |
| 639 | - </button> | |
| 650 | + | |
| 651 | + <div class="hire-modal-body"> | |
| 652 | + <div class="hire-grid"> | |
| 653 | + <div | |
| 654 | + v-for="tpl in filteredTemplates" | |
| 655 | + :key="tpl.id" | |
| 656 | + class="hire-card" | |
| 657 | + :class="{ 'is-selected': selectedTemplates.includes(tpl.id) }" | |
| 658 | + @click="toggleTemplateSelection(tpl.id)" | |
| 659 | + > | |
| 660 | + <div class="hire-card-checkbox"> | |
| 661 | + <div class="check-circle">✓</div> | |
| 662 | + </div> | |
| 663 | + <div class="hire-card-header"> | |
| 664 | + <span class="hire-card-emoji">{{ agentEmoji(tpl.type) }}</span> | |
| 665 | + <span class="hire-card-name">{{ tpl.name }}</span> | |
| 666 | + </div> | |
| 667 | + <p class="hire-card-desc">{{ tpl.description || '暂无描述信息' }}</p> | |
| 668 | + <div class="hire-card-meta"> | |
| 669 | + <span class="badge-type">{{ tpl.type }}</span> | |
| 670 | + <span v-if="tpl.benchmarkScore" class="badge-score">⭐ {{ tpl.benchmarkScore }}</span> | |
| 671 | + <span class="badge-used">🔥 {{ tpl.usedCount }} 次</span> | |
| 672 | + </div> | |
| 673 | + </div> | |
| 674 | + <div v-if="filteredTemplates.length === 0" class="hire-empty"> | |
| 675 | + <span>📭</span> | |
| 676 | + <p>没有找到符合条件的模板 Agent</p> | |
| 677 | + </div> | |
| 678 | + </div> | |
| 679 | + </div> | |
| 680 | + | |
| 681 | + <div class="hire-modal-footer"> | |
| 682 | + <div class="hire-selection-info"> | |
| 683 | + 已选择 <span class="highlight-count">{{ selectedTemplates.length }}</span> 名成员 | |
| 684 | + </div> | |
| 685 | + <div class="hire-actions"> | |
| 686 | + <button class="btn-fancy-cancel" @click="showHireModal = false">取消</button> | |
| 687 | + <button class="btn-fancy-submit" :disabled="selectedTemplates.length === 0 || hiring" @click="submitHire"> | |
| 688 | + {{ hiring ? '⏳ 办理入职中...' : `🎉 一键录用 (${selectedTemplates.length})` }} | |
| 689 | + </button> | |
| 690 | + </div> | |
| 640 | 691 | </div> |
| 641 | 692 | </div> |
| 642 | 693 | </div> |
| ... | ... | @@ -695,28 +746,79 @@ const agentSkills = ref([]) |
| 695 | 746 | |
| 696 | 747 | const showHireModal = ref(false) |
| 697 | 748 | const hiring = ref(false) |
| 698 | -const hireForm = ref({ role: '', name: '' }) | |
| 749 | +const hireSearchQuery = ref('') | |
| 750 | +const hireFilterType = ref('') | |
| 751 | +const hireSortBy = ref('score') | |
| 752 | +const selectedTemplates = ref([]) | |
| 753 | + | |
| 754 | +const uniqueTemplateTypes = computed(() => { | |
| 755 | + const types = new Set(templates.value.map(t => t.type).filter(Boolean)) | |
| 756 | + return Array.from(types).sort() | |
| 757 | +}) | |
| 758 | + | |
| 759 | +const filteredTemplates = computed(() => { | |
| 760 | + let list = templates.value | |
| 761 | + | |
| 762 | + if (hireFilterType.value) { | |
| 763 | + list = list.filter(t => t.type === hireFilterType.value) | |
| 764 | + } | |
| 765 | + | |
| 766 | + if (hireSearchQuery.value) { | |
| 767 | + const q = hireSearchQuery.value.toLowerCase() | |
| 768 | + list = list.filter(t => | |
| 769 | + t.name.toLowerCase().includes(q) || | |
| 770 | + t.id.toLowerCase().includes(q) || | |
| 771 | + (t.description && t.description.toLowerCase().includes(q)) | |
| 772 | + ) | |
| 773 | + } | |
| 774 | + | |
| 775 | + list = list.slice().sort((a, b) => { | |
| 776 | + if (hireSortBy.value === 'score') { | |
| 777 | + return (b.benchmarkScore || 0) - (a.benchmarkScore || 0) | |
| 778 | + } else if (hireSortBy.value === 'used') { | |
| 779 | + return (b.usedCount || 0) - (a.usedCount || 0) | |
| 780 | + } else { | |
| 781 | + return a.name.localeCompare(b.name) | |
| 782 | + } | |
| 783 | + }) | |
| 784 | + | |
| 785 | + return list | |
| 786 | +}) | |
| 787 | + | |
| 788 | +function toggleTemplateSelection(tplId) { | |
| 789 | + if (selectedTemplates.value.includes(tplId)) { | |
| 790 | + selectedTemplates.value = selectedTemplates.value.filter(id => id !== tplId) | |
| 791 | + } else { | |
| 792 | + selectedTemplates.value.push(tplId) | |
| 793 | + } | |
| 794 | +} | |
| 699 | 795 | |
| 700 | 796 | function openHireModal() { |
| 701 | - hireForm.value = { role: '', name: '' }; | |
| 797 | + selectedTemplates.value = []; | |
| 798 | + hireSearchQuery.value = ''; | |
| 799 | + hireFilterType.value = ''; | |
| 800 | + hireSortBy.value = 'score'; | |
| 702 | 801 | showHireModal.value = true; |
| 703 | 802 | if (templates.value.length === 0) loadTemplates(); |
| 704 | 803 | } |
| 705 | 804 | |
| 706 | 805 | async function submitHire() { |
| 707 | - if (!hireForm.value.role || !selectedCompany.value) return; | |
| 806 | + if (selectedTemplates.value.length === 0 || !selectedCompany.value) return; | |
| 708 | 807 | hiring.value = true; |
| 709 | 808 | try { |
| 710 | - const tpl = templates.value.find(t => t.id === hireForm.value.role); | |
| 711 | - await axios.post(`/api/company/${selectedCompany.value.id}/hire`, { | |
| 712 | - role: hireForm.value.role, | |
| 713 | - name: hireForm.value.name || undefined, | |
| 714 | - type: tpl?.type, | |
| 715 | - description: tpl?.description | |
| 716 | - }); | |
| 809 | + const companyId = selectedCompany.value.id; | |
| 810 | + for (const roleId of selectedTemplates.value) { | |
| 811 | + const tpl = templates.value.find(t => t.id === roleId); | |
| 812 | + if (!tpl) continue; | |
| 813 | + await axios.post(`/api/company/${companyId}/hire`, { | |
| 814 | + role: roleId, | |
| 815 | + type: tpl?.type, | |
| 816 | + description: tpl?.description | |
| 817 | + }); | |
| 818 | + } | |
| 717 | 819 | showHireModal.value = false; |
| 718 | - await loadCompanyStatus(selectedCompany.value.id); | |
| 719 | - await loadCompanyAgents(selectedCompany.value.id); | |
| 820 | + await loadCompanyStatus(companyId); | |
| 821 | + await loadCompanyAgents(companyId); | |
| 720 | 822 | } catch (e) { |
| 721 | 823 | alert(`❌ 雇佣失败: ${e.response?.data?.error || e.message}`); |
| 722 | 824 | } finally { |
| ... | ... | @@ -2050,4 +2152,122 @@ function closeDropdown() { |
| 2050 | 2152 | .btn-install-large { padding: 10px 20px; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; } |
| 2051 | 2153 | .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } |
| 2052 | 2154 | .modal-content { background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: 12px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; } |
| 2155 | + | |
| 2156 | +/* ═══ Fancy Hire Modal ═══ */ | |
| 2157 | +.hire-modal-overlay { | |
| 2158 | + position: fixed; inset: 0; background: rgba(0,0,0,0.65); | |
| 2159 | + backdrop-filter: blur(8px); | |
| 2160 | + display: flex; align-items: center; justify-content: center; z-index: 1000; | |
| 2161 | + animation: fadeIn 0.3s ease; | |
| 2162 | +} | |
| 2163 | +.hire-modal-content { | |
| 2164 | + background: var(--bg-primary); | |
| 2165 | + border: 1px solid var(--border-active); | |
| 2166 | + border-radius: 20px; width: 900px; max-width: 95vw; height: 85vh; | |
| 2167 | + display: flex; flex-direction: column; overflow: hidden; | |
| 2168 | + box-shadow: 0 24px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05); | |
| 2169 | + animation: slideUpFade 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| 2170 | +} | |
| 2171 | +@keyframes slideUpFade { | |
| 2172 | + 0% { transform: translateY(40px) scale(0.98); opacity: 0; } | |
| 2173 | + 100% { transform: translateY(0) scale(1); opacity: 1; } | |
| 2174 | +} | |
| 2175 | + | |
| 2176 | +.hire-modal-header { | |
| 2177 | + padding: 24px 32px; display: flex; justify-content: space-between; align-items: flex-start; | |
| 2178 | + border-bottom: 1px solid var(--border-primary); | |
| 2179 | + background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, transparent 100%); | |
| 2180 | +} | |
| 2181 | +.header-title-box { display: flex; gap: 16px; align-items: center; } | |
| 2182 | +.header-icon { font-size: 32px; padding: 12px; background: rgba(16,185,129,0.1); border-radius: 16px; border: 1px solid rgba(16,185,129,0.2); } | |
| 2183 | +.header-title-box h3 { font-size: 20px; font-weight: 800; margin: 0 0 6px 0; color: var(--text-primary); letter-spacing: 0.5px; } | |
| 2184 | +.header-title-box p { font-size: 13px; color: var(--text-tertiary); margin: 0; } | |
| 2185 | +.btn-close-fancy { | |
| 2186 | + width: 32px; height: 32px; border-radius: 50%; border: none; background: var(--bg-surface); | |
| 2187 | + color: var(--text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s; | |
| 2188 | + display: flex; align-items: center; justify-content: center; | |
| 2189 | +} | |
| 2190 | +.btn-close-fancy:hover { background: rgba(239, 68, 68, 0.1); color: #ef4444; transform: rotate(90deg); } | |
| 2191 | + | |
| 2192 | +.hire-modal-toolbar { | |
| 2193 | + padding: 16px 32px; display: flex; gap: 16px; align-items: center; justify-content: space-between; | |
| 2194 | + border-bottom: 1px solid var(--border-primary); background: var(--bg-secondary); | |
| 2195 | +} | |
| 2196 | +.search-box-fancy { | |
| 2197 | + flex: 1; position: relative; display: flex; align-items: center; | |
| 2198 | +} | |
| 2199 | +.search-box-fancy .search-icon { position: absolute; left: 14px; font-size: 14px; color: var(--text-muted); } | |
| 2200 | +.search-box-fancy input { | |
| 2201 | + width: 100%; padding: 10px 14px 10px 40px; border-radius: 12px; border: 1px solid var(--border-primary); | |
| 2202 | + background: var(--bg-input); color: var(--text-primary); font-size: 14px; transition: all 0.2s; | |
| 2203 | +} | |
| 2204 | +.search-box-fancy input:focus { outline: none; border-color: var(--accent); background: var(--bg-input-focus); box-shadow: 0 0 0 3px rgba(16,185,129,0.1); } | |
| 2205 | +.filter-group { display: flex; gap: 12px; } | |
| 2206 | +.fancy-select-wrap { position: relative; display: flex; align-items: center; } | |
| 2207 | +.fancy-select-wrap .select-icon { position: absolute; left: 12px; font-size: 13px; z-index: 10;} | |
| 2208 | +.fancy-select-wrap select { | |
| 2209 | + padding: 10px 32px 10px 36px; border-radius: 12px; border: 1px solid var(--border-primary); | |
| 2210 | + background: var(--bg-input); color: var(--text-primary); font-size: 13px; cursor: pointer; | |
| 2211 | + appearance: none; transition: all 0.2s; | |
| 2212 | +} | |
| 2213 | +.fancy-select-wrap select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(16,185,129,0.1); } | |
| 2214 | +.fancy-select-wrap::after { content: '▼'; position: absolute; right: 12px; font-size: 10px; color: var(--text-muted); pointer-events: none; } | |
| 2215 | + | |
| 2216 | +.hire-modal-body { flex: 1; overflow-y: auto; padding: 24px 32px; background: var(--bg-primary); } | |
| 2217 | +.hire-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; } | |
| 2218 | + | |
| 2219 | +.hire-card { | |
| 2220 | + position: relative; padding: 20px; border-radius: 16px; background: var(--bg-surface); | |
| 2221 | + border: 1px solid var(--border-primary); cursor: pointer; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); | |
| 2222 | + display: flex; flex-direction: column; overflow: hidden; height: 160px; | |
| 2223 | +} | |
| 2224 | +.hire-card::before { | |
| 2225 | + content: ''; position: absolute; inset: 0; background: linear-gradient(135deg, rgba(16,185,129,0.1) 0%, transparent 100%); | |
| 2226 | + opacity: 0; transition: opacity 0.3s; pointer-events: none; | |
| 2227 | +} | |
| 2228 | +.hire-card:hover { transform: translateY(-4px); border-color: rgba(16,185,129,0.4); box-shadow: 0 12px 24px rgba(0,0,0,0.15); } | |
| 2229 | +.hire-card:hover::before { opacity: 1; } | |
| 2230 | +.hire-card.is-selected { border-color: var(--accent); background: rgba(16,185,129,0.05); box-shadow: 0 0 0 1px var(--accent) inset; } | |
| 2231 | + | |
| 2232 | +.hire-card-checkbox { position: absolute; top: 16px; right: 16px; z-index: 2; } | |
| 2233 | +.check-circle { | |
| 2234 | + width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-active); | |
| 2235 | + display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 800; | |
| 2236 | + color: transparent; transition: all 0.2s; background: var(--bg-primary); | |
| 2237 | +} | |
| 2238 | +.hire-card.is-selected .check-circle { background: var(--accent); border-color: var(--accent); color: white; transform: scale(1.1); } | |
| 2239 | + | |
| 2240 | +.hire-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; z-index: 2; } | |
| 2241 | +.hire-card-emoji { font-size: 24px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); } | |
| 2242 | +.hire-card-name { font-size: 16px; font-weight: 700; color: var(--text-primary); } | |
| 2243 | +.hire-card-desc { font-size: 12px; color: var(--text-secondary); line-height: 1.5; margin: 0 0 14px 0; z-index: 2; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1; } | |
| 2244 | +.hire-card-meta { display: flex; gap: 8px; flex-wrap: wrap; z-index: 2; } | |
| 2245 | +.hire-card-meta span { padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 600; } | |
| 2246 | +.badge-type { background: var(--bg-primary); color: var(--text-tertiary); border: 1px solid var(--border-primary); text-transform: uppercase; } | |
| 2247 | +.badge-score { background: rgba(251,191,36,0.1); color: var(--yellow-text); border: 1px solid rgba(251,191,36,0.2); } | |
| 2248 | +.badge-used { background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); } | |
| 2249 | + | |
| 2250 | +.hire-empty { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: var(--text-muted); } | |
| 2251 | +.hire-empty span { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } | |
| 2252 | + | |
| 2253 | +.hire-modal-footer { | |
| 2254 | + padding: 20px 32px; background: var(--bg-secondary); border-top: 1px solid var(--border-primary); | |
| 2255 | + display: flex; justify-content: space-between; align-items: center; | |
| 2256 | +} | |
| 2257 | +.hire-selection-info { font-size: 14px; color: var(--text-secondary); } | |
| 2258 | +.highlight-count { font-size: 18px; font-weight: 800; color: var(--accent-light); margin: 0 4px; } | |
| 2259 | +.hire-actions { display: flex; gap: 12px; } | |
| 2260 | +.btn-fancy-cancel { | |
| 2261 | + padding: 10px 24px; border-radius: 12px; border: 1px solid var(--border-primary); | |
| 2262 | + background: var(--bg-surface); color: var(--text-secondary); font-size: 14px; font-weight: 600; | |
| 2263 | + cursor: pointer; transition: all 0.2s; | |
| 2264 | +} | |
| 2265 | +.btn-fancy-cancel:hover { background: var(--bg-surface-hover); color: var(--text-primary); } | |
| 2266 | +.btn-fancy-submit { | |
| 2267 | + padding: 10px 28px; border-radius: 12px; border: none; font-size: 14px; font-weight: 700; | |
| 2268 | + background: linear-gradient(135deg, var(--accent) 0%, #059669 100%); color: white; | |
| 2269 | + cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 12px rgba(16,185,129,0.3); | |
| 2270 | +} | |
| 2271 | +.btn-fancy-submit:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(16,185,129,0.4); } | |
| 2272 | +.btn-fancy-submit:disabled { background: var(--bg-surface); border: 1px solid var(--border-primary); color: var(--text-muted); box-shadow: none; cursor: not-allowed; opacity: 0.7; } | |
| 2053 | 2273 | </style> | ... | ... |