Skill: sunnyxiaoli/idea-generator (version 1.0.2)

--- name: idea-generator description: 创意工作台启动器。当用户说「启动创意工作台」「打开工作台」「开启工作台」时激活。负责启动工作台服务并返回访问链接，创意生成由用户在工作台网页中操作。 --- # Idea Generator - 创意工作台启动器 ## 激活后执行以下步骤 ### 第 1 步：检查服务是否已在运行 ```bash curl -s http://localhost:50000/ > /dev/null 2>&1 && echo "RUNNING" || echo "STOPPED" ``` ### 第 2 步：如果未运行，自动启动 输出 `STOPPED` 时执行： ```bash for SKILL_DIR in \ "$HOME/openclaw/workspace/skills/idea-generator" \ "$HOME/.openclaw/skills/idea-generator" do [ -f "$SKILL_DIR/scripts/server.py" ] && break done cd "$SKILL_DIR/scripts" && nohup python3 server.py > server.log 2>&1 & sleep 2 ``` 输出 `RUNNING` 时跳过此步。 ### 第 3 步：回复用户 ``` 🎨 创意工作台已启动！ 请在浏览器打开：http://localhost:50000/live-dashboard.html 在工作台中输入创意主题和需求，点击「开始生成」即可。 ``` **到此结束，不执行任何创意生成流程。** --- ## 创意生成流程（由工作台触发，非对话触发） 工作台网页点击「开始生成」后，会通过 cron 任务唤醒 AI 执行创意生成。AI 收到唤醒后： ### 第 1 步：状态门控（必须最先执行） ```bash curl -s http://localhost:50000/state.json | python3 -c " import sys, json d = json.load(sys.stdin) status = d.get('status', 'idle') if status == 'running': print('GO') print('主题:', d.get('topic', '')) print('需求:', d.get('demand', '')) print('轮数:', d.get('rounds_count', 3)) else: print('ABORT:', status) " ``` - 输出以 `GO` 开头 → 读取主题/需求/轮数，进入创意生成 - 输出以 `ABORT` 开头 → **立即终止，回复「任务已取消」，不做任何操作** > 🔴 状态不是 `running` 就必须停止，这是硬性约束。 ### 第 2 步：读参考文件 | 文件 | 内容 | 路径 | |------|------|------| | 创意生成标准 | 冲击波创意专家人设、核心心法、风格参考、输出格式 | `references/creative-standards.md` | | 评分标准详解 | 四维评分体系、维度说明、分级反馈策略、评分示例 | `references/scoring-guide.md` | ### 第 3 步：核心流程（N 轮迭代） **严格按 `rounds_count` 执行轮数（默认 3）。每轮必经以下步骤，轮次间承接递进。** ``` 第N轮： [1] 分析需求（读取上一轮反馈，首轮无，规划搜索词条，搜索词条不要过泛，要注意聚焦某个洞察或优秀案例。其次读取上一轮反馈后，不能钻牛角尖，而是发散思维进行创作，不要只限制于某个方向死钻） [2] 网络搜索（百度 + B站，必须真实调用，搜索词条不要过泛，要注意聚焦某个洞察或优秀案例） [3] 生成创意（5个，去重） [4] 筛选评分（≥90分入围） [5] 记录反馈（沉淀至下一轮） ``` --- ## 搜索规则 ### ⛔ 绝对禁止伪造 所有搜索发现必须来自工具的实际返回内容，禁止凭空捏造任何趋势、数据或用户观点。 ### 搜索方案（优先方案A） **方案A：browser（推荐）** 每个平台的完整流程： 1. `browser(action=open, url=搜索URL)` → 记录返回的 `targetId` 2. `exec sleep 2` → 等待加载 3. `browser(action=snapshot, targetId=<targetId>)` → 提取真实内容 4. `POST /log/search` → 记录搜索结果 5. `browser(action=close, targetId=<targetId>)` → **立即关闭**，不保留 > 🔴 **每次搜索用完立即关闭标签页**，不复用，不积累。每轮搜索 2 个平台 = 打开 2 个、关闭 2 个。 **方案B：web_fetch（browser 不可用时）** ``` web_fetch(url=搜索URL, maxChars=2000) → 提取内容 → POST /log/search ``` ### 搜索平台 | 平台 | URL | |------|-----| | 百度 | `https://www.baidu.com/s?wd={关键词}` | | B站 | `https://search.bilibili.com/all?keyword={关键词}` | ### 搜索记录格式 ```json { "round": 0, "kw": "搜索关键词", "findings": "搜索发现（≥30字，来自真实返回内容）", "platform": "平台名" } ``` --- ## 创意输出格式 ```json { "发现": "创意基于的消费者洞察、社会现象、数据或趋势（≤50字）", "创意": "一句话核心创意（≤11字），简洁有力易记", "创意描述": "不超过三句话：如何执行、为何有效、解决什么营销问题" } ``` > 详细创意心法和风格参考见 `references/creative-standards.md` --- ## 评分标准（概要） **四维冲击波评分（满分 100）：** | 维度 | 满分 | |------|------| | 冲击波特质（事件化/反套路/情感共鸣/可视化） | 40 | | 话题传播力（可分享性/参与门槛/延展空间） | 25 | | 营销效果（需求匹配/行动召唤/转化潜力） | 25 | | 可执行性（资源投入/风险可控） | 10 | **入围标准：总分 ≥ 90，且冲击波特质 ≥ 35** > 详细评分规则和分级反馈策略见 `references/scoring-guide.md` --- ## 轮次承接规则 - **第 1 轮**：分析原始需求，搜索洞察，生成 5 个候选，评分筛选，记录反馈 - **第 N 轮**：在思考中明确写出「承接第 N-1 轮反馈：...」，用新关键词搜索，生成 5 个新候选（去重），评分筛选，记录反馈 **创意去重：** - 与已有创意相似度 > 60% → 评分时降权 - 相似度 > 80% → 直接过滤，不进入评分 --- ## API 端点（http://localhost:50000） | 端点 | 作用 | |------|------| | `POST /init` | 初始化任务 | | `POST /round/start` | 开始轮次（参数：`round`, `theme`） | | `POST /log/thinking` | 记录思考（参数：`round`, `content`） | | `POST /log/search` | 记录搜索发现 | | `POST /idea/add` | 添加创意（参数：`round`, `ideas`） | | `POST /idea/evaluate` | 评分筛选（参数：`round`, `evaluations`） | | `POST /round/feedback` | 本轮反馈（参数：`round`, `content`） | | `POST /done` | 完成任务 | ### 🔴 轮次编号规则（重要） **round 参数从 0 开始：** - 第 1 轮 → `round=0` - 第 2 轮 → `round=1` - 第 3 轮 → `round=2` > 这是数组索引，不是人类习惯的编号。如果传错会导致过程从第二轮开始显示。 ### API 调用示例（必须严格遵循） #### 1. 开始轮次 `/round/start` ```json { "round": 0, // 第1轮用0，第2轮用1，第3轮用2 "theme": "本轮主题" } ``` #### 2. 记录思考 `/log/thinking` ```json { "round": 0, "content": "本轮分析思路..." } ``` #### 3. 记录搜索 `/log/search` ```json { "round": 0, "kw": "搜索关键词", "findings": "搜索发现（≥30字）", "platform": "百度" } ``` #### 4. 添加创意 `/idea/add` ```json { "round": 0, "ideas": [ { "发现": "洞察描述（≤50字）", "创意": "创意名称（≤11字）", "创意描述": "执行方式+有效性+解决的问题" }, // ... 共5个创意 ] } ``` #### 5. 评分筛选 `/idea/evaluate`（🔴 最关键，必须包含完整评分） ```json { "round": 0, "evaluations": [ { "idx": 0, // 创意索引（0-4） "total": 91, // 总分（必须传此字段！） "dimensions": { // 四维评分（可选但建议传） "冲击波特质": 38, "话题传播力": 23, "营销效果": 23, "可执行性": 7 }, "feedback": "" }, // ... 每个创意都要评分 ] } ``` > 🔴 **评分必须传 `total` 字段！** 如果只传 `score` 或不传，会导致显示0分。 #### 6. 本轮反馈 `/round/feedback` ```json { "round": 0, "content": "本轮总结：入围创意、淘汰原因、下轮方向..." } ``` #### 7. 完成任务 `/done` ```json {} // 无参数 ``` --- ## 核心规则 1. **单会话完整执行** — 所有轮次在一次回复中连续完成，不分多次 2. **思考过程可视化** — 向用户展示完整思考、搜索发现、创意生成、评分理由 3. **搜索不可跳过** — 每轮必须先有真实数据支撑，再发想创意 4. **每步立即更新** — 完成一个步骤立即调用对应 API 5. **全量透明** — 每轮所有创意（含淘汰）完整展示，评估过程可见 6. **每步停止检查** — 每次调用任何 API 之前必须检查状态： ```bash curl -s http://localhost:50000/state.json | python3 -c \ "import sys,json; d=json.load(sys.stdin); print('STOPPED') if d.get('status') != 'running' else print('OK')" ``` 输出 `STOPPED` → 立即调用 `POST /done`，停止所有操作，不进入本轮 > 🔴 **这是最高优先级约束。不管处于哪个阶段，查到就停。**

path: README.md
content: # 🚀 冲击波创意工作台

AI 驱动的迭代式营销创意生成器。在 OpenClaw 对话框说一句话，AI 自动搜索+生成+评分，实时工作台全程可视化。

---

## 安装（3步）

### 第 1 步：解压并放到技能目录

将 `idea-generator/` 文件夹复制到以下任一位置：

```bash
# 方案A：用户工作区（推荐）
cp -r idea-generator/ ~/openclaw/workspace/skills/

# 方案B：OpenClaw 全局技能目录
cp -r idea-generator/ ~/.openclaw/skills/
```

### 第 2 步：重启 OpenClaw

```bash
openclaw gateway restart
```

### 第 3 步：直接用！

在 OpenClaw 对话框输入：

> 启动创意工作台，帮我为【你的产品/品牌】生成创意

AI 会自动：
1. 启动工作台服务
2. 在浏览器提示打开 `http://localhost:50000/live-dashboard.html`
3. 搜索百度 / B站获取行业洞察
4. 每轮生成 5 个候选创意，≥90分入围
5. 多轮迭代递进优化

---

## 前置要求

| 需求 | 说明 |
|------|------|
| OpenClaw | 已安装并运行（[安装指南](https://github.com/openclaw/openclaw)） |
| Python 3.8+ | 通常系统自带；无需 pip install（纯标准库） |
| 网络 | 可访问百度、B站 |

---

## 触发方式

直接在对话框说（任意一种）：

- `启动创意工作台`
- `帮我想关于XX的创意`
- `为XX产品出个营销方案`
- `头脑风暴：XX`

如果没带主题，AI 会追问你具体需求。

---

## 端口说明

默认端口：**50000**

如需更换：
```bash
echo "51000" > idea-generator/scripts/.server_port
```

---

## 文件结构

```
idea-generator/
├── README.md               # 本文件
├── SKILL.md                # OpenClaw 技能描述（AI 读取）
├── scripts/
│   ├── server.py           # API 服务器（状态管理 + SSE 推送）
│   ├── start.sh            # 手动启动脚本（可选）
│   ├── live-dashboard.html # 实时工作台前端
│   └── requirements.txt    # Python 依赖说明（纯标准库）
└── references/
    ├── creative-standards.md  # 创意生成标准（AI 读取）
    └── scoring-guide.md       # 评分详解（AI 读取）
```

---

## 常见问题

**Q：说「启动创意工作台」没反应？**  
确认 OpenClaw 已重启（`openclaw gateway restart`），技能目录位置正确。

**Q：工作台打不开？**  
检查端口占用：`lsof -i :50000`。换个端口：`echo "51000" > scripts/.server_port`。

**Q：AI 没有开始生成创意？**  
重新对话，明确说出主题，例如：「帮我为XX品牌生成3轮创意」。

**Q：停止后任务还在执行？**  
点击「停止」后等 5-10 秒。强制重置：`bash scripts/start.sh`。


path: scripts/state.json
content: {
  "status": "idle",
  "topic": "",
  "demand": "",
  "rounds_count": 3,
  "current_round": -1,
  "phase": "待机",
  "progress": {
    "qualified": 0,
    "pool": []
  },
  "rounds": [],
  "logs": [],
  "latest": "—"
}

path: scripts/search_validator.py
content: #!/usr/bin/env python3
"""搜索验证器 - 简化版：只验证有内容+有URL"""
import json
import os
import re

STATE_FILE = os.path.join(os.path.dirname(__file__), "state.json")

def validate_search_result(search_record: dict) -> dict:
    """
    简化验证：只要有内容和来源URL即可
    返回: {"valid": bool, "issues": [str]}
    """
    issues = []
    
    findings = search_record.get("findings", "")
    source_url = search_record.get("source_url", "")
    
    # 检查1: 是否有搜索内容
    if not findings or len(findings) < 30:  # 降低长度要求
        issues.append("❌ 搜索内容过短或为空")
    
    # 检查2: 是否有来源URL（可选，因为web_fetch可能没有）
    if not source_url:
        issues.append("⚠️ 缺少来源URL（使用web_fetch时可能没有）")
    
    return {
        "valid": len(findings) > 0,  # 只要有内容就通过
        "issues": issues
    }

def validate_all_searches() -> dict:
    """验证所有搜索记录"""
    try:
        with open(STATE_FILE) as f:
            state = json.load(f)
    except:
        return {"error": "无法读取状态文件"}
    
    results = {
        "total_searches": 0,
        "valid_searches": 0,
        "details": []
    }
    
    for round_data in state.get("rounds", []):
        if round_data is None:
            continue
        round_num = round_data.get("round", -1)
        
        for search in round_data.get("searches", []):
            results["total_searches"] += 1
            validation = validate_search_result(search)
            
            if validation["valid"]:
                results["valid_searches"] += 1
            else:
                results["details"].append({
                    "round": round_num,
                    "kw": search.get("kw", ""),
                    "issues": validation["issues"]
                })
    
    results["overall_valid"] = results["valid_searches"] > 0
    return results

if __name__ == "__main__":
    import sys
    
    if len(sys.argv) > 1 and sys.argv[1] == "check":
        try:
            with open(STATE_FILE) as f:
                state = json.load(f)
            
            # 简化检查：只要有搜索记录就通过
            total_searches = 0
            for r in state.get("rounds", []):
                if r is None:
                    continue
                total_searches += len(r.get("searches", []))
            
            if total_searches > 0:
                print("✅ 搜索验证通过")
            else:
                print("❌ 搜索验证失败: 没有搜索记录")
                sys.exit(1)
        except Exception as e:
            print(f"❌ 搜索验证失败: {e}")
            sys.exit(1)
    else:
        print("用法: python search_validator.py check")


path: scripts/run_idea_generator.py
content: #!/usr/bin/env python3
"""
创意生成器主执行脚本 - 直接执行，不通过 sessions_spawn
修复了之前架构问题：不再创建新对话窗口，而是在当前会话中完整执行
"""

import json
import os
import sys
import time
import urllib.request
import urllib.parse

# 项目目录
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_FILE = os.path.join(SCRIPT_DIR, "state.json")
TRIGGER_FILE = os.path.join(SCRIPT_DIR, ".trigger_agent")

def log(message):
    """记录日志到 stdout（避免重复写入文件）"""
    print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)

def api_call(endpoint, data=None):
    """调用本地 API"""
    try:
        url = f"http://localhost:50000{endpoint}"
        req_data = json.dumps(data).encode('utf-8') if data else None
        req = urllib.request.Request(
            url, 
            data=req_data,
            headers={'Content-Type': 'application/json'} if data else {}
        )
        with urllib.request.urlopen(req, timeout=10) as response:
            return json.loads(response.read().decode('utf-8'))
    except Exception as e:
        log(f"API 调用失败 {endpoint}: {e}")
        return None

def main():
    # 读取触发文件
    if not os.path.exists(TRIGGER_FILE):
        log("错误: 没有找到触发文件")
        return
    
    try:
        with open(TRIGGER_FILE, 'r', encoding='utf-8') as f:
            trigger_data = json.load(f)
        
        topic = trigger_data.get('topic', '').strip()
        rounds_count = int(trigger_data.get('rounds', 3))
        demand = trigger_data.get('demand', '').strip()
        port = trigger_data.get('port', '50000')
        
        if not topic:
            log("错误: 主题为空")
            return
            
        log(f"开始执行创意生成任务: {topic} ({rounds_count}轮)")
        
        # 删除触发文件，避免重复执行
        os.remove(TRIGGER_FILE)
        
        # 更新状态为 AI 引擎启动中
        api_call("/phase", {"phase": "AI 引擎启动中..."})
        
        # 执行创意生成的主逻辑（这里应该调用实际的生成逻辑）
        # 由于这是在 OpenClaw 环境中，我们需要模拟调用过程
        # 实际的创意生成逻辑应该由 OpenClaw agent 来执行
        
        log("任务已准备就绪，请在 OpenClaw 中执行创意生成流程")
        log("提示: 使用 idea-generator 技能来处理此任务")
        
        # 更新状态为就绪
        api_call("/phase", {"phase": "就绪，等待 OpenClaw 处理"})
        
    except Exception as e:
        log(f"执行失败: {e}")
        # 标记任务为失败
        api_call("/phase", {"phase": f"执行失败: {e}"})

if __name__ == "__main__":
    main()

path: scripts/gateway-ws.js
content: #!/usr/bin/env node
/**
 * Gateway WebSocket client — 直接操作 openclaw webchat 对话
 * 用法:
 *   node gateway-ws.js create <sessionKey>            创建新会话
 *   node gateway-ws.js send <sessionKey> <message>    发送消息到会话
 *   node gateway-ws.js abort <sessionKey>             中止会话
 */
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');

// 读取 gateway 配置
const configPath = path.join(process.env.HOME, '.openclaw/openclaw.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const port = config.gateway?.port || 18789;
const [REDACTED_SECRET] || '';

const action = process.argv[2]; // create | send | abort
const sessionKey = process.argv[3];
const message = process.argv.slice(4).join(' ');

if (!action || !sessionKey) {
  console.error('Usage: node gateway-ws.js <create|send|abort> <sessionKey> [message]');
  process.exit(1);
}

const wsUrl = `ws://127.0.0.1:${port}`;

function run() {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl);
    let msgId = 1;
    let authenticated = false;
    let done = false;

    const timeout = setTimeout(() => {
      if (!done) {
        done = true;
        console.error('TIMEOUT');
        ws.close();
        resolve({ ok: false, error: 'timeout' });
      }
    }, 10000);

    function sendMsg(method, params) {
      const id = msgId++;
      const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params });
      ws.send(payload);
      return id;
    }

    ws.on('open', () => {
      // Authenticate
      sendMsg('auth', { token });
    });

    ws.on('message', (data) => {
      let msg;
      try { msg = JSON.parse(data.toString()); } catch { return; }

      // Auth response
      if (msg.result && msg.result.authenticated) {
        authenticated = true;

        if (action === 'create') {
          sendMsg('sessions.create', { key: sessionKey });
        } else if (action === 'send') {
          sendMsg('sessions.send', { key: sessionKey, message });
        } else if (action === 'abort') {
          sendMsg('sessions.abort', { key: sessionKey });
        }
        return;
      }

      // Handle response to our action
      if (msg.id && msg.id >= 2 && !done) {
        done = true;
        clearTimeout(timeout);
        if (msg.error) {
          console.log(JSON.stringify({ ok: false, error: msg.error }));
        } else {
          console.log(JSON.stringify({ ok: true, result: msg.result }));
        }
        ws.close();
        resolve(msg);
      }
    });

    ws.on('error', (err) => {
      if (!done) {
        done = true;
        clearTimeout(timeout);
        console.error('WS_ERROR:', err.message);
        resolve({ ok: false, error: err.message });
      }
    });

    ws.on('close', () => {
      if (!done) {
        done = true;
        clearTimeout(timeout);
        resolve({ ok: false, error: 'connection closed' });
      }
    });
  });
}

run().then(() => process.exit(0)).catch(() => process.exit(1));


path: scripts/watchdog.sh
content: #!/bin/bash
# watchdog.sh - 守护 idea-generator 服务器
SCRIPT_DIR="$(dirname "$0")"
PORT=50000

# 检查服务器是否响应
if ! curl -s --max-time 2 "http://localhost:$PORT/state.json" > /dev/null 2>&1; then
    echo "[$(date)] idea-generator server not responding, restarting..."
    # 杀掉旧进程
    kill $(lsof -t -i:$PORT) 2>/dev/null
    sleep 1
    # 重启
    cd "$SCRIPT_DIR" && nohup python3 server.py >> /tmp/idea-server.log 2>&1 &
    echo "[$(date)] Server restarted on port $PORT"
fi


path: scripts/start.sh
content: #!/usr/bin/env bash
# 创意工作台启动脚本
# 用法: bash start.sh [端口]
# 示例: bash start.sh 51000

set -e

DIR="$(cd "$(dirname "$0")" && pwd)"
PORT_FILE="$DIR/.server_port"
LOG_FILE="$DIR/server.log"

# 支持命令行传入端口
if [ -n "$1" ]; then
  echo "$1" > "$PORT_FILE"
  echo "[start] 端口设置为 $1"
fi

# 读取当前端口（用于提示）
if [ -f "$PORT_FILE" ]; then
  PORT=$(cat "$PORT_FILE")
else
  PORT=50000
  echo "$PORT" > "$PORT_FILE"
fi

# 如果已有进程在运行，先停掉
OLD_PID=$(lsof -ti :"$PORT" 2>/dev/null || true)
if [ -n "$OLD_PID" ]; then
  echo "[start] 端口 $PORT 已被占用 (PID $OLD_PID)，正在停止旧进程..."
  kill "$OLD_PID" 2>/dev/null || true
  sleep 1
fi

echo "[start] 启动创意工作台服务..."
cd "$DIR"
nohup python3 server.py > "$LOG_FILE" 2>&1 &
SERVER_PID=$!

# 等待服务就绪（最多 5 秒）
for i in 1 2 3 4 5; do
  sleep 1
  if curl -s "http://localhost:$PORT/" > /dev/null 2>&1; then
    echo "[start] ✅ 服务已启动 (PID $SERVER_PID)"
    echo ""
    echo "  🎨 工作台地址：http://localhost:$PORT/live-dashboard.html"
    echo ""
    exit 0
  fi
done

echo "[start] ⚠️ 服务启动超时，请查看日志：$LOG_FILE"
exit 1


path: scripts/server.py
content: #!/usr/bin/env python3
"""冲击波营销创意生成系统 - API Server"""
import http.server, socketserver, os, json, time, urllib.request, subprocess, threading
from pathlib import Path

DIR = os.path.dirname(__file__) or "."
STATE_FILE = os.path.join(DIR, "state.json")
PORT_FILE = os.path.join(DIR, ".server_port")
TRIGGER_FILE = os.path.join(DIR, ".trigger_agent")

def _load():
    if os.path.exists(STATE_FILE):
        with open(STATE_FILE) as f: return json.load(f)
    return _default()

def _default():
    return {"status":"idle","topic":"","demand":"","rounds_count":3,"current_round":-1,"phase":"待机","progress":{"qualified":0,"pool":[]},"rounds":[],"logs":[],"latest":"—"}

def _save(state):
    with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2)

def _notify():
    try:
        if os.path.exists(PORT_FILE):
            port = open(PORT_FILE).read().strip()
            urllib.request.urlopen(urllib.request.Request(f"http://localhost:{port}/notify", headers={"Cache-Control":"no-cache"}), timeout=1)
    except: pass

def api_reset(data=None):
    _save(_default())
    return _default()

def api_init(data):
    s=_default()
    s.update({"status":"running","topic":data.get("topic",""),"demand":data.get("demand",""),"rounds_count":int(data.get("rounds",3)),"phase":"就绪，待开始"})
    _save(s)
    return s

def api_start_round(data):
    s=_load()
    # 🔧 容错：人类习惯 round=1 表示第1轮，自动转换为数组索引 0
    rn_raw = int(data.get("round", 0))
    # 如果 round >= 1 且 rounds 数组为空或只有 null，说明是人类习惯编号，需要转换
    if rn_raw >= 1 and (len(s["rounds"]) == 0 or all(r is None for r in s["rounds"])):
        rn = rn_raw - 1  # 转换：round=1 → 索引0，round=2 → 索引1
    else:
        rn = rn_raw  # 已经是正确的索引
    rnd={"round":rn,"theme":data.get("theme",""),"demand":data.get("demand",""),"status":"in_progress","thinking":None,"searches":[],"insights":[],"ideas":[],"evaluation":None,"feedback":None,"timestamp":time.strftime("%H:%M:%S")}
    while len(s["rounds"])<=rn: s["rounds"].append(None)
    s["rounds"][rn]=rnd
    s["current_round"]=rn
    s["phase"]=f"第{rn+1}轮进行中"
    _save(s)
    return s

def api_log_thinking(data):
    s=_load()
    rn=int(data["round"])
    if rn<len(s["rounds"]) and s["rounds"][rn]:
        s["rounds"][rn]["thinking"]={"content":data.get("content",""),"time":time.strftime("%H:%M:%S")}
    _save(s)
    return s

def api_log_search(data):
    s=_load()
    rn=int(data["round"])
    if rn<len(s["rounds"]) and s["rounds"][rn]:
        search_record={
            "kw":data.get("kw",""),
            "findings":data.get("findings",""),
            "raw_content":data.get("raw_content",""),  # 原始搜索内容
            "source_url":data.get("source_url",""),    # 搜索来源URL
            "platform":data.get("platform",""),        # 搜索平台
            "time":time.strftime("%H:%M:%S")
        }
        s["rounds"][rn]["searches"].append(search_record)
    _save(s)
    return s

def api_log_insight(data):
    s=_load()
    rn=int(data["round"])
    if rn<len(s["rounds"]) and s["rounds"][rn]:
        s["rounds"][rn]["insights"].append({"title":data.get("title",""),"content":data.get("content",""),"time":time.strftime("%H:%M:%S")})
    _save(s)
    return s

def api_add_candidates(data):
    s=_load()
    rn=int(data["round"])
    ideas=data.get("ideas",[])
    if rn<len(s["rounds"]) and s["rounds"][rn]:
        for idea in ideas: idea["status"]="pending"
        s["rounds"][rn]["ideas"]=ideas
    _save(s)
    return s

def api_validate_search(data=None):
    """验证搜索结果是否真实"""
    import subprocess
    try:
        result = subprocess.run(
            ["python3", os.path.join(DIR, "search_validator.py"), "check"],
            capture_output=True, text=True, timeout=5
        )
        return {
            "valid": result.returncode == 0,
            "message": result.stdout.strip() or result.stderr.strip()
        }
    except Exception as e:
        return {"valid": False, "error": str(e)}

def api_evaluate_round(data):
    s=_load()
    # 🔧 容错：自动修正 round 参数（同 api_start_round）
    rn_raw = int(data.get("round", 0))
    if rn_raw >= 1 and (len(s["rounds"]) == 0 or all(r is None for r in s["rounds"])):
        rn = rn_raw - 1
    else:
        rn = rn_raw
    evals=data.get("evaluations",[])
    if rn<len(s["rounds"]) and s["rounds"][rn]:
        rnd=s["rounds"][rn]
        
        # 🔴 强制搜索验证：如果搜索结果无效，拒绝评分
        # 但先检查是否有搜索记录，如果没有则跳过验证
        has_searches = len(rnd.get("searches", [])) > 0
        if has_searches:
            search_validation = api_validate_search()
            if not search_validation.get("valid"):
                return {"error": "搜索验证失败，请重新执行真实搜索", "details": search_validation}
        else:
            # 如果没有搜索记录，记录警告但允许继续
            print(f"⚠️ 第{rn}轮没有搜索记录，跳过搜索验证")
        
        passed,rejected=[],[]
        for i, ev in enumerate(evals):
            # 支持两种格式：idx(索引) 或 idea(名称)
            idx = int(ev.get("idx", i))  # 默认使用当前索引
            idea_name = ev.get("idea", "")
            
            # 如果提供了名称，尝试匹配
            if idea_name:
                for j, idea in enumerate(rnd["ideas"]):
                    if idea.get("创意") == idea_name:
                        idx = j
                        break
            
            if 0<=idx<len(rnd["ideas"]):
                idea=rnd["ideas"][idx]
                # 🔧 容错：支持多种评分字段名
                # total, score, 总分, 分数, score_total
                total = ev.get("total") or ev.get("score") or ev.get("总分") or ev.get("分数") or ev.get("score_total") or 0
                total = int(total) if total else 0
                qualified=total>=90  # 直接用分数判断
                # 🔧 容错：支持多种维度字段名
                dims = ev.get("dimensions") or ev.get("dims") or ev.get("维度") or ev.get("评分维度") or {}
                idea.update({"status":"qualified" if qualified else "rejected","total":total,"feedback":ev.get("feedback",""),"dims":dims})
                (passed if qualified else rejected).append(idea)
        rnd["evaluation"]={"passed":passed,"rejected":rejected,"avg_score":round(sum(int(ev.get("total") or ev.get("score") or ev.get("总分") or 0) for ev in evals)/len(evals),1) if evals else 0,"timestamp":time.strftime("%H:%M:%S")}
        rnd["status"]="done"
        for idea in passed:
            if not any(p.get("创意")==idea.get("创意") for p in s["progress"]["pool"]):
                s["progress"]["pool"].append(idea)
        s["progress"]["qualified"]=len(s["progress"]["pool"])
    _save(s)
    return s

def api_log_feedback(data):
    s=_load()
    rn=int(data["round"])
    if rn<len(s["rounds"]) and s["rounds"][rn]:
        s["rounds"][rn]["feedback"]={"content":data.get("content",""),"time":time.strftime("%H:%M:%S")}
    _save(s)
    return s

def api_update_phase(data):
    s=_load()
    s["phase"]=data.get("phase","")
    _save(s)
    return s

def api_done(data=None):
    s=_load()
    s.update({"status":"done","phase":"完成"})
    _save(s)
    return s

def api_stop(data=None):
    """停止当前任务 - 强制终止 isolated agent session"""
    s = _load()
    cron_job_id = s.get("cron_job_id", "")
    session_id = s.get("session_id", "")  # e.g. idea-gen-xxxxxxxxxxxx

    # 1. 更新状态文件（AI 每步检查此状态，立即门控）
    s.update({"status": "stopped", "phase": "已停止"})
    _save(s)

    # 2. 删除触发文件，防止未启动的 agent 误读旧任务
    trigger_file = os.path.join(DIR, ".trigger_agent")
    if os.path.exists(trigger_file):
        os.remove(trigger_file)

    # 3. 删除 cron 任务（防止任务还未启动就被触发）
    if cron_job_id:
        try:
            result = subprocess.run(
                ["openclaw", "cron", "rm", cron_job_id, "--json"],
                capture_output=True, text=True, timeout=5
            )
            print(f"[stop] cron rm {cron_job_id}: returncode={result.returncode}")
        except Exception as e:
            print(f"[stop] cron rm failed: {e}")

    # 4. 强制取消正在运行的 isolated agent 任务
    # openclaw tasks cancel 接受 session key 作为 lookup
    if session_id:
        try:
            result = subprocess.run(
                ["openclaw", "tasks", "cancel", session_id],
                capture_output=True, text=True, timeout=10
            )
            print(f"[stop] tasks cancel {session_id}: returncode={result.returncode} stdout={result.stdout.strip()} stderr={result.stderr.strip()}")
        except Exception as e:
            print(f"[stop] tasks cancel failed: {e}")

    print(f"[stop] 停止完成。session_id={session_id}, cron_job_id={cron_job_id}")
    return s

def api_clear_process(data=None):
    """清空创意过程"""
    s=_load()
    s["rounds"]=[]
    s["current_round"]=-1
    if s["status"]!="running":
        s["status"]="idle"
        s["phase"]="待机"
    _save(s)
    return s

def api_clear_pool(data=None):
    """清空创意池"""
    s=_load()
    s["progress"]={"qualified":0,"pool":[]}
    _save(s)
    return s

def api_remove_idea(data):
    """删除单个创意"""
    s=_load()
    idx=int(data.get("idx", -1))
    pool=s.get("progress",{}).get("pool",[])
    if 0<=idx<len(pool):
        pool.pop(idx)
        s["progress"]["pool"]=pool
        s["progress"]["qualified"]=len(pool)
        _save(s)
    return s

def api_run(data):
    """创建任务并立即启动创意生成"""
    import uuid as _uuid
    topic = data.get("topic", "").strip()
    rounds = int(data.get("rounds", 3))
    demand = data.get("demand", "").strip()

    if not topic:
        return {"error": "请输入创意需求"}

    # 检查是否已有任务在运行
    current = _load()
    if current.get("status") == "running":
        return {"error": "已有任务在执行中，请等待完成或先点停止", "current_topic": current.get("topic", "")}

    # 清除残留的触发文件（防止上次异常中断留下的旧文件）
    if os.path.exists(TRIGGER_FILE):
        os.remove(TRIGGER_FILE)

    # 生成本次任务唯一的 session key（用于在 openclaw 前端创建新对话）
    session_id = f"idea-gen-{_uuid.uuid4().hex[:12]}"
    session_key = f"agent:main:{session_id}"

    # 初始化任务状态（保留历史创意池！只重置运行状态）
    s = _load()
    existing_pool = s.get("progress", {}).get("pool", [])
    s.update({
        "status": "running",
        "topic": topic,
        "demand": demand,
        "rounds_count": rounds,
        "phase": "任务已创建，AI 即将响应...",
        "progress": {"qualified": len(existing_pool), "pool": existing_pool},
        "rounds": [],
        "logs": [{"type": "info", "title": "任务已创建", "content": f"主题：{topic} | {rounds}轮迭代", "time": time.strftime("%H:%M:%S")}],
        "latest": "任务已创建，AI 即将响应...",
        "session_id": session_id,
        "session_key": session_key,
        "cron_job_id": ""  # will be filled after cron add
    })
    _save(s)

    # 写入触发文件
    port_val = open(PORT_FILE).read().strip() if os.path.exists(PORT_FILE) else "50000"
    trigger_data = {
        "topic": topic,
        "rounds": rounds,
        "demand": demand,
        "port": port_val,
        "session_id": session_id,
        "session_key": session_key,
        "time": time.time()
    }
    with open(TRIGGER_FILE, "w", encoding="utf-8") as f:
        json.dump(trigger_data, f, ensure_ascii=False)

    # 🔔 通过 openclaw cron 创建一次性任务，在独立的 isolated session 中运行
    # 注意：--session-key 使用简短 ID（非 agent:main: 前缀）
    wake_ok = False
    try:
        from datetime import datetime, timezone, timedelta
        # 设定 10 秒后执行
        run_at = (datetime.now(timezone.utc) + timedelta(seconds=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
        skill_dir = os.path.abspath(os.path.join(DIR, ".."))
        skill_md_path = os.path.join(skill_dir, "SKILL.md")
        msg = (
            f"[idea-generator] 创意任务已就绪：{topic}（{rounds}轮），"
            f"请立即读取 SKILL.md 并开始执行创意生成。"
        )
        cron_cmd = [
            "openclaw", "cron", "add",
            "--name", f"idea-gen-{session_id}",
            "--at", run_at,
            "--session", "isolated",
            "--session-key", session_id,
            "--message", msg,
            "--delete-after-run",
            "--json"
        ]
        result = subprocess.run(cron_cmd, capture_output=True, text=True, timeout=10)
        if result.returncode == 0:
            cron_result = json.loads(result.stdout)
            wake_ok = True
            cron_job_id = cron_result.get("id", "")
            # 记录 cron_job_id 到 state，供 stop 时使用
            s2 = _load()
            s2["cron_job_id"] = cron_job_id
            _save(s2)
            print(f"[info] cron 任务创建成功: {cron_job_id}")
        else:
            print(f"[warn] cron add failed: {result.stderr.strip()}")
    except Exception as e:
        print(f"[warn] cron add exception: {e}")

    # 降级方案：如果 cron 失败，从环境变量读取 gateway 配置
    if not wake_ok:
        try:
            gateway_port = os.environ.get("OPENCLAW_GATEWAY_PORT", "18789")
            hook_token = os.environ.get("OPENCLAW_HOOK_TOKEN", "")
            if not hook_token:
                print("[warn] OPENCLAW_HOOK_TOKEN not set, skipping wake hook fallback")
            else:
                wake_payload = json.dumps({
                    "text": f"[idea-generator] 创意任务已就绪：{topic}（{rounds}轮），请立即读取 SKILL.md 并开始执行创意生成。",
                    "mode": "now"
                }).encode("utf-8")
                req = urllib.request.Request(
                    f"http://127.0.0.1:{gateway_port}/hooks/wake",
                    data=wake_payload,
                    headers={
                        "Content-Type": "application/json",
                        "Authorization": f"Bearer {hook_token}"
                    },
                    method="POST"
                )
                resp = urllib.request.urlopen(req, timeout=3)
                wake_ok = resp.status == 200
                print(f"[info] wake hook fallback: ok={wake_ok}")
        except Exception as e2:
            print(f"[warn] wake hook also failed: {e2}")

    return {
        "ok": True,
        "topic": topic,
        "rounds": rounds,
        "wake": wake_ok,
        "session_id": session_id,
        "msg": "任务已创建！AI 正在新对话中响应..." if wake_ok else "任务已创建！等待 AI 响应。",
        "ready": True
    }

class Handler(http.server.BaseHTTPRequestHandler):
    def send_json(self, data, code=200):
        p = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", len(p))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(p)

    def read_json(self):
        clen = int(self.headers.get("Content-Length", 0))
        return json.loads(self.rfile.read(clen).decode("utf-8")) if clen else {}

    def do_GET(self):
        path = self.path.split("?")[0]
        if path == "/state.json":
            try:
                data = open(STATE_FILE).read()
            except:
                data = "{}"
            p = data.encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", len(p))
            self.send_header("Access-Control-Allow-Origin", "*")
            self.send_header("Cache-Control", "no-cache")
            self.end_headers()
            self.wfile.write(p)
        elif path == "/notify":
            self.send_response(200)
            self.send_header("Content-Type", "text/plain")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(b"ok")
        else:
            fp = os.path.join(DIR, path.lstrip("/") or "live-dashboard.html")
            if os.path.isfile(fp):
                with open(fp, "rb") as f:
                    d = f.read()
                ext = os.path.splitext(fp)[1].lower()
                ct = {".html": "text/html; charset=utf-8", ".js": "application/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".ico": "image/x-icon"}.get(ext, "application/octet-stream")
                self.send_response(200)
                self.send_header("Content-Type", ct)
                self.send_header("Content-Length", len(d))
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                self.wfile.write(d)
            else:
                self.send_error(404)

    def do_POST(self):
        path = self.path.split("?")[0]
        try:
            data = self.read_json()
        except:
            data = {}
        routes = {
            "/reset": (200, api_reset),
            "/init": (200, api_init),
            "/run": (200, api_run),
            "/stop": (200, api_stop),
            "/clear/process": (200, api_clear_process),
            "/clear/pool": (200, api_clear_pool),
            "/idea/remove": (200, api_remove_idea),
            "/round/start": (200, api_start_round),
            "/round/feedback": (200, api_log_feedback),
            "/log/thinking": (200, api_log_thinking),
            "/log/search": (200, api_log_search),
            "/log/insight": (200, api_log_insight),
            "/idea/add": (200, api_add_candidates),
            "/idea/evaluate": (200, api_evaluate_round),
            "/search/validate": (200, api_validate_search),
            "/phase": (200, api_update_phase),
            "/done": (200, api_done)
        }
        if path in routes:
            self.send_json(routes[path][1](data), routes[path][0])
        elif path == "/shutdown":
            self.send_json({"ok": True})
            self.wfile.flush()
            def _e():
                time.sleep(0.3)
                os._exit(0)
            threading.Thread(target=_e, daemon=True).start()
        else:
            self.send_error(404)

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "*")
        self.end_headers()

    def log_message(self, *a): pass

if __name__ == "__main__":
    socketserver.TCPServer.allow_reuse_address = True
    port = int(open(PORT_FILE).read().strip()) if os.path.exists(PORT_FILE) else 50000
    # 启动时：如果上次状态是 running（意外重启/崩溃），自动重置为 stopped，避免前端卡住
    try:
        s = _load()
        if s.get("status") == "running":
            s["status"] = "stopped"
            s["phase"] = "服务重启，任务已中断"
            _save(s)
            print("[startup] 检测到上次任务未完成，已重置为 stopped 状态")
    except Exception as e:
        print(f"[startup] 状态检查失败: {e}")
    # 清除残留的触发文件
    if os.path.exists(TRIGGER_FILE):
        os.remove(TRIGGER_FILE)
        print("[startup] 清除残留 .trigger_agent 文件")
    with socketserver.ThreadingTCPServer(("", port), Handler) as httpd:
        if not os.path.exists(PORT_FILE):
            port = httpd.server_address[1]
            with open(PORT_FILE, "w") as f:
                f.write(str(port))
        print(f"Port: {port}")
        print(f"Dashboard: http://localhost:{port}/live-dashboard.html")
        httpd.serve_forever()


path: scripts/test_workbench.py
content: #!/usr/bin/env python3
"""创意工作台功能测试脚本"""
import requests
import json
import time

API_BASE = "http://localhost:50000"

def test_api(endpoint, method="GET", data=None):
    """测试API端点"""
    url = f"{API_BASE}{endpoint}"
    try:
        if method == "GET":
            response = requests.get(url, timeout=5)
        elif method == "POST":
            headers = {"Content-Type": "application/json"}
            response = requests.post(url, json=data, headers=headers, timeout=5)
        else:
            return False, f"不支持的HTTP方法: {method}"
        
        if response.status_code == 200:
            return True, response.json()
        else:
            return False, f"HTTP {response.status_code}: {response.text}"
    except Exception as e:
        return False, f"请求失败: {str(e)}"

def run_tests():
    """运行所有测试"""
    print("=== 创意工作台功能测试 ===\n")
    
    # 1. 测试状态获取
    print("1. 测试状态获取...")
    success, result = test_api("/state.json")
    if success:
        print(f"   ✅ 状态获取成功: {result.get('status', 'unknown')}")
    else:
        print(f"   ❌ 状态获取失败: {result}")
    
    # 2. 测试重置功能
    print("\n2. 测试重置功能...")
    success, result = test_api("/reset", "POST")
    if success:
        print(f"   ✅ 重置成功: {result.get('status', 'unknown')}")
    else:
        print(f"   ❌ 重置失败: {result}")
    
    # 3. 测试初始化任务
    print("\n3. 测试初始化任务...")
    data = {
        "topic": "测试创意工作台功能",
        "demand": "测试需求描述",
        "rounds": 1
    }
    success, result = test_api("/init", "POST", data)
    if success:
        print(f"   ✅ 初始化成功: {result.get('status', 'unknown')}")
    else:
        print(f"   ❌ 初始化失败: {result}")
    
    # 4. 测试开始轮次
    print("\n4. 测试开始轮次...")
    data = {"round": 0, "theme": "测试主题"}
    success, result = test_api("/round/start", "POST", data)
    if success:
        print(f"   ✅ 开始轮次成功: {result.get('current_round', -1)}")
    else:
        print(f"   ❌ 开始轮次失败: {result}")
    
    # 5. 测试记录思考
    print("\n5. 测试记录思考...")
    data = {"round": 0, "content": "测试思考内容"}
    success, result = test_api("/log/thinking", "POST", data)
    if success:
        print(f"   ✅ 记录思考成功")
    else:
        print(f"   ❌ 记录思考失败: {result}")
    
    # 6. 测试记录搜索
    print("\n6. 测试记录搜索...")
    data = {
        "round": 0,
        "kw": "测试关键词",
        "findings": "测试搜索发现，这是一个测试搜索内容，用于验证搜索功能是否正常工作。",
        "source_url": "https://www.baidu.com/s?wd=测试",
        "platform": "百度"
    }
    success, result = test_api("/log/search", "POST", data)
    if success:
        print(f"   ✅ 记录搜索成功")
    else:
        print(f"   ❌ 记录搜索失败: {result}")
    
    # 7. 测试添加创意
    print("\n7. 测试添加创意...")
    data = {
        "round": 0,
        "ideas": [
            {
                "发现": "测试发现内容",
                "创意": "测试创意名称",
                "创意描述": "测试创意描述，这是一个测试创意。"
            }
        ]
    }
    success, result = test_api("/idea/add", "POST", data)
    if success:
        print(f"   ✅ 添加创意成功")
    else:
        print(f"   ❌ 添加创意失败: {result}")
    
    # 8. 测试评分筛选
    print("\n8. 测试评分筛选...")
    data = {
        "round": 0,
        "evaluations": [
            {
                "idx": 0,
                "total": 95,
                "feedback": "测试反馈内容"
            }
        ]
    }
    success, result = test_api("/idea/evaluate", "POST", data)
    if success:
        print(f"   ✅ 评分筛选成功: 入围{result.get('progress', {}).get('qualified', 0)}个创意")
    else:
        print(f"   ❌ 评分筛选失败: {result}")
    
    # 9. 测试反馈
    print("\n9. 测试反馈...")
    data = {"round": 0, "content": "测试反馈内容"}
    success, result = test_api("/round/feedback", "POST", data)
    if success:
        print(f"   ✅ 反馈成功")
    else:
        print(f"   ❌ 反馈失败: {result}")
    
    # 10. 测试完成任务
    print("\n10. 测试完成任务...")
    success, result = test_api("/done", "POST")
    if success:
        print(f"   ✅ 完成任务成功: {result.get('status', 'unknown')}")
    else:
        print(f"   ❌ 完成任务失败: {result}")
    
    print("\n=== 测试完成 ===")

if __name__ == "__main__":
    run_tests()

path: scripts/requirements.txt
content: # Python 依赖说明
# 
# 本技能核心功能（API 服务器、状态管理、SSE 推送）
# 均使用 Python 标准库实现，无需额外安装第三方包：
#
#   http.server, socketserver  - HTTP 服务器
#   json, os, time, threading  - 基础工具
#   urllib.request             - HTTP 请求（触发 OpenClaw）
#   subprocess                 - 调用 openclaw CLI
#   pathlib                    - 路径处理
#
# 如果你需要扩展搜索功能（如 requests、playwright），
# 请在下方取消注释或添加：

# requests>=2.28.0
# playwright>=1.40.0


path: scripts/live-dashboard.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>创意工作台</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box}
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#09090b;color:#fafafa;min-height:100vh}
  .container{max-width:1400px;margin:0 auto;padding:24px}
  
  /* Header */
  .header{display:flex;justify-content:space-between;align-items:center;padding:16px 0;border-bottom:1px solid #27272a;margin-bottom:24px}
  .header-left{display:flex;align-items:center;gap:12px}
  .status-dot{width:8px;height:8px;border-radius:50%;background:#71717a}
  .status-dot.running{background:#22c55e;box-shadow:0 0 8px #22c55e}
  .status-dot.connected{background:#22c55e}
  .topic{font-size:14px;color:#a1a1aa}
  .connection{font-size:12px;color:#52525b;display:flex;align-items:center;gap:6px}
  .connection-dot{width:6px;height:6px;border-radius:50%;background:#22c55e}
  .btn-reconnect{background:#27272a;color:#a1a1aa;border:1px solid #3f3f46;border-radius:5px;padding:3px 10px;font-size:11px;cursor:pointer;transition:all .15s;display:none}
  .btn-reconnect:hover{background:#3f3f46;color:#fafafa}
  
  /* Input Row - All in one line */
  .input-row{display:flex;gap:12px;align-items:stretch;margin-bottom:20px}
  .input-row textarea{flex:1;background:#18181b;border:1px solid #27272a;border-radius:8px;padding:12px 16px;color:#fafafa;font-size:14px;resize:none;min-height:48px;line-height:20px;font-family:inherit;overflow:hidden;scrollbar-width:none}
  .input-row textarea::-webkit-scrollbar{display:none}
  .input-row textarea:focus{outline:none;border-color:#3f3f46}
  .input-row textarea::placeholder{color:#52525b}
  .input-group{display:flex;align-items:stretch;gap:8px}
  .rounds-input{width:80px;background:#18181b;border:1px solid #27272a;border-radius:8px;padding:0 30px 0 12px;color:#fafafa;font-size:14px;text-align:center;position:relative;-moz-appearance:textfield}
  .rounds-input::-webkit-outer-spin-button,.rounds-input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
  .rounds-input:focus{outline:none;border-color:#3f3f46}
  
  /* Buttons */
  .btn{border:none;border-radius:8px;padding:0 24px;font-size:14px;font-weight:500;cursor:pointer;transition:all .15s;white-space:nowrap;display:flex;align-items:center;justify-content:center}
  .btn-start{background:#fafafa;color:#09090b}
  .btn-start:hover{background:#e4e4e7}
  .btn-start:disabled{opacity:.4;cursor:not-allowed}
  .btn-stop{background:#dc2626;color:#fff}
  .btn-stop:hover{background:#b91c1c}
  .btn-stop:disabled{opacity:.4;cursor:not-allowed}
  .btn-ghost{background:transparent;color:#71717a;border:1px solid #27272a;padding:6px 12px;font-size:13px;border-radius:6px}
  .btn-ghost:hover{border-color:#3f3f46;color:#fafafa}
  
  /* Main Content */
  .main{display:grid;grid-template-columns:1fr 320px;gap:16px;height:calc(100vh - 200px)}
  
  /* Panels */
  .panel{background:#18181b;border:1px solid #27272a;border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
  .panel-header{padding:14px 16px;border-bottom:1px solid #27272a;display:flex;justify-content:space-between;align-items:center;font-size:13px;font-weight:500;color:#a1a1aa}
  .panel-body{flex:1;overflow-y:auto;padding:16px}
  
  /* Scrollbar */
  .panel-body::-webkit-scrollbar{width:4px}
  .panel-body::-webkit-scrollbar-track{background:transparent}
  .panel-body::-webkit-scrollbar-thumb{background:#27272a;border-radius:2px}
  .panel-body::-webkit-scrollbar-thumb:hover{background:#3f3f46}
  
  /* Cards */
  .card{background:#09090b;border:1px solid #27272a;border-radius:8px;padding:14px;margin-bottom:12px;position:relative}
  .card-title{font-weight:500;margin-bottom:6px;padding-right:20px}
  .card-desc{font-size:13px;color:#71717a;line-height:1.5}
  .card-score{color:#22c55e;font-weight:600;font-size:13px;margin-top:8px}
  .card-delete{position:absolute;top:10px;right:10px;background:none;border:none;color:#52525b;cursor:pointer;font-size:18px;line-height:1}
  .card-delete:hover{color:#dc2626}
  
  /* Round Section */
  .round-section{border:1px solid #27272a;border-radius:8px;margin-bottom:16px;overflow:hidden}
  .round-header{padding:12px 14px;background:#09090b;border-bottom:1px solid #27272a;display:flex;justify-content:space-between;font-size:13px;font-weight:500}
  .round-content{padding:14px}
  
  /* Content Blocks */
  .block{margin-bottom:14px}
  .block-label{font-size:11px;color:#52525b;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
  .block-content{background:#09090b;border:1px solid #27272a;border-radius:6px;padding:10px;font-size:13px;color:#a1a1aa;line-height:1.6}
  
  /* States */
  .empty{color:#52525b;text-align:center;padding:60px 20px;font-size:13px}
  .error{color:#dc2626;font-size:13px}
  .hint{text-align:center;font-size:12px;color:#a1a1aa;opacity:0.85;padding:16px 0 24px;pointer-events:none}
</style>
</head>
<body>
<div class="container">
  <!-- Header -->
  <div class="header">
    <div class="header-left">
      <div class="status-dot" id="dot"></div>
      <span class="topic" id="topic">待机</span>
    </div>
    <div style="display:flex;align-items:center;gap:16px">
      <div class="connection">
        <div class="connection-dot" id="conn-dot"></div>
        <span id="conn-status">已连接</span>
        <button class="btn-reconnect" id="btn-reconnect" onclick="reconnect()">重连</button>
      </div>
    </div>
  </div>

  <!-- Input Row: All in one line -->
  <div class="input-row">
    <textarea id="input-topic" placeholder="输入创意需求，例如：母亲节情感营销、新品推广策划..."></textarea>
    <div class="input-group">
      <span style="font-size:13px;color:#71717a;white-space:nowrap;align-self:center">迭代轮数</span>
      <div style="position:relative;display:flex;align-items:stretch">
        <input type="number" class="rounds-input" id="input-rounds" value="3" min="1">
        <div style="position:absolute;right:4px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:0">
          <button onclick="$('input-rounds').stepUp()" style="background:none;border:none;color:#71717a;cursor:pointer;padding:2px 6px;line-height:1;font-size:10px;transition:color .15s" onmouseover="this.style.color='#fafafa'" onmouseout="this.style.color='#71717a'">▲</button>
          <button onclick="$('input-rounds').stepDown()" style="background:none;border:none;color:#71717a;cursor:pointer;padding:2px 6px;line-height:1;font-size:10px;transition:color .15s" onmouseover="this.style.color='#fafafa'" onmouseout="this.style.color='#71717a'">▼</button>
        </div>
      </div>
    </div>
    <button class="btn btn-start" id="btn-start">开始</button>
    <button class="btn btn-stop" id="btn-stop" style="display:none">停止</button>
  </div>
  <div id="error-msg" class="error" style="display:none;margin-bottom:16px"></div>

  <!-- Main Content -->
  <div class="main">
    <!-- Left: Process -->
    <div class="panel">
      <div class="panel-header">
        <span>创意过程</span>
        <button class="btn-ghost" id="btn-clear-process">清空</button>
      </div>
      <div class="panel-body" id="stream">
        <div class="empty">等待启动</div>
      </div>
    </div>
    
    <!-- Right: Pool -->
    <div class="panel">
      <div class="panel-header">
        <span>创意池 <span id="pool-count"></span></span>
        <button class="btn-ghost" id="btn-clear-pool">清空</button>
      </div>
      <div class="panel-body" id="pool">
        <div class="empty">暂无创意</div>
      </div>
    </div>
  </div>
</div>
<div class="hint">提示：如果点击开始后没有正常启动，尝试在openclaw前端输入"重启工作台"，然后再测试</div>
<script>
var API = location.origin + '/';
function $(id){return document.getElementById(id)}

function render(d){
  $('topic').textContent=d.topic||'待机';
  $('dot').className='status-dot'+(d.status==='running'?' running':d.status==='done'?' connected':'');
  var isRunning = d.status==='running';
  $('btn-start').style.display=isRunning?'none':'inline-block';
  $('btn-start').disabled=false;
  $('btn-stop').style.display=isRunning?'inline-block':'none';
  
  // Pool - 按分数排序显示（保留原始索引用于删除）
  var pool=d.progress?.pool||[];
  var poolWithIdx=pool.map((p,i)=>({...p,_origIdx:i}));
  poolWithIdx.sort((a,b)=>(b.total||0)-(a.total||0));
  $('pool-count').textContent='('+pool.length+')';
  $('pool').innerHTML=pool.length?poolWithIdx.map((p)=>{
    var score=p.total||0;
    var pstatus=p.status||'';
    var isPending=pstatus==='pending'||(score===0&&!pstatus);
    var scoreHtml=isPending
      ?'<div style="color:#71717a;font-size:13px;margin-top:8px">待评分</div>'
      :'<div class="card-score" style="color:'+(score>=90?'#22c55e':'#ef4444')+'">'+score+'分</div>';
    return '<div class="card"><button class="card-delete" onclick="removeIdea('+p._origIdx+')" title="删除此创意">✕</button><div class="card-title">'+( p.创意||'')+' </div><div class="card-desc">'+(p.创意描述||'')+' </div>'+scoreHtml+'</div>';
  }).join(''):'<div class="empty">暂无创意</div>';
  
  // Process
  var rounds=d.rounds||[];
  if(rounds.length){
    $('stream').innerHTML=rounds.map((r,ri)=>{
      if(!r)return '';
      var pass=r.evaluation?.passed?.length||0,total=r.ideas?.length||0;
      var h='<div class="round-section"><div class="round-header"><span>第'+(ri+1)+'轮</span><span>'+pass+'/'+total+' 入围</span></div><div class="round-content">';
      if(r.thinking?.content)h+='<div class="block"><div class="block-label">思考</div><div class="block-content">'+r.thinking.content.replace(/\n/g,'<br>')+'</div></div>';
      if(r.searches?.length)h+='<div class="block"><div class="block-label">搜索</div>'+r.searches.map(s=>'<div class="block-content" style="margin-bottom:8px"><strong style="color:#a1a1aa">'+s.kw+'</strong><br>'+s.findings.replace(/\|/g,'<br>')+'</div>').join('')+'</div>';
      if(r.ideas?.length)h+='<div class="block"><div class="block-label">创意</div>'+r.ideas.map(idea=>{
        var score=idea.total||0;
        var istatus=idea.status||'';
        var isIdeaPending=istatus==='pending'||(score===0&&!istatus);
        var ideaScoreHtml=isIdeaPending
          ?'<span style="color:#71717a;font-size:13px">待评分</span>'
          :'<span style="color:'+(score>=90?'#22c55e':'#ef4444')+';font-size:14px;font-weight:600">'+score+'分</span>';
        return '<div class="card"><div style="display:flex;justify-content:space-between"><span class="card-title">'+(idea.创意||'')+'</span>'+ideaScoreHtml+'</div><div class="card-desc">'+(idea.创意描述||'')+'</div>'+(idea.feedback?'<div style="font-size:12px;color:#52525b;margin-top:6px">→ '+idea.feedback+'</div>':'')+  '</div>';
      }).join('')+'</div>';
      if(r.feedback?.content)h+='<div class="block"><div class="block-label">反馈</div><div class="block-content">'+r.feedback.content.replace(/\|/g,'<br>')+'</div></div>';
      return h+'</div></div>';
    }).join('');
  }else{
    $('stream').innerHTML='<div class="empty">'+(d.status==='running'?'AI 思考中...':'等待启动')+'</div>';
  }
}

function setConnected(yes){
  if(yes){
    $('conn-dot').style.background='#22c55e';
    $('conn-status').textContent='已连接';
    $('btn-reconnect').style.display='none';
  }else{
    $('conn-dot').style.background='#dc2626';
    $('conn-status').textContent='已断开';
    $('btn-reconnect').style.display='inline-block';
    $('btn-start').style.display='inline-block';
    $('btn-start').disabled=false;
    $('btn-stop').style.display='none';
  }
}

function reconnect(){
  $('btn-reconnect').textContent='重连中...';
  $('btn-reconnect').disabled=true;
  fetchState().finally(()=>{
    $('btn-reconnect').textContent='重连';
    $('btn-reconnect').disabled=false;
  });
}

function fetchState(){
  return fetch(API+'state.json?t='+Date.now()).then(r=>r.json()).then(d=>{
    setConnected(true);
    render(d);
  }).catch(()=>{
    setConnected(false);
  });
}

function start(){
  var topic=$('input-topic').value.trim();
  var rounds=parseInt($('input-rounds').value)||3;
  if(!topic){$('error-msg').textContent='请输入创意需求';$('error-msg').style.display='block';return}
  $('error-msg').style.display='none';
  $('btn-start').disabled=true;
  $('btn-start').textContent='启动中...';
  fetch(API+'run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic,rounds})})
  .then(r=>r.json())
  .then(d=>{
    if(d.error){$('error-msg').textContent=d.error;$('error-msg').style.display='block';}
    else if(d.ready){
      // 只更新过程面板，不清空创意池
      $('stream').innerHTML='<div class="empty" style="color:#22c55e;padding:40px 20px"><div style="font-size:16px;margin-bottom:12px">✓ 任务已创建</div><div style="font-size:13px;color:#a1a1aa;margin-bottom:16px">AI 正在新对话中执行创意生成...</div>'+(d.session_id?'<div style="font-size:12px;color:#52525b">Session: '+d.session_id+'</div>':'')+'</div>';
    }
  })
  .catch(e=>{$('error-msg').textContent='连接失败：'+e.message;$('error-msg').style.display='block';})
  .finally(()=>{$('btn-start').disabled=false;$('btn-start').textContent='开始'});
}

function stop(){
  // 立即本地更新 UI
  $('btn-stop').disabled=true;
  $('btn-stop').textContent='停止中...';
  $('dot').className='status-dot';
  fetch(API+'stop',{method:'POST'})
  .then(r=>r.json())
  .then(()=>{
    // 停止成功，更新进程面板显示提示
    $('stream').innerHTML='<div class="empty" style="color:#ef4444">任务已停止</div>';
    fetchState();
  })
  .catch(()=>{
    // 即使请求失败，也强制恢复 UI
    $('btn-start').style.display='inline-block';
    $('btn-stop').style.display='none';
    $('btn-start').disabled=false;
  })
  .finally(()=>{
    $('btn-stop').disabled=false;
    $('btn-stop').textContent='停止';
  });
}

function reset(){
  $('input-topic').value='';
  fetch(API+'reset',{method:'POST'});
}

function clearProcess(){fetch(API+'clear/process',{method:'POST'}).then(()=>fetchState())}
function clearPool(){fetch(API+'clear/pool',{method:'POST'}).then(()=>fetchState())}
function removeIdea(origIdx){fetch(API+'idea/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({idx:origIdx})}).then(()=>fetchState())}

$('btn-start').addEventListener('click',start);
$('btn-stop').addEventListener('click',stop);
$('btn-clear-process').addEventListener('click',clearProcess);
$('btn-clear-pool').addEventListener('click',clearPool);

// Auto-resize textarea
var ta=$('input-topic');
ta.addEventListener('input',function(){this.style.height='auto';this.style.height=Math.max(48,this.scrollHeight)+'px'});
ta.style.height='48px';

fetchState();
setInterval(fetchState,1000);
</script>
</body>
</html>


path: references/creative-standards.md
content: # 创意生成标准

你是一名专业的「冲击波广告创意专家」，专门生成具有高内容性、强冲击力的广告创意。

---

## 核心创意心法

1. **内容为王，冲击力至上**：创意必须是具有传播价值的真实内容，而非空洞的宣传话术。不要局限于广告片，而是以广告位为载体，制作吸引关注的广告事件、社会事件。

2. **改变熟悉的东西**：在消费者已知的事物、场景、话语或行为上进行创造性改造，制造意外感和新鲜感。

3. **高效解决营销问题**：每个创意必须明确服务于商业目标（提升销量、强化品牌认知、制造声量等）。

4. **简洁直接，一目了然**：创意核心必须能用一句话概括，易于传播与记忆。

5. **具备可延展的话题性**：创意本身应能引发公众、媒体、KOL的自发讨论与二次创作。

---

## 创意风格参考

### 事件化
将广告转化为具有新闻价值的事件。
- 例："开上停机坪"、"联合国发布"

### 反套路
打破行业常规表达，制造意外感。
- 例："广告说了不算"、"今天广告不上班"

### 情感共鸣
抓住社会共同情绪，引发深层连接。
- 例："妈妈别紧张"、"上海回来了"

### 跨界碰撞
融合不同领域元素制造新鲜感。
- 例："脉冲星歌曲"、"久石让×久石让"

### 可视化比喻
将抽象卖点转化为可观看、可拍摄的视觉符号。
- 例："双胞胎价差"、"生锈的钢铁海报"

---

## 创意输出格式

每个创意必须包含三个字段：

```json
{
  "发现": "描述创意基于的消费者洞察、社会现象、数据或趋势，不超过50字",
  "创意": "一句话概括核心创意，不超过11字，需简洁、有力、易记",
  "创意描述": "用不超过三句话解释创意如何执行、为何有效、解决什么营销问题，逻辑直接，避免复杂描述"
}
```

### 字段限制

| 字段 | 字数限制 | 要求 |
|------|---------|------|
| 发现 | ≤50字 | 必须基于真实洞察，不能凭空杜撰 |
| 创意 | ≤11字 | 简洁有力，一句话讲清核心 |
| 创意描述 | ≤3句话 | 执行方式+有效性+解决的问题 |

---

## 执行要求

- ✅ 创意必须具有"冲击波"特质：简洁、意外、可传播、能解决营销问题
- ✅ 每个创意必须基于真实的洞察或发现，不能凭空杜撰
- ✅ 内容性强：创意本身就是有价值的内容，而非简单的宣传
- ✅ 冲击力强：视觉冲击、情感冲击或认知冲击
- ✅ 避免使用复杂术语、过长句子、模糊表述
- ✅ 可结合时事、节日、社会热点，但需确保与品牌/产品相关性强
- ✅ 保持创意的原创性和可执行性

---

## 反模式（避免）

- ❌ 空洞的宣传话术（"品质卓越"、"值得信赖"）
- ❌ 复杂的执行逻辑（需要多句话才能解释清楚）
- ❌ 缺乏洞察支撑（凭空想象，没有真实依据）
- ❌ 难以视觉化（纯概念，无法拍摄或呈现）
- ❌ 与需求脱节（创意很酷但不解决问题）


path: references/scoring-guide.md
content: # 创意评分标准

## 核心原则

1. **围绕营销需求**：脱离需求的创意不能高分通过
2. **冲击波特质优先**：优先评估是否具备事件化、反套路、情感共鸣特质
3. **分级反馈策略**：
   - 高分创意（≥80分）：提供完整反馈（发现、描述、改进方向）
   - 低分创意（<80分）：仅说明标题和分数，反馈控制在50字以内

---

## 评估维度（满分100分）

### 1. 冲击波特质（40分）

| 子维度 | 满分 | 评估要点 |
|--------|------|----------|
| 事件化潜力 | 10分 | 是否具备新闻价值，能转化为社会事件 |
| 反套路程度 | 10分 | 是否打破行业常规，制造意外感 |
| 情感共鸣力 | 10分 | 是否抓住社会共同情绪或用户痛点 |
| 可视化程度 | 10分 | 抽象卖点是否转化为可观看的视觉符号 |

**打分参考：**
- 9-10分：具备极强的事件化/反套路/情感/可视化特质，能引发广泛讨论
- 7-8分：特质明显，有较好的传播潜力
- 5-6分：有一定特质，但不够突出
- 3-4分：特质较弱，难以引发关注
- 0-2分：缺乏冲击波特质

---

### 2. 话题传播力（25分）

| 子维度 | 满分 | 评估要点 |
|--------|------|----------|
| 可分享性 | 10分 | 是否引发自发讨论和二次创作 |
| 参与门槛 | 8分 | 是否简单易懂，3秒内让用户明白 |
| 延展空间 | 7分 | 是否具备多平台传播潜力 |

**打分参考：**
- 可分享性：高（用户主动分享）、中（被引导后分享）、低（不愿分享）
- 参与门槛：低门槛（3秒理解）> 中门槛（需要解释）> 高门槛（需要学习）
- 延展空间：能否适配抖音、微博等多平台

---

### 3. 营销效果（25分）

| 子维度 | 满分 | 评估要点 |
|--------|------|----------|
| 需求匹配度 | 10分 | 是否精准解决营销问题 |
| 行动召唤力 | 8分 | 是否明确驱动购买或讨论 |
| 转化潜力 | 7分 | 是否具备销售转化可能 |

**打分参考：**
- 需求匹配度：是否直接回应营销目标（销量/认知/声量）
- 行动召唤力：是否有明确的行动引导（购买、参与、讨论）
- 转化潜力：是否能带来实际的商业转化

---

### 4. 可执行性（10分）

| 子维度 | 满分 | 评估要点 |
|--------|------|----------|
| 资源投入 | 5分 | 技术难度、成本是否合理 |
| 风险可控 | 5分 | 是否涉及敏感内容或法律风险 |

**打分参考：**
- 资源投入：低成本可执行 > 需要一定投入 > 需要大量资源
- 风险可控：无风险 > 低风险 > 有争议风险

---

## 入围标准

**≥90分入围高质量创意池**

入围条件：
- 总分 ≥ 90分
- 冲击波特质得分 ≥ 35分（满分40）
- 无明显硬伤（如敏感内容、法律风险）

---

## 分级反馈策略

### 高分创意（≥80分）— 完整反馈

```
✅ [创意名称] — XX分

【评分详情】
- 冲击波特质：XX/40（事件化X + 反套路X + 情感X + 可视化X）
- 话题传播力：XX/25（可分享X + 门槛X + 延展X）
- 营销效果：XX/25（匹配X + 召唤X + 转化X）
- 可执行性：XX/10（资源X + 风险X）

【优势亮点】
- 具体优势描述...

【改进方向】
- 可优化的点...
```

### 低分创意（<80分）— 简洁反馈

```
❌ [创意名称] — XX分
简短反馈（≤50字）：核心问题说明
```

---

## 评分示例

### 示例1：高分创意

**创意：仅妈妈可见行动联盟**

| 维度 | 得分 | 说明 |
|------|------|------|
| 冲击波特质 | 38/40 | 事件化9 + 反套路9 + 情感10 + 可视化10 |
| 话题传播力 | 23/25 | 可分享9 + 门槛8 + 延展6 |
| 营销效果 | 23/25 | 匹配9 + 召唤8 + 转化6 |
| 可执行性 | 7/10 | 资源4 + 风险3 |
| **总分** | **91/100** | ✅ 入围 |

**反馈：**
- 优势：社会洞察精准（家庭责任议题），行动性强（打卡参与），易二次传播（对比海报）
- 改进：可增加品牌露出方式，避免过于公益化

---

### 示例2：中等创意

**创意：妈妈的十年时光机**

| 维度 | 得分 | 说明 |
|------|------|------|
| 冲击波特质 | 35/40 | 事件化8 + 反套路8 + 情感10 + 可视化9 |
| 话题传播力 | 20/25 | 可分享8 + 门槛6 + 延展6 |
| 营销效果 | 20/25 | 匹配8 + 召唤7 + 转化5 |
| 可执行性 | 6/10 | 资源3 + 风险3 |
| **总分** | **81/100** | ❌ 未入围 |

**反馈：**
- 核心问题：技术门槛高（AR/VR开发成本大），参与成本高（需要设备），传播受限（门槛过滤了大量用户）

---

### 示例3：低分创意

**创意：妈妈节大促活动**

**总分：52/100** ❌ 未入围

**反馈：** 缺乏冲击波特质，常规促销活动，无事件化/反套路/情感共鸣元素，难以引发传播。


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)