Commit 36ade1cb02e8542b4b5b4df23c795caf47c22e9f

Authored by yibo
1 parent 5ab66253

feat(dsh): GLM Coding Plan 额度用尽自动切 DeepSeek,恢复后切回;加 codex/claude 订阅引擎

- fallback.sh:GLM 报额度类错误(1308/1310/1316-1321/1309/1113)且无产出时改用 DeepSeek 重跑;
  冷却到 next_flush_time,查不到至少 5h;瞬时 1302/1305 只冷却 60s。test-fallback.sh 为本地检查
- run-headless / run-reply-member / dsh-fb.sh(viewer 用) 接入;patches/deepseek.yml = deepseek-flash(V4.1 Flash)
- run-codex.sh / run-claude.sh + orchestrator .reply_engine、viewer .job_engine 引擎切换
  (实测:手机在大陆出口,Anthropic 403 地区限制,claude 引擎暂不可用)
- patches/claude-sub.yml:订阅 OAuth token 直连被服务端拒,仅留作记录

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
device/dsh/dsh-fb.sh 0 → 100755
  1 +#!/data/data/com.termux/files/usr/bin/bash
  2 +# wechat-viewer 用:等价于 `dsh headless <args>`,但 GLM 额度用尽时自动切 DeepSeek(见 fallback.sh)。
  3 +# 用法:dsh-fb.sh --patch no-phone.yml "prompt"
  4 +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fallback.sh"
  5 +dsh_with_fallback /data/data/com.termux/files/usr/bin/dsh headless @PATCH@ "$@"
... ...
device/dsh/fallback.sh 0 → 100644
  1 +# source 后用:dsh_with_fallback <命令...>,命令里的 @PATCH@ 是 DeepSeek 补丁的插入点。
  2 +# 先走默认模型(GLM Coding Plan);它额度用尽/限流且没产出答案时,改用 DeepSeek 重跑,
  3 +# 并写 $DSH_FB_STATE=冷却截止 epoch:冷却期内直接走 DeepSeek,冷却过后下一次调用先探测 GLM,恢复了就自动切回。
  4 +# 错误码依据智谱文档 docs.bigmodel.cn/cn/api/api-code(均为 HTTP 429):
  5 +# 额度类 1308/1310/1316-1321(响应带 next_flush_time=限额重置时间)、1309 套餐到期、1113 欠费
  6 +# → 冷却到 next_flush_time(+60s);解析不到时间 = 至少 5 小时(MIN_COOLDOWN)
  7 +# 瞬时类 1302 速率限制 / 1305 模型过载(文档:稍后重试)→ 只冷却 60s,别因瞬时 429 锁 5 小时
  8 +# ponytail: 只在「没产出答案」时重跑(防止 admin 任务已执行过工具又被重放一遍);
  9 +# 错误码没覆盖到的 RATE_LIMIT/429 按瞬时类处理,上限=未知的长期限流会每 60s 白打一次 GLM;
  10 +# next_flush_time 的确切格式文档没写,按「秒/毫秒时间戳、或日期时间串(无时区=北京时间)」解析,真机见到后再收紧。
  11 +DSH_FB_STATE="${DSH_FB_STATE:-$HOME/dsh/.glm_down_until}"
  12 +DSH_FB_MIN_COOLDOWN="${DSH_FB_MIN_COOLDOWN:-18000}" # 额度类查不到重置时间时的最短冷却=5 小时
  13 +DSH_FB_MARGIN="${DSH_FB_MARGIN:-60}" # 在报错给的重置时间上多等一点
  14 +DSH_FB_SHORT="${DSH_FB_SHORT:-60}" # 瞬时限流的冷却秒数
  15 +DSH_FB_PATCH="${DSH_FB_PATCH:-$HOME/dsh/patches/deepseek.yml}"
  16 +_N='(^|[^0-9])'; _M='([^0-9]|$)' # 错误码前后不能是数字(macOS grep 不支持 \b)
  17 +DSH_FB_QUOTA="${DSH_FB_QUOTA:-QUOTA|next_flush_time|使用上限|${_N}(1308|1310|1309|1113|131[6-9]|132[01])${_M}}"
  18 +DSH_FB_TRANSIENT="${DSH_FB_TRANSIENT:-RATE_LIMIT|HTTP_429|${_N}(1302|1305)${_M}}"
  19 +
  20 +# $1=stderr 文件 → 输出额度类冷却截止 epoch
  21 +_dsh_fb_until() {
  22 + local now t; now=$(date +%s)
  23 + t=$(python3 - "$1" "$now" <<'PY'
  24 +import re, sys, calendar
  25 +txt, now = open(sys.argv[1], errors="replace").read(), int(sys.argv[2])
  26 +DT = r"20\d\d-\d\d-\d\d[ T]\d\d:\d\d(?::\d\d)?(?:Z|[+-]\d\d:?\d\d)?"
  27 +def dt(v): # 日期时间串 → epoch;无时区按北京时间
  28 + m = re.search(r"(20\d\d)-(\d\d)-(\d\d)[ T](\d\d):(\d\d)(?::(\d\d))?(Z|[+-]\d\d:?\d\d)?", v)
  29 + if not m: return 0
  30 + y, mo, d, h, mi, s = (int(x or 0) for x in m.groups()[:6])
  31 + z, off = m.group(7), 8 * 3600
  32 + if z == "Z": off = 0
  33 + elif z: off = (1 if z[0] == "+" else -1) * (int(z[1:3]) * 3600 + int(z[-2:]) * 60)
  34 + return calendar.timegm((y, mo, d, h, mi, s)) - off
  35 +t = 0
  36 +k = re.search(r"next_flush_time\W{0,4}(\d{9,14})", txt) # 数字时间戳(秒或毫秒)
  37 +if k:
  38 + t = int(k.group(1)); t = t // 1000 if t > 10**11 else t
  39 +if not t:
  40 + k = re.search(r"next_flush_time\W{0,4}(" + DT + ")", txt) # 日期时间串
  41 + if k: t = dt(k.group(1))
  42 +if not t:
  43 + m = re.findall(DT, txt) # 文案里的「将在 … 重置」
  44 + if m: t = dt(m[-1])
  45 +if not t:
  46 + r = re.search(r"retry[- ]after\D{0,3}(\d+)", txt, re.I)
  47 + if r: t = now + int(r.group(1))
  48 +if now < t <= now + 8 * 86400: print(t)
  49 +PY
  50 +)
  51 + if [ -n "$t" ]; then echo $(( t + DSH_FB_MARGIN )); else echo $(( now + DSH_FB_MIN_COOLDOWN )); fi
  52 +}
  53 +
  54 +dsh_with_fallback() {
  55 + local x pid rc down=0 glm=() ds=() out err
  56 + for x in "$@"; do
  57 + if [ "$x" = "@PATCH@" ]; then ds+=(--patch "$DSH_FB_PATCH"); else glm+=("$x"); ds+=("$x"); fi
  58 + done
  59 + [ -r "$DSH_FB_STATE" ] && down=$(cat "$DSH_FB_STATE" 2>/dev/null || echo 0)
  60 + export TMPDIR="${PREFIX:-/data/data/com.termux/files/usr}/tmp" # Android 环境里 TMPDIR 常被预设成不可写的 /data/local/tmp,必须覆盖
  61 + out=$(mktemp) && err=$(mktemp) || exec "${glm[@]}" # 建不了临时文件就退回原行为,别让切换逻辑弄坏主链路
  62 + trap 'kill "$pid" 2>/dev/null' TERM INT # 不再 exec,转发信号,超时强杀仍能杀到 dsh
  63 + if [ "$(date +%s)" -ge "${down:-0}" ]; then
  64 + "${glm[@]}" >"$out" 2>"$err" & pid=$!; wait "$pid"; rc=$?
  65 + if [ "$rc" -ne 0 ] && [ ! -s "$out" ]; then
  66 + if grep -qE "$DSH_FB_QUOTA" "$err"; then down=$(_dsh_fb_until "$err")
  67 + elif grep -qE "$DSH_FB_TRANSIENT" "$err"; then down=$(( $(date +%s) + DSH_FB_SHORT ))
  68 + else down=""; fi
  69 + if [ -n "$down" ]; then
  70 + echo "$down" > "$DSH_FB_STATE"
  71 + echo "[fallback] GLM 额度/限流,切 DeepSeek 至 epoch $down" >&2
  72 + "${ds[@]}" >"$out" 2>"$err" & pid=$!; wait "$pid"; rc=$?
  73 + fi
  74 + fi
  75 + else
  76 + "${ds[@]}" >"$out" 2>"$err" & pid=$!; wait "$pid"; rc=$?
  77 + fi
  78 + cat "$err" >&2; cat "$out"; rm -f "$out" "$err"; return "$rc"
  79 +}
... ...
device/dsh/patches/claude-sub.yml 0 → 100644
  1 +# Claude Code 订阅(OAuth token,sk-ant-oat…)作为 dsh provider;
  2 +# 用法:run-headless.sh --patch ~/dsh/patches/claude-sub.yml "任务"
  3 +# token 来自 ~/dsh/.secrets.env 的 CLAUDE_CODE_OAUTH_TOKEN(run-headless.sh 会 source)
  4 +- id: llm-pi-ai
  5 + config:
  6 + providers:
  7 + anthropic-sub:
  8 + displayName: Claude (subscription OAuth)
  9 + apiKeyEnv: CLAUDE_CODE_OAUTH_TOKEN
  10 + api: anthropic-messages
  11 + baseURL: https://api.anthropic.com
  12 + models:
  13 + - id: claude-sonnet-5-5
  14 + contextWindow: 200000
  15 + maxTokens: 32768
  16 +- id: agent-default-model
  17 + config:
  18 + provider: anthropic-sub
  19 + model: claude-sonnet-5-5
... ...
device/dsh/patches/deepseek.yml 0 → 100644
  1 +# DeepSeek 官方 API 作为 dsh provider(走 dsh-llm-deepseek 直连插件,路由 deepseek-official)。
  2 +# 用法:run-headless.sh --patch ~/dsh/patches/deepseek.yml "任务"
  3 +# key 来自 ~/dsh/.secrets.env 的 DEEPSEEK_API_KEY(run-headless.sh 会 source);国内网络可直连。
  4 +# 模型:deepseek-flash(文本+图片,默认)/ deepseek-v4-pro(纯文本)。
  5 +- insert:
  6 + - id: llm-deepseek
  7 + name: '@deepseek-ai/dsh-llm-deepseek-api-key'
  8 + config:
  9 + apiKeyEnv: DEEPSEEK_API_KEY
  10 +- id: agent-default-model
  11 + config:
  12 + provider: deepseek-official
  13 + model: deepseek-flash
... ...
device/dsh/run-claude.sh 0 → 100755
  1 +#!/data/data/com.termux/files/usr/bin/bash
  2 +# Claude Code 订阅引擎(dsh 的替代)。用法:run-claude.sh <admin|member|chat> "prompt"
  3 +# 认证:Mac 上 `claude setup-token`(官方的 headless 长效 OAuth token,走订阅),
  4 +# 把 CLAUDE_CODE_OAUTH_TOKEN=... 写进 ~/dsh/.secrets.env(不进 git)。也可在手机上直接 `claude` 登录。
  5 +# stdout=最终答案,与 dsh headless 同形。member/chat 是陌生人/抓取内容=不可信输入,
  6 +# 用 --tools "" 彻底关掉全部工具(比 codex 的 read-only 沙箱更严)。
  7 +set -euo pipefail
  8 +export PREFIX="${PREFIX:-/data/data/com.termux/files/usr}"
  9 +export HOME="${HOME:-/data/data/com.termux/files/home}"
  10 +export PATH="$PREFIX/bin:$PATH"
  11 +export TMPDIR="$PREFIX/tmp"; mkdir -p "$TMPDIR"
  12 +# 官方 claude 2.1.150+ 只发原生二进制(无 android),固定在纯 JS 的 2.1.100,禁止自升级
  13 +export DISABLE_AUTOUPDATER=1
  14 +# Android 没有 /usr/bin/env,cli.js 的 shebang 直接执行会 ENOENT,所以显式用 node 跑
  15 +claude() { exec node "$PREFIX/lib/node_modules/@anthropic-ai/claude-code/cli.js" "$@"; }
  16 +SECRETS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.secrets.env"
  17 +if [ -f "$SECRETS" ]; then set -a; source "$SECRETS"; set +a; fi
  18 +
  19 +MODE="$1"; P="$2"
  20 +case "$MODE" in
  21 + admin) claude -p "$P" --dangerously-skip-permissions ;;
  22 + member)
  23 + SOUL="$HOME/pixelphone/SOUL.md"
  24 + [ -r "$SOUL" ] && P="$(cat "$SOUL")
  25 +
  26 +$P"
  27 + claude -p "$P" --tools "" ;;
  28 + chat) claude -p "$P" --tools "" ;;
  29 + *) echo "mode must be admin|member|chat" >&2; exit 2 ;;
  30 +esac
... ...
device/dsh/run-codex.sh 0 → 100755
  1 +#!/data/data/com.termux/files/usr/bin/bash
  2 +# Codex 订阅引擎(dsh 的替代)。用法:run-codex.sh <admin|member|chat> "prompt"
  3 +# 认证:手机上 `codex login --device-auth`(ChatGPT 订阅),token 落在 ~/.codex/auth.json,不进 git。
  4 +# stdout=最终答案(codex exec 的进度走 stderr),与 dsh headless 同形,调用方不用改解析。
  5 +# 本机无 bwrap/Landlock:admin 与 dsh 一致免沙箱;member/chat 是陌生人/抓取内容=不可信输入,
  6 +# 用 read-only 沙箱 + 无网络(ponytail: codex 无「零工具」开关,read-only 仍能读文件;上限=可读到手机上的文件,
  7 +# 若在意就保持 member 走 dsh no-tools)。
  8 +set -euo pipefail
  9 +export PREFIX="${PREFIX:-/data/data/com.termux/files/usr}"
  10 +export HOME="${HOME:-/data/data/com.termux/files/home}"
  11 +export PATH="$PREFIX/bin:$PATH"
  12 +export TMPDIR="$PREFIX/tmp"; mkdir -p "$TMPDIR"
  13 +
  14 +MODE="$1"; P="$2"
  15 +case "$MODE" in
  16 + admin)
  17 + exec codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$P" ;;
  18 + member)
  19 + SOUL="$HOME/pixelphone/SOUL.md"
  20 + [ -r "$SOUL" ] && P="$(cat "$SOUL")
  21 +
  22 +$P"
  23 + exec codex exec --skip-git-repo-check --sandbox read-only "$P" ;;
  24 + chat)
  25 + exec codex exec --skip-git-repo-check --sandbox read-only "$P" ;;
  26 + *) echo "mode must be admin|member|chat" >&2; exit 2 ;;
  27 +esac
... ...
device/dsh/run-headless.sh
... ... @@ -20,4 +20,6 @@ if [ -f &quot;$SECRETS_FILE&quot; ]; then
20 20 set +a
21 21 fi
22 22  
23   -exec node --expose-internals "$DEPLOY_DIR/node_modules/.bin/dsh" --profile headless "$@"
  23 +# shellcheck disable=SC1091
  24 +source "$DEPLOY_DIR/fallback.sh" # GLM 额度用尽自动切 DeepSeek,恢复后切回
  25 +dsh_with_fallback node --expose-internals "$DEPLOY_DIR/node_modules/.bin/dsh" --profile headless @PATCH@ "$@"
... ...
device/dsh/run-reply-member.sh
... ... @@ -8,4 +8,5 @@ SOUL=/data/data/com.termux/files/home/pixelphone/SOUL.md
8 8 P="$1"; [ -r "$SOUL" ] && P="$(cat "$SOUL")
9 9  
10 10 $1"
11   -exec /data/data/com.termux/files/usr/bin/dsh headless --patch /data/data/com.termux/files/home/dsh/patches/no-tools.yml "$P"
  11 +. /data/data/com.termux/files/home/dsh/fallback.sh # GLM 额度用尽自动切 DeepSeek,恢复后切回
  12 +dsh_with_fallback /data/data/com.termux/files/usr/bin/dsh headless @PATCH@ --patch /data/data/com.termux/files/home/dsh/patches/no-tools.yml "$P"
... ...
device/dsh/test-fallback.sh 0 → 100755
  1 +#!/usr/bin/env bash
  2 +# 本地可跑的检查:假 dsh 模拟 GLM 额度用尽 → 应切 DeepSeek;冷却过后应切回 GLM。
  3 +set -u
  4 +D="$(cd "$(dirname "$0")" && pwd)"; T=$(mktemp -d)
  5 +export DSH_FB_STATE="$T/state" DSH_FB_MIN_COOLDOWN=2 DSH_FB_MARGIN=0 HOME="$T"
  6 +cat > "$T/fake" <<'F'
  7 +#!/usr/bin/env bash
  8 +if [[ "$*" == *deepseek* ]]; then echo "answer-from-deepseek"; exit 0; fi
  9 +if [ -e "$GLM_DOWN" ]; then echo "dsh: ${GLM_MSG:-QUOTA usage limit}" >&2; exit 1; fi
  10 +echo "answer-from-glm"
  11 +F
  12 +chmod +x "$T/fake"; export GLM_DOWN="$T/down"
  13 +. "$D/fallback.sh"; DSH_FB_PATCH="$T/deepseek.yml"
  14 +run() { dsh_with_fallback "$T/fake" @PATCH@ task 2>/dev/null; }
  15 +[ "$(run)" = answer-from-glm ] || { echo "FAIL 1 平时应走 GLM"; exit 1; }
  16 +touch "$GLM_DOWN"
  17 +[ "$(run)" = answer-from-deepseek ] || { echo "FAIL 2 额度用尽应切 DeepSeek"; exit 1; }
  18 +rm "$GLM_DOWN"
  19 +[ "$(run)" = answer-from-deepseek ] || { echo "FAIL 3 冷却期内应继续 DeepSeek"; exit 1; }
  20 +sleep 3
  21 +[ "$(run)" = answer-from-glm ] || { echo "FAIL 4 冷却后 GLM 恢复应切回"; exit 1; }
  22 +# 额度类:冷却取 next_flush_time(3 种格式);取不到=MIN_COOLDOWN;瞬时类 1302=SHORT
  23 +export DSH_FB_MIN_COOLDOWN=100 DSH_FB_SHORT=7
  24 +chk() { # chk <名字> <GLM_MSG> <最小秒> <最大秒>
  25 + rm -f "$DSH_FB_STATE"; touch "$GLM_DOWN"; export GLM_MSG="$2"; run >/dev/null
  26 + d=$(( $(cat "$DSH_FB_STATE") - $(date +%s) ))
  27 + [ "$d" -ge "$3" ] && [ "$d" -le "$4" ] || { echo "FAIL $1 期望 $3~$4s,实得 +${d}s"; exit 1; }
  28 +}
  29 +now=$(date +%s)
  30 +chk "秒级时间戳" "1308 {\"next_flush_time\":$((now+4))}" 1 8
  31 +chk "毫秒时间戳" "1310 {\"next_flush_time\": $(( (now+4)*1000 ))}" 1 8
  32 +chk "日期串(北京时间)" "1308 已达到使用上限 next_flush_time: $(python3 -c 'import time;print(time.strftime("%Y-%m-%d %H:%M:%S",time.gmtime(time.time()+8*3600+4)))')" 1 8
  33 +chk "额度类无时间=最短冷却" "1308 已达到 5 小时的使用上限" 95 105
  34 +chk "瞬时 1302=短冷却" "1302 您的账户已达到速率限制" 5 9
  35 +rm -f "$DSH_FB_STATE"; touch "$GLM_DOWN"; export GLM_MSG="boom"; run >/dev/null; [ ! -e "$DSH_FB_STATE" ] || { echo "FAIL 未知错误不应切换"; exit 1; }
  36 +echo PASS
... ...
device/pixelphone/wechat_reply/wechat_reply_orchestrator.py
... ... @@ -28,6 +28,16 @@ CIRCLE_DB = &quot;/data/data/com.termux/files/home/wechat-viewer/circle.db&quot;
28 28 EVENT_BUS = "/data/data/com.termux/files/home/pixelphone/event_bus"
29 29 DSH_RUNNER = "/data/data/com.termux/files/home/dsh/run-headless.sh" # admin:主人本人,全工具
30 30 MEMBER_RUNNER = "/data/data/com.termux/files/home/dsh/run-reply-member.sh" # member:陌生人输入,无工具/只读/带人设
  31 +CODEX_RUNNER = "/data/data/com.termux/files/home/dsh/run-codex.sh" # Codex 订阅引擎:run-codex.sh <admin|member> prompt
  32 +CLAUDE_RUNNER = "/data/data/com.termux/files/home/dsh/run-claude.sh" # Claude Code 订阅引擎,用法同上
  33 +ENGINE_FILE = "/data/data/com.termux/files/home/pixelphone/.reply_engine" # 内容 "codex"/"claude" 即切;缺省/出错=dsh,每次现读免重启
  34 +
  35 +
  36 +def _engine():
  37 + try:
  38 + return open(ENGINE_FILE).read().strip() or "dsh"
  39 + except OSError:
  40 + return "dsh"
31 41 ADMIN_SKILL = "/data/data/com.termux/files/home/.dsh/skills/reply-task-executor/SKILL.md"
32 42  
33 43 TERMUX_UID = 10329
... ... @@ -165,7 +175,9 @@ def generate(context_text, event=None, role=&quot;member&quot;):
165 175 f"遇到不确定的细节不要停下来等确认:按最合理的假设继续执行,"
166 176 f"把假设和需要火星人拍板的事项列在报告末尾"
167 177 f"(涉及钱/承诺/约时间的除外,这类一律标记「需火星人确认」)。\n"
168   - f"任务完成后,把结果报告写入 {result_file}。\n"
  178 + f"总步数控制在 20 步内;做完第一轮核查就立刻把当前结论写入 {result_file}(哪怕不完整),"
  179 + f"之后每有新发现覆盖更新——超时会被强杀,没落盘的内容全部丢失。\n"
  180 + f"任务完成后,把最终结果报告写入 {result_file}。\n"
169 181 f"报告用微信纯文本:短段落+简短列表,不要 Markdown 表格/井号标题/代码块。"
170 182 )
171 183 else:
... ... @@ -178,14 +190,14 @@ def generate(context_text, event=None, role=&quot;member&quot;):
178 190 # dsh 没有 -s skill 机制:让它自己读 SKILL.md。admin 是主人本人,手机 MCP 放开;
179 191 # 直发仍被 WECHAT_REPLY_NO_DIRECT_SEND 闸门挡住(env 传给 MCP 子进程),发送只走 orchestrator。
180 192 prompt = f"先用文件工具读取并严格遵循 skill:{ADMIN_SKILL}\n\n" + prompt
181   - cmd = [DSH_RUNNER, prompt]
  193 + cmd = {"codex": [CODEX_RUNNER, "admin", prompt], "claude": [CLAUDE_RUNNER, "admin", prompt]}.get(_engine(), [DSH_RUNNER, prompt])
182 194 else:
183 195 # 简单模式:零工具,纯粹文本补全
184 196 prompt = (
185 197 "【任务】下面是别人发到微信来的一条消息,请以你本人(火星人)的口吻,写一条你会真的发出去的"
186 198 "微信回复。只输出这条回复本身,不要解释、不要引号、不要用任何工具、不要真的发送。\n\n"
187 199 "【上下文】\n" + context_text)
188   - cmd = [MEMBER_RUNNER, prompt]
  200 + cmd = {"codex": [CODEX_RUNNER, "member", prompt], "claude": [CLAUDE_RUNNER, "member", prompt]}.get(_engine(), [MEMBER_RUNNER, prompt])
189 201  
190 202 env = dict(os.environ)
191 203 env.update({
... ... @@ -220,7 +232,18 @@ def generate(context_text, event=None, role=&quot;member&quot;):
220 232 return draft, p.returncode, stderr, result_file if role == "admin" else ""
221 233 except subprocess.TimeoutExpired:
222 234 log("生成超时")
223   - return "", -1, "timeout", result_file if role == "admin" else ""
  235 + partial = ""
  236 + if role == "admin" and result_file:
  237 + # 超时被强杀:捞 dsh 已落盘的阶段性结论(结果文件,其次 skill 的 .plan.md),别让用户空等
  238 + for f in (result_file, result_file + ".plan.md"):
  239 + try:
  240 + partial = open(f).read().strip()[:1500]
  241 + except OSError:
  242 + continue
  243 + if partial:
  244 + partial = "【任务超时没跑完,以下是阶段性结论】\n" + partial
  245 + break
  246 + return partial, -1, "timeout", result_file if role == "admin" else ""
224 247 except Exception as e:
225 248 log(f"生成异常: {e}")
226 249 return "", -1, str(e)[:200], result_file if role == "admin" else ""
... ...
device/wechat-viewer/app.py
... ... @@ -222,6 +222,11 @@ PHASE_TIER_PLAN = {
222 222 _JOB_ENGINE_FILE = Path(__file__).parent / ".job_engine"
223 223 _DSH_BIN = "/data/data/com.termux/files/usr/bin/dsh"
224 224 _DSH_NO_PHONE = "/data/data/com.termux/files/home/dsh/patches/no-phone.yml"
  225 +_DSH_FB = "/data/data/com.termux/files/home/dsh/dsh-fb.sh" # = `dsh headless …`,GLM 额度用尽自动切 DeepSeek(见 dsh/fallback.sh)
  226 +_SUB_RUNNERS = { # .job_engine 值写 "codex"/"claude" 即走对应订阅 CLI(均为不可信内容的只读/无工具模式)
  227 + "codex": "/data/data/com.termux/files/home/dsh/run-codex.sh",
  228 + "claude": "/data/data/com.termux/files/home/dsh/run-claude.sh",
  229 +}
225 230 _HERMES_SKILLS = Path("/data/data/com.termux/files/home/.dsh/skills")
226 231  
227 232  
... ... @@ -248,16 +253,26 @@ def _wechat_share_des(url: str) -&gt; str:
248 253 def _dsh_chat_cmd(user_input: str) -> list[str]:
249 254 """聊天界面用:dsh headless(stdout=最终答案,reasoning 在 stderr);关手机 MCP——对话内容不可信。
250 255 ponytail: 无多轮会话/无 profile 概念,每问一次起一个 dsh(~10s+);要多轮可用 --session-id。"""
251   - return [_DSH_BIN, "headless", "--patch", _DSH_NO_PHONE, user_input]
  256 + if _engine_of("chat") in _SUB_RUNNERS:
  257 + return [_SUB_RUNNERS[_engine_of("chat")], "chat", user_input]
  258 + return [_DSH_FB, "--patch", _DSH_NO_PHONE, user_input]
252 259  
253 260  
254 261 _PROFILE_GONE = "Hermes profile 管理已随 Hermes 下线(dsh 无 profile 概念)"
255 262  
256 263  
257   -def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]:
  264 +def _engine_of(job_type: str) -> str:
258 265 try:
259 266 cfg = json.loads(_JOB_ENGINE_FILE.read_text())
260   - if cfg.get(job_type, cfg.get("*", "hermes")) != "dsh" or cmd[:2] != ["hermes", "chat"]:
  267 + return cfg.get(job_type, cfg.get("*", "hermes"))
  268 + except Exception:
  269 + return "hermes"
  270 +
  271 +
  272 +def _maybe_dsh(job_type: str, cmd: list[str]) -> list[str]:
  273 + try:
  274 + engine = _engine_of(job_type)
  275 + if engine not in ("dsh", *_SUB_RUNNERS) or cmd[:2] != ["hermes", "chat"]:
261 276 return cmd
262 277 prompt = cmd[cmd.index("-q") + 1]
263 278 flag = "-s" if "-s" in cmd else "--skills"
... ... @@ -267,7 +282,9 @@ def _maybe_dsh(job_type: str, cmd: list[str]) -&gt; list[str]:
267 282 if hit:
268 283 paths.append(str(hit))
269 284 head = ("先用文件工具读取并严格遵循以下 skill:" + "、".join(paths) + "\n\n【任务】\n") if paths else ""
270   - return [_DSH_BIN, "headless", "--patch", _DSH_NO_PHONE, head + prompt]
  285 + if engine in _SUB_RUNNERS:
  286 + return [_SUB_RUNNERS[engine], "chat", head + prompt]
  287 + return [_DSH_FB, "--patch", _DSH_NO_PHONE, head + prompt]
271 288 except Exception:
272 289 return cmd # 翻译失败不影响既有链路
273 290  
... ...