|
|
@@ -0,0 +1,997 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""WikiPali 写入客户端。
|
|
|
+
|
|
|
+以「AI 模型身份」把句子写入 WikiPali 句子库。子命令:
|
|
|
+
|
|
|
+ endpoint 查看 / 切换 API 地址
|
|
|
+ whoami 显示当前凭据状态
|
|
|
+ ensure-model 幂等地建立模型记录并取模型身份 token
|
|
|
+ revoke 撤销该模型已签出的全部 token
|
|
|
+ channels 列出当前账号可编辑的 channel
|
|
|
+ grant 为某个 channel 签发 access token 并缓存
|
|
|
+ write 写入句子(分批 + 确认 + count 核对)
|
|
|
+
|
|
|
+约束:只用 Python 标准库,目录自包含,可整体复制到任意项目。
|
|
|
+密码只由 wp_login.py 接触,本脚本永不读密码。
|
|
|
+"""
|
|
|
+
|
|
|
+import argparse
|
|
|
+import base64
|
|
|
+import json
|
|
|
+import os
|
|
|
+import stat
|
|
|
+import sys
|
|
|
+import time
|
|
|
+import urllib.error
|
|
|
+import urllib.parse
|
|
|
+import urllib.request
|
|
|
+from datetime import datetime, timezone
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 站点清单
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
|
|
|
+# .org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
|
|
|
+# 开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
|
|
|
+
|
|
|
+SITES = [
|
|
|
+ {"key": "www", "url": "https://www.wikipali.org/api", "version": "稳定版", "domain": ".org"},
|
|
|
+ {"key": "www.cc", "url": "https://www.wikipali.cc/api", "version": "稳定版", "domain": ".cc"},
|
|
|
+ {"key": "next", "url": "https://next.wikipali.org/api", "version": "最新版", "domain": ".org"},
|
|
|
+ {"key": "next.cc", "url": "https://next.wikipali.cc/api", "version": "最新版", "domain": ".cc"},
|
|
|
+ {"key": "local", "url": "http://127.0.0.1:8000/api", "version": "开发机", "domain": "本机"},
|
|
|
+]
|
|
|
+
|
|
|
+ONLINE_URLS = [s["url"] for s in SITES if s["key"] != "local"]
|
|
|
+LOCAL_URL = SITES[-1]["url"]
|
|
|
+DEFAULT_API_URL = SITES[0]["url"]
|
|
|
+
|
|
|
+CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
|
|
|
+CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
|
|
|
+
|
|
|
+# access token 剩余不足这么多秒就重新签发,避免写到一半过期
|
|
|
+TOKEN_REFRESH_MARGIN = 3600
|
|
|
+
|
|
|
+DEFAULT_TIMEOUT = 30
|
|
|
+WRITE_TIMEOUT = 120
|
|
|
+DEFAULT_BATCH = 50
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 错误类型
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+class WpError(Exception):
|
|
|
+ """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
|
|
|
+
|
|
|
+
|
|
|
+class ApiError(WpError):
|
|
|
+ def __init__(self, status, message, url=None, body=None):
|
|
|
+ self.status = status
|
|
|
+ self.url = url
|
|
|
+ self.body = body
|
|
|
+ super().__init__(message)
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 凭据文件
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def load_creds():
|
|
|
+ if not os.path.exists(CREDS_PATH):
|
|
|
+ return {"current": "online"}
|
|
|
+ try:
|
|
|
+ with open(CREDS_PATH, "r", encoding="utf-8") as fh:
|
|
|
+ data = json.load(fh)
|
|
|
+ except (OSError, ValueError) as exc:
|
|
|
+ raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
|
|
|
+ if not isinstance(data, dict):
|
|
|
+ raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
|
|
|
+ data.setdefault("current", "online")
|
|
|
+ return data
|
|
|
+
|
|
|
+
|
|
|
+def save_creds(creds):
|
|
|
+ os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
|
|
|
+ tmp = CREDS_PATH + ".tmp"
|
|
|
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
|
+ fd = os.open(tmp, flags, 0o600)
|
|
|
+ try:
|
|
|
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
|
+ json.dump(creds, fh, ensure_ascii=False, indent=2)
|
|
|
+ fh.write("\n")
|
|
|
+ except Exception:
|
|
|
+ os.unlink(tmp)
|
|
|
+ raise
|
|
|
+ os.replace(tmp, CREDS_PATH)
|
|
|
+ os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
|
|
|
+
|
|
|
+
|
|
|
+def bucket_name_for(api_url):
|
|
|
+ """凭据桶名。线上四地址共用 online 桶;开发机 local;其余地址自成一桶。"""
|
|
|
+ if api_url in ONLINE_URLS:
|
|
|
+ return "online"
|
|
|
+ if api_url == LOCAL_URL:
|
|
|
+ return "local"
|
|
|
+ return "site:" + api_url
|
|
|
+
|
|
|
+
|
|
|
+def get_bucket(creds, name, api_url=None):
|
|
|
+ bucket = creds.setdefault(name, {})
|
|
|
+ bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
|
|
|
+ bucket.setdefault("user", {})
|
|
|
+ bucket.setdefault("model", {})
|
|
|
+ bucket.setdefault("access_tokens", {})
|
|
|
+ return bucket
|
|
|
+
|
|
|
+
|
|
|
+def normalize_api_url(url):
|
|
|
+ url = url.rstrip("/")
|
|
|
+ parsed = urllib.parse.urlparse(url)
|
|
|
+ if parsed.scheme not in ("http", "https"):
|
|
|
+ raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
|
|
|
+ host = (parsed.hostname or "").lower()
|
|
|
+ if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
|
|
|
+ raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
|
|
|
+ return url
|
|
|
+
|
|
|
+
|
|
|
+def resolve_api_url(cli_api, creds):
|
|
|
+ """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
|
|
|
+
|
|
|
+ 前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
|
|
|
+ """
|
|
|
+ if cli_api:
|
|
|
+ return normalize_api_url(expand_site_alias(cli_api)), "cli"
|
|
|
+ env = os.environ.get("WIKIPALI_API_URL")
|
|
|
+ if env:
|
|
|
+ return normalize_api_url(expand_site_alias(env)), "env"
|
|
|
+ current = creds.get("current", "online")
|
|
|
+ bucket = creds.get(current)
|
|
|
+ if isinstance(bucket, dict) and bucket.get("api_url"):
|
|
|
+ return normalize_api_url(bucket["api_url"]), "creds"
|
|
|
+ return DEFAULT_API_URL, "default"
|
|
|
+
|
|
|
+
|
|
|
+def expand_site_alias(value):
|
|
|
+ """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
|
|
|
+ value = value.strip()
|
|
|
+ if value.isdigit():
|
|
|
+ idx = int(value) - 1
|
|
|
+ if 0 <= idx < len(SITES):
|
|
|
+ return SITES[idx]["url"]
|
|
|
+ raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
|
|
|
+ for site in SITES:
|
|
|
+ if value == site["key"]:
|
|
|
+ return site["url"]
|
|
|
+ if "://" in value:
|
|
|
+ return value
|
|
|
+ raise WpError(
|
|
|
+ f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def site_label(api_url):
|
|
|
+ for site in SITES:
|
|
|
+ if site["url"] == api_url:
|
|
|
+ return f"{site['version']} · {site['domain']}"
|
|
|
+ return "自定义地址"
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# HTTP
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def mask(token):
|
|
|
+ if not token:
|
|
|
+ return "(无)"
|
|
|
+ if len(token) <= 16:
|
|
|
+ return token[:4] + "…"
|
|
|
+ return token[:8] + "…" + token[-4:]
|
|
|
+
|
|
|
+
|
|
|
+def jwt_payload(token):
|
|
|
+ """不验签地读出 JWT payload,仅用于显示有效期。"""
|
|
|
+ try:
|
|
|
+ part = token.split(".")[1]
|
|
|
+ part += "=" * (-len(part) % 4)
|
|
|
+ return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
|
|
|
+ except Exception:
|
|
|
+ return {}
|
|
|
+
|
|
|
+
|
|
|
+def token_expiry(token):
|
|
|
+ exp = jwt_payload(token).get("exp")
|
|
|
+ return int(exp) if isinstance(exp, (int, float)) else None
|
|
|
+
|
|
|
+
|
|
|
+def fmt_ts(ts):
|
|
|
+ if not ts:
|
|
|
+ return "未知"
|
|
|
+ return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
|
+
|
|
|
+
|
|
|
+def note(msg):
|
|
|
+ print(msg, file=sys.stderr)
|
|
|
+
|
|
|
+
|
|
|
+def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
|
|
|
+ """发一个 JSON 请求,返回解析后的响应体(dict)。
|
|
|
+
|
|
|
+ 网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
|
|
|
+ HTTP 层失败抛 ApiError,带上服务端 message。
|
|
|
+ """
|
|
|
+ url = api_url + "/" + path.lstrip("/")
|
|
|
+ if query:
|
|
|
+ url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
|
|
|
+ data = None
|
|
|
+ headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
|
|
|
+ if body is not None:
|
|
|
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
|
+ headers["Content-Type"] = "application/json"
|
|
|
+ if token:
|
|
|
+ headers["Authorization"] = "Bearer " + token
|
|
|
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
|
+ try:
|
|
|
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
|
+ raw = resp.read().decode("utf-8", "replace")
|
|
|
+ status = resp.status
|
|
|
+ except urllib.error.HTTPError as exc:
|
|
|
+ raw = exc.read().decode("utf-8", "replace")
|
|
|
+ status = exc.code
|
|
|
+ payload = safe_json(raw)
|
|
|
+ message = payload.get("message") if isinstance(payload, dict) else None
|
|
|
+ raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
|
|
|
+ payload = safe_json(raw)
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
|
|
|
+ if not payload.get("ok", False):
|
|
|
+ raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
|
|
|
+ return payload.get("data")
|
|
|
+
|
|
|
+
|
|
|
+def safe_json(raw):
|
|
|
+ try:
|
|
|
+ return json.loads(raw)
|
|
|
+ except ValueError:
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+class Client:
|
|
|
+ """按站点收发请求,并在线上地址之间做出声的 fallback。"""
|
|
|
+
|
|
|
+ def __init__(self, api_url, source, creds, allow_fallback=True):
|
|
|
+ self.api_url = api_url
|
|
|
+ self.source = source
|
|
|
+ self.creds = creds
|
|
|
+ self.bucket_name = bucket_name_for(api_url)
|
|
|
+ self.bucket = get_bucket(creds, self.bucket_name, api_url)
|
|
|
+ self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
|
|
|
+
|
|
|
+ # -- 凭据 ---------------------------------------------------------------
|
|
|
+
|
|
|
+ @property
|
|
|
+ def user_token(self):
|
|
|
+ token = (self.bucket.get("user") or {}).get("token")
|
|
|
+ if not token:
|
|
|
+ raise WpError(
|
|
|
+ "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
|
|
|
+ " ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
|
|
|
+ )
|
|
|
+ return token
|
|
|
+
|
|
|
+ @property
|
|
|
+ def model(self):
|
|
|
+ model = self.bucket.get("model") or {}
|
|
|
+ if not model.get("token"):
|
|
|
+ raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
|
|
|
+ return model
|
|
|
+
|
|
|
+ def save(self):
|
|
|
+ save_creds(self.creds)
|
|
|
+
|
|
|
+ # -- 请求 ---------------------------------------------------------------
|
|
|
+
|
|
|
+ def fallback_order(self):
|
|
|
+ """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
|
|
|
+ cur = next((s for s in SITES if s["url"] == self.api_url), None)
|
|
|
+ if not cur:
|
|
|
+ return []
|
|
|
+ others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
|
|
|
+ others.sort(
|
|
|
+ key=lambda s: (
|
|
|
+ 0 if s["version"] == cur["version"] else 1,
|
|
|
+ 0 if s["domain"] == cur["domain"] else 1,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return [s["url"] for s in others]
|
|
|
+
|
|
|
+ def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
|
|
|
+ urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
|
|
|
+ last = None
|
|
|
+ for idx, url in enumerate(urls):
|
|
|
+ try:
|
|
|
+ data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
|
|
|
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
|
+ # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
|
|
|
+ last = exc
|
|
|
+ reason = getattr(exc, "reason", exc)
|
|
|
+ if idx + 1 < len(urls):
|
|
|
+ note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
|
|
|
+ continue
|
|
|
+ if url != self.api_url:
|
|
|
+ # fallback 成功后本次会话都用它,但不写回凭据文件
|
|
|
+ note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
|
|
|
+ self.api_url = url
|
|
|
+ return data
|
|
|
+ raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
|
|
|
+
|
|
|
+ def api_note(self):
|
|
|
+ src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
|
|
|
+ return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
|
|
|
+
|
|
|
+
|
|
|
+def make_client(args, allow_fallback=True):
|
|
|
+ creds = load_creds()
|
|
|
+ api_url, source = resolve_api_url(getattr(args, "api", None), creds)
|
|
|
+ return Client(api_url, source, creds, allow_fallback=allow_fallback)
|
|
|
+
|
|
|
+
|
|
|
+def explain_api_error(exc, what):
|
|
|
+ """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
|
|
|
+ if exc.status == 401:
|
|
|
+ return WpError(
|
|
|
+ f"{what}:401 凭据失效或已被撤销。\n"
|
|
|
+ " · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
|
|
|
+ " · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
|
|
|
+ " 不要自动重试。"
|
|
|
+ )
|
|
|
+ if exc.status == 403:
|
|
|
+ return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
|
|
|
+ if exc.status == 404:
|
|
|
+ return WpError(
|
|
|
+ f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
|
|
|
+ " 可切到最新版试试:python3 wp.py endpoint next\n"
|
|
|
+ " 否则才是资源真的不存在。"
|
|
|
+ )
|
|
|
+ if exc.status == 409:
|
|
|
+ return WpError(f"{what}:409 同名记录已存在。")
|
|
|
+ if exc.status == 422:
|
|
|
+ return WpError(f"{what}:422 参数校验失败——{exc}")
|
|
|
+ return WpError(f"{what}:HTTP {exc.status} {exc}")
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 子命令:endpoint
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def cmd_endpoint(args):
|
|
|
+ creds = load_creds()
|
|
|
+ current_url, source = resolve_api_url(getattr(args, "api", None), creds)
|
|
|
+
|
|
|
+ if not args.target:
|
|
|
+ for idx, site in enumerate(SITES, 1):
|
|
|
+ mark = " ← 当前" if site["url"] == current_url else ""
|
|
|
+ print(f" {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
|
|
|
+ if source in ("cli", "env"):
|
|
|
+ src = "--api" if source == "cli" else "WIKIPALI_API_URL"
|
|
|
+ note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
|
|
|
+ if current_url not in [s["url"] for s in SITES]:
|
|
|
+ print(f" *) {current_url:<32} 自定义地址 ← 当前")
|
|
|
+ print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
|
|
|
+ return 0
|
|
|
+
|
|
|
+ url = normalize_api_url(expand_site_alias(args.target))
|
|
|
+ name = bucket_name_for(url)
|
|
|
+ bucket = get_bucket(creds, name, url)
|
|
|
+ bucket["api_url"] = url
|
|
|
+ creds["current"] = name
|
|
|
+ save_creds(creds)
|
|
|
+ print(f"已切换到 {url}({site_label(url)})")
|
|
|
+ if name != "online":
|
|
|
+ note("提示:该地址与线上四站不共用数据库/密钥,凭据是独立的一桶,可能需要重新登录。")
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 子命令:whoami
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def cmd_whoami(args):
|
|
|
+ client = make_client(args)
|
|
|
+ print(f"API : {client.api_note()}")
|
|
|
+ print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
|
|
|
+
|
|
|
+ user = client.bucket.get("user") or {}
|
|
|
+ if user.get("token"):
|
|
|
+ exp = token_expiry(user["token"])
|
|
|
+ expired = exp is not None and exp < time.time()
|
|
|
+ print(f"用户 : {user.get('username', '?')} uid={user.get('uid', '?')}")
|
|
|
+ print(f" token {mask(user['token'])} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
|
|
|
+ else:
|
|
|
+ print("用户 : 未登录(! python3 scripts/wp_login.py)")
|
|
|
+
|
|
|
+ model = client.bucket.get("model") or {}
|
|
|
+ if model.get("token"):
|
|
|
+ exp = token_expiry(model["token"])
|
|
|
+ expired = exp is not None and exp < time.time()
|
|
|
+ print(f"模型 : {model.get('name', '?')} uid={model.get('uid', '?')}")
|
|
|
+ print(f" token {mask(model['token'])} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
|
|
|
+ else:
|
|
|
+ print("模型 : 未建立(python3 wp.py ensure-model --name <模型名>)")
|
|
|
+
|
|
|
+ tokens = client.bucket.get("access_tokens") or {}
|
|
|
+ if tokens:
|
|
|
+ print("access token:")
|
|
|
+ for uid, item in tokens.items():
|
|
|
+ exp = item.get("exp") or token_expiry(item.get("token", ""))
|
|
|
+ expired = exp is not None and exp < time.time()
|
|
|
+ book = item.get("book", 0)
|
|
|
+ scope = "全部 book" if book == 0 else f"book {book}"
|
|
|
+ name = item.get("channel_name") or ""
|
|
|
+ print(f" {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{' ⚠ 已过期' if expired else ''}")
|
|
|
+ else:
|
|
|
+ print("access token:无(python3 wp.py grant <channel>)")
|
|
|
+
|
|
|
+ if args.check:
|
|
|
+ try:
|
|
|
+ data = client.call("GET", "v2/auth/current", token=client.user_token)
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "校验用户 token")
|
|
|
+ print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 子命令:ensure-model
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def cmd_ensure_model(args):
|
|
|
+ client = make_client(args)
|
|
|
+ token = client.user_token
|
|
|
+
|
|
|
+ name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
|
|
|
+ if not name:
|
|
|
+ raise WpError(
|
|
|
+ "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
|
|
|
+ "该名字会成为句子的作者署名,不要用别的模型的名字。"
|
|
|
+ )
|
|
|
+
|
|
|
+ try:
|
|
|
+ current = client.call("GET", "v2/auth/current", token=token)
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "取当前用户信息")
|
|
|
+ studio_name = current.get("realName")
|
|
|
+ if not studio_name:
|
|
|
+ raise WpError("服务端没有返回 realName,无法确定 studio_name。")
|
|
|
+
|
|
|
+ # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
|
|
|
+ try:
|
|
|
+ listed = client.call(
|
|
|
+ "GET", "v2/ai-model", token=token,
|
|
|
+ query={"view": "studio", "name": studio_name, "keyword": name},
|
|
|
+ )
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "查询模型列表")
|
|
|
+ rows = (listed or {}).get("rows") or []
|
|
|
+ found = next((r for r in rows if r.get("name") == name), None)
|
|
|
+
|
|
|
+ if found:
|
|
|
+ print(f"已存在模型记录:{name} uid={found['uid']}")
|
|
|
+ else:
|
|
|
+ body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
|
|
|
+ for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
|
|
|
+ if value is not None:
|
|
|
+ body[field] = value
|
|
|
+ try:
|
|
|
+ found = client.call("POST", "v2/ai-model", token=token, body=body)
|
|
|
+ print(f"已创建模型记录:{name} uid={found['uid']}")
|
|
|
+ except ApiError as exc:
|
|
|
+ if exc.status != 409:
|
|
|
+ raise explain_api_error(exc, "创建模型记录")
|
|
|
+ # 并发或模糊匹配漏网:重查一次
|
|
|
+ listed = client.call(
|
|
|
+ "GET", "v2/ai-model", token=token,
|
|
|
+ query={"view": "studio", "name": studio_name, "keyword": name},
|
|
|
+ )
|
|
|
+ rows = (listed or {}).get("rows") or []
|
|
|
+ found = next((r for r in rows if r.get("name") == name), None)
|
|
|
+ if not found:
|
|
|
+ raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
|
|
|
+ print(f"已存在模型记录:{name} uid={found['uid']}")
|
|
|
+
|
|
|
+ # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
|
|
|
+ patch = {}
|
|
|
+ for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
|
|
|
+ if value is not None and found.get(field) != value:
|
|
|
+ patch[field] = value
|
|
|
+ if args.privacy and found.get("privacy") != args.privacy:
|
|
|
+ patch["privacy"] = args.privacy
|
|
|
+ if patch:
|
|
|
+ try:
|
|
|
+ found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
|
|
|
+ print(f"已更新字段:{', '.join(patch)}")
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "更新模型记录")
|
|
|
+
|
|
|
+ # 3) 取模型身份 token
|
|
|
+ try:
|
|
|
+ issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "签发模型身份 token")
|
|
|
+
|
|
|
+ client.bucket["model"] = {
|
|
|
+ "uid": issued["uid"],
|
|
|
+ "name": issued["name"],
|
|
|
+ "token": issued["token"],
|
|
|
+ "issued_at": iso_now(),
|
|
|
+ }
|
|
|
+ client.save()
|
|
|
+ exp = token_expiry(issued["token"])
|
|
|
+ print(f"模型身份 token 已缓存:{mask(issued['token'])} 到期 {fmt_ts(exp)}")
|
|
|
+ print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+def cmd_revoke(args):
|
|
|
+ client = make_client(args)
|
|
|
+ model = client.bucket.get("model") or {}
|
|
|
+ uid = args.uid or model.get("uid")
|
|
|
+ if not uid:
|
|
|
+ raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
|
|
|
+ if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
|
|
|
+ print("已取消。")
|
|
|
+ return 1
|
|
|
+ try:
|
|
|
+ data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "撤销模型 token")
|
|
|
+ if model.get("uid") == uid:
|
|
|
+ client.bucket["model"] = {"uid": uid, "name": model.get("name")}
|
|
|
+ client.save()
|
|
|
+ print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
|
|
|
+ print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 子命令:channels
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def fetch_channels(client, search=None):
|
|
|
+ try:
|
|
|
+ data = client.call(
|
|
|
+ "GET", "v2/channel", token=client.user_token,
|
|
|
+ query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
|
|
|
+ )
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "获取可编辑 channel 列表")
|
|
|
+ return (data or {}).get("rows") or []
|
|
|
+
|
|
|
+
|
|
|
+def cmd_channels(args):
|
|
|
+ client = make_client(args)
|
|
|
+ rows = fetch_channels(client, args.search)
|
|
|
+ if args.json:
|
|
|
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
|
|
|
+ return 0
|
|
|
+ if not rows:
|
|
|
+ print("当前账号没有任何可编辑的 channel。")
|
|
|
+ return 1
|
|
|
+ print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
|
|
|
+ for idx, ch in enumerate(rows, 1):
|
|
|
+ print(
|
|
|
+ f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
|
|
|
+ f"{ch.get('uid', '')[:8]}… {ch.get('role', '')}"
|
|
|
+ )
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+def pick_channel(client, given, interactive=True):
|
|
|
+ """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
|
|
|
+ rows = fetch_channels(client)
|
|
|
+ if not rows:
|
|
|
+ raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
|
|
|
+
|
|
|
+ if given:
|
|
|
+ for ch in rows:
|
|
|
+ if ch.get("uid") == given:
|
|
|
+ return ch["uid"], ch.get("name")
|
|
|
+ if given.isdigit() and 1 <= int(given) <= len(rows):
|
|
|
+ ch = rows[int(given) - 1]
|
|
|
+ return ch["uid"], ch.get("name")
|
|
|
+ matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
|
|
|
+ if len(matched) == 1:
|
|
|
+ return matched[0]["uid"], matched[0].get("name")
|
|
|
+ if len(matched) > 1:
|
|
|
+ names = ", ".join(c.get("name", "") for c in matched[:5])
|
|
|
+ raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
|
|
|
+ # 不在可编辑列表里的 uid:直接用,但回显不出名字
|
|
|
+ if len(given) >= 32:
|
|
|
+ note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
|
|
|
+ return given, None
|
|
|
+ raise WpError(f"找不到 channel:{given}")
|
|
|
+
|
|
|
+ if not (interactive and sys.stdin.isatty()):
|
|
|
+ raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
|
|
|
+
|
|
|
+ print("可编辑 channel:")
|
|
|
+ for idx, ch in enumerate(rows, 1):
|
|
|
+ print(f" {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
|
|
|
+ raw = input("选择序号:").strip()
|
|
|
+ if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
|
|
|
+ raise WpError("输入无效。")
|
|
|
+ ch = rows[int(raw) - 1]
|
|
|
+ return ch["uid"], ch.get("name")
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 子命令:grant
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def cached_access_token(client, channel_uid, book):
|
|
|
+ item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
|
|
|
+ if not item or not item.get("token"):
|
|
|
+ return None
|
|
|
+ # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
|
|
|
+ if item.get("book", 0) != 0 and item.get("book") != book:
|
|
|
+ return None
|
|
|
+ exp = item.get("exp") or token_expiry(item["token"])
|
|
|
+ if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
|
|
|
+ return None
|
|
|
+ return item
|
|
|
+
|
|
|
+
|
|
|
+def grant_access_token(client, channel_uid, channel_name, book, force=False):
|
|
|
+ if not force:
|
|
|
+ cached = cached_access_token(client, channel_uid, book)
|
|
|
+ if cached:
|
|
|
+ return cached
|
|
|
+
|
|
|
+ # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
|
|
|
+ payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
|
|
|
+ try:
|
|
|
+ data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "签发 access token")
|
|
|
+ rows = (data or {}).get("rows") or []
|
|
|
+ if not rows:
|
|
|
+ # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
|
|
|
+ raise WpError(
|
|
|
+ f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
|
|
|
+ "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
|
|
|
+ )
|
|
|
+ row = rows[0]
|
|
|
+ item = {
|
|
|
+ "token": row["token"],
|
|
|
+ "book": int(book),
|
|
|
+ "exp": (row.get("payload") or {}).get("exp"),
|
|
|
+ "granted_at": iso_now(),
|
|
|
+ }
|
|
|
+ if channel_name:
|
|
|
+ item["channel_name"] = channel_name
|
|
|
+ client.bucket.setdefault("access_tokens", {})[channel_uid] = item
|
|
|
+ client.save()
|
|
|
+ return item
|
|
|
+
|
|
|
+
|
|
|
+def cmd_grant(args):
|
|
|
+ client = make_client(args)
|
|
|
+ uid, name = pick_channel(client, args.channel)
|
|
|
+ item = grant_access_token(client, uid, name, args.book, force=args.force)
|
|
|
+ scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
|
|
|
+ print(f"channel : {name or '(未知)'} {uid}")
|
|
|
+ print(f"范围 : {scope}")
|
|
|
+ print(f"token : {mask(item['token'])} 到期 {fmt_ts(item.get('exp'))}")
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 子命令:write
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
|
|
|
+
|
|
|
+
|
|
|
+def load_sentences(args):
|
|
|
+ if args.file == "-":
|
|
|
+ raw = sys.stdin.read()
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ with open(args.file, "r", encoding="utf-8") as fh:
|
|
|
+ raw = fh.read()
|
|
|
+ except OSError as exc:
|
|
|
+ raise WpError(f"读不了输入文件:{exc}")
|
|
|
+ try:
|
|
|
+ data = json.loads(raw)
|
|
|
+ except ValueError as exc:
|
|
|
+ raise WpError(f"输入不是合法 JSON:{exc}")
|
|
|
+
|
|
|
+ default_channel = None
|
|
|
+ if isinstance(data, dict):
|
|
|
+ default_channel = data.get("channel_uid") or data.get("channel")
|
|
|
+ data = data.get("sentences")
|
|
|
+ if not isinstance(data, list) or not data:
|
|
|
+ raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
|
|
|
+ return data, default_channel
|
|
|
+
|
|
|
+
|
|
|
+def normalize_sentences(rows, channel_uid, default_content_type):
|
|
|
+ out = []
|
|
|
+ for idx, row in enumerate(rows):
|
|
|
+ if not isinstance(row, dict):
|
|
|
+ raise WpError(f"第 {idx + 1} 条不是对象。")
|
|
|
+ missing = [f for f in SENT_REQUIRED if row.get(f) is None]
|
|
|
+ if missing:
|
|
|
+ raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
|
|
|
+ try:
|
|
|
+ sent = {
|
|
|
+ "book_id": int(row["book_id"]),
|
|
|
+ "paragraph": int(row["paragraph"]),
|
|
|
+ "word_start": int(row["word_start"]),
|
|
|
+ "word_end": int(row["word_end"]),
|
|
|
+ "content": str(row["content"]),
|
|
|
+ "content_type": row.get("content_type") or default_content_type,
|
|
|
+ "channel_uid": row.get("channel_uid") or channel_uid,
|
|
|
+ }
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
|
|
|
+ if not sent["channel_uid"]:
|
|
|
+ raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
|
|
|
+ out.append(sent)
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def sent_key(sent):
|
|
|
+ return (
|
|
|
+ int(sent["book_id"]),
|
|
|
+ int(sent["paragraph"]),
|
|
|
+ int(sent["word_start"]),
|
|
|
+ int(sent["word_end"]),
|
|
|
+ sent["channel_uid"],
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def row_key(row):
|
|
|
+ channel = row.get("channel") or {}
|
|
|
+ return (
|
|
|
+ int(row.get("book", -1)),
|
|
|
+ int(row.get("paragraph", -1)),
|
|
|
+ int(row.get("word_start", -1)),
|
|
|
+ int(row.get("word_end", -1)),
|
|
|
+ channel.get("uid"),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def confirm(question):
|
|
|
+ if not sys.stdin.isatty():
|
|
|
+ return False
|
|
|
+ answer = input(f"{question} [y/N] ").strip().lower()
|
|
|
+ return answer in ("y", "yes")
|
|
|
+
|
|
|
+
|
|
|
+def iso_now():
|
|
|
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
+
|
|
|
+
|
|
|
+def cmd_write(args):
|
|
|
+ client = make_client(args)
|
|
|
+ # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
|
|
|
+ client.user_token
|
|
|
+ model = client.model
|
|
|
+ rows, file_channel = load_sentences(args)
|
|
|
+
|
|
|
+ channel_hint = args.channel or file_channel
|
|
|
+ uid = name = None
|
|
|
+ if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
|
|
|
+ uid, name = pick_channel(client, channel_hint)
|
|
|
+ sentences = normalize_sentences(rows, uid, args.content_type)
|
|
|
+
|
|
|
+ channels = sorted({s["channel_uid"] for s in sentences})
|
|
|
+ books = sorted({s["book_id"] for s in sentences})
|
|
|
+ names = {uid: name} if uid else {}
|
|
|
+ for cuid in channels:
|
|
|
+ if cuid not in names:
|
|
|
+ names[cuid] = channel_display_name(client, cuid)
|
|
|
+
|
|
|
+ # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
|
|
|
+ print("=" * 72)
|
|
|
+ print(f"API : {client.api_note()}")
|
|
|
+ for cuid in channels:
|
|
|
+ print(f"channel : {names.get(cuid) or '(未知)'} {cuid}")
|
|
|
+ print(f"book : {', '.join(str(b) for b in books)}")
|
|
|
+ print(f"模型身份 : {model.get('name')} uid={model.get('uid')}")
|
|
|
+ print(f"句子数 : {len(sentences)}(每批 {args.batch})")
|
|
|
+ print("-" * 72)
|
|
|
+ for sent in sentences[: args.preview]:
|
|
|
+ summary = sent["content"].replace("\n", " ")
|
|
|
+ if len(summary) > 50:
|
|
|
+ summary = summary[:50] + "…"
|
|
|
+ print(f" {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']} {summary}")
|
|
|
+ if len(sentences) > args.preview:
|
|
|
+ print(f" …… 其余 {len(sentences) - args.preview} 条")
|
|
|
+ print("-" * 72)
|
|
|
+ print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
|
|
|
+ print("=" * 72)
|
|
|
+
|
|
|
+ if args.dry_run:
|
|
|
+ print("--dry-run:未发送任何请求。")
|
|
|
+ return 0
|
|
|
+ if not args.yes and not confirm("确认写入?"):
|
|
|
+ print("已取消,未写入任何内容。")
|
|
|
+ return 1
|
|
|
+
|
|
|
+ # 每个 channel 一张 access token(缓存命中就不重签)
|
|
|
+ tokens = {}
|
|
|
+ for cuid in channels:
|
|
|
+ book_scope = 0 if len(books) > 1 else books[0]
|
|
|
+ if args.book is not None:
|
|
|
+ book_scope = args.book
|
|
|
+ item = grant_access_token(client, cuid, names.get(cuid), book_scope)
|
|
|
+ tokens[cuid] = item["token"]
|
|
|
+
|
|
|
+ written = {}
|
|
|
+ failed = []
|
|
|
+ model_token = model["token"]
|
|
|
+ for start in range(0, len(sentences), args.batch):
|
|
|
+ batch = sentences[start : start + args.batch]
|
|
|
+ body = {
|
|
|
+ "sentences": [
|
|
|
+ {
|
|
|
+ "book_id": s["book_id"],
|
|
|
+ "paragraph": s["paragraph"],
|
|
|
+ "word_start": s["word_start"],
|
|
|
+ "word_end": s["word_end"],
|
|
|
+ "channel_uid": s["channel_uid"],
|
|
|
+ "content": s["content"],
|
|
|
+ "content_type": s["content_type"],
|
|
|
+ "access_token": tokens[s["channel_uid"]],
|
|
|
+ }
|
|
|
+ for s in batch
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ try:
|
|
|
+ data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
|
|
|
+ except ApiError as exc:
|
|
|
+ if exc.status != 401:
|
|
|
+ raise explain_api_error(exc, "写入句子")
|
|
|
+ # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
|
|
|
+ model_token = refresh_model_token(client)
|
|
|
+ try:
|
|
|
+ data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
|
|
|
+ except ApiError as retry_exc:
|
|
|
+ raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
|
|
|
+ returned = (data or {}).get("rows") or []
|
|
|
+ for row in returned:
|
|
|
+ written[row_key(row)] = row
|
|
|
+ got = len(returned)
|
|
|
+ print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
|
|
|
+ if got < len(batch):
|
|
|
+ # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
|
|
|
+ for s in batch:
|
|
|
+ if sent_key(s) not in written:
|
|
|
+ failed.append(s)
|
|
|
+
|
|
|
+ print("-" * 72)
|
|
|
+ print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
|
|
|
+ sample = next(iter(written.values()), None)
|
|
|
+ if sample:
|
|
|
+ editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
|
|
|
+ print(f"署名核对:第一条的 editor = {editor}")
|
|
|
+ if failed:
|
|
|
+ print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
|
|
|
+ for s in failed[:10]:
|
|
|
+ print(f" {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']} channel={s['channel_uid'][:8]}…")
|
|
|
+ if len(failed) > 10:
|
|
|
+ print(f" …… 其余 {len(failed) - 10} 条")
|
|
|
+ return 1
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+def channel_display_name(client, uid):
|
|
|
+ try:
|
|
|
+ data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
|
|
|
+ except (ApiError, WpError):
|
|
|
+ return None
|
|
|
+ if isinstance(data, dict):
|
|
|
+ return data.get("name")
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def refresh_model_token(client):
|
|
|
+ note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
|
|
|
+ model = client.bucket.get("model") or {}
|
|
|
+ if not model.get("uid"):
|
|
|
+ raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
|
|
|
+ try:
|
|
|
+ issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
|
|
|
+ except ApiError as exc:
|
|
|
+ raise explain_api_error(exc, "重新签发模型 token")
|
|
|
+ model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
|
|
|
+ client.bucket["model"] = model
|
|
|
+ client.save()
|
|
|
+ return issued["token"]
|
|
|
+
|
|
|
+
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# CLI
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+
|
|
|
+def build_parser():
|
|
|
+ parser = argparse.ArgumentParser(
|
|
|
+ prog="wp.py",
|
|
|
+ description="WikiPali 写入客户端(以 AI 模型身份写入句子)",
|
|
|
+ formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
+ )
|
|
|
+ parser.add_argument("--api", help="本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件")
|
|
|
+ sub = parser.add_subparsers(dest="command", required=True)
|
|
|
+
|
|
|
+ p = sub.add_parser("endpoint", help="查看 / 切换 API 地址")
|
|
|
+ p.add_argument("target", nargs="?", help="序号、简称(www/www.cc/next/next.cc/local)或完整 url")
|
|
|
+ p.set_defaults(func=cmd_endpoint)
|
|
|
+
|
|
|
+ p = sub.add_parser("whoami", help="显示当前凭据状态")
|
|
|
+ p.add_argument("--check", action="store_true", help="额外向服务端校验用户 token")
|
|
|
+ p.set_defaults(func=cmd_whoami)
|
|
|
+
|
|
|
+ p = sub.add_parser("ensure-model", help="幂等地建立模型记录并取模型身份 token")
|
|
|
+ p.add_argument("--name", help="模型标识,如 claude-opus-5(会成为句子作者署名)")
|
|
|
+ p.add_argument("--model", help="底层模型 id")
|
|
|
+ p.add_argument("--url", dest="url", help="模型服务地址")
|
|
|
+ p.add_argument("--description", help="描述")
|
|
|
+ p.add_argument("--privacy", choices=["private", "public"], default="private")
|
|
|
+ p.set_defaults(func=cmd_ensure_model)
|
|
|
+
|
|
|
+ p = sub.add_parser("revoke", help="撤销该模型已签出的全部 token")
|
|
|
+ p.add_argument("--uid", help="模型 uid,缺省用缓存里的")
|
|
|
+ p.add_argument("-y", "--yes", action="store_true")
|
|
|
+ p.set_defaults(func=cmd_revoke)
|
|
|
+
|
|
|
+ p = sub.add_parser("channels", help="列出当前账号可编辑的 channel")
|
|
|
+ p.add_argument("--search", help="按名字过滤")
|
|
|
+ p.add_argument("--json", action="store_true", help="输出原始 JSON")
|
|
|
+ p.set_defaults(func=cmd_channels)
|
|
|
+
|
|
|
+ p = sub.add_parser("grant", help="为某个 channel 签发 access token 并缓存")
|
|
|
+ p.add_argument("channel", nargs="?", help="channel uid / 列表序号 / 名字片段;省略则交互选择")
|
|
|
+ p.add_argument("--book", type=int, default=0, help="限定 book,0 表示不限(默认)")
|
|
|
+ p.add_argument("--force", action="store_true", help="即使缓存未过期也重新签发")
|
|
|
+ p.set_defaults(func=cmd_grant)
|
|
|
+
|
|
|
+ p = sub.add_parser("write", help="写入句子")
|
|
|
+ p.add_argument("file", help="句子 JSON 文件,- 表示从 stdin 读")
|
|
|
+ p.add_argument("--channel", help="目标 channel(uid / 序号 / 名字片段)")
|
|
|
+ p.add_argument("--book", type=int, help="access token 的 book 范围,缺省按句子推断")
|
|
|
+ p.add_argument("--batch", type=int, default=DEFAULT_BATCH, help=f"每批条数,默认 {DEFAULT_BATCH}")
|
|
|
+ p.add_argument("--content-type", default="markdown")
|
|
|
+ p.add_argument("--preview", type=int, default=5, help="确认时预览几条")
|
|
|
+ p.add_argument("--dry-run", action="store_true", help="只做校验与回显,不发请求")
|
|
|
+ p.add_argument("-y", "--yes", action="store_true", help="跳过交互确认(非交互环境必须显式给)")
|
|
|
+ p.set_defaults(func=cmd_write)
|
|
|
+
|
|
|
+ return parser
|
|
|
+
|
|
|
+
|
|
|
+def main(argv=None):
|
|
|
+ args = build_parser().parse_args(argv)
|
|
|
+ try:
|
|
|
+ return args.func(args)
|
|
|
+ except WpError as exc:
|
|
|
+ print(f"错误:{exc}", file=sys.stderr)
|
|
|
+ return 1
|
|
|
+ except KeyboardInterrupt:
|
|
|
+ print("\n已中断。", file=sys.stderr)
|
|
|
+ return 130
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ sys.exit(main())
|