Commit 830715ab08bbe82deeacd98a98f4c2580cae2ced

Authored by yibo
1 parent 6c7687dc

feat: redesign hire agent UI with batch hiring & sync idearuncreate template from test company

.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>
... ...