Skill: magicaldd/novel-workshop (version 2.0.0)

--- name: novel-workshop description: | 多模型命题小说创作工坊。用户给出写作命题，自动完成：AI 写初稿 → 三路并行审阅（逻辑/文学/锐评）→ AI 改稿 → 飞书文档完整存档。 一键启动，全程自动，零手动干预。支持飞书实时进度推送。 触发词：命题写作、写一篇小说、命题小说、创作工坊、novel workshop --- # 🎲 命题小说多模型创作工坊 ## 工作流 ``` 用户命题 → MiMo 写初稿 → Gemini+Claude 三路并行审阅 → Gemini 改稿 → 飞书文档完整存档 ``` 全流程约 2-5 分钟，飞书群聊实时进度推送。 ## 使用方式 用户给出写作命题后，执行脚本： ```bash python3 skills/novel-workshop/workflow.py "用户的原始命题" "文档标题" ``` **重要**： - 第一个参数是用户的**原始命题原话**，不要修改、不要补充解释 - 第二个参数是飞书文档标题（简短的标识名） - 脚本后台运行，自动推送进度到飞书群聊 - 脚本完成后输出 `__SUMMARY__:` JSON，包含文档链接和评分 ## 前置要求 1. **OpenRouter API Key**：在 `openclaw.json` 的 `models.providers.openrouter.apiKey` 中配置 2. **飞书应用**：在 `openclaw.json` 的 `channels.feishu` 中配置 `appId` 和 `appSecret` 3. **Python 依赖**：`requests`（通常已预装） 4. **模型注册**（在 `openclaw.json` 的 `models.registered` 中添加）： - `xiaomi/mimo-v2-flash`（写初稿） - `google/gemini-2.5-pro`（审阅+改稿） - `anthropic/claude-opus-4.6`（锐评） ## 环境变量（可选覆盖） | 变量 | 说明 | 默认值 | |------|------|--------| | `FEISHU_CHAT_ID` | 飞书群聊 ID（进度推送目标） | 从 openclaw.json 读取 | | `FEISHU_FOLDER_TOKEN` | 飞书文件夹 token（文档创建位置） | 无（创建到根目录） | | `FEISHU_OWNER_OPEN_ID` | 文档所有者 open_id（自动授权） | 无 | | `OPENROUTER_API_KEY` | OpenRouter API Key | 从 openclaw.json 读取 | ## 输出 ### 本地文件 `~/.openclaw/workspace/novels/{标题}.md` — 完整 markdown（初稿+审阅+终稿） ### 飞书文档 自动创建到指定文件夹，包含完整内容： - Part 1：初稿全文 - Part 2：三路审阅全文（逻辑检阅 + 文学性分析 + 锐评） - Part 3：终稿全文 ### 进度消息（飞书群聊） ``` [░░░░░] 0/5 收到命题！工作流启动中 🎲 [█░░░░] 1/5 初稿完成 ✅《标题》(N字) 三路审阅启动中… [██░░░] 2/5 审阅完成 ✅ 逻辑 X/10 | 文学 X/10 | 改稿启动中… [███░░] 3/5 改稿完成 ✅ 保存中… [████░] 4/5 存档完成 ✅ 正在写入飞书文档… [█████] 5/5 全部完成！🎲 链接 + 评分 ``` ## 模型角色 | 角色 | 默认模型 | 备选 | |------|----------|------| | 初稿写作 | xiaomi/mimo-v2-flash | google/gemini-2.5-pro | | 逻辑审阅 | google/gemini-2.5-pro | anthropic/claude-opus-4.6 | | 文学审阅 | google/gemini-2.5-pro | anthropic/claude-opus-4.6 | | 锐评 | anthropic/claude-opus-4.6 | google/gemini-2.5-pro | | 改稿 | google/gemini-2.5-pro | anthropic/claude-opus-4.6 | ## ⚠️ 铁律 1. **Prompt 原封不动**：用户怎么说的就怎么传给模型，不要添加解释或修改 2. **飞书文档不省略任何内容**：初稿全文、审阅全文、终稿全文，一个字都不能少 3. **改稿不用写初稿的模型**：避免"自己改自己"导致的保守倾向 4. **全程自动**：用户只需给命题，不需要说"继续"

path: workflow.py
content: #!/usr/bin/env python3
"""
🎲 命题小说多模型创作工坊 v4
用法: python3 workflow.py "写作prompt" ["文档标题"]

流程: MiMo写初稿 → Gemini+Claude三路并行审阅 → Gemini改稿 → 本地存档 + 飞书文档完整写入
全程自动，飞书群聊实时进度推送。

配置: 自动从 ~/.openclaw/openclaw.json 读取 API Key 和飞书配置。
      可用环境变量覆盖: OPENROUTER_API_KEY, FEISHU_CHAT_ID, FEISHU_FOLDER_TOKEN, FEISHU_OWNER_OPEN_ID
"""

import sys, json, os, time, requests, concurrent.futures, re

# ============ 配置 ============
OPENROUTER_API_KEY = None
OPENROUTER_BASE = "https://openrouter.ai/api/v1"

MODELS = {
    "write": "xiaomi/mimo-v2-flash",
    "review_logic": "google/gemini-2.5-pro",
    "review_literary": "google/gemini-2.5-pro",
    "review_sharp": "anthropic/claude-opus-4.6",
    "revise": "google/gemini-2.5-pro",
}

FALLBACK_MODELS = {
    "xiaomi/mimo-v2-flash": "google/gemini-2.5-pro",
    "google/gemini-2.5-pro": "anthropic/claude-opus-4.6",
    "anthropic/claude-opus-4.6": "google/gemini-2.5-pro",
}

# 飞书配置
FEISHU_APP_ID = None
FEISHU_APP_SECRET = None
FEISHU_CHAT_ID = os.environ.get("FEISHU_CHAT_ID", "oc_4680833fd5ab374f3e26c90739ef6946")
FEISHU_FOLDER_TOKEN = os.environ.get("FEISHU_FOLDER_TOKEN", "U01TfC1RdlwEBzdHJCIcXwCQnVg")
FEISHU_OWNER_OPEN_ID = os.environ.get("FEISHU_OWNER_OPEN_ID", "ou_5b28826e6f7c9e54fcb49ba0b7e0b944")
FEISHU_TOKEN = None

# ============ 初始化 ============

def load_config():
    global OPENROUTER_API_KEY, FEISHU_APP_ID, FEISHU_APP_SECRET
    try:
        cfg = json.load(open(os.path.expanduser("~/.openclaw/openclaw.json")))
        providers = cfg.get("models", {}).get("providers", {})
        OPENROUTER_API_KEY = providers.get("openrouter", {}).get("apiKey", "")
        feishu = cfg.get("channels", {}).get("feishu", {})
        FEISHU_APP_ID = feishu.get("appId", "")
        FEISHU_APP_SECRET = feishu.get("appSecret", "")
    except Exception as e:
        print(f"⚠️ 读取配置失败: {e}")
    if not OPENROUTER_API_KEY:
        OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
    if not OPENROUTER_API_KEY:
        print("❌ 找不到 OpenRouter API Key")
        sys.exit(1)

def get_feishu_token():
    global FEISHU_TOKEN
    if not FEISHU_APP_ID or not FEISHU_APP_SECRET:
        return None
    try:
        resp = requests.post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
            json={"app_id": FEISHU_APP_ID, "app_secret": FEISHU_APP_SECRET}, timeout=10).json()
        FEISHU_TOKEN = resp.get("tenant_access_token", "")
        return FEISHU_TOKEN
    except:
        return None

# ============ 飞书消息推送 ============

def feishu_send(msg):
    """发送消息到飞书群聊"""
    if not FEISHU_TOKEN:
        print(f"  [飞书] (无token，跳过) {msg}")
        return
    try:
        resp = requests.post(
            f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id",
            headers={"Authorization": f"Bearer {FEISHU_TOKEN}", "Content-Type": "application/json"},
            json={
                "receive_id": FEISHU_CHAT_ID,
                "msg_type": "text",
                "content": json.dumps({"text": msg})
            },
            timeout=10
        ).json()
        if resp.get("code") == 0:
            print(f"  [飞书] ✅ 已发送: {msg[:50]}...")
        else:
            print(f"  [飞书] ⚠️ 发送失败: {resp.get('msg', 'unknown')}")
    except Exception as e:
        print(f"  [飞书] ⚠️ 发送异常: {e}")

# ============ OpenRouter API ============

def chat(model, messages, timeout=180):
    resp = requests.post(
        f"{OPENROUTER_BASE}/chat/completions",
        headers={
            "Authorization": f"Bearer {OPENROUTER_API_KEY}",
            "Content-Type": "application/json",
            "HTTP-Referer": "https://openclaw.ai"
        },
        json={"model": model, "messages": messages, "max_tokens": 16384},
        timeout=timeout
    )
    data = resp.json()
    if "error" in data:
        raise Exception(f"API error: {data['error']}")
    return data["choices"][0]["message"]["content"]

def chat_with_fallback(model, messages, role_name, timeout=180):
    try:
        print(f"  📡 {role_name}: 使用 {model}...")
        result = chat(model, messages, timeout)
        print(f"  ✅ {role_name}: 完成 ({len(result)} chars)")
        return result
    except Exception as e:
        print(f"  ⚠️ {role_name}: {model} 失败 ({e}), 尝试备选...")
        fallback = FALLBACK_MODELS.get(model)
        if fallback:
            try:
                result = chat(fallback, messages, timeout)
                print(f"  ✅ {role_name}: 备选 {fallback} 完成")
                return result
            except Exception as e2:
                print(f"  ❌ {role_name}: 备选也失败 ({e2})")
                return f"[审阅失败: {e2}]"
        return f"[审阅失败: {e}]"

def extract_score(text):
    matches = re.findall(r'(\d+(?:\.\d+)?)\s*/\s*10', text)
    return matches[0] if matches else "?"

# ============ 主流程 ============

def main():
    if len(sys.argv) < 2:
        print("用法: python3 workflow.py \"写作prompt\" [\"文档标题\"]")
        sys.exit(1)

    write_prompt = sys.argv[1]
    doc_title = sys.argv[2] if len(sys.argv) > 2 else "命题创作"

    load_config()
    get_feishu_token()

    start_time = time.time()

    # Step 0: 通知
    feishu_send(f"[░░░░░] 0/5 收到命题！工作流启动中 🎲")

    # ---- Step 1: 写初稿 ----
    print(f"\n{'='*50}\n  [█░░░░] 1/5 写初稿\n{'='*50}")

    draft = chat_with_fallback(
        MODELS["write"],
        [{"role": "user", "content": f"{write_prompt}\n\n直接输出正文（含标题），不需要任何解释或前言。"}],
        "初稿写作",
        timeout=240
    )

    # 提取标题（第一行去掉#号）
    first_line = draft.strip().split('\n')[0].strip().lstrip('#').strip()
    story_title = first_line if first_line else doc_title

    feishu_send(f"[█░░░░] 1/5 初稿完成 ✅《{story_title}》({len(draft)}字) 三路审阅启动中…")

    # ---- Step 2: 三路并行审阅 ----
    print(f"\n{'='*50}\n  [██░░░] 2/5 三路并行审阅\n{'='*50}")

    review_tasks = {
        "logic": {
            "model": MODELS["review_logic"],
            "name": "逻辑检阅",
            "prompt": f"你是严谨的文学逻辑审阅专家。审阅以下小说，从以下维度分析：\n1. 情节自洽性\n2. 时间线\n3. 因果关系\n4. 世界观矛盾\n\n评分1-10，详细说明。用中文。\n\n---以下是小说全文---\n\n{draft}"
        },
        "literary": {
            "model": MODELS["review_literary"],
            "name": "文学性分析",
            "prompt": f"你是资深文学评论家。审阅以下小说，从以下维度分析：\n1. 语言风格\n2. 意象构建\n3. 叙事手法\n4. 主题深度\n\n评分1-10，详细说明。用中文。\n\n---以下是小说全文---\n\n{draft}"
        },
        "sharp": {
            "model": MODELS["review_sharp"],
            "name": "锐评",
            "prompt": f"你是毒舌但有料的文学批评家，风格尖锐、一针见血。对以下小说进行锐评：\n1. 用最犀利的语言指出最大的问题\n2. 不要客气，不要留面子\n3. 给出3个核心改动建议（具体、可操作）\n\n用中文。\n\n---以下是小说全文---\n\n{draft}"
        }
    }

    reviews = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
        futures = {}
        for key, task in review_tasks.items():
            future = executor.submit(
                chat_with_fallback, task["model"],
                [{"role": "user", "content": task["prompt"]}],
                task["name"], 180
            )
            futures[future] = key

        for future in concurrent.futures.as_completed(futures):
            key = futures[future]
            reviews[key] = future.result()

    logic_score = extract_score(reviews.get("logic", ""))
    literary_score = extract_score(reviews.get("literary", ""))

    feishu_send(f"[██░░░] 2/5 审阅完成 ✅ 逻辑 {logic_score}/10 | 文学 {literary_score}/10 | 改稿启动中…")

    # ---- Step 3: 改稿 ----
    print(f"\n{'='*50}\n  [███░░] 3/5 改稿\n{'='*50}")

    revision_prompt = f"""你是一位优秀的作家。以下是一篇小说初稿和三份审阅意见。请根据审阅意见全面修改小说，输出完整新版本。

修改要求：
1. 认真采纳锐评的建议，大胆改动
2. 保留原作优秀的部分
3. 让角色更真实立体
4. 结尾不要鸡汤金句，留白更好

直接输出修改后的完整小说（含标题），不需要解释修改了什么。

## 初稿
{draft}

## 审阅A：逻辑检阅
{reviews.get('logic', '[未完成]')}

## 审阅B：文学性分析
{reviews.get('literary', '[未完成]')}

## 审阅C：锐评
{reviews.get('sharp', '[未完成]')}"""

    revision = chat_with_fallback(
        MODELS["revise"],
        [{"role": "user", "content": revision_prompt}],
        "改稿",
        timeout=300
    )

    feishu_send(f"[███░░] 3/5 改稿完成 ✅ 保存中…")

    # ---- Step 4: 本地存档 ----
    print(f"\n{'='*50}\n  [████░] 4/5 存档\n{'='*50}")

    local_path = os.path.expanduser(f"~/.openclaw/workspace/novels/{doc_title}.md")
    os.makedirs(os.path.dirname(local_path), exist_ok=True)
    with open(local_path, "w") as f:
        f.write(f"# 🎲 命题创作《{doc_title}》— 多模型创作工坊\n\n")
        f.write(f"**日期**：{time.strftime('%Y-%m-%d')}\n")
        f.write(f"**评分**：逻辑 {logic_score}/10 | 文学 {literary_score}/10\n\n---\n\n")
        f.write(f"## Part 1：初稿\n\n{draft}\n\n---\n\n")
        f.write(f"## Part 2：三路审阅\n\n")
        f.write(f"### 逻辑检阅\n\n{reviews.get('logic', '[未完成]')}\n\n")
        f.write(f"### 文学性分析\n\n{reviews.get('literary', '[未完成]')}\n\n")
        f.write(f"### 锐评\n\n{reviews.get('sharp', '[未完成]')}\n\n---\n\n")
        f.write(f"## Part 3：终稿\n\n{revision}\n")
    print(f"  💾 本地保存: {local_path}")

    # 飞书文档创建 + 写入完整内容
    doc_id = ""
    doc_url = ""
    if FEISHU_TOKEN:
        doc_id = create_feishu_doc(f"🎲 命题创作《{doc_title}》— 多模型创作工坊", FEISHU_TOKEN)
        if doc_id:
            doc_url = f"https://feishu.cn/docx/{doc_id}"
            print(f"  📄 飞书文档已创建: {doc_url}")
            feishu_send(f"[████░] 4/5 存档完成 ✅ 正在写入飞书文档…")
            # 读取本地完整 md 文件，写入飞书文档（不省略任何内容！）
            write_feishu_doc_content(doc_id, local_path, FEISHU_TOKEN)
        else:
            feishu_send(f"[████░] 4/5 存档完成 ✅ 飞书文档创建失败，仅本地保存")
    else:
        feishu_send(f"[████░] 4/5 存档完成 ✅ 仅本地保存")

    # ---- Step 5: 完成通知 ----
    elapsed = time.time() - start_time

    if doc_url:
        final_msg = f"[█████] 5/5 全部完成！🎲\n\n⏱️ 总耗时: {elapsed:.0f} 秒\n📊 评分: 逻辑 {logic_score}/10 | 文学 {literary_score}/10\n📄 飞书文档: {doc_url}"
    else:
        final_msg = f"[█████] 5/5 写作完成！🎲\n\n⏱️ 总耗时: {elapsed:.0f} 秒\n📊 评分: 逻辑 {logic_score}/10 | 文学 {literary_score}/10\n💾 已保存到本地"
    feishu_send(final_msg)

    print(f"\n{'='*50}")
    print(f"  🎲 完成！总耗时: {elapsed:.0f} 秒")
    print(f"{'='*50}")

    # JSON 摘要
    summary = {
        "story_title": story_title,
        "doc_title": doc_title,
        "doc_url": doc_url,
        "doc_id": doc_id,
        "local_path": local_path,
        "scores": {"logic": logic_score, "literary": literary_score},
        "elapsed_seconds": round(elapsed),
        "status": "success"
    }
    print(f"\n__SUMMARY__:{json.dumps(summary, ensure_ascii=False)}")


def write_feishu_doc_content(doc_id, local_path, token):
    """读取本地 md 文件完整内容，通过飞书 API 分段写入文档。
    使用 openclaw CLI 的 feishu_doc write 工具，确保不省略任何内容。"""
    import subprocess
    try:
        with open(local_path, "r") as f:
            full_content = f.read()

        if not full_content.strip():
            print("  ⚠️ 本地文件为空，跳过写入")
            return

        # 使用 openclaw tool 调用 feishu_doc write
        # 先尝试直接用飞书 API 写入纯文本 block
        # 飞书文档 API: 创建文本块
        headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

        # 将内容按段落拆分为 block
        lines = full_content.split('\n')
        blocks = []
        current_text = []

        for line in lines:
            stripped = line.strip()
            if stripped.startswith('# '):
                # 跳过一级标题（已经是文档标题）
                continue
            elif stripped.startswith('## '):
                if current_text:
                    blocks.append({"type": "text", "content": '\n'.join(current_text)})
                    current_text = []
                blocks.append({"type": "heading2", "content": stripped[3:]})
            elif stripped.startswith('### '):
                if current_text:
                    blocks.append({"type": "text", "content": '\n'.join(current_text)})
                    current_text = []
                blocks.append({"type": "heading3", "content": stripped[4:]})
            elif stripped == '---':
                if current_text:
                    blocks.append({"type": "text", "content": '\n'.join(current_text)})
                    current_text = []
                blocks.append({"type": "divider", "content": ""})
            else:
                current_text.append(line)

        if current_text:
            blocks.append({"type": "text", "content": '\n'.join(current_text)})

        # 使用飞书 Descendant API 批量创建 block
        doc_block_id = doc_id  # 根 block = 文档 ID

        created = 0
        for block in blocks:
            try:
                if block["type"] == "divider":
                    block_data = {
                        "block_type": 22,  # divider
                        "divider": {}
                    }
                elif block["type"] == "heading2":
                    block_data = {
                        "block_type": 4,  # heading2
                        "heading2": {
                            "elements": [{"text_run": {"content": block["content"]}}],
                            "style": {}
                        }
                    }
                elif block["type"] == "heading3":
                    block_data = {
                        "block_type": 5,  # heading3
                        "heading3": {
                            "elements": [{"text_run": {"content": block["content"]}}],
                            "style": {}
                        }
                    }
                else:
                    # 文本块，每段不超过 500 字符，超过则拆分
                    text = block["content"].strip()
                    if not text:
                        continue
                    # 按段落拆分
                    paragraphs = text.split('\n\n')
                    for para in paragraphs:
                        para = para.strip()
                        if not para:
                            continue
                        # 飞书单个文本块有字符限制，按行再拆
                        sub_lines = para.split('\n')
                        for sub_line in sub_lines:
                            sub_line_stripped = sub_line.strip()
                            if not sub_line_stripped:
                                continue
                            para_data = {
                                "block_type": 2,  # text
                                "text": {
                                    "elements": [{"text_run": {"content": sub_line_stripped}}],
                                    "style": {}
                                }
                            }
                            resp = requests.post(
                                f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_block_id}/children",
                                headers=headers,
                                json={"children": [para_data], "index": -1},
                                timeout=15
                            ).json()
                            if resp.get("code") == 0:
                                created += 1
                            else:
                                print(f"  ⚠️ 写入失败: {resp.get('msg', '')[:80]}")
                    continue

                resp = requests.post(
                    f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_block_id}/children",
                    headers=headers,
                    json={"children": [block_data], "index": -1},
                    timeout=15
                ).json()
                if resp.get("code") == 0:
                    created += 1
                else:
                    print(f"  ⚠️ 写入失败: {resp.get('msg', '')[:80]}")
            except Exception as e:
                print(f"  ⚠️ 写入异常: {e}")

        print(f"  📝 飞书文档写入完成: {created} 个文本块")

    except Exception as e:
        print(f"  ❌ 飞书文档写入失败: {e}")


def create_feishu_doc(title, token):
    try:
        resp = requests.post(
            "https://open.feishu.cn/open-apis/docx/v1/documents",
            headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
            json={"title": title, "folder_token": FEISHU_FOLDER_TOKEN},
            timeout=15
        ).json()
        doc_id = resp.get("data", {}).get("document", {}).get("document_id", "")
        if doc_id:
            try:
                requests.post(
                    f"https://open.feishu.cn/open-apis/drive/v1/permissions/{doc_id}/members?type=docx&need_notification=false",
                    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
                    json={"member_type": "openid", "member_id": FEISHU_OWNER_OPEN_ID, "perm": "full_access"},
                    timeout=10
                )
            except:
                pass
        return doc_id
    except:
        return ""


if __name__ == "__main__":
    main()


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