`protocol_version = "HTTP/1.1"` ⇒ 同一条 keep-alive 连接上的多个请求由**同一个 Handler
实例**依次处理(`handle_one_request` 循环),而上一轮给 `_read_body` 加的缓存
(`_body_read` / `_body_cache`)从不在请求之间重置。后果两条:
- 第 2 个请求拿到第 1 个请求的 body ⇒ `out/http.jsonl` 里的摘要记成上一份;
- 第 2 个请求自己那份 body **没人从 `rfile` 读走**,残留字节留在内核缓冲里,被下一个
请求的请求行一并吃掉 ⇒ `BaseHTTPRequestHandler` 回
`501 Unsupported method ('{"sessionId":...}POST')`。
W76 设备实测里这表现成「系统分享 3 张图,只投出 2 条消息」,很容易被误判成 App 批量
投递的缺陷。修法是在 `handle_one_request` 入口把缓存清掉,按请求隔离。
验证:
1. 重启假后端后,在同一条 keep-alive 连接上连发 3 个**不同 body** 的
`POST /api/v1/chat`,三发全 202,`out/http.jsonl` 记下三个不同 body(同一 client port)。
2. 完整设备重放(DocumentsUI 选 3 张图 → 分享 → Cyrene → 发送)三发三 202,
3 条独立消息落库。
2099 lines
87 KiB
Python
2099 lines
87 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""Cyrene 假后端(测试台)。纯标准库,零第三方依赖。
|
||
|
||
它的存在理由只有一个:有些客户端行为**只在特定服务端行为下**才验得到,而真后端的行为不可编排、
|
||
造场景的代价又高到不可做(例如「WS 只回放最近 50 条、库里却有 120 条」这种洞,用真后端得灌 51 条
|
||
消息、每条跑一次模型回合)。这里可以精确、免费、可复现地造出这些场景,并把**客户端到底发了什么**记下来。
|
||
|
||
它**不是**产品的一部分,也**不是**后端行为的权威来源:所有形状都是照客户端代码解析出来的(每个
|
||
字段的出处都写在下面注释与 README 里),不代表真后端真的这么回。
|
||
|
||
python fake_backend.py --port 18080
|
||
|
||
端点一览见 README.md。控制端点一律挂在 `/__control/` 前缀下,不碰 `/api/v1/**`。
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import struct
|
||
import sys
|
||
import threading
|
||
import time
|
||
import uuid
|
||
import wave
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import parse_qs, unquote, urlsplit
|
||
|
||
# 控制台在 Windows 的默认编码(cp936 / cp1252)会把中文和上游帧原样打印时炸掉;
|
||
# 这里固定成 UTF-8,让「贴出来的日志」与 out/*.jsonl 是同一份文本。
|
||
for _stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
MAIN_SESSION_ID = "web"
|
||
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||
|
||
MAX_PAGE_SIZE = 200 # 与 MessageRepository.MAX_PAGE_SIZE 一致
|
||
DEFAULT_PAGE_SIZE = 50 # 与 MessageRepository.DEFAULT_PAGE_SIZE 一致
|
||
TITLE_MAX_CHARS = 80
|
||
MESSAGE_CONTENT_MAX_RUNES = 4000
|
||
|
||
# GET /api/v1/meta/enums 的载荷(逐字取自客户端仓库里「线上抓下来的」夹具:
|
||
# app/src/test/kotlin/top/yeij/cyrene/data/api/dto/MetaDtoRealPayloadTest.kt:24,
|
||
# 所以这张表的事实来源是客户端仓库,与任何正在运行的实例无关)。
|
||
ENUMS_PAYLOAD = {
|
||
"platforms": [
|
||
{"id": "web", "label": "网页"},
|
||
{"id": "kook", "label": "KOOK"},
|
||
{"id": "qq-obv11", "label": "QQ"},
|
||
{"id": "weixin-ilink", "label": "微信"},
|
||
{"id": "telegram", "label": "Telegram"},
|
||
{"id": "webhook", "label": "Webhook"},
|
||
],
|
||
"moods": [
|
||
{"id": "calm", "label": "平静"},
|
||
{"id": "happy", "label": "开心"},
|
||
{"id": "excited", "label": "兴奋"},
|
||
{"id": "sad", "label": "低落"},
|
||
{"id": "angry", "label": "生气"},
|
||
],
|
||
"modes": [
|
||
{"id": "chat", "label": "对话"},
|
||
{"id": "code", "label": "代码"},
|
||
{"id": "home", "label": "家居"},
|
||
{"id": "work", "label": "工作"},
|
||
{"id": "docs", "label": "文档"},
|
||
{"id": "voice", "label": "语音"},
|
||
{"id": "vision", "label": "视觉"},
|
||
{"id": "agents", "label": "智能体"},
|
||
{"id": "settings", "label": "设置"},
|
||
],
|
||
"unknown": "display-raw",
|
||
}
|
||
|
||
# GET /api/v1/personas(UserPersonaDto,PersonaDto.kt:16)。形状取自客户端解码测试的示例载荷
|
||
# (PersonaDtoDecodingTest.kt:32),**不是**真后端的真实角色清单。
|
||
# 刻意省掉夹具里的 avatar / chatBackground 路径:那些是服务端文件路径,假后端不提供图片,
|
||
# 带着它们只会让客户端去要一堆 404 的图。
|
||
PERSONAS = [
|
||
{
|
||
"id": "xilian",
|
||
"name": "昔涟",
|
||
"character": "memokeeper",
|
||
"isDefault": True,
|
||
"mode": "chat",
|
||
"memoryNamespace": "xilian",
|
||
"mood": "calm",
|
||
},
|
||
{
|
||
"id": "qiqi",
|
||
"name": "七七",
|
||
"character": "poker",
|
||
"isDefault": False,
|
||
"mode": "code",
|
||
"memoryNamespace": "qiqi",
|
||
},
|
||
]
|
||
|
||
# 会话种子的 createdAt 取自客户端夹具里的真实值(SessionDtoRealPayloadTest.kt:37),
|
||
# 排序规则也照它:pinned DESC, type='main' DESC, created_at ASC
|
||
SEED_MAIN_CREATED_AT = 1788724031196
|
||
SEED_SUB_CREATED_AT = 1791092728237
|
||
|
||
|
||
def now_ms() -> int:
|
||
return int(time.time() * 1000)
|
||
|
||
|
||
# —— 语音夹具(接收侧那条路:`voice` DTO 键 + `GET /api/v1/media/{name}`)———————
|
||
#
|
||
# 为什么要造假音频:**本端没有别的造法**。生产库里一条带 `voice` 的真消息都没有(拉 200 条 0 命中),
|
||
# 而语音的 `voice` 键**只在四个 REST 读取面**填充、实时帧与握手回放都没有(契约 v0.79 §4.4 覆盖率)
|
||
# ⇒ 想验「语音气泡 + 播放」,必须有一条**带 voice 键的历史消息**,而它只能由服务端造。
|
||
#
|
||
# 这份夹具是**按客户端解出来的形状**写的(`VoiceDto.kt` / `HistoryMessageDto.voice`),
|
||
# 不是真后端的权威形状;它只负责让那条路在设备上跑起来。
|
||
VOICE_FIXTURE_NAME = "fakevoice0000000000000000000000.wav"
|
||
VOICE_FIXTURE_MIME = "audio/wav"
|
||
VOICE_FIXTURE_DURATION_MS = 2000
|
||
|
||
|
||
def make_voice_wav(duration_ms: int = VOICE_FIXTURE_DURATION_MS, freq: float = 440.0, rate: int = 8000) -> bytes:
|
||
"""生成一段**真的能播**的 WAV(16-bit PCM 单声道,首尾各 20ms 淡入淡出)。
|
||
|
||
为什么是真音频而不是几个字节:这条路验的就是「MediaPlayer 放得出来」。给一串随便的字节,
|
||
客户端只会走到「这段语音本机播不了」那一档——那验的是失败分支,不是主路径。
|
||
纯标准库(`wave` + `math`),零依赖;每次启动现生成,不落盘(本工具不留持久数据)。
|
||
"""
|
||
frames = max(1, int(rate * duration_ms / 1000))
|
||
fade = max(1, int(rate * 0.02))
|
||
buffer = io.BytesIO()
|
||
with wave.open(buffer, "wb") as handle:
|
||
handle.setnchannels(1)
|
||
handle.setsampwidth(2)
|
||
handle.setframerate(rate)
|
||
samples = bytearray()
|
||
for index in range(frames):
|
||
gain = min(1.0, index / fade, (frames - index) / fade)
|
||
value = int(12000 * gain * math.sin(2 * math.pi * freq * index / rate))
|
||
samples += struct.pack("<h", value)
|
||
handle.writeframes(bytes(samples))
|
||
return buffer.getvalue()
|
||
|
||
|
||
def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS):
|
||
"""按控制端点给的种类造一个 `voice` 键的值;`none` ⇒ 不打这个键(返回 None)。
|
||
|
||
`kind` 的取值(都对着契约 §4.4 那几档,供设备上一档一档看):
|
||
|
||
| kind | `source` / `status` | 音频副本 | 设备上该看到什么 |
|
||
|---|---|---|---|
|
||
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文即转写文本 |
|
||
| `empty` | `asr` / `empty` | 有 | 同上,但正文为空 ⇒ 多一句「没有识别出文字」 |
|
||
| `failed` | `asr` / `failed` | 有 | 播放键照在,正文是服务端那行提示(假后端不会替你编,用 `text=` 给) |
|
||
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信那条路) |
|
||
"""
|
||
if kind in (None, "", "none"):
|
||
return None
|
||
name = VOICE_FIXTURE_NAME if kind != "nocopy" else None
|
||
duration = duration_ms if duration_ms > 0 else None
|
||
copy_keys = (
|
||
{"name": name, "mime": VOICE_FIXTURE_MIME, "size": len(VOICE_WAV), "url": "/api/v1/media/{}".format(name)}
|
||
if name
|
||
else {}
|
||
)
|
||
source = "platform" if kind == "nocopy" else "asr"
|
||
status = {"transcribed": "transcribed", "empty": "empty", "failed": "failed", "nocopy": "transcribed"}[kind]
|
||
payload = {"source": source, "status": status}
|
||
payload.update(copy_keys)
|
||
if duration is not None:
|
||
payload["durationMs"] = duration
|
||
return payload
|
||
|
||
|
||
# 夹具音频只生成一次(启动时),`size` 与真正发出去的字节因此是同一份。
|
||
VOICE_WAV = make_voice_wav()
|
||
|
||
|
||
IMAGE_MIME_BY_EXTENSION = {
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".png": "image/png",
|
||
".gif": "image/gif",
|
||
".webp": "image/webp",
|
||
".bmp": "image/bmp",
|
||
}
|
||
|
||
|
||
def image_fixture(path: str) -> str:
|
||
"""本地图片文件 → base64 `data:` URI(`/__control/messages` 的 `image_file` 用它灌一条带图的消息)。
|
||
|
||
mime 按扩展名给,白名单与客户端 upload / 附件那一套同源(png / jpeg / gif / webp / bmp);
|
||
认不出的扩展名**直接报错**而不是编一个 `application/octet-stream`——客户端在字节那一步还会
|
||
自己嗅探(`ImageFormats.sniff`),这里给错只会让场景看起来和实际不是一回事。
|
||
|
||
为什么给 `data:` 而不是 URL:本工具**不代取外链**、也不起静态文件服务。`data:` 让客户端
|
||
(`ImageSource.Inline` → `ImageDataUri.bytes`)拿到的是**原样的字节**——预览里那些从字节里
|
||
现读的东西(分辨率、EXIF 拍摄日期)因此与源文件逐字节一致,不会被中间商转码掉。
|
||
"""
|
||
extension = os.path.splitext(path)[1].lower()
|
||
mime = IMAGE_MIME_BY_EXTENSION.get(extension)
|
||
if mime is None:
|
||
raise ValueError(
|
||
"unknown image extension {!r} (expected one of {})".format(
|
||
extension, ", ".join(sorted(IMAGE_MIME_BY_EXTENSION))
|
||
)
|
||
)
|
||
with open(path, "rb") as handle:
|
||
raw = handle.read()
|
||
if not raw:
|
||
raise ValueError("empty image file: {}".format(path))
|
||
return "data:{};base64,{}".format(mime, base64.b64encode(raw).decode("ascii"))
|
||
|
||
|
||
# —— 附件夹具(`documents` DTO 键)———————————————————————————————
|
||
#
|
||
# 为什么要造它:**纯附件消息**(`documents` 非空、`content` 为空)是「气泡外侧那枚时间」新补的那一档,
|
||
# 而它在库里**没有别的造法** —— 本工具不实现 upload / 摄取链路(见 README §11),
|
||
# `PATCH /api/v1/messages/{id}` 又要求 `content` 非空 ⇒ 一条这样的消息以前根本灌不出来。
|
||
#
|
||
# 形状按客户端解出来的写(`HistoryMessageDto.documents` → `DocumentRefDto.kt:67`)。与真后端的
|
||
# 一致处:**空 / 缺失就整键省略**(后端 `omitempty`),客户端把那两种形状都归一成空列表。
|
||
DOCUMENT_FIXTURE_NAME = "6c1f0a2b3d4e5f60718293a4b5c6d7e8.txt"
|
||
|
||
DOCUMENT_FIXTURE_REASON = "这个夹具不实现真正的摄取,所以没有可注入的正文"
|
||
|
||
|
||
def documents_fixture(kind: str):
|
||
"""按控制端点给的种类造一个 `documents` 键的值;`none` ⇒ 不打这个键(返回 None)。
|
||
|
||
`kind` 三档对应芯片上看得见的那三种样子(文案出处 `DocumentChips.statusLabel`):
|
||
|
||
| kind | `status` | 设备上那枚芯片 |
|
||
|---|---|---|
|
||
| `extracted` | `extracted` | 常规色 · `TXT · 12 B · 10 字 · 已读入` |
|
||
| `listed` | `listed` | 常规色 · `… · 只看得到清单`(正文没进上下文,**不是**读不到) |
|
||
| `unreadable` | `unreadable` | 灰态 · `… · 读不到`,`reason` 那句原话留给预览弹层 |
|
||
|
||
只给**一枚**芯片:造这一档要验的是「有附件、没有正文时那枚时间挂在哪一块」,不是芯片排的换行。
|
||
"""
|
||
if kind in (None, "", "none"):
|
||
return None
|
||
if kind not in ("extracted", "listed", "unreadable"):
|
||
raise KeyError(kind)
|
||
payload = {
|
||
"name": DOCUMENT_FIXTURE_NAME,
|
||
"mime": "text/plain",
|
||
"size": 12,
|
||
"status": kind,
|
||
}
|
||
if kind == "extracted":
|
||
payload["chars"] = 10
|
||
if kind == "unreadable":
|
||
payload["reason"] = DOCUMENT_FIXTURE_REASON
|
||
return [payload]
|
||
|
||
|
||
def iso_now() -> str:
|
||
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||
|
||
|
||
def log(tag: str, message: str) -> None:
|
||
print("[{}] {:<4} {}".format(datetime.now().strftime("%H:%M:%S"), tag, message), flush=True)
|
||
|
||
|
||
def redact(value, keep: int = 8) -> str:
|
||
"""凭据类字段只留指纹,绝不把 token 原样写进 out/ 下的文件。"""
|
||
if not value:
|
||
return ""
|
||
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
|
||
return "<redacted sha256:{}>".format(digest)
|
||
|
||
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
|
||
BODY_SUMMARY_STRING_MAX = 200
|
||
|
||
def summarize_body(raw) -> dict | None:
|
||
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
|
||
|
||
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
|
||
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
|
||
数组长度),只把超长字符串换成 `<N chars>`。
|
||
|
||
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
|
||
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
|
||
也不记大块内容。
|
||
"""
|
||
if not raw:
|
||
return None
|
||
try:
|
||
parsed = json.loads(raw.decode("utf-8"))
|
||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||
return {"bytes": len(raw), "note": "not json"}
|
||
return _summarize_value(parsed)
|
||
|
||
def _summarize_value(value):
|
||
if isinstance(value, dict):
|
||
return {key: _summarize_value(item) for key, item in value.items()}
|
||
if isinstance(value, list):
|
||
return [_summarize_value(item) for item in value]
|
||
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
|
||
return "<{} chars>".format(len(value))
|
||
return value
|
||
|
||
|
||
class JsonlLog:
|
||
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
|
||
|
||
def __init__(self, path: str):
|
||
self.path = path
|
||
self.lock = threading.Lock()
|
||
self.dir = os.path.dirname(path)
|
||
os.makedirs(self.dir, exist_ok=True)
|
||
|
||
def append(self, record: dict) -> None:
|
||
line = json.dumps(record, ensure_ascii=False)
|
||
with self.lock:
|
||
# 目录**每次写都补一次**:README 说「删掉 `out/` 即清空」,而人在服务跑着的时候删掉
|
||
# 它就再也不会自己回来——之后每一条请求都会在写日志时抛 FileNotFoundError,
|
||
# 表现成「假后端活着但什么都不回」(W76 实测踩到)。重建目录只是一次 stat,
|
||
# 代价可以忽略;让它自己长回来,比要求人先停服务再删要可靠。
|
||
os.makedirs(self.dir, exist_ok=True)
|
||
with open(self.path, "a", encoding="utf-8", newline="\n") as handle:
|
||
handle.write(line + "\n")
|
||
|
||
|
||
def envelope(data) -> dict:
|
||
"""成功信封:ApiEnvelope.kt:8 —— 成功 `{data}`,失败 `{error:{code,message}}`。"""
|
||
return {"data": data}
|
||
|
||
|
||
def error_envelope(code: str, message: str) -> dict:
|
||
return {"error": {"code": code, "message": message}}
|
||
|
||
|
||
def collapse_whitespace(text: str) -> str:
|
||
return " ".join(text.split())
|
||
|
||
|
||
def truncate60(text: str) -> str:
|
||
"""服务端 lastMessage.content:折叠空白 + 截断到 60 字符 + 省略号(SessionDto.kt:83)。"""
|
||
collapsed = collapse_whitespace(text)
|
||
if len(collapsed) <= 60:
|
||
return collapsed
|
||
return collapsed[:60] + "…"
|
||
|
||
|
||
class Session:
|
||
def __init__(self, session_id: str, kind: str, created_at: int, title=None, pinned=False, persona_id=None):
|
||
self.id = session_id
|
||
self.type = kind
|
||
self.created_at = created_at
|
||
self.title = title
|
||
self.pinned = pinned
|
||
self.persona_id = persona_id
|
||
self.read_at = 0
|
||
self.busy_mode = None
|
||
self.messages = []
|
||
|
||
def summary(self) -> dict:
|
||
"""SessionSummaryDto(SessionDto.kt:16)。title / lastMessage / personaId 缺席即整键省略。"""
|
||
row = {
|
||
"id": self.id,
|
||
"type": self.type,
|
||
"createdAt": self.created_at,
|
||
"pinned": self.pinned,
|
||
"unreadCount": self.unread_count(),
|
||
}
|
||
if self.title is not None:
|
||
row["title"] = self.title
|
||
if self.persona_id:
|
||
row["personaId"] = self.persona_id
|
||
if self.messages:
|
||
last = self.messages[-1]
|
||
row["lastMessage"] = {
|
||
"role": last["role"],
|
||
"content": truncate60(last.get("content", "")),
|
||
"createdAt": last["createdAt"],
|
||
}
|
||
return row
|
||
|
||
def unread_count(self) -> int:
|
||
return sum(1 for m in self.messages if m["createdAt"] > self.read_at)
|
||
|
||
|
||
class Store:
|
||
"""全部状态都在内存里:不落盘成数据库、不调模型、重启即回到种子状态。"""
|
||
|
||
def __init__(self, args):
|
||
self.args = args
|
||
self.lock = threading.RLock()
|
||
self.sessions = {}
|
||
self.message_seq = 0
|
||
self.state_seq = 0
|
||
self.clients = []
|
||
self.upstream_records = []
|
||
self.seen_client_msg_ids = {}
|
||
# 上行落库模式(--hold-replies / POST /__control/hold-replies):挂起的上行消息一条不落库。
|
||
self.hold_replies = args.hold_replies
|
||
self.held_messages = []
|
||
self.hold_seq = 0
|
||
self.max_upstream_kept = args.keep_records
|
||
self.out_dir = args.out_dir
|
||
self.upstream_log = JsonlLog(os.path.join(args.out_dir, "upstream.jsonl"))
|
||
self.downstream_log = JsonlLog(os.path.join(args.out_dir, "downstream.jsonl"))
|
||
self.http_log = JsonlLog(os.path.join(args.out_dir, "http.jsonl"))
|
||
self.downstream_count = 0
|
||
self.http_count = 0
|
||
self.reset()
|
||
|
||
# —— 种子 / 复位 ——
|
||
|
||
def reset(self) -> None:
|
||
with self.lock:
|
||
self.sessions = {}
|
||
self.message_seq = 0
|
||
self.state_seq = 0
|
||
self.seen_client_msg_ids = {}
|
||
self.upstream_records = []
|
||
# 挂起的上行消息属于「库里的状态」,一起复位;hold_replies 是**模式**,复位不动它。
|
||
self.held_messages = []
|
||
self.hold_seq = 0
|
||
main = Session(MAIN_SESSION_ID, "main", SEED_MAIN_CREATED_AT)
|
||
sub = Session(
|
||
"web:5eed0000000000000000000000000001",
|
||
"sub",
|
||
SEED_SUB_CREATED_AT,
|
||
pinned=True,
|
||
)
|
||
self.sessions[main.id] = main
|
||
self.sessions[sub.id] = sub
|
||
|
||
def ordered_sessions(self):
|
||
with self.lock:
|
||
rows = list(self.sessions.values())
|
||
rows.sort(key=lambda s: (0 if s.pinned else 1, 0 if s.type == "main" else 1, s.created_at))
|
||
return rows
|
||
|
||
# —— 消息 ——
|
||
|
||
def next_frame_seq(self) -> int:
|
||
with self.lock:
|
||
self.state_seq += 1
|
||
return self.state_seq
|
||
|
||
def add_message(
|
||
self,
|
||
session_id: str,
|
||
role: str,
|
||
content: str,
|
||
client_msg_id=None,
|
||
platform=None,
|
||
mode=None,
|
||
mood=None,
|
||
documents=None,
|
||
created_at=None,
|
||
message_id=None,
|
||
voice=None,
|
||
image=None,
|
||
) -> dict:
|
||
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
|
||
|
||
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
|
||
[voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)——
|
||
只有控制端点能造出来,真后端的入站语音副本链本工具不模拟。
|
||
[image] 非空 = 这条带一张图(契约 §1.1:base64 `data:` URI 或 http(s) URL,两种形状客户端
|
||
都认,判据见 `ImageSource.of`)。本工具**不代取 URL**,所以只由控制端点灌 `data:` URI
|
||
(见 `/__control/messages` 的 `image_file`)——与 `voice` 同一性质:造场景用,不是后端行为。
|
||
"""
|
||
with self.lock:
|
||
session = self.sessions.get(session_id)
|
||
if session is None:
|
||
raise KeyError(session_id)
|
||
if message_id is None:
|
||
self.message_seq += 1
|
||
message_id = "msg-{:06d}".format(self.message_seq)
|
||
previous = session.messages[-1]["createdAt"] if session.messages else 0
|
||
stamp = created_at if created_at is not None else max(now_ms(), previous + 1, session.created_at + 1)
|
||
message = {
|
||
"id": message_id,
|
||
"sessionId": session_id,
|
||
"role": role,
|
||
"content": content,
|
||
"createdAt": stamp,
|
||
}
|
||
if client_msg_id:
|
||
message["clientMsgId"] = client_msg_id
|
||
if platform:
|
||
message["platform"] = platform
|
||
if mode:
|
||
message["mode"] = mode
|
||
if mood:
|
||
message["mood"] = mood
|
||
if documents:
|
||
message["documents"] = documents
|
||
if voice:
|
||
message["voice"] = voice
|
||
if image:
|
||
message["image"] = image
|
||
session.messages.append(message)
|
||
return message
|
||
|
||
def hold_message(self, session_id: str, role: str, content: str, client_msg_id=None, mode=None) -> dict:
|
||
"""上行消息**挂起**:不落库、不广播 `message_persisted`(--hold-replies 打开时走这条)。
|
||
|
||
为什么要这个模式:默认行为下客户端一发 `chat`,一个往返就收到那条用户消息的
|
||
`message_persisted`,乐观气泡立刻被收走 ⇒ 「服务端把这条**拒掉**」这类场景根本凑不出来
|
||
(手里没有还挂着的待发气泡)。真后端忙时也是先排队 / 先拒绝,不会立刻落库。
|
||
|
||
id 在这里**先占号**(与真正落库共用同一个 `message_seq`,不会撞号),`createdAt` 留到
|
||
真正落库那一刻再盖([persist_held_message])。幂等键**不在**这一刻认领 —— 与契约一致:
|
||
队列满的判定发生在认领之前(WsEvent.kt:398),所以挂起期间重发同一帧会得到第二条挂起
|
||
记录,而不是 `chat_duplicate`。
|
||
"""
|
||
with self.lock:
|
||
self.message_seq += 1
|
||
self.hold_seq += 1
|
||
record = {
|
||
"holdId": "hold-{:06d}".format(self.hold_seq),
|
||
"sessionId": session_id,
|
||
"messageId": "msg-{:06d}".format(self.message_seq),
|
||
"role": role,
|
||
"clientMsgId": client_msg_id,
|
||
"mode": mode,
|
||
"content": content,
|
||
"heldAt": iso_now(),
|
||
}
|
||
self.held_messages.append(record)
|
||
return record
|
||
|
||
def persist_held_message(self, hold: dict) -> dict:
|
||
"""把一条挂起的上行消息**显式**落库(控制端点 `POST /__control/persist`)。
|
||
|
||
落库动作与默认路径逐字一致:进同一个会话、认领幂等键、由调用方广播 `message_persisted`。
|
||
"""
|
||
with self.lock:
|
||
message = self.add_message(
|
||
hold["sessionId"],
|
||
hold["role"],
|
||
hold["content"],
|
||
client_msg_id=hold.get("clientMsgId"),
|
||
mode=hold.get("mode"),
|
||
message_id=hold["messageId"],
|
||
)
|
||
if hold.get("clientMsgId"):
|
||
self.seen_client_msg_ids[hold["clientMsgId"]] = message["id"]
|
||
self.held_messages.remove(hold)
|
||
return message
|
||
|
||
def held_for(self, session_id=None) -> list:
|
||
with self.lock:
|
||
return [h for h in self.held_messages if session_id is None or h["sessionId"] == session_id]
|
||
|
||
def history_replay(self, session_id: str, limit: int) -> list:
|
||
"""握手 history 帧的回放:**最近 N 条、升序**。N 与库里的条数之差就是那个洞。"""
|
||
with self.lock:
|
||
session = self.sessions.get(session_id)
|
||
if session is None:
|
||
return []
|
||
return [dict(m) for m in session.messages[-limit:]] if limit > 0 else []
|
||
|
||
def find_message(self, message_id: str):
|
||
with self.lock:
|
||
for session in self.sessions.values():
|
||
for message in session.messages:
|
||
if message["id"] == message_id:
|
||
return session, message
|
||
return None, None
|
||
|
||
# —— 客户端连接 ——
|
||
|
||
def add_client(self, client) -> None:
|
||
with self.lock:
|
||
self.clients.append(client)
|
||
|
||
def remove_client(self, client) -> None:
|
||
with self.lock:
|
||
if client in self.clients:
|
||
self.clients.remove(client)
|
||
|
||
def broadcast(self, frame: dict, origin: str) -> int:
|
||
with self.lock:
|
||
targets = list(self.clients)
|
||
sent = 0
|
||
for client in targets:
|
||
if client.send_frame(frame, origin):
|
||
sent += 1
|
||
return sent
|
||
|
||
# —— 记录 ——
|
||
|
||
def record_upstream(self, client_label: str, raw: str, frame, parse_error=None) -> dict:
|
||
record = {
|
||
"ts": iso_now(),
|
||
"epochMs": now_ms(),
|
||
"client": client_label,
|
||
"type": (frame or {}).get("type") if isinstance(frame, dict) else None,
|
||
"sessionId": None,
|
||
"raw": raw,
|
||
}
|
||
if isinstance(frame, dict):
|
||
data = frame.get("data")
|
||
if isinstance(data, dict):
|
||
record["sessionId"] = data.get("sessionId")
|
||
record["frame"] = frame
|
||
if parse_error is not None:
|
||
record["parseError"] = parse_error
|
||
self.upstream_log.append(record)
|
||
with self.lock:
|
||
self.upstream_records.append(record)
|
||
if len(self.upstream_records) > self.max_upstream_kept:
|
||
self.upstream_records = self.upstream_records[-self.max_upstream_kept:]
|
||
# 终端上实时可见:这是「客户端到底发没发那一帧」的第一现场。
|
||
log("WS<-", json.dumps(record.get("frame", raw), ensure_ascii=False))
|
||
return record
|
||
|
||
def record_downstream(self, client_label: str, origin: str, raw: str) -> None:
|
||
record = {
|
||
"ts": iso_now(),
|
||
"epochMs": now_ms(),
|
||
"to": client_label,
|
||
"origin": origin,
|
||
"raw": raw,
|
||
}
|
||
self.downstream_log.append(record)
|
||
with self.lock:
|
||
self.downstream_count += 1
|
||
|
||
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
|
||
"""把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
|
||
|
||
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
|
||
也没有理由把它原文写进一个可能被 commit 的文件。
|
||
|
||
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
|
||
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
|
||
`out/http.jsonl` 里读得出来,不用再去猜。
|
||
"""
|
||
recorded_headers = {}
|
||
for key, value in headers.items():
|
||
if key.lower() == "authorization":
|
||
recorded_headers[key] = redact(value)
|
||
else:
|
||
recorded_headers[key] = value
|
||
if "token" in query:
|
||
query = dict(query)
|
||
query["token"] = [redact(v) for v in query["token"]]
|
||
record = {
|
||
"ts": iso_now(),
|
||
"epochMs": now_ms(),
|
||
"client": handler_label,
|
||
"method": method,
|
||
"path": path,
|
||
"query": query,
|
||
"headers": recorded_headers,
|
||
}
|
||
summary = summarize_body(body)
|
||
if summary is not None:
|
||
record["body"] = summary
|
||
self.http_log.append(record)
|
||
with self.lock:
|
||
self.http_count += 1
|
||
return record
|
||
|
||
def state(self) -> dict:
|
||
with self.lock:
|
||
sessions = [
|
||
{
|
||
"id": s.id,
|
||
"type": s.type,
|
||
"title": s.title,
|
||
"pinned": s.pinned,
|
||
"messages": len(s.messages),
|
||
"unreadCount": s.unread_count(),
|
||
"readAt": s.read_at,
|
||
"busy": s.busy_mode,
|
||
"oldestCreatedAt": s.messages[0]["createdAt"] if s.messages else None,
|
||
"newestCreatedAt": s.messages[-1]["createdAt"] if s.messages else None,
|
||
}
|
||
for s in self.sessions.values()
|
||
]
|
||
counts = {}
|
||
for record in self.upstream_records:
|
||
key = record.get("type") or "<unparsed>"
|
||
counts[key] = counts.get(key, 0) + 1
|
||
return {
|
||
"port": self.args.port,
|
||
"host": self.args.host,
|
||
"historyReplay": self.args.history_replay,
|
||
"streamChunk": self.args.stream_chunk,
|
||
"autoReply": self.args.auto_reply,
|
||
"interruptAck": self.args.interrupt_ack,
|
||
"holdReplies": self.hold_replies,
|
||
"heldMessages": [dict(h) for h in self.held_messages],
|
||
"outDir": self.out_dir,
|
||
"sessions": sessions,
|
||
"wsClients": len(self.clients),
|
||
"wsUpstreamFrames": len(self.upstream_records),
|
||
"wsUpstreamByType": counts,
|
||
"wsDownstreamFrames": self.downstream_count,
|
||
"httpRequests": self.http_count,
|
||
}
|
||
|
||
|
||
class WsConnection:
|
||
"""手写的 WebSocket 连接(RFC 6455 的最小集:文本帧 + close + ping/pong)。
|
||
|
||
只做本工具需要的事:收上行文本帧、下发 JSON 帧。不做扩展、不做压缩、不做分片发送
|
||
(收方向支持分片拼接,因为客户端万一发大包会分片)。
|
||
"""
|
||
|
||
def __init__(self, store: Store, rfile, wfile, client_label: str):
|
||
self.store = store
|
||
self.rfile = rfile
|
||
self.wfile = wfile
|
||
self.label = client_label
|
||
self.write_lock = threading.Lock()
|
||
self.closed = False
|
||
|
||
# —— 发送 ——
|
||
|
||
def send_frame(self, frame: dict, origin: str) -> bool:
|
||
text = json.dumps(frame, ensure_ascii=False, separators=(",", ":"))
|
||
return self.send_text(text, origin)
|
||
|
||
def send_text(self, text: str, origin: str) -> bool:
|
||
if self.closed:
|
||
return False
|
||
payload = text.encode("utf-8")
|
||
header = bytearray()
|
||
header.append(0x80 | 0x1) # FIN + text
|
||
length = len(payload)
|
||
if length < 126:
|
||
header.append(length)
|
||
elif length < 65536:
|
||
header.append(126)
|
||
header += struct.pack(">H", length)
|
||
else:
|
||
header.append(127)
|
||
header += struct.pack(">Q", length)
|
||
try:
|
||
with self.write_lock:
|
||
self.wfile.write(bytes(header) + payload)
|
||
self.wfile.flush()
|
||
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
||
self.closed = True
|
||
return False
|
||
self.store.record_downstream(self.label, origin, text)
|
||
return True
|
||
|
||
def send_close(self, code: int = 1001, reason: str = "server closing") -> None:
|
||
if self.closed:
|
||
return
|
||
payload = struct.pack(">H", code) + reason.encode("utf-8")
|
||
header = bytearray([0x80 | 0x8, len(payload)])
|
||
try:
|
||
with self.write_lock:
|
||
self.wfile.write(bytes(header) + payload)
|
||
self.wfile.flush()
|
||
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
||
pass
|
||
self.closed = True
|
||
|
||
# —— 接收 ——
|
||
|
||
def _read_exact(self, count: int):
|
||
try:
|
||
data = self.rfile.read(count)
|
||
except (ConnectionResetError, OSError, ValueError):
|
||
return None
|
||
if data is None or len(data) < count:
|
||
return None
|
||
return data
|
||
|
||
def run(self) -> None:
|
||
fragment = bytearray()
|
||
fragment_opcode = None
|
||
while not self.closed:
|
||
head = self._read_exact(2)
|
||
if head is None:
|
||
break
|
||
fin = bool(head[0] & 0x80)
|
||
opcode = head[0] & 0x0F
|
||
masked = bool(head[1] & 0x80)
|
||
length = head[1] & 0x7F
|
||
if length == 126:
|
||
extra = self._read_exact(2)
|
||
if extra is None:
|
||
break
|
||
length = struct.unpack(">H", extra)[0]
|
||
elif length == 127:
|
||
extra = self._read_exact(8)
|
||
if extra is None:
|
||
break
|
||
length = struct.unpack(">Q", extra)[0]
|
||
mask = self._read_exact(4) if masked else None
|
||
if masked and mask is None:
|
||
break
|
||
payload = self._read_exact(length) if length else b""
|
||
if payload is None:
|
||
break
|
||
if mask:
|
||
payload = bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
|
||
|
||
if opcode == 0x8: # close
|
||
self.send_close(1000, "ack")
|
||
break
|
||
if opcode == 0x9: # ping → pong(OkHttp pingInterval 30s 靠它活着)
|
||
self._send_control(0xA, payload)
|
||
continue
|
||
if opcode == 0xA: # pong
|
||
continue
|
||
|
||
if opcode == 0x0: # continuation
|
||
fragment += payload
|
||
if fragment_opcode is None:
|
||
continue
|
||
if not fin:
|
||
continue
|
||
self._on_text(bytes(fragment), fragment_opcode)
|
||
fragment = bytearray()
|
||
fragment_opcode = None
|
||
continue
|
||
|
||
if not fin:
|
||
fragment = bytearray(payload)
|
||
fragment_opcode = opcode
|
||
continue
|
||
|
||
if opcode == 0x1:
|
||
self._on_text(bytes(payload), opcode)
|
||
# 二进制帧(0x2)本工具不处理:客户端不会发。
|
||
|
||
self.closed = True
|
||
|
||
def _send_control(self, opcode: int, payload: bytes) -> None:
|
||
header = bytearray([0x80 | opcode])
|
||
length = len(payload)
|
||
if length < 126:
|
||
header.append(length)
|
||
elif length < 65536:
|
||
header.append(126)
|
||
header += struct.pack(">H", length)
|
||
else:
|
||
header.append(127)
|
||
header += struct.pack(">Q", length)
|
||
try:
|
||
with self.write_lock:
|
||
self.wfile.write(bytes(header) + payload)
|
||
self.wfile.flush()
|
||
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
||
self.closed = True
|
||
|
||
def _on_text(self, payload: bytes, opcode: int) -> None:
|
||
try:
|
||
text = payload.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
self.store.record_upstream(self.label, payload.hex(), None, "invalid utf-8")
|
||
return
|
||
try:
|
||
frame = json.loads(text)
|
||
except json.JSONDecodeError as exc:
|
||
self.store.record_upstream(self.label, text, None, "bad json: {}".format(exc))
|
||
return
|
||
self.store.record_upstream(self.label, text, frame if isinstance(frame, dict) else None)
|
||
handle_upstream(self.store, self, frame)
|
||
|
||
|
||
# ============ 上行帧的处置 ============
|
||
|
||
def handle_upstream(store: Store, client, frame) -> None:
|
||
if not isinstance(frame, dict):
|
||
return
|
||
kind = frame.get("type")
|
||
data = frame.get("data") if isinstance(frame.get("data"), dict) else {}
|
||
|
||
if kind == "chat":
|
||
session_id = data.get("sessionId") or MAIN_SESSION_ID
|
||
client_msg_id = data.get("clientMsgId")
|
||
if client_msg_id and client_msg_id in store.seen_client_msg_ids:
|
||
# 幂等回执(WsEvent.kt:328 chat_duplicate):不落库、不起回合。
|
||
client.send_frame(
|
||
{
|
||
"type": "chat_duplicate",
|
||
"sessionId": session_id,
|
||
"data": {
|
||
"sessionId": session_id,
|
||
"clientMsgId": client_msg_id,
|
||
"messageId": store.seen_client_msg_ids[client_msg_id],
|
||
},
|
||
},
|
||
"chat_duplicate",
|
||
)
|
||
return
|
||
if session_id not in store.sessions:
|
||
client.send_frame(
|
||
{
|
||
"type": "error",
|
||
"data": {
|
||
"code": "bad_request",
|
||
"message": "unknown session {}".format(session_id),
|
||
"sessionId": session_id,
|
||
},
|
||
},
|
||
"error",
|
||
)
|
||
return
|
||
try:
|
||
if store.hold_replies:
|
||
# 挂起:**不**落库、**不**广播 message_persisted(这一处是默认路径里的第一个广播点)。
|
||
# 上行帧已经被 record_upstream 记过(out/upstream.jsonl + wsUpstreamByType),
|
||
# 回复路径照旧不受影响。
|
||
hold = store.hold_message(session_id, "user", data.get("text", ""), client_msg_id=client_msg_id)
|
||
log(
|
||
"hold",
|
||
"chat 挂起 {} clientMsgId={}(等 /__control/persist)".format(hold["holdId"], client_msg_id),
|
||
)
|
||
if store.args.auto_reply:
|
||
send_reply_round(store, session_id, "收到:{}".format(data.get("text", "")[:20]))
|
||
return
|
||
message = store.add_message(
|
||
session_id,
|
||
"user",
|
||
data.get("text", ""),
|
||
client_msg_id=client_msg_id,
|
||
)
|
||
except KeyError:
|
||
return
|
||
if client_msg_id:
|
||
store.seen_client_msg_ids[client_msg_id] = message["id"]
|
||
store.broadcast(
|
||
{"type": "message_persisted", "sessionId": session_id, "data": {"message": message}},
|
||
"message_persisted",
|
||
)
|
||
if store.args.auto_reply:
|
||
send_reply_round(store, session_id, "收到:{}".format(data.get("text", "")[:20]))
|
||
return
|
||
|
||
if kind == "interrupt":
|
||
session_id = data.get("sessionId") or MAIN_SESSION_ID
|
||
if store.args.interrupt_ack:
|
||
with store.lock:
|
||
session = store.sessions.get(session_id)
|
||
if session is not None:
|
||
session.busy_mode = None
|
||
store.broadcast(
|
||
{
|
||
"type": "session_interrupted",
|
||
"sessionId": session_id,
|
||
"data": {"sessionId": session_id, "mode": "idle", "dropped": 0},
|
||
},
|
||
"interrupt_ack",
|
||
)
|
||
return
|
||
# 其余上行帧(voice_input / mode_switch / subagent_ask / task_output_query)只记录,不处置。
|
||
|
||
|
||
def split_chunks(text: str, count: int):
|
||
"""把整段正文切成至多 [count] 段(尽量等长;文本比 count 短时就按字符切)。"""
|
||
count = max(1, min(count, len(text)))
|
||
if count <= 1:
|
||
return [text]
|
||
size, extra = divmod(len(text), count)
|
||
parts = []
|
||
index = 0
|
||
for position in range(count):
|
||
step = size + (1 if position < extra else 0)
|
||
parts.append(text[index : index + step])
|
||
index += step
|
||
return [part for part in parts if part]
|
||
|
||
|
||
def send_reply_round(store: Store, session_id: str, text: str, chunks: int = 3, mode: str = "default", persist: bool = True):
|
||
"""下发一轮助手回复。
|
||
|
||
[mode] `chunk` 走**旧形状** `stream_chunk` + `stream_end`(WsEvent.kt:206),
|
||
用来验客户端那条回退分支(ChatViewModel.kt:1468);`structured` 走 message.start/delta/end。
|
||
`default` 由 `--stream-chunk` 决定。
|
||
"""
|
||
if mode == "default":
|
||
mode = "chunk" if store.args.stream_chunk else "structured"
|
||
turn_id = "turn-{:012x}".format(uuid.uuid4().int & 0xFFFFFFFFFFFF)
|
||
seq_base = store.next_frame_seq()
|
||
deltas = split_chunks(text, chunks)
|
||
|
||
if mode == "chunk":
|
||
for offset, delta in enumerate(deltas):
|
||
store.broadcast(
|
||
{
|
||
"type": "stream_chunk",
|
||
"turnId": turn_id,
|
||
"seq": seq_base + offset,
|
||
"sessionId": session_id,
|
||
"data": {"delta": delta},
|
||
},
|
||
"reply:stream_chunk",
|
||
)
|
||
else:
|
||
store.broadcast(
|
||
{
|
||
"type": "message.start",
|
||
"turnId": turn_id,
|
||
"seq": seq_base,
|
||
"sessionId": session_id,
|
||
"data": {"index": 0},
|
||
},
|
||
"reply:message.start",
|
||
)
|
||
for offset, delta in enumerate(deltas):
|
||
store.broadcast(
|
||
{
|
||
"type": "message.delta",
|
||
"turnId": turn_id,
|
||
"seq": seq_base + offset + 1,
|
||
"sessionId": session_id,
|
||
"data": {"delta": delta, "index": 0},
|
||
},
|
||
"reply:message.delta",
|
||
)
|
||
store.broadcast(
|
||
{
|
||
"type": "message.end",
|
||
"turnId": turn_id,
|
||
"seq": seq_base + len(deltas) + 1,
|
||
"sessionId": session_id,
|
||
"data": {"index": 0},
|
||
},
|
||
"reply:message.end",
|
||
)
|
||
|
||
store.broadcast(
|
||
{
|
||
"type": "stream_end",
|
||
"turnId": turn_id,
|
||
"seq": seq_base + len(deltas) + 2,
|
||
"sessionId": session_id,
|
||
"data": {"stopReason": "stop", "steps": 1},
|
||
},
|
||
"reply:stream_end",
|
||
)
|
||
|
||
if persist:
|
||
try:
|
||
message = store.add_message(session_id, "assistant", text, mode="chat")
|
||
except KeyError:
|
||
return {"turnId": turn_id, "mode": mode, "chunks": len(deltas), "persisted": False}
|
||
store.broadcast(
|
||
{"type": "message_persisted", "sessionId": session_id, "data": {"message": message}},
|
||
"message_persisted",
|
||
)
|
||
return {"turnId": turn_id, "mode": mode, "chunks": len(deltas), "persisted": True, "messageId": message["id"]}
|
||
return {"turnId": turn_id, "mode": mode, "chunks": len(deltas), "persisted": False}
|
||
|
||
|
||
# ============ HTTP ============
|
||
|
||
MESSAGES_RE = re.compile(r"^/api/v1/sessions/([^/]+)/messages$")
|
||
SESSION_RE = re.compile(r"^/api/v1/sessions/([^/]+)$")
|
||
SESSION_READ_RE = re.compile(r"^/api/v1/sessions/([^/]+)/read$")
|
||
MESSAGE_RE = re.compile(r"^/api/v1/messages/([^/]+)$")
|
||
MEDIA_RE = re.compile(r"^/api/v1/media/([^/]+)$")
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
protocol_version = "HTTP/1.1"
|
||
server_version = "CyreneFakeBackend"
|
||
store: Store = None
|
||
|
||
# —— 基础设施 ——
|
||
|
||
def log_message(self, fmt, *args):
|
||
return
|
||
|
||
def _label(self) -> str:
|
||
host, port = self.client_address[0], self.client_address[1]
|
||
return "{}:{}".format(host, port)
|
||
|
||
def _split(self):
|
||
parts = urlsplit(self.path)
|
||
query = parse_qs(parts.query, keep_blank_values=True)
|
||
return parts.path, query
|
||
|
||
def handle_one_request(self):
|
||
"""每个请求进来时**先把 body 缓存清掉**。
|
||
|
||
`protocol_version` 是 HTTP/1.1,同一条 keep-alive 连接上的多个请求由**同一个 Handler 实例**
|
||
依次处理(`handle_one_request` 循环),所以 `_read_body` 的缓存必须按请求隔离。少了这一步,
|
||
第 2 个请求会拿到第 1 个请求的 body(摘要记成上一份),而它自己那份**根本没人从 `rfile` 读走**
|
||
——残留字节留在内核缓冲里,被下一个请求的请求行一并吃掉,`BaseHTTPRequestHandler` 于是回
|
||
`501 Unsupported method ('{"sessionId":...}POST')`。客户端表现成「3 张图只投出去 2 张」,
|
||
而真正的锅在这里。
|
||
"""
|
||
self._body_read = False
|
||
self._body_cache = b""
|
||
super().handle_one_request()
|
||
|
||
def _read_body(self):
|
||
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
|
||
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
|
||
if getattr(self, "_body_read", False):
|
||
return self._body_cache
|
||
self._body_read = True
|
||
self._body_cache = b""
|
||
length = self.headers.get("Content-Length")
|
||
if not length:
|
||
return self._body_cache
|
||
try:
|
||
size = int(length)
|
||
except ValueError:
|
||
return self._body_cache
|
||
if size <= 0:
|
||
return self._body_cache
|
||
self._body_cache = self.rfile.read(size) or b""
|
||
return self._body_cache
|
||
|
||
def _parse_json_body(self):
|
||
raw = self._read_body()
|
||
if not raw:
|
||
return None, None
|
||
try:
|
||
return json.loads(raw.decode("utf-8")), raw
|
||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||
return None, str(exc)
|
||
|
||
def _send_json(self, status: int, payload) -> None:
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
try:
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
||
self.close_connection = True
|
||
|
||
def _ok(self, data, status: int = 200) -> None:
|
||
self._send_json(status, envelope(data))
|
||
|
||
def _fail(self, status: int, code: str, message: str) -> None:
|
||
self._send_json(status, error_envelope(code, message))
|
||
|
||
# —— 方法入口 ——
|
||
|
||
def do_GET(self):
|
||
self._handle("GET")
|
||
|
||
def do_POST(self):
|
||
self._handle("POST")
|
||
|
||
def do_PATCH(self):
|
||
self._handle("PATCH")
|
||
|
||
def do_DELETE(self):
|
||
self._handle("DELETE")
|
||
|
||
def do_OPTIONS(self):
|
||
self._handle("OPTIONS")
|
||
|
||
def _handle(self, method: str):
|
||
path, query = self._split()
|
||
# 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
|
||
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
|
||
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
|
||
body = (
|
||
self._read_body()
|
||
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
|
||
else b""
|
||
)
|
||
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
|
||
try:
|
||
if path == "/ws/chat":
|
||
self._handle_ws(method, query)
|
||
return
|
||
if path.startswith("/__control/"):
|
||
self._handle_control(method, path, query)
|
||
return
|
||
if path.startswith("/api/v1/"):
|
||
self._handle_api(method, path, query)
|
||
return
|
||
if path == "/":
|
||
self._ok({"name": "cyrene-fake-backend", "control": "/__control/state"})
|
||
return
|
||
self._fail(404, "not_found", "no such endpoint: {}".format(path))
|
||
except BrokenPipeError:
|
||
self.close_connection = True
|
||
except Exception as exc: # 假后端绝不该把线程炸掉;把原因如实回给调用方
|
||
log("ERR ", "{} {} -> {}".format(method, path, exc))
|
||
try:
|
||
self._fail(500, "fake_backend_error", repr(exc))
|
||
except Exception:
|
||
self.close_connection = True
|
||
|
||
# —— WebSocket 握手 ——
|
||
|
||
def _handle_ws(self, method: str, query) -> None:
|
||
if method != "GET":
|
||
self._fail(405, "bad_request", "websocket handshake must be GET")
|
||
return
|
||
upgrade = (self.headers.get("Upgrade") or "").lower()
|
||
key = self.headers.get("Sec-WebSocket-Key")
|
||
version = self.headers.get("Sec-WebSocket-Version")
|
||
if "websocket" not in upgrade or not key:
|
||
self._fail(400, "bad_request", "not a websocket handshake")
|
||
return
|
||
if version not in (None, "13"):
|
||
self._fail(400, "bad_request", "unsupported Sec-WebSocket-Version {}".format(version))
|
||
return
|
||
|
||
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode("ascii")).digest()).decode("ascii")
|
||
self.send_response(101)
|
||
self.send_header("Upgrade", "websocket")
|
||
self.send_header("Connection", "Upgrade")
|
||
self.send_header("Sec-WebSocket-Accept", accept)
|
||
self.end_headers()
|
||
self.wfile.flush()
|
||
|
||
token = (query.get("token") or [""])[0]
|
||
client = WsConnection(self.store, self.rfile, self.wfile, self._label())
|
||
self.store.add_client(client)
|
||
log("WS ", "handshake 101 client={} token={}".format(self._label(), redact(token) or "<none>"))
|
||
try:
|
||
# 握手后立刻发 history,回放条数由 --history-replay 决定(这就是那个洞)。
|
||
replay = self.store.history_replay(MAIN_SESSION_ID, self.store.args.history_replay)
|
||
client.send_frame(
|
||
{
|
||
"type": "history",
|
||
"sessionId": MAIN_SESSION_ID,
|
||
"seq": self.store.next_frame_seq(),
|
||
"data": {"messages": replay},
|
||
},
|
||
"history",
|
||
)
|
||
log(
|
||
"WS->",
|
||
"history replayed={} (historyReplay={})".format(len(replay), self.store.args.history_replay),
|
||
)
|
||
client.run()
|
||
finally:
|
||
self.store.remove_client(client)
|
||
self.close_connection = True
|
||
log("WS ", "closed client={}".format(self._label()))
|
||
|
||
# —— /api/v1/** ——
|
||
|
||
def _handle_api(self, method: str, path: str, query) -> None:
|
||
# 鉴权:**一律不校验**(README 里明说)。token / X-API-Version 照收不误,已经记进 http.jsonl。
|
||
if path == "/api/v1/auth/login" and method == "POST":
|
||
body, raw = self._parse_json_body()
|
||
self._ok(
|
||
{
|
||
"token": "fake-token-{}".format(uuid.uuid4().hex[:16]),
|
||
"expiresAt": int(time.time()) + 86400, # Unix **秒**(LoginDto.kt:14)
|
||
"role": "owner",
|
||
"refreshToken": "fake-refresh-{}".format(uuid.uuid4().hex[:16]),
|
||
}
|
||
)
|
||
return
|
||
if path == "/api/v1/auth/refresh" and method == "POST":
|
||
self._ok(
|
||
{
|
||
"token": "fake-token-{}".format(uuid.uuid4().hex[:16]),
|
||
"expiresAt": int(time.time()) + 86400,
|
||
"role": "owner",
|
||
"refreshToken": "fake-refresh-{}".format(uuid.uuid4().hex[:16]),
|
||
}
|
||
)
|
||
return
|
||
if path == "/api/v1/auth/logout" and method == "POST":
|
||
self._parse_json_body()
|
||
self._ok({"revoked": True, "revokedCount": 1})
|
||
return
|
||
if path == "/api/v1/health" and method == "GET":
|
||
# 存活探针:**不鉴权**、不做任何重活(对齐真后端
|
||
# `internal/services/gateway/rest.go` 的 handleHealth,路由挂在公开那一组)。
|
||
# 客户端 W41 起在「WS 未就绪」时先探它一次,通就回落 REST 发那条消息——
|
||
# 假后端没有这个端点的话,探测会吃到 404、一律判「不通」,W41 那条阶梯在本机就退化成 W40。
|
||
self._ok({"status": "ok"})
|
||
return
|
||
if path == "/api/v1/meta/enums" and method == "GET":
|
||
self._ok(ENUMS_PAYLOAD)
|
||
return
|
||
if path == "/api/v1/personas" and method == "GET":
|
||
self._ok(PERSONAS)
|
||
return
|
||
if path == "/api/v1/sessions":
|
||
if method == "GET":
|
||
self._ok([s.summary() for s in self.store.ordered_sessions()])
|
||
return
|
||
if method == "POST":
|
||
self._create_session()
|
||
return
|
||
if path == "/api/v1/chat" and method == "POST":
|
||
self._rest_chat()
|
||
return
|
||
|
||
match = MESSAGES_RE.match(path)
|
||
if match and method == "GET":
|
||
self._list_messages(unquote(match.group(1)), query)
|
||
return
|
||
|
||
match = SESSION_READ_RE.match(path)
|
||
if match and method == "POST":
|
||
self._mark_read(unquote(match.group(1)))
|
||
return
|
||
|
||
match = SESSION_RE.match(path)
|
||
if match:
|
||
session_id = unquote(match.group(1))
|
||
if method == "PATCH":
|
||
self._update_session(session_id)
|
||
return
|
||
if method == "DELETE":
|
||
self._delete_session(session_id)
|
||
return
|
||
|
||
match = MESSAGE_RE.match(path)
|
||
if match:
|
||
message_id = unquote(match.group(1))
|
||
if method == "PATCH":
|
||
self._update_message(message_id)
|
||
return
|
||
if method == "DELETE":
|
||
self._delete_message(message_id)
|
||
return
|
||
|
||
match = MEDIA_RE.match(path)
|
||
if match and method == "GET":
|
||
self._serve_media(unquote(match.group(1)))
|
||
return
|
||
|
||
self._fail(404, "not_found", "no such endpoint: {} {}".format(method, path))
|
||
|
||
def _serve_media(self, name: str) -> None:
|
||
"""`GET /api/v1/media/{name}`(契约 v0.79 §1.2 **U19**,语音副本播放端点)。
|
||
|
||
⚠️ **真后端要 Bearer `owner|admin`**(语音是私密内容);本工具**不校验任何鉴权**(README 首条),
|
||
所以这条端点在设备上验的是「客户端有没有把请求发对地方、字节能不能播」,**不**能验鉴权。
|
||
|
||
只认夹具那一个名字:其余(含穿越形态)一律 `404 media_not_found`——与真后端
|
||
「名字非法 / 穿越 / 不存在统一同码」的取向一致,也方便顺手验一次失败分支。
|
||
"""
|
||
if name != VOICE_FIXTURE_NAME:
|
||
self._fail(404, "media_not_found", "media not found")
|
||
return
|
||
data = VOICE_WAV
|
||
try:
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", VOICE_FIXTURE_MIME)
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.send_header("Content-Disposition", 'inline; filename="{}"'.format(name))
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("Accept-Ranges", "bytes")
|
||
self.send_header("Cache-Control", "private, max-age=86400, immutable")
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
log("http", "media {} bytes (voice fixture)".format(len(data)))
|
||
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
|
||
self.close_connection = True
|
||
|
||
def _create_session(self) -> None:
|
||
body, raw = self._parse_json_body()
|
||
if raw is not None and body is None:
|
||
self._fail(400, "bad_request", "invalid json body")
|
||
return
|
||
persona_id = None
|
||
if isinstance(body, dict):
|
||
persona_id = body.get("personaId")
|
||
known = {p["id"] for p in PERSONAS}
|
||
if persona_id is not None and persona_id not in known:
|
||
self._fail(400, "bad_request", "unknown personaId: {}".format(persona_id))
|
||
return
|
||
session_id = "web:{}".format(uuid.uuid4().hex)
|
||
with self.store.lock:
|
||
self.store.sessions[session_id] = Session(
|
||
session_id,
|
||
"sub",
|
||
now_ms(),
|
||
persona_id=persona_id,
|
||
)
|
||
payload = {"sessionId": session_id}
|
||
if persona_id:
|
||
payload["personaId"] = persona_id
|
||
log("http", "created session {} persona={}".format(session_id, persona_id))
|
||
self._ok(payload, status=201)
|
||
|
||
def _update_session(self, session_id: str) -> None:
|
||
body, raw = self._parse_json_body()
|
||
if not isinstance(body, dict):
|
||
self._fail(400, "bad_request", "invalid json body")
|
||
return
|
||
if "personaId" in body:
|
||
# 会话角色只在创建时写入,之后不可变(SessionsApi.kt:46)。
|
||
self._fail(400, "bad_request", "personaId is immutable")
|
||
return
|
||
if not body:
|
||
self._fail(400, "bad_request", "title or pinned is required")
|
||
return
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
self._fail(404, "not_found", "unknown session {}".format(session_id))
|
||
return
|
||
response = {"id": session_id}
|
||
if "title" in body:
|
||
title = body.get("title")
|
||
if not isinstance(title, str) or not title.strip():
|
||
self._fail(400, "bad_request", "title is required")
|
||
return
|
||
if len(title.strip()) > TITLE_MAX_CHARS:
|
||
self._fail(400, "bad_request", "title too long")
|
||
return
|
||
session.title = title.strip()
|
||
response["title"] = session.title
|
||
if "pinned" in body:
|
||
session.pinned = bool(body.get("pinned"))
|
||
response["pinned"] = session.pinned
|
||
self._ok(response)
|
||
|
||
def _delete_session(self, session_id: str) -> None:
|
||
if session_id == MAIN_SESSION_ID:
|
||
self._fail(403, "forbidden", "main session cannot be deleted")
|
||
return
|
||
with self.store.lock:
|
||
if session_id not in self.store.sessions:
|
||
self._fail(404, "not_found", "unknown session {}".format(session_id))
|
||
return
|
||
del self.store.sessions[session_id]
|
||
self._ok({"deleted": session_id})
|
||
|
||
def _list_messages(self, session_id: str, query) -> None:
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
# 契约 v0.63 的会话级 404(ChatViewModel.kt:1808 按这个码判「会话没了」)。
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
messages = [dict(m) for m in session.messages]
|
||
|
||
limit_raw = (query.get("limit") or [None])[0]
|
||
before_raw = (query.get("before") or [None])[0]
|
||
after_raw = (query.get("after") or [None])[0]
|
||
platform = (query.get("platform") or [None])[0]
|
||
search = (query.get("q") or [None])[0]
|
||
|
||
limit = DEFAULT_PAGE_SIZE
|
||
if limit_raw not in (None, ""):
|
||
try:
|
||
limit = int(limit_raw)
|
||
except ValueError:
|
||
self._fail(400, "bad_request", "limit must be an integer")
|
||
return
|
||
limit = max(1, min(limit, MAX_PAGE_SIZE))
|
||
|
||
before = self._parse_cursor(before_raw, "before")
|
||
if before is _BAD:
|
||
return
|
||
after = self._parse_cursor(after_raw, "after")
|
||
if after is _BAD:
|
||
return
|
||
if after is not None and before is not None:
|
||
self._fail(400, "bad_request", "after and before are mutually exclusive")
|
||
return
|
||
if after is not None and search:
|
||
self._fail(400, "bad_request", "after and q are mutually exclusive")
|
||
return
|
||
|
||
if platform:
|
||
messages = [m for m in messages if m.get("platform") == platform]
|
||
if search:
|
||
needle = search.lower()
|
||
messages = [m for m in messages if needle in (m.get("content") or "").lower()]
|
||
|
||
if after is not None:
|
||
# 正向增量:created_at > after,**升序**(契约 §1 行 6)。
|
||
page = [m for m in sorted(messages, key=lambda m: m["createdAt"]) if m["createdAt"] > after][:limit]
|
||
elif before is not None:
|
||
# 倒序分页:created_at < before,**最新在前**。
|
||
page = [m for m in sorted(messages, key=lambda m: m["createdAt"], reverse=True) if m["createdAt"] < before][
|
||
:limit
|
||
]
|
||
else:
|
||
page = sorted(messages, key=lambda m: m["createdAt"], reverse=True)[:limit]
|
||
|
||
self._ok(page)
|
||
|
||
def _parse_cursor(self, raw, name: str):
|
||
if raw in (None, ""):
|
||
return None
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
self._fail(400, "bad_request", "{} must be a unix-millis integer".format(name))
|
||
return _BAD
|
||
|
||
def _mark_read(self, session_id: str) -> None:
|
||
body, raw = self._parse_json_body()
|
||
if raw is not None and body is None:
|
||
self._fail(400, "bad_request", "invalid json body")
|
||
return
|
||
before = None
|
||
if isinstance(body, dict) and body.get("before") is not None:
|
||
candidate = body.get("before")
|
||
if not isinstance(candidate, int) or candidate <= 0:
|
||
self._fail(400, "bad_request", "before must be a positive unix-millis integer")
|
||
return
|
||
before = candidate
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
# 游标只前进(SQL MAX(read_at, before)),重复 / 乱序都幂等安全。
|
||
session.read_at = max(session.read_at, before if before is not None else now_ms())
|
||
read_at = session.read_at
|
||
# 契约 §3.9:成功后广播 session_read,载荷里**只有 readAt**,会话归属在帧壳上。
|
||
self.store.broadcast(
|
||
{"type": "session_read", "sessionId": session_id, "data": {"readAt": read_at}},
|
||
"session_read",
|
||
)
|
||
self._ok({"sessionId": session_id, "readAt": read_at})
|
||
|
||
def _update_message(self, message_id: str) -> None:
|
||
body, raw = self._parse_json_body()
|
||
if not isinstance(body, dict):
|
||
self._fail(400, "bad_request", "invalid json body")
|
||
return
|
||
content = body.get("content")
|
||
if not isinstance(content, str) or not content.strip():
|
||
self._fail(400, "bad_request", "content is required")
|
||
return
|
||
if len(content) > MESSAGE_CONTENT_MAX_RUNES:
|
||
self._fail(400, "bad_request", "content too long")
|
||
return
|
||
session, message = self.store.find_message(message_id)
|
||
if message is None:
|
||
self._fail(404, "not_found", "unknown message {}".format(message_id))
|
||
return
|
||
message["content"] = content
|
||
log("http", "edited {} -> {}".format(message_id, json.dumps(content, ensure_ascii=False)))
|
||
# 契约 §3.8:data **就是**编辑后的完整消息 DTO,没有 message 包一层(WsEvent.kt:678)。
|
||
self.store.broadcast(
|
||
{"type": "message_edited", "sessionId": session.id, "data": message},
|
||
"message_edited",
|
||
)
|
||
self._ok(message)
|
||
|
||
def _delete_message(self, message_id: str) -> None:
|
||
with self.store.lock:
|
||
for session in self.store.sessions.values():
|
||
for index, message in enumerate(session.messages):
|
||
if message["id"] == message_id:
|
||
del session.messages[index]
|
||
self.store.broadcast(
|
||
{"type": "message_deleted", "sessionId": session.id, "data": {"id": message_id}},
|
||
"message_deleted",
|
||
)
|
||
self._ok({"deleted": message_id})
|
||
return
|
||
self._fail(404, "not_found", "unknown message {}".format(message_id))
|
||
|
||
def _rest_chat(self) -> None:
|
||
"""`POST /api/v1/chat`(ChatApi.kt:21)——**额外**支持的写路径,形状照 ChatAckDto。"""
|
||
body, raw = self._parse_json_body()
|
||
if not isinstance(body, dict):
|
||
self._fail(400, "bad_request", "invalid json body")
|
||
return
|
||
session_id = body.get("sessionId") or MAIN_SESSION_ID
|
||
if session_id not in self.store.sessions:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
if self.store.hold_replies:
|
||
# 挂起:回执形状逐字不变(`ChatAckDto`),但那条消息**没有**落库、也**没有**广播
|
||
# message_persisted(这是默认路径里的第二个广播点)。`messageId` 是挂起时先占的号,
|
||
# 它会在 /__control/persist 那一刻真的进库。
|
||
hold = self.store.hold_message(
|
||
session_id,
|
||
"user",
|
||
body.get("text", ""),
|
||
client_msg_id=body.get("clientMsgId"),
|
||
mode=body.get("mode"),
|
||
)
|
||
log(
|
||
"hold",
|
||
"rest chat 挂起 {} messageId={}(等 /__control/persist)".format(hold["holdId"], hold["messageId"]),
|
||
)
|
||
if self.store.args.auto_reply:
|
||
send_reply_round(self.store, session_id, "收到:{}".format(body.get("text", "")[:20]))
|
||
self._ok(
|
||
{
|
||
"stream": True,
|
||
"queued": False,
|
||
"queueDepth": 0,
|
||
"messageId": hold["messageId"],
|
||
"sessionId": session_id,
|
||
},
|
||
status=202,
|
||
)
|
||
return
|
||
message = self.store.add_message(
|
||
session_id,
|
||
"user",
|
||
body.get("text", ""),
|
||
client_msg_id=body.get("clientMsgId"),
|
||
mode=body.get("mode"),
|
||
)
|
||
self.store.broadcast(
|
||
{"type": "message_persisted", "sessionId": session_id, "data": {"message": message}},
|
||
"message_persisted",
|
||
)
|
||
if self.store.args.auto_reply:
|
||
send_reply_round(self.store, session_id, "收到:{}".format(body.get("text", "")[:20]))
|
||
self._ok(
|
||
{
|
||
"stream": True,
|
||
"queued": False,
|
||
"queueDepth": 0,
|
||
"messageId": message["id"],
|
||
"sessionId": session_id,
|
||
},
|
||
status=202,
|
||
)
|
||
|
||
# —— /__control/** ——
|
||
|
||
def _handle_control(self, method: str, path: str, query) -> None:
|
||
action = path[len("/__control/") :].strip("/")
|
||
|
||
if action == "state" and method in ("GET", "POST"):
|
||
self._ok(self.store.state())
|
||
return
|
||
|
||
if action == "upstream" and method in ("GET", "POST"):
|
||
limit = self._int_param(query, "limit", 20)
|
||
with self.store.lock:
|
||
records = self.store.upstream_records[-limit:]
|
||
self._ok({"count": len(records), "records": records})
|
||
return
|
||
|
||
if action == "requests" and method in ("GET", "POST"):
|
||
limit = self._int_param(query, "limit", 20)
|
||
records = []
|
||
if os.path.exists(self.store.http_log.path):
|
||
with open(self.store.http_log.path, "r", encoding="utf-8") as handle:
|
||
records = [json.loads(line) for line in handle if line.strip()]
|
||
self._ok({"count": len(records), "records": records[-limit:]})
|
||
return
|
||
|
||
if action == "reset" and method in ("GET", "POST"):
|
||
self.store.reset()
|
||
log("ctl ", "state reset to seed")
|
||
self._ok({"reset": True})
|
||
return
|
||
|
||
if action == "messages" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
count = self._int_param(query, "count", 1)
|
||
role = self._param(query, "role", "user")
|
||
prefix = self._param(query, "prefix", "seed")
|
||
template = self._param(query, "text", None)
|
||
platform = self._param(query, "platform", None)
|
||
voice_kind = self._param(query, "voice", None)
|
||
image_file = self._param(query, "image_file", None)
|
||
try:
|
||
voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS))
|
||
except KeyError:
|
||
self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none")
|
||
return
|
||
try:
|
||
image = image_fixture(image_file) if image_file else None
|
||
except (OSError, ValueError) as error:
|
||
self._fail(400, "bad_request", "image_file unusable: {}".format(error))
|
||
return
|
||
try:
|
||
documents = documents_fixture(self._param(query, "documents", None))
|
||
except KeyError:
|
||
self._fail(400, "bad_request", "documents must be one of extracted/listed/unreadable/none")
|
||
return
|
||
if session_id not in self.store.sessions:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
first_id = last_id = None
|
||
for index in range(1, count + 1):
|
||
# `text=` **空串**与「不带 text」是两件事:前者是「就要一条没有正文的消息」
|
||
# (纯附件 / 纯图 / 无转写的语音都靠它造),后者走原来的 `seed #N` 默认正文。
|
||
content = template.format(n=index) if template is not None else "{} #{}".format(prefix, index)
|
||
message = self.store.add_message(
|
||
session_id,
|
||
role,
|
||
content,
|
||
platform=platform,
|
||
voice=voice,
|
||
image=image,
|
||
documents=documents,
|
||
)
|
||
first_id = first_id or message["id"]
|
||
last_id = message["id"]
|
||
with self.store.lock:
|
||
session = self.store.sessions[session_id]
|
||
total = len(session.messages)
|
||
oldest = session.messages[0]["createdAt"]
|
||
newest = session.messages[-1]["createdAt"]
|
||
log("ctl ", "seeded {} msgs into {} (total={})".format(count, session_id, total))
|
||
self._ok(
|
||
{
|
||
"session": session_id,
|
||
"added": count,
|
||
"total": total,
|
||
"firstId": first_id,
|
||
"lastId": last_id,
|
||
"oldestCreatedAt": oldest,
|
||
"newestCreatedAt": newest,
|
||
}
|
||
)
|
||
return
|
||
|
||
if action == "message" and method in ("GET", "POST"):
|
||
# 取某一条(1 基下标),用来在脚本里**精确**拿游标:例如「回放里最旧那条的前一条」。
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
index = self._int_param(query, "index", 1)
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
total = len(session.messages)
|
||
if index < 1 or index > total:
|
||
self._fail(404, "not_found", "index {} out of range (1..{})".format(index, total))
|
||
return
|
||
message = dict(session.messages[index - 1])
|
||
self._ok({"session": session_id, "index": index, "total": total, "message": message})
|
||
return
|
||
|
||
if action == "busy" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
mode = self._param(query, "mode", "thinking")
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
session.busy_mode = mode
|
||
sent = self.store.broadcast(
|
||
{"type": "session_busy", "sessionId": session_id, "data": {"sessionId": session_id, "mode": mode}},
|
||
"control:busy",
|
||
)
|
||
self._ok({"session": session_id, "mode": mode, "delivered": sent})
|
||
return
|
||
|
||
if action == "idle" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
mode = self._param(query, "mode", "idle")
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
session.busy_mode = None
|
||
sent = self.store.broadcast(
|
||
{"type": "session_idle", "sessionId": session_id, "data": {"sessionId": session_id, "mode": mode}},
|
||
"control:idle",
|
||
)
|
||
self._ok({"session": session_id, "mode": mode, "delivered": sent})
|
||
return
|
||
|
||
if action == "reply" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
text = self._param(query, "text", "这是一条来自假后端的回复。")
|
||
chunks = self._int_param(query, "chunks", 3)
|
||
mode = self._param(query, "frame", "default")
|
||
persist = self._param(query, "persist", "1") not in ("0", "false", "no")
|
||
if mode not in ("default", "chunk", "structured"):
|
||
self._fail(400, "bad_request", "frame must be default|chunk|structured")
|
||
return
|
||
if session_id not in self.store.sessions:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
result = send_reply_round(self.store, session_id, text, chunks=chunks, mode=mode, persist=persist)
|
||
result["delivered"] = len(self.store.clients)
|
||
self._ok(result)
|
||
return
|
||
|
||
if action == "interrupted" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
mode = self._param(query, "mode", "idle")
|
||
dropped = self._int_param(query, "dropped", 0)
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
session.busy_mode = None
|
||
sent = self.store.broadcast(
|
||
{
|
||
"type": "session_interrupted",
|
||
"sessionId": session_id,
|
||
"data": {"sessionId": session_id, "mode": mode, "dropped": dropped},
|
||
},
|
||
"control:interrupted",
|
||
)
|
||
self._ok({"session": session_id, "mode": mode, "dropped": dropped, "delivered": sent})
|
||
return
|
||
|
||
if action == "hold-replies" and method in ("GET", "POST"):
|
||
# 运行期开关(等价于启动参数 --hold-replies),用来在同一个进程里做「先挂着 → 再落库」两段。
|
||
on = self._param(query, "on", "1") not in ("0", "false", "no")
|
||
self.store.hold_replies = on
|
||
with self.store.lock:
|
||
held = len(self.store.held_messages)
|
||
log("ctl ", "hold-replies {} (held={})".format("on" if on else "off", held))
|
||
self._ok({"holdReplies": on, "held": held})
|
||
return
|
||
|
||
if action == "persist" and method in ("GET", "POST"):
|
||
# 显式补上挂起那一刻没做的那次落库:进库 + 广播 message_persisted(与默认路径同一形状)。
|
||
session_id = self._param(query, "session", None)
|
||
ident = self._param(query, "id", None)
|
||
index = self._int_param(query, "index", None)
|
||
candidates = self.store.held_for(session_id)
|
||
if ident:
|
||
candidates = [
|
||
hold
|
||
for hold in candidates
|
||
if ident in (hold["holdId"], hold["messageId"], hold.get("clientMsgId"))
|
||
]
|
||
elif index is not None:
|
||
if index < 1 or index > len(candidates):
|
||
self._fail(404, "not_found", "index {} out of range (1..{})".format(index, len(candidates)))
|
||
return
|
||
candidates = [candidates[index - 1]]
|
||
else:
|
||
candidates = candidates[:1] # 不给选择器就按 FIFO 落最旧的那条
|
||
if not candidates:
|
||
self._fail(
|
||
404,
|
||
"no_held_message",
|
||
"no held message matches (hold-replies={}, held={}); see GET /__control/state".format(
|
||
self.store.hold_replies, len(self.store.held_messages)
|
||
),
|
||
)
|
||
return
|
||
hold = candidates[0]
|
||
message = self.store.persist_held_message(hold)
|
||
sent = self.store.broadcast(
|
||
{"type": "message_persisted", "sessionId": message["sessionId"], "data": {"message": message}},
|
||
"message_persisted",
|
||
)
|
||
log("ctl ", "persisted {} as {} (delivered={})".format(hold["holdId"], message["id"], sent))
|
||
self._ok(
|
||
{
|
||
"persisted": True,
|
||
"session": message["sessionId"],
|
||
"holdId": hold["holdId"],
|
||
"messageId": message["id"],
|
||
"clientMsgId": hold.get("clientMsgId"),
|
||
"delivered": sent,
|
||
"remaining": len(self.store.held_messages),
|
||
"message": message,
|
||
}
|
||
)
|
||
return
|
||
|
||
if action == "pending-full" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
mode = self._param(query, "mode", "thinking")
|
||
maximum = self._int_param(query, "max", 5)
|
||
client_msg_id = self._param(query, "clientMsgId", None)
|
||
message_id = self._param(query, "messageId", None)
|
||
data = {"sessionId": session_id, "mode": mode, "max": maximum}
|
||
# 两个可选键**整键缺席**,绝不填空串(WsEvent.kt:798)。
|
||
if client_msg_id:
|
||
data["clientMsgId"] = client_msg_id
|
||
if message_id:
|
||
data["messageId"] = message_id
|
||
sent = self.store.broadcast(
|
||
{"type": "pending_full", "sessionId": session_id, "data": data},
|
||
"control:pending_full",
|
||
)
|
||
self._ok({"delivered": sent, "data": data})
|
||
return
|
||
|
||
if action == "error" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
code = self._param(query, "code", "chat_busy")
|
||
message = self._param(query, "message", "session queue is full")
|
||
client_msg_id = self._param(query, "clientMsgId", None)
|
||
message_id = self._param(query, "messageId", None)
|
||
data = {"code": code, "message": message, "sessionId": session_id}
|
||
if client_msg_id:
|
||
data["clientMsgId"] = client_msg_id
|
||
if message_id:
|
||
data["messageId"] = message_id
|
||
sent = self.store.broadcast({"type": "error", "data": data}, "control:error")
|
||
self._ok({"delivered": sent, "data": data})
|
||
return
|
||
|
||
if action == "frame" and method in ("GET", "POST"):
|
||
body, raw = self._parse_json_body()
|
||
payload = body
|
||
inline = self._param(query, "json", None)
|
||
if payload is None and inline:
|
||
try:
|
||
payload = json.loads(inline)
|
||
except json.JSONDecodeError as exc:
|
||
self._fail(400, "bad_request", "json param is not valid json: {}".format(exc))
|
||
return
|
||
if payload is None and isinstance(raw, str):
|
||
self._fail(400, "bad_request", "body must be a json frame: {}".format(raw))
|
||
return
|
||
if not isinstance(payload, dict) or not payload.get("type"):
|
||
self._fail(400, "bad_request", "a frame needs at least {\"type\": ...}")
|
||
return
|
||
sent = self.store.broadcast(payload, "control:frame")
|
||
self._ok({"delivered": sent, "frame": payload})
|
||
return
|
||
|
||
if action == "forget" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", None)
|
||
if not session_id:
|
||
self._fail(400, "bad_request", "session is required")
|
||
return
|
||
with self.store.lock:
|
||
if session_id not in self.store.sessions:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
del self.store.sessions[session_id]
|
||
log("ctl ", "forgot session {} (its /messages now 404s)".format(session_id))
|
||
self._ok({"forgot": session_id})
|
||
return
|
||
|
||
if action == "create" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", None)
|
||
if not session_id:
|
||
self._fail(400, "bad_request", "session is required")
|
||
return
|
||
kind = self._param(query, "type", "sub")
|
||
title = self._param(query, "title", None)
|
||
pinned = self._param(query, "pinned", "0") not in ("0", "false", "no")
|
||
persona_id = self._param(query, "personaId", None)
|
||
with self.store.lock:
|
||
self.store.sessions[session_id] = Session(
|
||
session_id,
|
||
kind,
|
||
now_ms(),
|
||
title=title,
|
||
pinned=pinned,
|
||
persona_id=persona_id,
|
||
)
|
||
self._ok({"session": session_id, "type": kind})
|
||
return
|
||
|
||
if action == "clear" and method in ("GET", "POST"):
|
||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||
with self.store.lock:
|
||
session = self.store.sessions.get(session_id)
|
||
if session is None:
|
||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||
return
|
||
session.messages = []
|
||
session.read_at = 0
|
||
self._ok({"cleared": session_id})
|
||
return
|
||
|
||
if action == "ws-close" and method in ("GET", "POST"):
|
||
code = self._int_param(query, "code", 1001)
|
||
reason = self._param(query, "reason", "fake backend closed the socket")
|
||
with self.store.lock:
|
||
targets = list(self.store.clients)
|
||
for client in targets:
|
||
client.send_close(code, reason)
|
||
log("ctl ", "closed {} client sockets (code={})".format(len(targets), code))
|
||
self._ok({"closed": len(targets), "code": code})
|
||
return
|
||
|
||
self._fail(404, "not_found", "no such control action: {}".format(action))
|
||
|
||
def _param(self, query, name, default):
|
||
values = query.get(name)
|
||
if not values:
|
||
return default
|
||
return values[0]
|
||
|
||
def _int_param(self, query, name, default):
|
||
raw = self._param(query, name, None)
|
||
if raw in (None, ""):
|
||
return default
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
return default
|
||
|
||
|
||
_BAD = object() # _parse_cursor 的失败哨兵
|
||
|
||
|
||
class Server(ThreadingHTTPServer):
|
||
daemon_threads = True
|
||
allow_reuse_address = True
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
prog="fake_backend.py",
|
||
description="Cyrene 可编程假后端(开发用测试台,不是产品的一部分)",
|
||
)
|
||
parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
|
||
parser.add_argument("--port", type=int, default=18080, help="监听端口(默认 18080;别用真后端的 10942)")
|
||
parser.add_argument(
|
||
"--history-replay",
|
||
type=int,
|
||
default=50,
|
||
dest="history_replay",
|
||
help="WS 握手 history 帧回放的条数(默认 50,与真后端一致)。比库里的条数小就是那个 after 洞",
|
||
)
|
||
parser.add_argument(
|
||
"--stream-chunk",
|
||
action="store_true",
|
||
dest="stream_chunk",
|
||
help="回复走旧形状 stream_chunk + stream_end(用来验客户端那条回退分支)",
|
||
)
|
||
parser.add_argument(
|
||
"--auto-reply",
|
||
action="store_true",
|
||
dest="auto_reply",
|
||
help="收到上行 chat 帧后自动回一轮(默认关:不自动回,保持确定性)",
|
||
)
|
||
parser.add_argument(
|
||
"--interrupt-ack",
|
||
action="store_true",
|
||
dest="interrupt_ack",
|
||
help="收到上行 interrupt 帧后回一帧 session_interrupted{mode:idle,dropped:0}(默认关)",
|
||
)
|
||
parser.add_argument(
|
||
"--hold-replies",
|
||
action="store_true",
|
||
dest="hold_replies",
|
||
help="上行消息**只记录、不落库**:收到 chat / POST /api/v1/chat 后不广播 message_persisted,"
|
||
"由 POST /__control/persist 显式补落库(默认关,默认行为逐字不变)。用来复现「忙时被拒 / 排队 / 队列满」",
|
||
)
|
||
parser.add_argument(
|
||
"--out-dir",
|
||
default=os.path.join(HERE, "out"),
|
||
dest="out_dir",
|
||
help="记录目录(默认 <脚本目录>/out):upstream.jsonl / downstream.jsonl / http.jsonl",
|
||
)
|
||
parser.add_argument(
|
||
"--keep-records",
|
||
type=int,
|
||
default=1000,
|
||
dest="keep_records",
|
||
help="内存里保留的上行帧条数上限(默认 1000;文件里是全量)",
|
||
)
|
||
return parser
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
args = build_parser().parse_args(argv)
|
||
store = Store(args)
|
||
Handler.store = store
|
||
|
||
server = Server((args.host, args.port), Handler)
|
||
host, port = server.server_address[0], server.server_address[1]
|
||
print("=" * 72)
|
||
print("Cyrene 假后端已启动 http://{}:{}".format(host, port))
|
||
print(" WS ws://{}:{}/ws/chat?token=<任意>".format(host, port))
|
||
print(" history 握手回放 {} 条(--history-replay)".format(args.history_replay))
|
||
print(" 回复形状 {}".format("stream_chunk 旧形状" if args.stream_chunk else "message.* 结构化"))
|
||
print(" 自动回复 {} / interrupt 回执 {}".format(args.auto_reply, args.interrupt_ack))
|
||
print(
|
||
" 上行落库 {}".format(
|
||
"挂起(--hold-replies:等 POST /__control/persist 才落库)"
|
||
if args.hold_replies
|
||
else "立即(收到即落库 + message_persisted)"
|
||
)
|
||
)
|
||
print(" 记录目录 {}".format(args.out_dir))
|
||
print(" 控制台 GET /__control/state")
|
||
print("=" * 72)
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\nbye")
|
||
finally:
|
||
server.server_close()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|