#!/usr/bin/env bash # ============================================================================= # inkos-write-next-atomic.sh # # 用原子命令组装 inkos write next 的完整管线,绝不直接调用 inkos write next。 # # 使用的原子命令: # inkos plan chapter – 预检:生成章节意图(无 LLM 调用) # inkos compose chapter – 预检:编译上下文/规则栈/追踪产物(无 LLM 调用) # inkos draft – 撰写章节草稿(LLM) # inkos audit – 连续性审计(LLM) # inkos revise – 修复问题 / 润色文笔(LLM) # # 管线: # plan → compose → draft → [audit → revise]×N → polish → 完成 # ============================================================================= set -euo pipefail # --------------------------------------------------------------------------- # LLM 环境变量(确保 inkos 使用正确的 API 端点和模型) # --------------------------------------------------------------------------- export INKOS_LLM_MODEL="${INKOS_LLM_MODEL:-deepseek-v3.2-exp}" export INKOS_LLM_BASE_URL="${INKOS_LLM_BASE_URL:-https://platform.shuyanai.com/v1}" # --------------------------------------------------------------------------- # 用法说明 # --------------------------------------------------------------------------- usage() { cat <<'EOF' 用法:inkos-write-next-atomic.sh [book-id] [创作引导] [选项] 用原子命令组装 inkos 写下一章的完整管线: plan → [确认意图] → compose → draft → [确认草稿] → audit → revise×N → polish 参数: book-id 作品 ID(可选,仅一个作品时自动检测) 创作引导 章节创作引导文本(可选,不提供则交互式输入) 选项: -h, --help 显示此帮助信息 交互式检查点: 第 1 步后 用 less 查看意图文件,可选择重新输入引导或继续 第 3 步后 确认草稿,可选择输入提示词用 rewrite 重写 环境变量: INKOS_LLM_MODEL 模型(默认 deepseek-v4-flash) INKOS_LLM_BASE_URL API 端点(默认 https://platform.shuyanai.com/v1) EOF exit 0 } # 检查 --help / -h for arg in "$@"; do if [[ "$arg" == "--help" || "$arg" == "-h" ]]; then usage fi done # --------------------------------------------------------------------------- # 辅助函数 # --------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' banner() { echo -e "${CYAN}${BOLD}==>${NC} ${BOLD}$*${NC}"; } ok() { echo -e " ${GREEN}✓${NC} $*"; } warn() { echo -e " ${YELLOW}⚠${NC} $*"; } fail() { echo -e " ${RED}✗${NC} $*"; } stage() { echo -e "\n${CYAN}━━━ $* ━━━${NC}"; } info() { echo -e " $*"; } die() { echo -e "${RED}${BOLD}致命错误:${NC} $*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "缺少命令:$1,请先安装。" } # 解析 JSON 字段(取首个匹配值) json_field() { local json="$1" key="$2" echo "$json" | jq -r ".${key} // empty" 2>/dev/null } # spinner:在后台任务运行时显示旋转动画和耗时 spinner() { local pid="$1" msg="$2" spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' i=0 local start="${3:-$SECONDS}" while kill -0 "$pid" 2>/dev/null; do local elapsed=$((SECONDS - start)) printf "\r ${spin:$i:1} %s (已耗时 %ds)..." "$msg" "$elapsed" >&2 i=$(( (i + 1) % ${#spin} )) sleep 0.2 done wait "$pid" } # run_with_spinner <描述> <命令> [参数...] # 在后台运行命令,显示 spinner,捕获 stdout 到临时文件,保持原始退出码 run_with_spinner() { local msg="$1" start_ts="$SECONDS" shift local tmpfile tmpfile=$(mktemp) # 后台运行命令,stdout → tmpfile,stderr → /dev/null "$@" > "$tmpfile" 2>/dev/null & local pid=$! spinner "$pid" "$msg" "$start_ts" local rc=$? # 清掉 spinner 行,输出结果 local elapsed=$((SECONDS - start_ts)) printf "\r\033[K ${GREEN}✓${NC} %s (耗时 %ds)\n" "$msg" "$elapsed" >&2 cat "$tmpfile" rm -f "$tmpfile" return $rc } # --------------------------------------------------------------------------- # 自动检测项目中的作品 ID # --------------------------------------------------------------------------- detect_book_id() { local books_dir books_dir="$(findProjectRoot)/books" if [[ ! -d "$books_dir" ]]; then die "未找到 books/ 目录。请在 inkos 项目内运行此脚本。" fi local books books=($(ls -1 "$books_dir" 2>/dev/null || true)) if [[ ${#books[@]} -eq 0 ]]; then die "项目中还没有作品。请先用 inkos book create --title ... 创建。" fi if [[ ${#books[@]} -eq 1 ]]; then echo "${books[0]}" return fi # 多作品时让用户选择 echo "" >&2 info "检测到多个作品:" >&2 for i in "${!books[@]}"; do info " [$((i+1))] ${books[$i]}" >&2 done echo "" >&2 read -r -p "请选择作品 [1-${#books[@]}]:" choice || true if [[ ! "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#books[@]} )); then die "选择无效。" fi echo "${books[$((choice-1))]}" } findProjectRoot() { local dir="$PWD" while [[ "$dir" != "/" ]]; do if [[ -f "$dir/inkos.json" ]]; then echo "$dir" return fi dir="$(dirname "$dir")" done die "当前目录及父目录均未找到 inkos.json。请在 inkos 项目内运行此脚本。" } # --------------------------------------------------------------------------- # 章节号提取 # --------------------------------------------------------------------------- get_latest_chapter() { local book_id="$1" root root="$(findProjectRoot)" local index="$root/books/$book_id/chapters/index.json" if [[ ! -f "$index" ]]; then echo "1" return fi jq -r 'if length == 0 then 1 else (max_by(.number) | .number + 1) end' "$index" 2>/dev/null || echo "1" } get_last_written_chapter() { local book_id="$1" root root="$(findProjectRoot)" local index="$root/books/$book_id/chapters/index.json" if [[ ! -f "$index" ]]; then echo "0" return fi jq -r 'max_by(.number) | .number // 0' "$index" 2>/dev/null || echo "0" } # --------------------------------------------------------------------------- # 前置检查 # --------------------------------------------------------------------------- require_cmd inkos require_cmd jq require_cmd less echo "" echo -e "${BOLD}${CYAN}╔══════════════════════════════════════╗${NC}" echo -e "${BOLD}${CYAN}║ InkOS 原子写下一章(原子管线) ║${NC}" echo -e "${BOLD}${CYAN}╚══════════════════════════════════════╝${NC}" echo "" ROOT="$(findProjectRoot)" BOOK_ID="${1:-$(detect_book_id)}" NEXT_CH="$(get_latest_chapter "$BOOK_ID")" LAST_CH="$(get_last_written_chapter "$BOOK_ID")" info "项目 :${ROOT}" info "作品 :${BOOK_ID}" info "下一章 :第 ${NEXT_CH} 章" if (( LAST_CH > 0 )); then info "最新章 :第 ${LAST_CH} 章" fi # 可选章节引导 CONTEXT="${2:-}" if [[ -z "$CONTEXT" ]]; then echo "" read -r -p "章节创作引导(可选,回车跳过):" CONTEXT || true fi # 用数组安全传参(处理空格等特殊字符) CONTEXT_ARGS=() if [[ -n "$CONTEXT" ]]; then CONTEXT_ARGS=(--context "$CONTEXT") fi # --------------------------------------------------------------------------- # 第 1 步:plan chapter(循环,允许用户查看意图后重新输入引导) # --------------------------------------------------------------------------- while true; do stage "第 1/5 步:规划章节意图" PLAN_OUTPUT="" PLAN_OUTPUT=$(run_with_spinner "正在规划章节意图" inkos plan chapter "$BOOK_ID" "${CONTEXT_ARGS[@]}" --json) || { fail "章节意图规划失败" echo "$PLAN_OUTPUT" exit 1 } if [[ -z "$(json_field "$PLAN_OUTPUT" chapterNumber)" ]]; then fail "章节意图 JSON 解析失败(可能 stdout/stderr 混合)" exit 1 fi PLAN_GOAL="$(json_field "$PLAN_OUTPUT" goal)" PLAN_CH="$(json_field "$PLAN_OUTPUT" chapterNumber)" PLAN_INTENT_FILE="$(json_field "$PLAN_OUTPUT" intentPath)" ok "已生成第 ${PLAN_CH} 章意图" if [[ -n "$PLAN_GOAL" ]]; then info "章节目标:${PLAN_GOAL}" fi if [[ -n "$PLAN_INTENT_FILE" ]]; then info "意图文件:${PLAN_INTENT_FILE}" fi # ---- 检查点:查看意图文件 ---- echo "" INTENT_FULL_PATH="$ROOT/books/$BOOK_ID/$PLAN_INTENT_FILE" if [[ -n "$PLAN_INTENT_FILE" && -f "$INTENT_FULL_PATH" ]]; then info "正在用 less 显示意图文件(按 q 退出,↑↓ 滚动,/ 搜索)……" echo "" less -R "$INTENT_FULL_PATH" || true else warn "意图文件不存在(${INTENT_FULL_PATH}),跳过预览。" fi echo "" info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" info " c / 回车 — 继续下一步(compile)" info " r — 重新输入创作引导,重新 plan" info " q — 退出" info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" read -r -p "请选择 [c/r/q](默认 c):" PLAN_CHOICE || true case "${PLAN_CHOICE:-c}" in c|C|"") break ;; r|R) echo "" read -r -p "请输入新的创作引导:" CONTEXT || true CONTEXT_ARGS=() if [[ -n "$CONTEXT" ]]; then CONTEXT_ARGS=(--context "$CONTEXT") fi ;; q|Q) info "用户退出。" exit 0 ;; *) warn "无效输入,默认继续。" break ;; esac done # --------------------------------------------------------------------------- # 第 2 步:compose chapter(无 LLM 调用 — 编译上下文/规则栈/追踪产物) # --------------------------------------------------------------------------- stage "第 2/5 步:编译章节上下文" COMPOSE_OUTPUT="" COMPOSE_OUTPUT=$(inkos compose chapter "$BOOK_ID" "${CONTEXT_ARGS[@]}" --json 2>/dev/null) || { fail "章节上下文编译失败" echo "$COMPOSE_OUTPUT" exit 1 } if [[ -z "$(json_field "$COMPOSE_OUTPUT" contextPath)" ]]; then fail "章节上下文 JSON 解析失败(可能 stdout/stderr 混合)" exit 1 fi COMPOSE_CH="$(json_field "$COMPOSE_OUTPUT" chapterNumber)" COMPOSE_CTX="$(json_field "$COMPOSE_OUTPUT" contextPath)" COMPOSE_RULES="$(json_field "$COMPOSE_OUTPUT" ruleStackPath)" COMPOSE_TRACE="$(json_field "$COMPOSE_OUTPUT" tracePath)" ok "已编译第 ${COMPOSE_CH} 章上下文" info "上下文 :${COMPOSE_CTX}" info "规则栈 :${COMPOSE_RULES}" info "追踪文件 :${COMPOSE_TRACE}" # --------------------------------------------------------------------------- # 第 3 步:draft(LLM — 撰写章节草稿,然后循环确认 / rewrite 重写) # --------------------------------------------------------------------------- stage "第 3/5 步:撰写章节草稿" DRAFT_OUTPUT="" DRAFT_OUTPUT=$(run_with_spinner "正在生成草稿" inkos draft "$BOOK_ID" "${CONTEXT_ARGS[@]}" --json) || { fail "草稿撰写失败" echo "$DRAFT_OUTPUT" exit 1 } DRAFT_CH="$(json_field "$DRAFT_OUTPUT" chapterNumber)" DRAFT_TITLE="$(json_field "$DRAFT_OUTPUT" title)" DRAFT_WORDS="$(json_field "$DRAFT_OUTPUT" wordCount)" DRAFT_FILE="$(json_field "$DRAFT_OUTPUT" filePath)" if [[ -z "$DRAFT_CH" || -z "$DRAFT_FILE" ]]; then fail "草稿 JSON 解析失败(缺少 chapterNumber 或 filePath)" exit 1 fi # ---- 确认循环:less 预览 → [c/r/q] → rewrite 后回到预览 ---- while true; do info "第 ${DRAFT_CH} 章:${DRAFT_TITLE}" info "字数:${DRAFT_WORDS}" info "文件:${DRAFT_FILE}" # 检查字数警告 LEN_WARNINGS="$(json_field "$DRAFT_OUTPUT" lengthWarnings)" if [[ -n "$LEN_WARNINGS" && "$LEN_WARNINGS" != "null" ]]; then warn "字数警告:" echo "$DRAFT_OUTPUT" | jq -r '.lengthWarnings[]? | " - \(.)"' 2>/dev/null || true fi echo "" if [[ -n "$DRAFT_FILE" && -f "$DRAFT_FILE" ]]; then info "正在用 less 显示草稿(按 q 退出,↑↓ 滚动,/ 搜索)……" echo "" less -R "$DRAFT_FILE" || true else warn "草稿文件不存在(${DRAFT_FILE}),跳过预览。" fi echo "" info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" info " c / 回车 — 继续下一步(审计)" info " r — 输入提示词,用 rewrite 重写本章" info " q — 退出" info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" read -r -p "请选择 [c/r/q](默认 c):" DRAFT_CHOICE || true case "${DRAFT_CHOICE:-c}" in c|C|"") break ;; r|R) echo "" read -r -p "请输入重写提示词:" REWRITE_BRIEF || true if [[ -z "$REWRITE_BRIEF" ]]; then warn "未输入提示词,跳过重写。" continue fi REWRITE_OUTPUT="" REWRITE_OUTPUT=$(run_with_spinner "正在 rewrite 重写" inkos revise "$BOOK_ID" "$DRAFT_CH" --mode rewrite --brief "$REWRITE_BRIEF" --json) || { warn "重写失败,将使用当前草稿继续。" echo "$REWRITE_OUTPUT" continue } REWRITE_APPLIED="$(json_field "$REWRITE_OUTPUT" applied)" REWRITE_WORDS="$(json_field "$REWRITE_OUTPUT" wordCount)" if [[ "$REWRITE_APPLIED" == "true" ]]; then ok "重写已应用 —— ${REWRITE_WORDS} 字" DRAFT_TITLE="$(json_field "$REWRITE_OUTPUT" title)" DRAFT_WORDS="$REWRITE_WORDS" # 重写后回到预览,让用户再次确认 else warn "重写未应用 —— $(json_field "$REWRITE_OUTPUT" skippedReason)" continue fi ;; q|Q) info "用户退出。" exit 0 ;; *) warn "无效输入,默认继续。" break ;; esac done # --------------------------------------------------------------------------- # 第 4 步:审计 + 修订循环 # --------------------------------------------------------------------------- MAX_REVISE_ROUNDS=3 AUDIT_PASSED=false REVISED=false ROUND=0 stage "第 4/5 步:审计 + 修订循环" while true; do ROUND=$((ROUND + 1)) # ---- 审计 ---- AUDIT_OUTPUT="" AUDIT_OUTPUT=$(run_with_spinner "正在审计(第 ${ROUND} 轮)" inkos audit "$BOOK_ID" "$DRAFT_CH" --json) || { fail "审计失败" echo "$AUDIT_OUTPUT" exit 1 } AUDIT_PASSED_FLAG="$(json_field "$AUDIT_OUTPUT" passed)" AUDIT_SUMMARY="$(json_field "$AUDIT_OUTPUT" summary)" if [[ "$AUDIT_PASSED_FLAG" == "true" ]]; then ok "审计通过 —— ${AUDIT_SUMMARY}" AUDIT_PASSED=true break fi warn "审计未通过(第 ${ROUND} 轮)—— ${AUDIT_SUMMARY}" # 打印问题列表 ISSUE_COUNT=$(echo "$AUDIT_OUTPUT" | jq '.issues | length' 2>/dev/null || echo "0") if (( ISSUE_COUNT > 0 )); then echo "$AUDIT_OUTPUT" | jq -r '.issues[] | " [\(.severity)] \(.category): \(.description)"' 2>/dev/null fi # 检查是否还有修订轮次 if (( ROUND >= MAX_REVISE_ROUNDS )); then warn "已达最大修订轮次(${MAX_REVISE_ROUNDS})。章节将保留为草稿状态。" break fi echo "" read -r -p "是否现在修订?[Y/n]:" DO_REVISE || true if [[ "$DO_REVISE" =~ ^[Nn] ]]; then warn "跳过修订。章节将保留为草稿状态。" break fi # 选择修订模式 echo "" info "修订模式:" info " 1) spot-fix – 定点修复,最小改动(默认)" info " 2) rewrite – 全章改写,保留剧情走向" info " 3) rework – 深层重构" info " 4) anti-detect – 去 AI 痕迹" read -r -p "请选择模式 [1-4](默认 1):" MODE_CHOICE || true case "${MODE_CHOICE:-1}" in 1) REVISE_MODE="spot-fix" ;; 2) REVISE_MODE="rewrite" ;; 3) REVISE_MODE="rework" ;; 4) REVISE_MODE="anti-detect" ;; *) REVISE_MODE="spot-fix" ;; esac # ---- 修订 ---- REVISE_OUTPUT="" REVISE_OUTPUT=$(run_with_spinner "正在修订 [$REVISE_MODE]" inkos revise "$BOOK_ID" "$DRAFT_CH" --mode "$REVISE_MODE" --json) || { fail "修订失败" echo "$REVISE_OUTPUT" continue } REVISE_APPLIED="$(json_field "$REVISE_OUTPUT" applied)" REVISE_WORDS="$(json_field "$REVISE_OUTPUT" wordCount)" REVISE_STATUS="$(json_field "$REVISE_OUTPUT" status)" REVISE_SKIPPED="$(json_field "$REVISE_OUTPUT" skippedReason)" if [[ "$REVISE_APPLIED" == "true" ]]; then ok "修订已应用 —— ${REVISE_WORDS} 字,状态:${REVISE_STATUS}" REVISED=true echo "$REVISE_OUTPUT" | jq -r '.fixedIssues[]? | " 已修复:\(.)"' 2>/dev/null else warn "修订未应用 —— ${REVISE_SKIPPED:-无需修改}" fi done # --------------------------------------------------------------------------- # 第 5 步:润色(可选,仅审计通过后执行) # --------------------------------------------------------------------------- stage "第 5/5 步:文笔润色" if [[ "$AUDIT_PASSED" == "true" ]]; then echo "" read -r -p "是否进行文笔润色?[Y/n]:" DO_POLISH || true if [[ ! "$DO_POLISH" =~ ^[Nn] ]]; then POLISH_OUTPUT="" POLISH_OUTPUT=$(run_with_spinner "正在润色" inkos revise "$BOOK_ID" "$DRAFT_CH" --mode polish --json) || { warn "润色失败(非致命错误)" echo "$POLISH_OUTPUT" } POLISH_APPLIED="$(json_field "$POLISH_OUTPUT" applied)" POLISH_WORDS="$(json_field "$POLISH_OUTPUT" wordCount)" if [[ "$POLISH_APPLIED" == "true" ]]; then ok "润色已应用 —— ${POLISH_WORDS} 字" else info "润色跳过(无需修改)" fi else info "跳过润色。" fi else warn "审计未通过,跳过润色。" fi # --------------------------------------------------------------------------- # 完成 # --------------------------------------------------------------------------- echo "" echo -e "${GREEN}${BOLD}╔══════════════════════════════════════╗${NC}" echo -e "${GREEN}${BOLD}║ 写入下一章完成 ║${NC}" echo -e "${GREEN}${BOLD}╚══════════════════════════════════════╝${NC}" echo "" info "作品 :${BOOK_ID}" info "章节 :第 ${DRAFT_CH} 章 — ${DRAFT_TITLE}" info "字数 :${DRAFT_WORDS}" info "文件 :${DRAFT_FILE}" info "审计 :$( [[ "$AUDIT_PASSED" == "true" ]] && echo "${GREEN}通过${NC}" || echo "${YELLOW}未通过${NC}" )" info "已修订 :$( [[ "$REVISED" == "true" ]] && echo "${GREEN}是${NC}" || echo "否" )" echo "" banner "后续操作:" info " inkos review approve ${BOOK_ID} ${DRAFT_CH}" info " inkos export ${BOOK_ID}" info " inkos status" echo ""