包含: - 核心配置文件(AGENTS.md, SOUL.md, USER.md等) - 记忆系统(memory/文件夹) - 技能库(skills/文件夹) - 小说内容(novel/文件夹) - .gitignore配置
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""
|
|
Fanfic Writer v2.1 - OpenClaw Skill Entry Point
|
|
This function is called by OpenClaw to run the skill
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
|
|
# Import all modules
|
|
from .workspace import WorkspaceManager
|
|
from .phase_runner import PhaseRunner
|
|
from .writing_loop import WritingLoop
|
|
from .safety_mechanisms import FinalIntegration, BackpatchManager
|
|
from .resume_manager import RunLock, ResumeManager, RuntimeConfigManager
|
|
from .price_table import PriceTableManager, CostBudgetManager
|
|
from .config_manager import ConfigManager
|
|
from .state_manager import StateManager
|
|
from .atomic_io import atomic_write_json, atomic_write_text
|
|
from .utils import get_timestamp_iso
|
|
|
|
|
|
def run_skill(
|
|
# Book configuration
|
|
book_config: Optional[Dict[str, Any]] = None,
|
|
book_title: Optional[str] = None,
|
|
genre: Optional[str] = None,
|
|
target_words: int = 100000,
|
|
|
|
# Writing mode - "manual" requires confirmation at each phase
|
|
mode: str = "manual", # "manual" or "auto"
|
|
|
|
# Paths
|
|
workspace_root: Optional[str] = None,
|
|
run_dir: Optional[str] = None,
|
|
|
|
# Resume
|
|
resume: str = "off", # "off", "auto", "force"
|
|
|
|
# Budget
|
|
budget: Optional[float] = None,
|
|
|
|
# Chapters to write
|
|
chapters: Optional[str] = None, # "1" or "1-5"
|
|
|
|
# OpenClaw context - provides the model automatically!
|
|
oc_context: Optional[Dict[str, Any]] = None,
|
|
**kwargs
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Main entry point for OpenClaw to call this skill.
|
|
|
|
This function handles the complete workflow:
|
|
- Phase 1-5: Initialization (with human confirmation required)
|
|
- Phase 6: Writing loop (with human confirmation between chapters)
|
|
- Phase 7-9: Backpatch and finalization
|
|
|
|
Args:
|
|
book_config: Complete book configuration dict
|
|
book_title: Book title
|
|
genre: Genre (都市/玄幻/仙侠/etc)
|
|
target_words: Target word count
|
|
mode: "manual" or "auto"
|
|
model: Model to use
|
|
api_key: API key for model
|
|
workspace_root: Where to store novels
|
|
run_dir: Specific run directory (for resume)
|
|
resume: "off", "auto", "force"
|
|
budget: Cost budget in RMB
|
|
chapters: Chapter range "1-10" or single "5"
|
|
oc_context: OpenClaw context (provides model calling)
|
|
|
|
Returns:
|
|
Dict with status and paths
|
|
"""
|
|
results = {
|
|
"status": "pending",
|
|
"phase": None,
|
|
"message": "",
|
|
"run_dir": None,
|
|
"chapters_written": [],
|
|
"errors": []
|
|
}
|
|
|
|
# Get workspace root
|
|
if workspace_root:
|
|
base_dir = Path(workspace_root)
|
|
else:
|
|
base_dir = Path.home() / ".openclaw" / "novels"
|
|
|
|
# Determine mode
|
|
# In manual mode, require human confirmation at each phase
|
|
# In auto mode, run automatically but still save for review
|
|
|
|
try:
|
|
# Initialize workspace
|
|
workspace = WorkspaceManager(base_dir)
|
|
|
|
# Handle resume
|
|
if resume != "off" and run_dir:
|
|
# Resume existing run
|
|
run_dir = Path(run_dir)
|
|
resume_mgr = ResumeManager(run_dir)
|
|
can_resume, reason, resume_info = resume_mgr.can_resume(mode=resume)
|
|
|
|
if can_resume:
|
|
resume_mgr.resume(resume_info)
|
|
results["message"] = f"Resumed at chapter {resume_info['resume_point']['chapter']}"
|
|
elif resume == "force":
|
|
results["message"] = f"Force resume: {reason}"
|
|
else:
|
|
results["message"] = f"Cannot resume: {reason}, starting new run"
|
|
|
|
# Get model callable from OpenClaw context
|
|
# The model is whatever OpenClaw is currently using - no hardcoding!
|
|
def call_model(prompt: str, **model_kwargs) -> str:
|
|
"""
|
|
Call model via OpenClaw context.
|
|
|
|
IMPORTANT: This uses OpenClaw's current model automatically.
|
|
No model configuration needed in the skill itself.
|
|
"""
|
|
if oc_context and hasattr(oc_context, 'model_call'):
|
|
# OpenClaw provides model_call method
|
|
return oc_context.model_call(prompt, **model_kwargs)
|
|
elif oc_context and 'model_callable' in oc_context:
|
|
# Or as a callable in context
|
|
return oc_context['model_callable'](prompt, **model_kwargs)
|
|
elif oc_context and 'generate' in oc_context:
|
|
# Alternative method name
|
|
return oc_context['generate'](prompt, **model_kwargs)
|
|
else:
|
|
# Fallback: use prompt as-is (for debugging)
|
|
return f"[Please configure model in OpenClaw - prompt length: {len(prompt)} chars]"
|
|
|
|
# If no book config, we need to create one
|
|
if not book_config and not book_title:
|
|
results["status"] = "awaiting_config"
|
|
results["message"] = "Please provide book_title and genre"
|
|
return results
|
|
|
|
# Create book config if not provided
|
|
# Note: model is provided by OpenClaw automatically, not hardcoded in skill
|
|
if not book_config:
|
|
book_config = {
|
|
"version": "2.1.0",
|
|
"book": {
|
|
"title": book_title,
|
|
"genre": genre,
|
|
"target_word_count": target_words,
|
|
},
|
|
"generation": {
|
|
"mode": mode
|
|
}
|
|
}
|
|
|
|
# Phase 1-5: Initialization (always require human confirmation in design)
|
|
# For OpenClaw, we check if mode is "auto" or "manual"
|
|
# In manual mode, we return the generated files for review
|
|
|
|
if not run_dir:
|
|
# Create new book
|
|
runner = PhaseRunner(workspace)
|
|
|
|
# Phase 1: Initialize
|
|
results["phase"] = "1_init"
|
|
run_path, book_uid, run_id = runner.phase1_initialize(
|
|
book_title=book_config.get("book", {}).get("title", "未命名"),
|
|
genre=book_config.get("book", {}).get("genre", "都市"),
|
|
target_words=book_config.get("book", {}).get("target_word_count", 100000),
|
|
mode=mode
|
|
)
|
|
results["run_dir"] = str(run_path)
|
|
|
|
# Phase 2-5 would be here but require human confirmation
|
|
# For now, return to get confirmation
|
|
|
|
results["status"] = "awaiting_confirmation"
|
|
results["message"] = f"Phase 1 complete. Please confirm to continue to Phase 2."
|
|
return results
|
|
|
|
# If we have run_dir, continue with writing
|
|
run_dir = Path(run_dir)
|
|
|
|
# Acquire lock
|
|
run_lock = RunLock(run_dir)
|
|
lock_success, lock_error = run_lock.acquire(mode=mode)
|
|
if not lock_success:
|
|
results["status"] = "error"
|
|
results["errors"].append(f"Cannot acquire lock: {lock_error}")
|
|
return results
|
|
|
|
try:
|
|
# Get current state
|
|
state_path = run_dir / "4-state" / "4-writing-state.json"
|
|
with open(state_path, 'r', encoding='utf-8') as f:
|
|
state = json.load(f)
|
|
|
|
current_chapter = state.get('current_chapter', 0)
|
|
|
|
# Determine chapters to write
|
|
if chapters:
|
|
if '-' in chapters:
|
|
start, end = map(int, chapters.split('-'))
|
|
chapter_list = list(range(start, end + 1))
|
|
else:
|
|
chapter_list = [int(chapters)]
|
|
else:
|
|
chapter_list = [current_chapter + 1]
|
|
|
|
# Create writing loop with real model
|
|
loop = WritingLoop(
|
|
run_dir=run_dir,
|
|
model_callable=call_model,
|
|
config_manager=ConfigManager(run_dir),
|
|
state_manager=StateManager(run_dir)
|
|
)
|
|
|
|
# Write chapters
|
|
for ch in chapter_list:
|
|
# Check if paused
|
|
if state.get('flags', {}).get('is_paused'):
|
|
results["message"] = f"Paused at chapter {ch}"
|
|
break
|
|
|
|
result = loop.write_chapter(ch)
|
|
|
|
results["chapters_written"].append({
|
|
"chapter": ch,
|
|
"status": result.get('qc_status'),
|
|
"score": result.get('qc_score')
|
|
})
|
|
|
|
# In manual mode, require confirmation after each chapter
|
|
if mode == "manual":
|
|
results["status"] = "awaiting_confirmation"
|
|
results["message"] = f"Chapter {ch} complete. Score: {result.get('qc_score')}. Confirm to continue?"
|
|
return results
|
|
|
|
results["status"] = "complete"
|
|
results["message"] = f"Wrote {len(results['chapters_written'])} chapters"
|
|
|
|
finally:
|
|
run_lock.release()
|
|
|
|
except Exception as e:
|
|
results["status"] = "error"
|
|
results["errors"].append(str(e))
|
|
results["message"] = f"Error: {str(e)}"
|
|
|
|
return results
|
|
|
|
|
|
def get_required_confirmations(phase: str) -> list:
|
|
"""
|
|
Return list of items that require human confirmation for a given phase
|
|
|
|
This helps OpenClaw know what to ask the user
|
|
"""
|
|
confirmations = {
|
|
"1_init": [
|
|
"书名 (book_title)",
|
|
"类型 (genre)",
|
|
"目标字数 (target_words)",
|
|
"存放目录 (workspace_root)"
|
|
],
|
|
"2_style": [
|
|
"风格指南 (style_guide.md)"
|
|
],
|
|
"3_outline": [
|
|
"主线大纲 (main_outline.md)"
|
|
],
|
|
"4_planning": [
|
|
"章节规划 (chapter_plan.json)"
|
|
],
|
|
"5_world": [
|
|
"世界观设定 (world_building.md)"
|
|
],
|
|
"6_write": [
|
|
"每章正文生成后确认",
|
|
"每章评分确认"
|
|
],
|
|
"7_backpatch": [
|
|
"回补修复确认"
|
|
],
|
|
"8_merge": [
|
|
"合并后确认"
|
|
],
|
|
"9_final": [
|
|
"最终检查报告确认"
|
|
]
|
|
}
|
|
|
|
return confirmations.get(phase, [])
|