diff --git a/.env.example b/.env.example index a19bde5..840a81e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # DS2API runtime +# Runtime listen port inside the app/container PORT=5001 +# Docker Compose host port (compose only; container still listens on PORT) +DS2API_HOST_PORT=6011 LOG_LEVEL=INFO # Admin authentication diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 8f1d865..ef509eb 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.24.x" + go-version: "1.26.x" - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 80f89aa..b964a99 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.24.x" + go-version: "1.26.x" - name: Setup Node uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index d096b58..e6fd9d9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ config.json *.swo *~ .DS_Store +opencode.json # Logs *.log diff --git a/API.en.md b/API.en.md index 73d6eaa..efdc5b7 100644 --- a/API.en.md +++ b/API.en.md @@ -52,8 +52,7 @@ cp config.example.json config.json Use it per deployment mode: - Local run: read `config.json` directly -- Docker / Vercel: generate Base64 from `config.json`, then set `DS2API_CONFIG_JSON` -- Compatibility note: `DS2API_CONFIG_JSON` may also contain raw JSON directly; `CONFIG_JSON` is the legacy fallback variable +- Docker / Vercel: generate Base64 from `config.json`, then set `DS2API_CONFIG_JSON`, or paste raw JSON directly ```bash DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" @@ -356,7 +355,8 @@ data: [DONE] ``` If `tool_choice=required` is violated in stream mode, DS2API emits `response.failed` then `[DONE]` (no `response.completed`). -Unknown tool names (outside declared `tools`) are rejected and will not be emitted as valid tool calls. + +> Current behavior: the parser tries to extract structured tool calls and does not enforce a hard allow-list reject; your tool executor should still validate against a whitelist before executing. ### `GET /v1/responses/{response_id}` @@ -642,8 +642,9 @@ Reads runtime settings and status, including: - `success` - `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`) - `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`) +- `compat` (`wide_input_strict_output`, `strip_reference_markers`) - `responses` / `embeddings` -- `auto_delete` (`sessions`) +- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`) - `claude_mapping` / `model_aliases` - `env_backed`, `needs_vercel_sync` - `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings @@ -654,9 +655,10 @@ Hot-updates runtime settings. Supported fields: - `admin.jwt_expire_hours` - `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours` +- `compat.wide_input_strict_output` / `compat.strip_reference_markers` - `responses.store_ttl_seconds` - `embeddings.provider` -- `auto_delete.sessions` +- `auto_delete.mode` - `claude_mapping` - `model_aliases` - `toolcall` policy is fixed and is no longer writable through settings @@ -684,6 +686,8 @@ The request can send config directly, or wrapped as `{"config": {...}, "mode":"m Query params `?mode=merge` / `?mode=replace` are also supported. Import accepts `keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. +> `compat` fields are managed via `/admin/settings` or the config file; this import endpoint does not update `compat`. + ### `GET /admin/config/export` Exports full config in three forms: `config`, `json`, and `base64`. @@ -758,17 +762,25 @@ Returned items also include `test_status`, usually `ok` or `failed`. "available_accounts": ["a@example.com"], "in_use_accounts": ["b@example.com"], "max_inflight_per_account": 2, - "recommended_concurrency": 8 + "global_max_inflight": 8, + "recommended_concurrency": 8, + "waiting": 0, + "max_queue_size": 8 } ``` | Field | Description | | --- | --- | -| `available` | Currently available accounts | -| `in_use` | Currently in-use accounts | +| `available` | Accounts that still have spare inflight capacity | +| `in_use` | Number of occupied in-flight slots | | `total` | Total accounts | +| `available_accounts` | List of account IDs with remaining inflight capacity | +| `in_use_accounts` | List of account IDs currently in use | | `max_inflight_per_account` | Per-account inflight limit | +| `global_max_inflight` | Global inflight limit | | `recommended_concurrency` | Suggested concurrency (`total × max_inflight_per_account`) | +| `waiting` | Number of queued requests currently waiting | +| `max_queue_size` | Waiting queue limit | ### `POST /admin/accounts/test` diff --git a/API.md b/API.md index 061529e..dd6dcb0 100644 --- a/API.md +++ b/API.md @@ -52,8 +52,7 @@ cp config.example.json config.json 按部署方式使用: - 本地运行:直接读取 `config.json` -- Docker / Vercel:从 `config.json` 生成 Base64,填入 `DS2API_CONFIG_JSON` -- 兼容写法:`DS2API_CONFIG_JSON` 也可直接填原始 JSON;`CONFIG_JSON` 是旧版兼容回退变量 +- Docker / Vercel:从 `config.json` 生成 Base64,填入 `DS2API_CONFIG_JSON`,也可以直接填原始 JSON ```bash DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')" @@ -509,8 +508,6 @@ data: {"type":"message_stop"} } ``` -返回项还会包含 `test_status`,当前值通常为 `ok` 或 `failed`。 - --- ## Gemini 兼容接口 @@ -651,8 +648,9 @@ data: {"type":"message_stop"} - `success` - `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`) - `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`) +- `compat`(`wide_input_strict_output`、`strip_reference_markers`) - `responses` / `embeddings` -- `auto_delete`(`sessions`) +- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理) - `claude_mapping` / `model_aliases` - `env_backed`、`needs_vercel_sync` - `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改 @@ -663,9 +661,10 @@ data: {"type":"message_stop"} - `admin.jwt_expire_hours` - `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours` +- `compat.wide_input_strict_output` / `compat.strip_reference_markers` - `responses.store_ttl_seconds` - `embeddings.provider` -- `auto_delete.sessions` +- `auto_delete.mode` - `claude_mapping` - `model_aliases` - `toolcall` 策略已固定,不再作为可写入字段 @@ -693,6 +692,8 @@ data: {"type":"message_stop"} 也支持在查询参数里传 `?mode=merge` / `?mode=replace`。 导入时会接受 `keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 +> `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。 + ### `GET /admin/config/export` 导出完整配置,返回 `config`、`json`、`base64` 三种格式。 @@ -765,17 +766,25 @@ data: {"type":"message_stop"} "available_accounts": ["a@example.com"], "in_use_accounts": ["b@example.com"], "max_inflight_per_account": 2, - "recommended_concurrency": 8 + "global_max_inflight": 8, + "recommended_concurrency": 8, + "waiting": 0, + "max_queue_size": 8 } ``` | 字段 | 说明 | | --- | --- | -| `available` | 当前可用账号数 | -| `in_use` | 当前使用中的账号数 | +| `available` | 仍有剩余并发槽位的账号数 | +| `in_use` | 当前已占用的 in-flight 槽位数 | | `total` | 总账号数 | +| `available_accounts` | 仍有剩余并发槽位的账号 ID 列表 | +| `in_use_accounts` | 当前处于使用中的账号 ID 列表 | | `max_inflight_per_account` | 每账号并发上限 | +| `global_max_inflight` | 全局并发上限 | | `recommended_concurrency` | 建议并发值(`total × max_inflight_per_account`) | +| `waiting` | 当前等待中的请求数 | +| `max_queue_size` | 等待队列上限 | ### `POST /admin/accounts/test` diff --git a/README.MD b/README.MD index 7696bfc..cf5f5c4 100644 --- a/README.MD +++ b/README.MD @@ -76,7 +76,7 @@ flowchart LR - **前端**:React 管理台(`webui/`),运行时托管静态构建产物 - **部署**:本地运行、Docker、Vercel Serverless、Linux systemd -### 3.0 底层架构调整(相较旧版本) +### 3.X 底层架构调整(相较旧版本) - **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。 - **统一执行链路**:Claude / Gemini 入口先经 `internal/translatorcliproxy` 做协议转换,再进入 `openai.ChatCompletions` 统一处理工具调用与流式语义,最后再转换回原协议响应。 @@ -111,7 +111,6 @@ flowchart LR | P0 | Anthropic SDK(messages) | ✅ | | P0 | Google Gemini SDK(generateContent) | ✅ | | P1 | LangChain / LlamaIndex / OpenWebUI(OpenAI 兼容接入) | ✅ | -| P2 | MCP 独立桥接层 | 规划中 | ## 模型支持 @@ -160,8 +159,7 @@ cp config.example.json config.json 后续部署建议: - 本地运行:直接读取 `config.json` -- Docker / Vercel:由 `config.json` 生成 `DS2API_CONFIG_JSON`(Base64)注入环境变量 -- 兼容写法:`DS2API_CONFIG_JSON` 也可以直接写原始 JSON;`CONFIG_JSON` 是旧版回退变量 +- Docker / Vercel:由 `config.json` 生成 `DS2API_CONFIG_JSON`(Base64)注入环境变量,也可以直接写原始 JSON ### 方式一:本地运行 @@ -191,7 +189,7 @@ go run ./cmd/ds2api cp .env.example .env cp config.example.json config.json -# 2. 编辑 .env(至少设置 DS2API_ADMIN_KEY) +# 2. 编辑 .env(至少设置 DS2API_ADMIN_KEY;如需修改宿主机端口,可额外设置 DS2API_HOST_PORT) # DS2API_ADMIN_KEY=请替换为强密码 # 3. 启动 @@ -201,7 +199,7 @@ docker-compose up -d docker-compose logs -f ``` -默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请调整 `ports` 配置。 +默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。 更新镜像:`docker-compose up -d --build` @@ -290,7 +288,8 @@ cp opencode.json.example opencode.json "o3": "deepseek-reasoner" }, "compat": { - "wide_input_strict_output": true + "wide_input_strict_output": true, + "strip_reference_markers": true }, "responses": { "store_ttl_seconds": 900 @@ -312,7 +311,7 @@ cp opencode.json.example opencode.json "token_refresh_interval_hours": 6 }, "auto_delete": { - "sessions": false + "mode": "none" } } ``` @@ -322,13 +321,14 @@ cp opencode.json.example opencode.json - `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token);实际 token 仅在运行时内存中维护并自动刷新 - `model_aliases`:常见模型名(如 GPT/Codex/Claude)到 DeepSeek 模型的映射 - `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出) -- `toolcall`:策略已固定为特征匹配 + 高置信早发,不再作为可配置项 +- `compat.strip_reference_markers`:建议保持 `true`,用于清理可见输出中的引用/标记 +- `toolcall`:旧字段,当前实现已固定为特征匹配 + 高置信早发;即使保留在配置里也会被忽略 - `responses.store_ttl_seconds`:`/v1/responses/{id}` 的内存缓存 TTL - `embeddings.provider`:embedding 提供方(当前内置 `deterministic/mock/builtin`) - `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`) - `admin`:管理后台设置(JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新 - `runtime`:运行时参数(并发限制、队列大小、托管账号 token 刷新间隔),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算,`token_refresh_interval_hours=6` 为默认强制重登间隔 -- `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新) +- `auto_delete.mode`:请求结束后如何清理 DeepSeek 远端聊天记录,支持 `none`(默认,不删除)、`single`(仅删除当前会话)、`all`(清空全部会话);旧配置里的 `auto_delete.sessions=true` 仍会被视为 `all` ### 环境变量 @@ -341,7 +341,6 @@ cp opencode.json.example opencode.json | `DS2API_JWT_EXPIRE_HOURS` | Admin JWT 过期小时数 | `24` | | `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` | | `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) | — | -| `CONFIG_JSON` | 旧版兼容配置注入 | — | | `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on`) | 关闭 | | `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 | | `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` | @@ -350,22 +349,16 @@ cp opencode.json.example opencode.json | `DS2API_DEV_PACKET_CAPTURE_LIMIT` | 本地抓包保留条数(超出自动淘汰) | `5` | | `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | 单条响应体最大记录字节数 | `2097152` | | `DS2API_ACCOUNT_MAX_INFLIGHT` | 每账号最大并发 in-flight 请求数 | `2` | -| `DS2API_ACCOUNT_CONCURRENCY` | 同上(兼容旧名) | — | | `DS2API_ACCOUNT_MAX_QUEUE` | 等待队列上限 | `recommended_concurrency` | -| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容旧名) | — | | `DS2API_GLOBAL_MAX_INFLIGHT` | 全局最大 in-flight 请求数 | `recommended_concurrency` | -| `DS2API_MAX_INFLIGHT` | 同上(兼容旧名) | — | | `DS2API_VERCEL_INTERNAL_SECRET` | Vercel 混合流式内部鉴权密钥 | 回退用 `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease 过期秒数 | `900` | -| `DS2API_DEV_PACKET_CAPTURE` | 本地开发抓包开关(记录最近会话请求/响应体) | 本地非 Vercel 默认开启 | -| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | 本地抓包保留条数(超出自动淘汰) | `5` | -| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | 单条响应体最大记录字节数 | `2097152` | | `VERCEL_TOKEN` | Vercel 同步 token | — | | `VERCEL_PROJECT_ID` | Vercel 项目 ID | — | | `VERCEL_TEAM_ID` | Vercel 团队 ID | — | | `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — | -> 提示:当检测到 `DS2API_CONFIG_JSON/CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。 +> 提示:当检测到 `DS2API_CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。 ## 鉴权模式 @@ -455,6 +448,7 @@ ds2api/ │ ├── deepseek/ # DeepSeek API 客户端、PoW WASM │ ├── js/ # Node 运行时流式处理与兼容逻辑 │ ├── devcapture/ # 开发抓包模块 +│ ├── rawsample/ # 原始流样本可见文本提取与回放辅助 │ ├── format/ # 输出格式化 │ ├── prompt/ # Prompt 构建 │ ├── server/ # HTTP 路由与中间件(chi router) @@ -476,6 +470,7 @@ ds2api/ ├── tests/ │ ├── compat/ # 兼容性测试夹具与期望输出 │ ├── node/ # Node 侧单元测试(chat-stream / tool-sieve) +│ ├── raw_stream_samples/ # 原始 SSE 样本与回放元数据 │ └── scripts/ # 统一测试脚本入口(unit/e2e) ├── docs/ # 部署 / 贡献 / 测试等辅助文档 ├── static/admin/ # WebUI 构建产物(不提交到 Git) diff --git a/README.en.md b/README.en.md index 65096ef..106ebe2 100644 --- a/README.en.md +++ b/README.en.md @@ -76,7 +76,7 @@ flowchart LR - **Frontend**: React admin panel (`webui/`), served as static build at runtime - **Deployment**: local run, Docker, Vercel serverless, Linux systemd -### 3.0 Architecture Changes (vs older releases) +### 3.X Architecture Changes (vs older releases) - **Unified routing core**: all protocol entries are now centralized through `internal/server/router.go`, with OpenAI / Claude / Gemini / Admin / WebUI routes registered in one tree to avoid multi-entry drift. - **Unified execution chain**: Claude/Gemini entries are translated by `internal/translatorcliproxy`, then executed through `openai.ChatCompletions` for shared tool-calling and stream semantics, then translated back to the client protocol. @@ -111,7 +111,6 @@ flowchart LR | P0 | Anthropic SDK (messages) | ✅ | | P0 | Google Gemini SDK (generateContent) | ✅ | | P1 | LangChain / LlamaIndex / OpenWebUI (OpenAI-compatible integration) | ✅ | -| P2 | MCP standalone bridge | Planned | ## Model Support @@ -160,8 +159,7 @@ cp config.example.json config.json Recommended per deployment mode: - Local run: read `config.json` directly -- Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON` -- Compatibility note: `DS2API_CONFIG_JSON` may also contain raw JSON directly; `CONFIG_JSON` is the legacy fallback variable +- Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON`, or paste raw JSON directly ### Option 1: Local Run @@ -191,7 +189,7 @@ Default URL: `http://localhost:5001` cp .env.example .env cp config.example.json config.json -# 2. Edit .env (at least set DS2API_ADMIN_KEY) +# 2. Edit .env (at least set DS2API_ADMIN_KEY; optionally set DS2API_HOST_PORT to change the host port) # DS2API_ADMIN_KEY=replace-with-a-strong-secret # 3. Start @@ -201,7 +199,7 @@ docker-compose up -d docker-compose logs -f ``` -The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, adjust the `ports` mapping. +The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping). Rebuild after updates: `docker-compose up -d --build` @@ -290,7 +288,8 @@ cp opencode.json.example opencode.json "o3": "deepseek-reasoner" }, "compat": { - "wide_input_strict_output": true + "wide_input_strict_output": true, + "strip_reference_markers": true }, "responses": { "store_ttl_seconds": 900 @@ -312,7 +311,7 @@ cp opencode.json.example opencode.json "token_refresh_interval_hours": 6 }, "auto_delete": { - "sessions": false + "mode": "none" } } ``` @@ -322,13 +321,14 @@ cp opencode.json.example opencode.json - `token`: Even if set in `config.json`, it is cleared during load (DS2API does not read persisted tokens from config); runtime tokens are maintained/refreshed in memory only - `model_aliases`: Map common model names (GPT/Codex/Claude) to DeepSeek models - `compat.wide_input_strict_output`: Keep `true` (current default policy) -- `toolcall`: Fixed to feature matching + high-confidence early emit, no longer configurable +- `compat.strip_reference_markers`: Keep `true`; it strips reference markers from visible output +- `toolcall`: Legacy field; the current behavior is fixed to feature matching + high-confidence early emit, and any config value is ignored - `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}` - `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in) - `claude_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models (still compatible with `claude_model_mapping`) - `admin`: Admin panel settings (JWT expiry, password hash, etc.), hot-reloadable via Admin Settings API - `runtime`: Runtime parameters (concurrency limits, queue sizes, managed token refresh interval), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values, `token_refresh_interval_hours=6` is the default forced re-login interval -- `auto_delete.sessions`: Whether to auto-delete DeepSeek sessions after request completion (default `false`, hot-reloadable via Settings) +- `auto_delete.mode`: How to clean up DeepSeek remote chat records after each request completes. Supported values: `none` (default, no deletion), `single` (delete only the current session), `all` (delete all sessions); legacy `auto_delete.sessions=true` is still treated as `all` ### Environment Variables @@ -341,17 +341,13 @@ cp opencode.json.example opencode.json | `DS2API_JWT_EXPIRE_HOURS` | Admin JWT TTL in hours | `24` | | `DS2API_CONFIG_PATH` | Config file path | `config.json` | | `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — | -| `CONFIG_JSON` | Legacy compatibility config input | — | | `DS2API_ENV_WRITEBACK` | Auto-write env-backed config to file and transition to file mode (`1/true/yes/on`) | Disabled | | `DS2API_WASM_PATH` | PoW WASM file path | Auto-detect | | `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` | | `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel | | `DS2API_ACCOUNT_MAX_INFLIGHT` | Max in-flight requests per account | `2` | -| `DS2API_ACCOUNT_CONCURRENCY` | Alias (legacy compat) | — | | `DS2API_ACCOUNT_MAX_QUEUE` | Waiting queue limit | `recommended_concurrency` | -| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — | | `DS2API_GLOBAL_MAX_INFLIGHT` | Global max in-flight requests | `recommended_concurrency` | -| `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — | | `DS2API_VERCEL_INTERNAL_SECRET` | Vercel hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL seconds | `900` | | `DS2API_DEV_PACKET_CAPTURE` | Local dev packet capture switch (record recent request/response bodies) | Enabled by default on non-Vercel local runtime | @@ -362,7 +358,7 @@ cp opencode.json.example opencode.json | `VERCEL_TEAM_ID` | Vercel team ID | — | | `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel deployment protection bypass for internal Node→Go calls | — | -> Note: when `DS2API_CONFIG_JSON/CONFIG_JSON` is detected, the Admin UI shows mode risk and auto-persistence status (including `DS2API_CONFIG_PATH` and mode-transition hints). +> Note: when `DS2API_CONFIG_JSON` is detected, the Admin UI shows mode risk and auto-persistence status (including `DS2API_CONFIG_PATH` and mode-transition hints). ## Authentication Modes @@ -449,6 +445,7 @@ ds2api/ │ ├── deepseek/ # DeepSeek API client, PoW WASM │ ├── js/ # Node runtime stream/compat logic │ ├── devcapture/ # Dev packet capture module +│ ├── rawsample/ # Visible-text extraction and replay helpers for raw stream samples │ ├── format/ # Output formatting │ ├── prompt/ # Prompt construction │ ├── server/ # HTTP routing and middleware (chi router) @@ -470,6 +467,7 @@ ds2api/ ├── tests/ │ ├── compat/ # Compatibility fixtures and expected outputs │ ├── node/ # Node-side unit tests (chat-stream / tool-sieve) +│ ├── raw_stream_samples/ # Raw SSE samples and replay metadata │ └── scripts/ # Unified test script entrypoints (unit/e2e) ├── docs/ # Deployment / contributing / testing docs ├── static/admin/ # WebUI build output (not committed to Git) diff --git a/app/handler.go b/app/handler.go index a8979fd..bc26a67 100644 --- a/app/handler.go +++ b/app/handler.go @@ -3,9 +3,17 @@ package app import ( "net/http" + "ds2api/internal/config" "ds2api/internal/server" ) func NewHandler() http.Handler { - return server.NewApp().Router + app, err := server.NewApp() + if err != nil { + config.Logger.Error("[app] init failed", "error", err) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.WriteUnhandledError(w, err) + }) + } + return app.Router } diff --git a/cmd/ds2api/main.go b/cmd/ds2api/main.go index 1466d1c..a081a48 100644 --- a/cmd/ds2api/main.go +++ b/cmd/ds2api/main.go @@ -18,9 +18,17 @@ import ( ) func main() { + if err := config.LoadDotEnv(); err != nil { + config.Logger.Warn("[dotenv] load failed", "error", err) + } + config.RefreshLogger() webui.EnsureBuiltOnStartup() _ = auth.AdminKey() - app := server.NewApp() + app, err := server.NewApp() + if err != nil { + config.Logger.Error("server initialization failed", "error", err) + os.Exit(1) + } port := strings.TrimSpace(os.Getenv("PORT")) if port == "" { port = "5001" diff --git a/config.example.json b/config.example.json index 7871b6b..f914050 100644 --- a/config.example.json +++ b/config.example.json @@ -28,11 +28,8 @@ "o3": "deepseek-reasoner" }, "compat": { - "wide_input_strict_output": true - }, - "toolcall": { - "mode": "feature_match", - "early_emit_confidence": "high" + "wide_input_strict_output": true, + "strip_reference_markers": true }, "responses": { "store_ttl_seconds": 900 @@ -50,9 +47,10 @@ "runtime": { "account_max_inflight": 2, "account_max_queue": 0, - "global_max_inflight": 0 + "global_max_inflight": 0, + "token_refresh_interval_hours": 6 }, "auto_delete": { - "sessions": false + "mode": "none" } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 39cb6b5..c147349 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -16,7 +16,8 @@ services: container_name: ds2api-dev command: ["go", "run", "./cmd/ds2api"] ports: - - "${PORT:-5001}:${PORT:-5001}" + # Host port is configurable via DS2API_HOST_PORT; container port stays fixed at 5001. + - "${DS2API_HOST_PORT:-6011}:5001" env_file: - .env environment: diff --git a/docker-compose.yml b/docker-compose.yml index e5e2ff1..9398fdc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,16 @@ services: ds2api: image: ghcr.io/cjackhwang/ds2api:latest - container_name: ds2api - restart: always - ports: - - "6011:5001" - volumes: - - ./config.json:/app/config.json # 配置文件 - - ./.env:/app/.env # 环境变量 - environment: - - TZ=Asia/Shanghai - - LOG_LEVEL=INFO - - DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY:-ds2api} + container_name: ds2api + restart: always + env_file: + - .env + ports: + # Host port is configurable via DS2API_HOST_PORT; container port stays fixed at 5001. + - "${DS2API_HOST_PORT:-6011}:5001" + volumes: + - ./config.json:/app/config.json # 配置文件 + environment: + - TZ=Asia/Shanghai + - LOG_LEVEL=INFO + - DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY:-ds2api} diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index 2ac7b7b..be2a86d 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -32,7 +32,6 @@ Config source (choose one): - **File**: `config.json` (recommended for local/Docker) - **Environment variable**: `DS2API_CONFIG_JSON` (recommended for Vercel; supports raw JSON or Base64) -- Compatibility note: `CONFIG_JSON` is the legacy fallback variable; `DS2API_CONFIG_JSON` may also contain raw JSON directly Unified recommendation (best practice): @@ -117,6 +116,8 @@ cp config.example.json config.json # Edit .env and set at least: # DS2API_ADMIN_KEY=your-admin-key +# Optionally set the host port: +# DS2API_HOST_PORT=6011 # Start docker-compose up -d @@ -125,7 +126,7 @@ docker-compose up -d docker-compose logs -f ``` -The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, adjust the `ports` mapping. +The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping). ### 2.2 Update @@ -138,7 +139,7 @@ docker-compose up -d --build The `Dockerfile` now provides two image paths: 1. **Default local/dev path (`runtime-from-source`)**: a three-stage build (WebUI build + Go build + runtime). -2. **Release path (`runtime-from-dist`)**: CI first creates `dist/ds2api__linux_.tar.gz`, then Docker directly reuses the binary and `static/admin` assets from those release archives, without running `npm build`/`go build` again. +2. **Release path (`runtime-from-dist`)**: the release workflow first creates tag-named release archives, then copies the Linux bundles to `dist/docker-input/linux_amd64.tar.gz` / `linux_arm64.tar.gz`; Docker consumes those prepared inputs directly, without rerunning `npm build`/`go build`. The release path keeps Docker images aligned with release archives and reduces duplicate build work. @@ -198,10 +199,10 @@ Notes: 2. **Import** the project on Vercel 3. **Set environment variables** (minimum required: one variable): - | Variable | Description | - | --- | --- | - | `DS2API_ADMIN_KEY` | Admin key (required) | - | `DS2API_CONFIG_JSON` | Config content, raw JSON or Base64 (optional, recommended) | +| Variable | Description | +| --- | --- | +| `DS2API_ADMIN_KEY` | Admin key (required) | +| `DS2API_CONFIG_JSON` | Config content, raw JSON or Base64 (optional, recommended) | 4. **Deploy** @@ -244,11 +245,8 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts | Variable | Description | Default | | --- | --- | --- | | `DS2API_ACCOUNT_MAX_INFLIGHT` | Per-account inflight limit | `2` | -| `DS2API_ACCOUNT_CONCURRENCY` | Alias (legacy compat) | — | | `DS2API_ACCOUNT_MAX_QUEUE` | Waiting queue limit | `recommended_concurrency` | -| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — | | `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` | -| `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — | | `DS2API_ENV_WRITEBACK` | When `DS2API_CONFIG_JSON` is present, auto-write to `DS2API_CONFIG_PATH` and switch to file-backed mode after success (`1/true/yes/on`) | Disabled | | `DS2API_VERCEL_INTERNAL_SECRET` | Hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` | @@ -314,7 +312,7 @@ Error: Command failed: go build -ldflags -s -w -o .../bootstrap ... 1. Open Vercel Project Settings → Build and Development Settings 2. **Clear** custom Go Build Flags / Build Command (recommended) 3. If ldflags must be used, set `-ldflags="-s -w"` (ensure it's one argument) -4. Verify `go.mod` uses a supported version (currently `go 1.24`) +4. Verify `go.mod` uses a supported version (currently `go 1.26.0`) 5. Redeploy (recommended: clear cache) #### Internal Package Import Error diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 4df1194..618ca4b 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -31,8 +31,7 @@ 配置来源(任选其一): - **文件方式**:`config.json`(推荐本地/Docker 使用) -- **环境变量方式**:`DS2API_CONFIG_JSON`(推荐 Vercel 使用,支持 JSON 字符串或 Base64 编码) -- 兼容写法:`CONFIG_JSON` 是旧版回退变量;`DS2API_CONFIG_JSON` 也可以直接写原始 JSON +- **环境变量方式**:`DS2API_CONFIG_JSON`(推荐 Vercel 使用,支持 JSON 字符串或 Base64 编码,也可以直接写原始 JSON) 统一建议(最优实践): @@ -117,6 +116,8 @@ cp config.example.json config.json # 编辑 .env(请改成你的强密码),至少设置: # DS2API_ADMIN_KEY=your-admin-key +# 如需修改宿主机端口,可额外设置: +# DS2API_HOST_PORT=6011 # 启动 docker-compose up -d @@ -125,7 +126,7 @@ docker-compose up -d docker-compose logs -f ``` -默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请调整 `ports` 配置。 +默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。 ### 2.2 更新 @@ -138,7 +139,7 @@ docker-compose up -d --build `Dockerfile` 提供两条构建路径: 1. **本地/开发默认路径(`runtime-from-source`)**:三阶段构建(WebUI 构建 + Go 构建 + 运行阶段)。 -2. **Release 路径(`runtime-from-dist`)**:CI 先生成 `dist/ds2api__linux_.tar.gz`,再由 Docker 直接复用该发布包内的二进制和 `static/admin` 产物组装运行镜像,不再重复执行 `npm build`/`go build`。 +2. **Release 路径(`runtime-from-dist`)**:发布工作流先生成 tag 命名的 Release 压缩包,再把 Linux 产物复制成 `dist/docker-input/linux_amd64.tar.gz` / `linux_arm64.tar.gz`;Docker 构建阶段直接消费这些输入,不再重复执行 `npm build`/`go build`。 Release 路径可确保 Docker 镜像与 release 压缩包使用同一套产物,减少重复构建带来的差异。 @@ -198,10 +199,10 @@ healthcheck: 2. **在 Vercel 上导入项目** 3. **配置环境变量**(最少只需设置以下一项): - | 变量 | 说明 | - | --- | --- | - | `DS2API_ADMIN_KEY` | 管理密钥(必填) | - | `DS2API_CONFIG_JSON` | 配置内容,JSON 字符串或 Base64 编码(可选,建议) | +| 变量 | 说明 | +| --- | --- | +| `DS2API_ADMIN_KEY` | 管理密钥(必填) | +| `DS2API_CONFIG_JSON` | 配置内容,JSON 字符串或 Base64 编码(可选,建议) | 4. **部署** @@ -244,11 +245,8 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空 | 变量 | 说明 | 默认值 | | --- | --- | --- | | `DS2API_ACCOUNT_MAX_INFLIGHT` | 每账号并发上限 | `2` | -| `DS2API_ACCOUNT_CONCURRENCY` | 同上(兼容别名) | — | | `DS2API_ACCOUNT_MAX_QUEUE` | 等待队列上限 | `recommended_concurrency` | -| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — | | `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` | -| `DS2API_MAX_INFLIGHT` | 同上(兼容别名) | — | | `DS2API_ENV_WRITEBACK` | 检测到 `DS2API_CONFIG_JSON` 时自动写入 `DS2API_CONFIG_PATH`,并在成功后转为文件模式(`1/true/yes/on`) | 关闭 | | `DS2API_VERCEL_INTERNAL_SECRET` | 混合流式内部鉴权 | 回退用 `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` | @@ -314,7 +312,7 @@ Error: Command failed: go build -ldflags -s -w -o .../bootstrap ... 1. 进入 Vercel Project Settings → Build and Development Settings 2. **清空**自定义 Go Build Flags / Build Command(推荐) 3. 若必须设置 ldflags,使用 `-ldflags="-s -w"`(保证它是一个参数) -4. 确认仓库 `go.mod` 为受支持版本(当前为 `go 1.24`) +4. 确认仓库 `go.mod` 为受支持版本(当前为 `go 1.26.0`) 5. 重新部署(建议清缓存后 Redeploy) #### Internal 包导入错误 diff --git a/docs/DeepSeekSSE流格式字段分析-2026-04-03.md b/docs/DeepSeekSSE流格式字段分析-2026-04-03.md deleted file mode 100644 index 689f3f8..0000000 --- a/docs/DeepSeekSSE流格式字段分析-2026-04-03.md +++ /dev/null @@ -1,82 +0,0 @@ -# DeepSeek SSE 流格式字段分析(2026-04-03) - -> 日期:2026-04-03(UTC) -> -> 样本:`tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/upstream.stream.sse` -> -> 模型:`deepseek-reasoner-search`(搜索 + 思考) - -## 1. SSE 事件层结构 - -原始流由标准 SSE 帧组成,常见形态: - -```text -event: -data: - -``` - -样本中主要 `event` 类型: - -- `ready`:流建立后返回请求/响应消息 ID。 -- `update_session`:会话时间戳更新。 -- `finish`:流式阶段结束。 -- (无 `event` 时)默认为 message 事件,`data:` 中承载主要增量数据。 - -## 2. `data` JSON 常见字段 - -上游增量主体多为 JSON Patch 风格对象: - -- `p`(path):字段路径,如 `response/fragments/-1/content`。 -- `o`(op,可选):操作类型,常见 `SET` / `APPEND` / `BATCH`。 -- `v`(value):值(字符串、布尔、对象、数组都可能)。 - -示例(语义): - -- `{"p":"response/fragments/-1/content","o":"APPEND","v":"..."}` -- `{"p":"response/fragments/-16/status","v":"FINISHED"}` -- `{"p":"response/status","o":"SET","v":"FINISHED"}` - -## 3. 搜索+思考场景关键路径 - -### 3.1 文本内容 - -- `response/fragments//content` -- `response/content` -- `response/thinking_content` -- `response/fragments`(`APPEND` + fragment 数组) - -### 3.2 搜索相关 - -- `response/fragments//results`(检索结果数组) -- `response/search_status`(检索状态,建议跳过展示) - -### 3.3 状态相关(重点) - -- `response/status = FINISHED`:**最终结束信号**(需要保留用于结束判定) -- `response/fragments//status = FINISHED`:**分片级状态**(高频,建议跳过输出) -- `response/quasi_status`:过程状态(建议跳过输出) - -## 4. 泄露问题根因(FINISHED 重复) - -在搜索 + 思考模型中,`response/fragments//status` 会出现大量不同 ``(例如 `-1/-2/-3/-16...`)的 `FINISHED`。 - -若只过滤固定少量索引(例如仅 `-1/-2/-3`),其他索引的状态会当普通文本透传,导致前端出现: - -- `FINISHEDFINISHEDFINISHED...` - -## 5. 适配建议(已落地) - -1. 跳过所有 `response/fragments/-?\d+/status`。 -2. 继续保留 `response/status=FINISHED` 作为真正结束判定。 -3. 通过独立仿真工具持续回放全部样本,作为回归门禁: - -```bash -./tests/scripts/run-raw-stream-sim.sh -``` - -## 6. 后续扩展建议 - -- 增加不同模型(`deepseek-chat-search` / 非 search / 非 thinking)样本。 -- 增加异常样本(限流、中断、content_filter、空结果)。 -- 为仿真报告加入字段覆盖率统计(路径频次、事件频次、终止路径命中率)。 diff --git a/docs/DeepSeekSSE行为结构说明-2026-04-05.md b/docs/DeepSeekSSE行为结构说明-2026-04-05.md new file mode 100644 index 0000000..dce0ba7 --- /dev/null +++ b/docs/DeepSeekSSE行为结构说明-2026-04-05.md @@ -0,0 +1,283 @@ +# DeepSeek SSE 行为结构说明(第三方逆向版) + +> 说明:本文基于当前仓库 `tests/raw_stream_samples/` 下全部 `upstream.stream.sse` 原始流样本整理而成,属于第三方逆向观察文档,不是官方协议。 +> 当前 corpus 由 4 份原始流组成,覆盖搜索+引用、风控终态、Markdown 输出和空格敏感输出等行为。 + +## 1. 样本覆盖 + +下列样本共同构成了本文的观察基础: + +| 样本 | 观察重点 | +| --- | --- | +| [guangzhou-weather-reasoner-search-20260404](../tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse) | 搜索+思考流程,包含 `reference:N` 引用标记与工具片段 | +| [content-filter-trigger-20260405-jwt3](../tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse) | `CONTENT_FILTER` 终态分支,包含拒答模板与 `ban_regenerate` | +| [markdown-format-example-20260405](../tests/raw_stream_samples/markdown-format-example-20260405/upstream.stream.sse) | Markdown 输出的早期样本,用于观察 token 级输出形态 | +| [markdown-format-example-20260405-spacefix](../tests/raw_stream_samples/markdown-format-example-20260405-spacefix/upstream.stream.sse) | Markdown 输出修正样本,用于验证空格 chunk 必须保留 | + +当前 corpus 的整体特征是 `message` 帧占绝对多数,控制事件只占很小一部分,但它们决定了流的生命周期和最终状态。 + +## 2. 总体结构 + +DeepSeek 的这类输出可以分成两层看: + +1. SSE 事件层。 +2. JSON 载荷层。 + +事件层负责传输边界,载荷层负责业务状态。实现时不要把 HTTP chunk、SSE block 和业务 JSON 混为一体。 + +最常见的时序可以概括为: + +```text +ready +update_session +message(初始化 envelope) +message(正文 / 片段 / 状态增量) +message(状态收口) +finish +update_session +title +close +``` + +`finish` 表示生成流结束,但不是唯一的终止信号;真正的语义终态通常还要结合 `response/status`、`quasi_status` 和 `close` 一起判断。 + +## 3. SSE 事件层 + +当前 corpus 中观察到的事件类型如下: + +| 事件 | 作用 | 处理建议 | +| --- | --- | --- | +| `ready` | 传输层就绪,通常携带 `request_message_id`、`response_message_id`、`model_type` | 记录元数据即可,不参与正文拼接 | +| `update_session` | 会话时间戳或心跳更新 | 当作会话状态帧处理 | +| `message` | 主体载荷,绝大多数业务信息都在这里 | 必须按顺序解析并保序累积 | +| `finish` | 生成阶段结束 | 作为流结束标记之一 | +| `title` | 会话标题生成结果 | 元数据帧,不参与正文拼接 | +| `close` | 连接关闭信息 | 仅用于收尾与审计 | + +说明: + +- `message` 是默认事件名,SSE 中没有显式 `event:` 时也应按 `message` 处理。 +- 目前样本里大量 `message` 帧没有独立的业务前缀,不能靠事件名区分正文和控制帧。 +- 可能出现空 payload 的 `message` 帧;它们应被视为 no-op,但不能打乱事件顺序。 + +## 4. 载荷层形态 + +`message` 的 `data:` 部分不是单一 schema,而是多种结构混合。当前 corpus 里主要见到以下几种形态: + +| 形态 | 典型结构 | 作用 | +| --- | --- | --- | +| 初始化 envelope | `{"v":{"response":{...}}}` | 给出会话初始状态、模型状态和片段容器 | +| 纯文本 token | `{"v":"..."}` | 直接输出可见文本 token | +| 路径补丁 | `{"p":"...","o":"APPEND|SET|BATCH","v":...}` | 对某个状态路径做增量更新 | +| 终态 batch | `{"v":[{"p":"status","v":"CONTENT_FILTER"}, ...]}` | 尾部状态收口,常见于风控终态 | + +一个简化后的典型样式如下: + +```json +{"v":"输出"} +{"p":"response/fragments/-1/content","o":"APPEND","v":"..."} +{"p":"response/fragments","o":"APPEND","v":[...]} +{"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":211},{"p":"quasi_status","v":"FINISHED"}]} +{"p":"response/status","o":"SET","v":"FINISHED"} +``` + +注意: + +- `v` 可能是字符串、对象、数组、布尔值或数字。 +- `o` 当前样本里主要见到 `APPEND`、`SET`、`BATCH`。 +- `v` 为数组时,通常表示一个批量 patch 集合,而不是正文数组。 + +## 5. 初始化 envelope + +每条流开头,常会先出现一个 `message` 帧,内容是完整的 `response` 初始状态。当前 corpus 中,这个 envelope 常见字段包括: + +- `message_id` +- `parent_id` +- `model` +- `role` +- `thinking_enabled` +- `ban_edit` +- `ban_regenerate` +- `status` +- `incomplete_message` +- `accumulated_token_usage` +- `files` +- `feedback` +- `inserted_at` +- `search_enabled` +- `fragments` +- `conversation_mode` +- `has_pending_fragment` +- `auto_continue` +- `search_triggered` + +这些字段更像会话状态和策略开关,不是正文内容。第三方实现应把它们保留在内部状态树里,而不是直接拼接到最终答案。 + +## 6. 路径结构 + +当前 corpus 里观察到的 `p` 路径可以归成几组: + +### 6.1 片段级路径 + +- `response/fragments/-N/content` +- `response/fragments/-N/status` +- `response/fragments/-N/results` +- `response/fragments/-N/elapsed_secs` + +这类路径表示某个片段对象的增量更新。`-N` 只是样本中的索引风格,不应被写死成固定数量。 + +### 6.2 片段容器路径 + +- `response/fragments` +- `fragments` + +这两类路径通常承载 fragment 数组。前者更像响应树中的分支,后者更像终态批处理里的片段集合。 + +### 6.3 语义状态路径 + +- `response/status` +- `response/has_pending_fragment` +- `quasi_status` +- `status` +- `ban_regenerate` + +这类路径决定流是否结束、是否被风控、是否还有待处理片段。它们不应作为正文输出。 + +### 6.4 统计与进度路径 + +- `accumulated_token_usage` + +这类路径用于使用量或进度统计,属于元数据。 + +### 6.5 非命名空间字段 + +在片段对象内部,还会看到 `content`、`references`、`result`、`queries`、`stage_id` 等字段。它们不一定带 `response/...` 前缀,但仍然是协议语义的一部分。 + +## 7. fragment 类型 + +当前 corpus 里已经观察到的 fragment 类型如下: + +| 类型 | 作用 | 是否应直接渲染 | +| --- | --- | --- | +| `RESPONSE` | 正常回答片段 | 是,属于正文 | +| `THINK` | 推理或阶段提示 | 通常否,按产品策略决定是否展示 | +| `TOOL_SEARCH` | 搜索工具调用元数据 | 否 | +| `TOOL_OPEN` | 打开 / 抽取结果的工具元数据 | 否 | +| `TIP` | 提示 / 警告类片段,常带 `style: WARNING` | 视产品策略决定,通常作为附注 | +| `TEMPLATE_RESPONSE` | 风控拒答模板 | 是,但它属于终态 fallback,不是普通正文 | + +观察到的典型片段字段: + +- `id` +- `type` +- `content` +- `references` +- `stage_id` +- `status` +- `queries` +- `results` +- `result` +- `elapsed_secs` +- `style` +- `hide_on_wip` + +第三方实现不要把 `fragment.type` 和 `p` 路径混为一谈。`type` 是语义分类,`p` 是状态树位置。 + +## 8. 终态行为 + +当前 corpus 里有两条很重要的终态分支。 + +### 8.1 正常完成 + +正常回答通常会出现如下收口顺序: + +1. `response` 的 `BATCH` 更新 `accumulated_token_usage`。 +2. `response` 的 `BATCH` 或单独 patch 更新 `quasi_status: FINISHED`。 +3. `response/status` 置为 `FINISHED`。 +4. `finish` 事件到来。 +5. 之后可能还有 `update_session`、`title`、`close`。 + +### 8.2 风控终态 + +`content-filter-trigger-20260405-jwt3` 展示了另一种终态路径: + +1. 先继续输出一段正常正文。 +2. 出现提示类 fragment,例如 `TIP`。 +3. 可能先把 `quasi_status` 提前收口到 `FINISHED`。 +4. 之后出现一个终态 batch,把 `ban_regenerate` 设为 `true`,把 `status` 置为 `CONTENT_FILTER`,并附带 `TEMPLATE_RESPONSE`。 +5. 最后再出现 `finish`,然后是收尾事件。 + +这个分支说明: + +- `finish` 不等于正常结束。 +- `CONTENT_FILTER` 是一个独立终态,不是普通异常。 +- `TEMPLATE_RESPONSE` 不应被当作常规回答流的中间片段,它是终态 fallback。 + +一个简化的风控尾部可以写成: + +```json +{"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":1269},{"p":"quasi_status","v":"FINISHED"}]} +{"v":[{"p":"ban_regenerate","v":true},{"p":"status","v":"CONTENT_FILTER"},{"p":"fragments","v":[{"id":38,"type":"TEMPLATE_RESPONSE","content":"..."}]},{"p":"quasi_status","v":"CONTENT_FILTER"}]} +{"event":"finish"} +``` + +## 9. 文本重建规则 + +如果你的目标是把流重建成最终可见文本,必须遵守下面这些规则: + +- 按接收顺序逐个追加 token。 +- 不要对每个 `v` 做 `trim` 或 `TrimSpace`。 +- 不要丢弃只包含空格的 chunk。 +- 不要合并连续空格、换行或 Markdown 符号附近的空白。 +- 不要把 `[reference:N]` 视为协议元数据,它在当前 corpus 里就是正文的一部分。 +- 如果你要屏蔽引用标记,应当把它做成可配置的后处理,而不是在解析阶段硬删。 + +这点对 Markdown、代码块、引用、表格都很关键。样本里已经证明,`#`、`-`、`>`、`|` 这类符号后面的空格必须原样保留,否则渲染结果会变形。 + +## 10. 推荐实现方式 + +对第三方开发者,建议把实现拆成三条线: + +1. 原始事件线:保留 SSE block 顺序、事件名和完整 JSON 载荷。 +2. 状态树线:维护 `response`、`fragments`、`status`、`quasi_status` 等结构。 +3. 可见文本线:只从明确应渲染的 token / fragment 中拼接最终文本。 + +一个简单的处理顺序可以是: + +```text +parse SSE block + -> 识别 event + -> 解析 JSON payload + -> 更新状态树 + -> 判定是否有可见文本 + -> 追加到输出缓冲 + -> 遇到 FINISHED / CONTENT_FILTER / finish 时收口 +``` + +实现时的兼容原则: + +- 未知路径保留,不要报错中断。 +- 未知 fragment.type 保留在日志里。 +- 不要假设所有模型都一定输出 `thinking_content`,当前 corpus 的推理更多是通过 fragment 类型表达。 +- 不要假设 `title` 一定存在,它只是后置元数据。 + +## 11. 本 corpus 证明了什么 + +当前样本足以证明以下行为: + +- 搜索类模型会把工具调用、结果、引用和正文混在同一条 SSE 流里。 +- 风控不会简单地“没有输出”,而是会在正常生成后切换到 `CONTENT_FILTER` 终态。 +- Markdown 和代码输出对空格非常敏感,空格 chunk 不能吞。 +- `message` 是主体承载层,`ready` / `update_session` / `finish` / `title` / `close` 是控制层。 +- `fragment.type` 是可视化和工具链分层的关键,不应只靠 `p` 路径判断。 + +## 12. 适用边界 + +本文是基于当前 corpus 的逆向说明,不是恒定协议。 + +- 新模型可能增加新的 `p` 路径。 +- 新版本可能增加新的 fragment.type。 +- `CONTENT_FILTER` 的终态模板内容可能变化。 +- 解析器应当对未知字段、未知路径、未知事件保持容忍。 + +如果你要把这份说明用于实际开发,建议同时保留原始流样本、回放脚本和回归测试,不要只依赖本文。 diff --git a/docs/TESTING.md b/docs/TESTING.md index de940f0..6975de5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -233,9 +233,32 @@ go run ./cmd/ds2api-tests --no-preflight ``` 说明: -- 该工具会重放 `tests/raw_stream_samples` 下全部样本,按上游 SSE 顺序做 1:1 仿真解析。 +- 该工具默认重放 `tests/raw_stream_samples/manifest.json` 声明的 canonical 样本,按上游 SSE 顺序做 1:1 仿真解析。 - 默认校验不出现 `FINISHED` 文本泄露,并要求存在结束信号。 -- 结果会写入 `artifacts/raw-stream-sim/*.json`,可供其他测试脚本或排障流程复用。 +- 每次运行都会把本地派生结果写入 `artifacts/raw-stream-sim///replay.output.txt`,并输出结构化报告。 +- 如果你有历史基线目录,可以通过 `--baseline-root` 让工具直接做文本对比。 +- 更完整的协议级行为结构说明见 [DeepSeekSSE行为结构说明-2026-04-05.md](./DeepSeekSSE行为结构说明-2026-04-05.md)。 + +### 对单个样本做回放比对 + +```bash +./tests/scripts/compare-raw-stream-sample.sh markdown-format-example-20260405-spacefix +``` + +说明: +- 该脚本会从 raw-only 样本目录读取 `upstream.stream.sse`。 +- 回放结果会写入 `artifacts/raw-stream-sim///`,便于直接查阅。 +- 如果传入历史基线目录,脚本会自动对比当前回放输出和基线文本。 + +### 采集永久样本 + +本地启动服务后,可以直接打: + +```bash +POST /admin/dev/raw-samples/capture +``` + +这个接口会把请求元信息和上游原始流写入 `tests/raw_stream_samples//`,以后可以直接拿来做回放和字段分析。派生输出会在本地回放时再生成,不再落在样本目录里。 ### 指定输出目录和超时 diff --git a/go.mod b/go.mod index 0d3452e..89b9297 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module ds2api go 1.26.0 require ( - github.com/andybalholm/brotli v1.0.6 - github.com/go-chi/chi/v5 v5.2.3 + github.com/andybalholm/brotli v1.2.1 + github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 github.com/refraction-networking/utls v1.8.2 - github.com/tetratelabs/wazero v1.9.0 + github.com/router-for-me/CLIProxyAPI/v6 v6.9.14 + github.com/tetratelabs/wazero v1.11.0 ) require ( - github.com/klauspost/compress v1.17.4 // indirect - github.com/router-for-me/CLIProxyAPI/v6 v6.9.8 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 09822f7..a5fdfe1 100644 --- a/go.sum +++ b/go.sum @@ -1,47 +1,45 @@ -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= -github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= -github.com/router-for-me/CLIProxyAPI/v6 v6.9.8 h1:O65R38THenp8E1IK0paQlOfop3Y6UYlfqSdLlepidSY= -github.com/router-for-me/CLIProxyAPI/v6 v6.9.8/go.mod h1:P1jsIPFXorYGuS2N/3BlZYkpRKi/z7+oR3+1tdG0u4k= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= -github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/router-for-me/CLIProxyAPI/v6 v6.9.14 h1:XItUHrPGE9E5xTeZIPjKGmKqfEs1AZbxl1RPfO5xtrc= +github.com/router-for-me/CLIProxyAPI/v6 v6.9.14/go.mod h1:P1jsIPFXorYGuS2N/3BlZYkpRKi/z7+oR3+1tdG0u4k= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/account/pool_edge_test.go b/internal/account/pool_edge_test.go index 6e90823..d8bff26 100644 --- a/internal/account/pool_edge_test.go +++ b/internal/account/pool_edge_test.go @@ -13,9 +13,7 @@ import ( func TestPoolEmptyNoAccounts(t *testing.T) { t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "2") - t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "") t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "") - t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "") t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`) pool := NewPool(config.LoadStore()) if _, ok := pool.Acquire("", nil); ok { @@ -165,9 +163,7 @@ func TestPoolAcquireWaitTargetAccount(t *testing.T) { func TestPoolMaxQueueSizeOverride(t *testing.T) { t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1") - t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "") t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "5") - t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "") t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"acc1@example.com","token":"t1"}]}`) pool := NewPool(config.LoadStore()) status := pool.Status() @@ -176,19 +172,6 @@ func TestPoolMaxQueueSizeOverride(t *testing.T) { } } -func TestPoolQueueSizeAliasEnv(t *testing.T) { - t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1") - t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "") - t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "") - t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "7") - t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"acc1@example.com","token":"t1"}]}`) - pool := NewPool(config.LoadStore()) - status := pool.Status() - if got, ok := status["max_queue_size"].(int); !ok || got != 7 { - t.Fatalf("expected max_queue_size=7, got %#v", status["max_queue_size"]) - } -} - func TestPoolMultipleAcquireReleaseCycles(t *testing.T) { pool := newSingleAccountPoolForTest(t, "1") for i := 0; i < 10; i++ { diff --git a/internal/account/pool_limits.go b/internal/account/pool_limits.go index 0f0854f..2ddbaf4 100644 --- a/internal/account/pool_limits.go +++ b/internal/account/pool_limits.go @@ -29,13 +29,8 @@ func (p *Pool) ApplyRuntimeLimits(maxInflightPerAccount, maxQueueSize, globalMax } func maxInflightFromEnv() int { - for _, key := range []string{"DS2API_ACCOUNT_MAX_INFLIGHT", "DS2API_ACCOUNT_CONCURRENCY"} { - raw := strings.TrimSpace(os.Getenv(key)) - if raw == "" { - continue - } - n, err := strconv.Atoi(raw) - if err == nil && n > 0 { + if raw := strings.TrimSpace(os.Getenv("DS2API_ACCOUNT_MAX_INFLIGHT")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { return n } } @@ -53,13 +48,8 @@ func defaultRecommendedConcurrency(accountCount, maxInflightPerAccount int) int } func maxQueueFromEnv(defaultSize int) int { - for _, key := range []string{"DS2API_ACCOUNT_MAX_QUEUE", "DS2API_ACCOUNT_QUEUE_SIZE"} { - raw := strings.TrimSpace(os.Getenv(key)) - if raw == "" { - continue - } - n, err := strconv.Atoi(raw) - if err == nil && n >= 0 { + if raw := strings.TrimSpace(os.Getenv("DS2API_ACCOUNT_MAX_QUEUE")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n >= 0 { return n } } diff --git a/internal/account/pool_test.go b/internal/account/pool_test.go index 89bef64..279cef4 100644 --- a/internal/account/pool_test.go +++ b/internal/account/pool_test.go @@ -12,9 +12,7 @@ import ( func newPoolForTest(t *testing.T, maxInflight string) *Pool { t.Helper() t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", maxInflight) - t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "") t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "") - t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "") t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], "accounts":[ @@ -29,9 +27,7 @@ func newPoolForTest(t *testing.T, maxInflight string) *Pool { func newSingleAccountPoolForTest(t *testing.T, maxInflight string) *Pool { t.Helper() t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", maxInflight) - t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "") t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "") - t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "") t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], "accounts":[{"email":"acc1@example.com","token":"token1"}] @@ -170,9 +166,9 @@ func TestPoolStatusRecommendedConcurrencyRespectsOverride(t *testing.T) { } } -func TestPoolAccountConcurrencyAliasEnv(t *testing.T) { - t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "") - t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "4") +func TestPoolGlobalMaxInflightEnv(t *testing.T) { + t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1") + t.Setenv("DS2API_GLOBAL_MAX_INFLIGHT", "4") t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], "accounts":[ @@ -183,15 +179,15 @@ func TestPoolAccountConcurrencyAliasEnv(t *testing.T) { pool := NewPool(config.LoadStore()) status := pool.Status() - if got, ok := status["max_inflight_per_account"].(int); !ok || got != 4 { + if got, ok := status["global_max_inflight"].(int); !ok || got != 4 { + t.Fatalf("unexpected global_max_inflight: %#v", status["global_max_inflight"]) + } + if got, ok := status["max_inflight_per_account"].(int); !ok || got != 1 { t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"]) } - if got, ok := status["recommended_concurrency"].(int); !ok || got != 8 { + if got, ok := status["recommended_concurrency"].(int); !ok || got != 2 { t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"]) } - if got, ok := status["max_queue_size"].(int); !ok || got != 8 { - t.Fatalf("unexpected max_queue_size: %#v", status["max_queue_size"]) - } } func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) { @@ -217,9 +213,7 @@ func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) { func TestPoolAcquireRotatesIntoTokenlessAccounts(t *testing.T) { t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1") - t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "") t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "") - t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "") t.Setenv("DS2API_CONFIG_JSON", `{ "keys":["k1"], "accounts":[ diff --git a/internal/adapter/claude/deps.go b/internal/adapter/claude/deps.go index a6398b3..0088e81 100644 --- a/internal/adapter/claude/deps.go +++ b/internal/adapter/claude/deps.go @@ -22,6 +22,7 @@ type DeepSeekCaller interface { type ConfigReader interface { ClaudeMapping() map[string]string + CompatStripReferenceMarkers() bool } type OpenAIChatRunner interface { diff --git a/internal/adapter/claude/deps_injection_test.go b/internal/adapter/claude/deps_injection_test.go index 39dfc2f..ae0c38f 100644 --- a/internal/adapter/claude/deps_injection_test.go +++ b/internal/adapter/claude/deps_injection_test.go @@ -7,6 +7,7 @@ type mockClaudeConfig struct { } func (m mockClaudeConfig) ClaudeMapping() map[string]string { return m.m } +func (mockClaudeConfig) CompatStripReferenceMarkers() bool { return true } func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) { req := map[string]any{ diff --git a/internal/adapter/claude/handler_messages.go b/internal/adapter/claude/handler_messages.go index c066ac8..5bfa839 100644 --- a/internal/adapter/claude/handler_messages.go +++ b/internal/adapter/claude/handler_messages.go @@ -149,6 +149,7 @@ func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Requ messages, thinkingEnabled, searchEnabled, + h.compatStripReferenceMarkers(), toolNames, ) streamRuntime.sendMessageStart() diff --git a/internal/adapter/claude/handler_routes.go b/internal/adapter/claude/handler_routes.go index 483ade7..3683456 100644 --- a/internal/adapter/claude/handler_routes.go +++ b/internal/adapter/claude/handler_routes.go @@ -21,6 +21,13 @@ type Handler struct { OpenAI OpenAIChatRunner } +func (h *Handler) compatStripReferenceMarkers() bool { + if h == nil || h.Store == nil { + return true + } + return h.Store.CompatStripReferenceMarkers() +} + var ( claudeStreamPingInterval = time.Duration(deepseek.KeepAliveTimeout) * time.Second claudeStreamIdleTimeout = time.Duration(deepseek.StreamIdleTimeout) * time.Second diff --git a/internal/adapter/claude/output_clean.go b/internal/adapter/claude/output_clean.go new file mode 100644 index 0000000..60217f2 --- /dev/null +++ b/internal/adapter/claude/output_clean.go @@ -0,0 +1,13 @@ +package claude + +import textclean "ds2api/internal/textclean" + +func cleanVisibleOutput(text string, stripReferenceMarkers bool) string { + if text == "" { + return text + } + if stripReferenceMarkers { + text = textclean.StripReferenceMarkers(text) + } + return text +} diff --git a/internal/adapter/claude/proxy_vercel_test.go b/internal/adapter/claude/proxy_vercel_test.go index 4a7736b..9a441e9 100644 --- a/internal/adapter/claude/proxy_vercel_test.go +++ b/internal/adapter/claude/proxy_vercel_test.go @@ -16,6 +16,8 @@ func (s claudeProxyStoreStub) ClaudeMapping() map[string]string { return s.mapping } +func (claudeProxyStoreStub) CompatStripReferenceMarkers() bool { return true } + type openAIProxyStub struct { status int body string diff --git a/internal/adapter/claude/stream_runtime_core.go b/internal/adapter/claude/stream_runtime_core.go index e5be865..241c093 100644 --- a/internal/adapter/claude/stream_runtime_core.go +++ b/internal/adapter/claude/stream_runtime_core.go @@ -19,13 +19,14 @@ type claudeStreamRuntime struct { toolNames []string messages []any - thinkingEnabled bool - searchEnabled bool - bufferToolContent bool + thinkingEnabled bool + searchEnabled bool + bufferToolContent bool + stripReferenceMarkers bool - messageID string - thinking strings.Builder - text strings.Builder + messageID string + thinking strings.Builder + text strings.Builder outputTokens int nextBlockIndex int @@ -45,21 +46,23 @@ func newClaudeStreamRuntime( messages []any, thinkingEnabled bool, searchEnabled bool, + stripReferenceMarkers bool, toolNames []string, ) *claudeStreamRuntime { return &claudeStreamRuntime{ - w: w, - rc: rc, - canFlush: canFlush, - model: model, - messages: messages, - thinkingEnabled: thinkingEnabled, - searchEnabled: searchEnabled, - bufferToolContent: len(toolNames) > 0, - toolNames: toolNames, - messageID: fmt.Sprintf("msg_%d", time.Now().UnixNano()), - thinkingBlockIndex: -1, - textBlockIndex: -1, + w: w, + rc: rc, + canFlush: canFlush, + model: model, + messages: messages, + thinkingEnabled: thinkingEnabled, + searchEnabled: searchEnabled, + bufferToolContent: len(toolNames) > 0, + stripReferenceMarkers: stripReferenceMarkers, + toolNames: toolNames, + messageID: fmt.Sprintf("msg_%d", time.Now().UnixNano()), + thinkingBlockIndex: -1, + textBlockIndex: -1, } } @@ -80,10 +83,11 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse contentSeen := false for _, p := range parsed.Parts { - if p.Text == "" { + cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers) + if cleanedText == "" { continue } - if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(p.Text) { + if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(cleanedText) { continue } contentSeen = true @@ -92,7 +96,7 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse if !s.thinkingEnabled { continue } - s.thinking.WriteString(p.Text) + s.thinking.WriteString(cleanedText) s.closeTextBlock() if !s.thinkingBlockOpen { s.thinkingBlockIndex = s.nextBlockIndex @@ -112,13 +116,13 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse "index": s.thinkingBlockIndex, "delta": map[string]any{ "type": "thinking_delta", - "thinking": p.Text, + "thinking": cleanedText, }, }) continue } - s.text.WriteString(p.Text) + s.text.WriteString(cleanedText) if s.bufferToolContent { if hasUnclosedCodeFence(s.text.String()) { continue @@ -144,7 +148,7 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse "index": s.textBlockIndex, "delta": map[string]any{ "type": "text_delta", - "text": p.Text, + "text": cleanedText, }, }) } diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index 6a020ef..50655bd 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -43,7 +43,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { s.closeTextBlock() finalThinking := s.thinking.String() - finalText := s.text.String() + finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) if s.bufferToolContent { detected := util.ParseStandaloneToolCalls(finalText, s.toolNames) @@ -64,7 +64,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { "input": map[string]any{}, }, }) - + inputBytes, _ := json.Marshal(tc.Input) s.send("content_block_delta", map[string]any{ "type": "content_block_delta", diff --git a/internal/adapter/claude/stream_status_test.go b/internal/adapter/claude/stream_status_test.go index 8743c44..7577792 100644 --- a/internal/adapter/claude/stream_status_test.go +++ b/internal/adapter/claude/stream_status_test.go @@ -28,6 +28,8 @@ func (streamStatusClaudeStoreStub) ClaudeMapping() map[string]string { } } +func (streamStatusClaudeStoreStub) CompatStripReferenceMarkers() bool { return true } + func captureClaudeStatusMiddleware(statuses *[]int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/adapter/gemini/deps.go b/internal/adapter/gemini/deps.go index 2d12249..9a9e658 100644 --- a/internal/adapter/gemini/deps.go +++ b/internal/adapter/gemini/deps.go @@ -22,6 +22,7 @@ type DeepSeekCaller interface { type ConfigReader interface { ModelAliases() map[string]string + CompatStripReferenceMarkers() bool } type OpenAIChatRunner interface { diff --git a/internal/adapter/gemini/handler_generate.go b/internal/adapter/gemini/handler_generate.go index a7de92d..f662d66 100644 --- a/internal/adapter/gemini/handler_generate.go +++ b/internal/adapter/gemini/handler_generate.go @@ -140,7 +140,15 @@ func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *ht } result := sse.CollectStream(resp, thinkingEnabled, true) - writeJSON(w, http.StatusOK, buildGeminiGenerateContentResponse(model, finalPrompt, result.Thinking, result.Text, toolNames, result.OutputTokens)) + stripReferenceMarkers := h.compatStripReferenceMarkers() + writeJSON(w, http.StatusOK, buildGeminiGenerateContentResponse( + model, + finalPrompt, + cleanVisibleOutput(result.Thinking, stripReferenceMarkers), + cleanVisibleOutput(result.Text, stripReferenceMarkers), + toolNames, + result.OutputTokens, + )) } func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string, outputTokens int) map[string]any { @@ -179,7 +187,7 @@ func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens func buildGeminiPartsFromFinal(finalText, finalThinking string, toolNames []string) []map[string]any { detected := util.ParseToolCalls(finalText, toolNames) - if len(detected) == 0 && strings.TrimSpace(finalThinking) != "" { + if len(detected) == 0 && finalThinking != "" { detected = util.ParseToolCalls(finalThinking, toolNames) } if len(detected) > 0 { @@ -196,7 +204,7 @@ func buildGeminiPartsFromFinal(finalText, finalThinking string, toolNames []stri } text := finalText - if strings.TrimSpace(text) == "" { + if text == "" { text = finalThinking } return []map[string]any{{"text": text}} diff --git a/internal/adapter/gemini/handler_routes.go b/internal/adapter/gemini/handler_routes.go index 1355689..28338a6 100644 --- a/internal/adapter/gemini/handler_routes.go +++ b/internal/adapter/gemini/handler_routes.go @@ -17,6 +17,13 @@ type Handler struct { OpenAI OpenAIChatRunner } +func (h *Handler) compatStripReferenceMarkers() bool { + if h == nil || h.Store == nil { + return true + } + return h.Store.CompatStripReferenceMarkers() +} + func RegisterRoutes(r chi.Router, h *Handler) { r.Post("/v1beta/models/{model}:generateContent", h.GenerateContent) r.Post("/v1beta/models/{model}:streamGenerateContent", h.StreamGenerateContent) diff --git a/internal/adapter/gemini/handler_stream_runtime.go b/internal/adapter/gemini/handler_stream_runtime.go index 1fd9021..601517a 100644 --- a/internal/adapter/gemini/handler_stream_runtime.go +++ b/internal/adapter/gemini/handler_stream_runtime.go @@ -27,7 +27,7 @@ func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Req rc := http.NewResponseController(w) _, canFlush := w.(http.Flusher) - runtime := newGeminiStreamRuntime(w, rc, canFlush, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames) + runtime := newGeminiStreamRuntime(w, rc, canFlush, model, finalPrompt, thinkingEnabled, searchEnabled, h.compatStripReferenceMarkers(), toolNames) initialType := "text" if thinkingEnabled { @@ -57,13 +57,14 @@ type geminiStreamRuntime struct { model string finalPrompt string - thinkingEnabled bool - searchEnabled bool - bufferContent bool - toolNames []string + thinkingEnabled bool + searchEnabled bool + bufferContent bool + stripReferenceMarkers bool + toolNames []string - thinking strings.Builder - text strings.Builder + thinking strings.Builder + text strings.Builder outputTokens int } @@ -75,18 +76,20 @@ func newGeminiStreamRuntime( finalPrompt string, thinkingEnabled bool, searchEnabled bool, + stripReferenceMarkers bool, toolNames []string, ) *geminiStreamRuntime { return &geminiStreamRuntime{ - w: w, - rc: rc, - canFlush: canFlush, - model: model, - finalPrompt: finalPrompt, - thinkingEnabled: thinkingEnabled, - searchEnabled: searchEnabled, - bufferContent: len(toolNames) > 0, - toolNames: toolNames, + w: w, + rc: rc, + canFlush: canFlush, + model: model, + finalPrompt: finalPrompt, + thinkingEnabled: thinkingEnabled, + searchEnabled: searchEnabled, + bufferContent: len(toolNames) > 0, + stripReferenceMarkers: stripReferenceMarkers, + toolNames: toolNames, } } @@ -113,20 +116,21 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse contentSeen := false for _, p := range parsed.Parts { - if p.Text == "" { + cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers) + if cleanedText == "" { continue } - if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(p.Text) { + if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(cleanedText) { continue } contentSeen = true if p.Type == "thinking" { if s.thinkingEnabled { - s.thinking.WriteString(p.Text) + s.thinking.WriteString(cleanedText) } continue } - s.text.WriteString(p.Text) + s.text.WriteString(cleanedText) if s.bufferContent { continue } @@ -136,7 +140,7 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse "index": 0, "content": map[string]any{ "role": "model", - "parts": []map[string]any{{"text": p.Text}}, + "parts": []map[string]any{{"text": cleanedText}}, }, }, }, @@ -148,7 +152,7 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse func (s *geminiStreamRuntime) finalize() { finalThinking := s.thinking.String() - finalText := s.text.String() + finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) if s.bufferContent { parts := buildGeminiPartsFromFinal(finalText, finalThinking, s.toolNames) diff --git a/internal/adapter/gemini/handler_test.go b/internal/adapter/gemini/handler_test.go index fdb4b79..20eed21 100644 --- a/internal/adapter/gemini/handler_test.go +++ b/internal/adapter/gemini/handler_test.go @@ -17,7 +17,8 @@ import ( type testGeminiConfig struct{} -func (testGeminiConfig) ModelAliases() map[string]string { return nil } +func (testGeminiConfig) ModelAliases() map[string]string { return nil } +func (testGeminiConfig) CompatStripReferenceMarkers() bool { return true } type testGeminiAuth struct { a *auth.RequestAuth @@ -62,8 +63,8 @@ func (m testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ m } type geminiOpenAIErrorStub struct { - status int - body string + status int + body string headers map[string]string } @@ -247,7 +248,7 @@ func TestStreamGenerateContentEmitsSSE(t *testing.T) { func TestGenerateContentOpenAIProxyErrorUsesGeminiEnvelope(t *testing.T) { h := &Handler{ - Store: testGeminiConfig{}, + Store: testGeminiConfig{}, OpenAI: geminiOpenAIErrorStub{ status: http.StatusUnauthorized, body: `{"error":{"message":"invalid api key"}}`, diff --git a/internal/adapter/gemini/output_clean.go b/internal/adapter/gemini/output_clean.go new file mode 100644 index 0000000..56b2545 --- /dev/null +++ b/internal/adapter/gemini/output_clean.go @@ -0,0 +1,13 @@ +package gemini + +import textclean "ds2api/internal/textclean" + +func cleanVisibleOutput(text string, stripReferenceMarkers bool) string { + if text == "" { + return text + } + if stripReferenceMarkers { + text = textclean.StripReferenceMarkers(text) + } + return text +} diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 563b0f2..fb6943f 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -22,8 +22,9 @@ type chatStreamRuntime struct { finalPrompt string toolNames []string - thinkingEnabled bool - searchEnabled bool + thinkingEnabled bool + searchEnabled bool + stripReferenceMarkers bool firstChunkSent bool bufferToolContent bool @@ -49,25 +50,27 @@ func newChatStreamRuntime( finalPrompt string, thinkingEnabled bool, searchEnabled bool, + stripReferenceMarkers bool, toolNames []string, bufferToolContent bool, emitEarlyToolDeltas bool, ) *chatStreamRuntime { return &chatStreamRuntime{ - w: w, - rc: rc, - canFlush: canFlush, - completionID: completionID, - created: created, - model: model, - finalPrompt: finalPrompt, - toolNames: toolNames, - thinkingEnabled: thinkingEnabled, - searchEnabled: searchEnabled, - bufferToolContent: bufferToolContent, - emitEarlyToolDeltas: emitEarlyToolDeltas, - streamToolCallIDs: map[int]string{}, - streamToolNames: map[int]string{}, + w: w, + rc: rc, + canFlush: canFlush, + completionID: completionID, + created: created, + model: model, + finalPrompt: finalPrompt, + toolNames: toolNames, + thinkingEnabled: thinkingEnabled, + searchEnabled: searchEnabled, + stripReferenceMarkers: stripReferenceMarkers, + bufferToolContent: bufferToolContent, + emitEarlyToolDeltas: emitEarlyToolDeltas, + streamToolCallIDs: map[int]string{}, + streamToolNames: map[int]string{}, } } @@ -98,7 +101,7 @@ func (s *chatStreamRuntime) sendDone() { func (s *chatStreamRuntime) finalize(finishReason string) { finalThinking := s.thinking.String() - finalText := sanitizeLeakedOutput(s.text.String()) + finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted { finishReason = "tool_calls" @@ -142,7 +145,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) { if evt.Content == "" { continue } - cleaned := sanitizeLeakedOutput(evt.Content) + cleaned := cleanVisibleOutput(evt.Content, s.stripReferenceMarkers) if cleaned == "" { continue } @@ -203,10 +206,11 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD newChoices := make([]map[string]any, 0, len(parsed.Parts)) contentSeen := false for _, p := range parsed.Parts { - if s.searchEnabled && sse.IsCitation(p.Text) { + cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers) + if s.searchEnabled && sse.IsCitation(cleanedText) { continue } - if p.Text == "" { + if cleanedText == "" { continue } contentSeen = true @@ -217,15 +221,15 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD } if p.Type == "thinking" { if s.thinkingEnabled { - s.thinking.WriteString(p.Text) - delta["reasoning_content"] = p.Text + s.thinking.WriteString(cleanedText) + delta["reasoning_content"] = cleanedText } } else { - s.text.WriteString(p.Text) + s.text.WriteString(cleanedText) if !s.bufferToolContent { - delta["content"] = p.Text + delta["content"] = cleanedText } else { - events := processToolSieveChunk(&s.toolSieve, p.Text, s.toolNames) + events := processToolSieveChunk(&s.toolSieve, cleanedText, s.toolNames) for _, evt := range events { if len(evt.ToolCallDeltas) > 0 { if !s.emitEarlyToolDeltas { @@ -264,7 +268,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD continue } if evt.Content != "" { - cleaned := sanitizeLeakedOutput(evt.Content) + cleaned := cleanVisibleOutput(evt.Content, s.stripReferenceMarkers) if cleaned == "" { continue } diff --git a/internal/adapter/openai/deps.go b/internal/adapter/openai/deps.go index b2270c7..22b1ff1 100644 --- a/internal/adapter/openai/deps.go +++ b/internal/adapter/openai/deps.go @@ -19,16 +19,19 @@ type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) + DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) DeleteAllSessionsForToken(ctx context.Context, token string) error } type ConfigReader interface { ModelAliases() map[string]string CompatWideInputStrictOutput() bool + CompatStripReferenceMarkers() bool ToolcallMode() string ToolcallEarlyEmitConfidence() string ResponsesStoreTTLSeconds() int EmbeddingsProvider() string + AutoDeleteMode() string AutoDeleteSessions() bool } diff --git a/internal/adapter/openai/deps_injection_test.go b/internal/adapter/openai/deps_injection_test.go index 9025ef3..2364540 100644 --- a/internal/adapter/openai/deps_injection_test.go +++ b/internal/adapter/openai/deps_injection_test.go @@ -3,23 +3,31 @@ package openai import "testing" type mockOpenAIConfig struct { - aliases map[string]string - wideInput bool - toolMode string - earlyEmit string - responsesTTL int - embedProv string + aliases map[string]string + wideInput bool + autoDeleteMode string + toolMode string + earlyEmit string + responsesTTL int + embedProv string } func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases } func (m mockOpenAIConfig) CompatWideInputStrictOutput() bool { return m.wideInput } +func (m mockOpenAIConfig) CompatStripReferenceMarkers() bool { return true } func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMode } func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit } func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL } func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv } -func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } +func (m mockOpenAIConfig) AutoDeleteMode() string { + if m.autoDeleteMode == "" { + return "none" + } + return m.autoDeleteMode +} +func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false } func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { cfg := mockOpenAIConfig{ diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index 2bf13a2..9c2924f 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -35,22 +35,9 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, status, detail) return } + var sessionID string defer func() { - // 自动删除会话(同步) - // 必须在 Release 之前同步删除,否则: - // 1. 异步删除时账号已被 Release - // 2. 新请求可能获取到同一账号并开始使用 - // 3. 异步删除仍在进行,会截断新请求正在使用的会话 - if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" { - deleteCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - err := h.DS.DeleteAllSessionsForToken(deleteCtx, a.DeepSeekToken) - if err != nil { - config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err) - } else { - config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID) - } - } + h.autoDeleteRemoteSession(r.Context(), a, sessionID) h.Auth.Release(a) }() @@ -67,7 +54,7 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { return } - sessionID, err := h.DS.CreateSession(r.Context(), a, 3) + sessionID, err = h.DS.CreateSession(r.Context(), a, 3) if err != nil { if a.UseConfigToken { writeOpenAIError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.") @@ -94,6 +81,39 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { h.handleNonStream(w, r.Context(), resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames) } +func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) { + mode := h.Store.AutoDeleteMode() + if mode == "none" || a.DeepSeekToken == "" { + return + } + + deleteBaseCtx := context.WithoutCancel(ctx) + deleteCtx, cancel := context.WithTimeout(deleteBaseCtx, 10*time.Second) + defer cancel() + + switch mode { + case "single": + if sessionID == "" { + config.Logger.Warn("[auto_delete_sessions] skipped single-session delete because session_id is empty", "account", a.AccountID) + return + } + _, err := h.DS.DeleteSessionForToken(deleteCtx, a.DeepSeekToken, sessionID) + if err != nil { + config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "mode", mode, "session_id", sessionID, "error", err) + return + } + config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID, "mode", mode, "session_id", sessionID) + case "all": + if err := h.DS.DeleteAllSessionsForToken(deleteCtx, a.DeepSeekToken); err != nil { + config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "mode", mode, "error", err) + return + } + config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID, "mode", mode) + default: + config.Logger.Warn("[auto_delete_sessions] unknown mode", "account", a.AccountID, "mode", mode) + } +} + func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled bool, toolNames []string) { if resp.StatusCode != http.StatusOK { defer resp.Body.Close() @@ -104,9 +124,10 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re _ = ctx result := sse.CollectStream(resp, thinkingEnabled, true) - finalThinking := result.Thinking - finalText := sanitizeLeakedOutput(result.Text) - if writeUpstreamEmptyOutputError(w, result) { + stripReferenceMarkers := h.compatStripReferenceMarkers() + finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers) + finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers) + if writeUpstreamEmptyOutputError(w, finalThinking, finalText, result.ContentFilter) { return } respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames) @@ -141,6 +162,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt created := time.Now().Unix() bufferToolContent := len(toolNames) > 0 emitEarlyToolDeltas := h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence() + stripReferenceMarkers := h.compatStripReferenceMarkers() initialType := "text" if thinkingEnabled { initialType = "thinking" @@ -156,6 +178,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt finalPrompt, thinkingEnabled, searchEnabled, + stripReferenceMarkers, toolNames, bufferToolContent, emitEarlyToolDeltas, diff --git a/internal/adapter/openai/handler_chat_auto_delete_test.go b/internal/adapter/openai/handler_chat_auto_delete_test.go new file mode 100644 index 0000000..0196db0 --- /dev/null +++ b/internal/adapter/openai/handler_chat_auto_delete_test.go @@ -0,0 +1,139 @@ +package openai + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" +) + +type autoDeleteModeDSStub struct { + resp *http.Response + singleCalls int + allCalls int + lastSessionID string + lastCtxErr error +} + +func (m *autoDeleteModeDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "session-id", nil +} + +func (m *autoDeleteModeDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "pow", nil +} + +func (m *autoDeleteModeDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { + return m.resp, nil +} + +func (m *autoDeleteModeDSStub) DeleteSessionForToken(_ context.Context, _ string, sessionID string) (*deepseek.DeleteSessionResult, error) { + m.singleCalls++ + m.lastSessionID = sessionID + return &deepseek.DeleteSessionResult{SessionID: sessionID, Success: true}, nil +} + +func (m *autoDeleteModeDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { + m.allCalls++ + return nil +} + +func (m *autoDeleteModeDSStub) DeleteSessionForTokenCtx(ctx context.Context, _ string, sessionID string) (*deepseek.DeleteSessionResult, error) { + m.singleCalls++ + m.lastSessionID = sessionID + m.lastCtxErr = ctx.Err() + return &deepseek.DeleteSessionResult{SessionID: sessionID, Success: true}, nil +} + +func TestChatCompletionsAutoDeleteModes(t *testing.T) { + tests := []struct { + name string + mode string + wantSingle int + wantAll int + }{ + {name: "none", mode: "none"}, + {name: "single", mode: "single", wantSingle: 1}, + {name: "all", mode: "all", wantAll: 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ds := &autoDeleteModeDSStub{ + resp: makeOpenAISSEHTTPResponse( + `data: {"p":"response/content","v":"hello"}`, + "data: [DONE]", + ), + } + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + autoDeleteMode: tc.mode, + }, + Auth: streamStatusAuthStub{}, + DS: ds, + } + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if ds.singleCalls != tc.wantSingle { + t.Fatalf("single delete calls=%d want=%d", ds.singleCalls, tc.wantSingle) + } + if ds.allCalls != tc.wantAll { + t.Fatalf("all delete calls=%d want=%d", ds.allCalls, tc.wantAll) + } + if tc.wantSingle > 0 && ds.lastSessionID != "session-id" { + t.Fatalf("expected single delete for session-id, got %q", ds.lastSessionID) + } + }) + } +} + +type autoDeleteCtxDSStub struct { + autoDeleteModeDSStub +} + +func (m *autoDeleteCtxDSStub) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) { + return m.autoDeleteModeDSStub.DeleteSessionForTokenCtx(ctx, token, sessionID) +} + +func (m *autoDeleteCtxDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { + m.allCalls++ + return nil +} + +func TestAutoDeleteRemoteSessionIgnoresCanceledParentContext(t *testing.T) { + ds := &autoDeleteCtxDSStub{} + h := &Handler{ + Store: mockOpenAIConfig{ + wideInput: true, + autoDeleteMode: "single", + }, + DS: ds, + } + a := &auth.RequestAuth{DeepSeekToken: "token", AccountID: "acct"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + h.autoDeleteRemoteSession(ctx, a, "session-id") + + if ds.singleCalls != 1 { + t.Fatalf("single delete calls=%d want=1", ds.singleCalls) + } + if ds.lastCtxErr != nil { + t.Fatalf("delete ctx should not inherit cancellation, got %v", ds.lastCtxErr) + } +} diff --git a/internal/adapter/openai/handler_routes.go b/internal/adapter/openai/handler_routes.go index a0cfcd6..1f8366a 100644 --- a/internal/adapter/openai/handler_routes.go +++ b/internal/adapter/openai/handler_routes.go @@ -28,6 +28,13 @@ type Handler struct { responses *responseStore } +func (h *Handler) compatStripReferenceMarkers() bool { + if h == nil || h.Store == nil { + return true + } + return h.Store.CompatStripReferenceMarkers() +} + type streamLease struct { Auth *auth.RequestAuth ExpiresAt time.Time diff --git a/internal/adapter/openai/leaked_output_sanitize.go b/internal/adapter/openai/leaked_output_sanitize.go index cb6e7c4..c139feb 100644 --- a/internal/adapter/openai/leaked_output_sanitize.go +++ b/internal/adapter/openai/leaked_output_sanitize.go @@ -9,9 +9,9 @@ var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\ var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`) // leakedMetaMarkerPattern matches DeepSeek special tokens in BOTH forms: -// - ASCII underscore: <|end_of_sentence|> -// - U+2581 variant: <|end▁of▁sentence|> (used in some DeepSeek outputs) -var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking)\s*[|\|]>`) +// - ASCII underscore: <|end_of_sentence|>, <|end_of_toolresults|>, <|end_of_instructions|> +// - U+2581 variant: <|end▁of▁sentence|>, <|end▁of▁toolresults|>, <|end▁of▁instructions|> +var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`) // leakedAgentXMLBlockPatterns catch agent-style XML blocks that leak through // when the sieve fails to capture them. These are applied only to complete diff --git a/internal/adapter/openai/leaked_output_sanitize_test.go b/internal/adapter/openai/leaked_output_sanitize_test.go index 6548d39..558cc48 100644 --- a/internal/adapter/openai/leaked_output_sanitize_test.go +++ b/internal/adapter/openai/leaked_output_sanitize_test.go @@ -19,9 +19,9 @@ func TestSanitizeLeakedOutputRemovesLeakedWireToolCallAndResult(t *testing.T) { } func TestSanitizeLeakedOutputRemovesStandaloneMetaMarkers(t *testing.T) { - raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C<|end▁of▁thinking|>D<|end▁of▁sentence|>E" + raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C<|end▁of▁thinking|>D<|end▁of▁sentence|>E<| end_of_toolresults |>F<|end▁of▁instructions|>G" got := sanitizeLeakedOutput(raw) - if got != "ABCDE" { + if got != "ABCDEFG" { t.Fatalf("unexpected sanitize result for meta markers: %q", got) } } diff --git a/internal/adapter/openai/output_clean.go b/internal/adapter/openai/output_clean.go new file mode 100644 index 0000000..b749876 --- /dev/null +++ b/internal/adapter/openai/output_clean.go @@ -0,0 +1,13 @@ +package openai + +import textclean "ds2api/internal/textclean" + +func cleanVisibleOutput(text string, stripReferenceMarkers bool) string { + if text == "" { + return text + } + if stripReferenceMarkers { + text = textclean.StripReferenceMarkers(text) + } + return sanitizeLeakedOutput(text) +} diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index bcbed0a..3c799e5 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -113,8 +113,10 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res return } result := sse.CollectStream(resp, thinkingEnabled, true) - sanitizedText := sanitizeLeakedOutput(result.Text) - if writeUpstreamEmptyOutputError(w, result) { + stripReferenceMarkers := h.compatStripReferenceMarkers() + sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers) + sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers) + if writeUpstreamEmptyOutputError(w, sanitizedThinking, sanitizedText, result.ContentFilter) { return } textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames) @@ -126,7 +128,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res return } - responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, result.Thinking, sanitizedText, toolNames) + responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, sanitizedThinking, sanitizedText, toolNames) if result.OutputTokens > 0 { if usage, ok := responseObj["usage"].(map[string]any); ok { usage["output_tokens"] = result.OutputTokens @@ -159,6 +161,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, } bufferToolContent := len(toolNames) > 0 emitEarlyToolDeltas := h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence() + stripReferenceMarkers := h.compatStripReferenceMarkers() streamRuntime := newResponsesStreamRuntime( w, @@ -169,6 +172,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, finalPrompt, thinkingEnabled, searchEnabled, + stripReferenceMarkers, toolNames, bufferToolContent, emitEarlyToolDeltas, diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/adapter/openai/responses_stream_runtime_core.go index eaae51b..55fc7be 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/adapter/openai/responses_stream_runtime_core.go @@ -23,8 +23,9 @@ type responsesStreamRuntime struct { traceID string toolChoice util.ToolChoicePolicy - thinkingEnabled bool - searchEnabled bool + thinkingEnabled bool + searchEnabled bool + stripReferenceMarkers bool bufferToolContent bool emitEarlyToolDeltas bool @@ -63,6 +64,7 @@ func newResponsesStreamRuntime( finalPrompt string, thinkingEnabled bool, searchEnabled bool, + stripReferenceMarkers bool, toolNames []string, bufferToolContent bool, emitEarlyToolDeltas bool, @@ -71,34 +73,35 @@ func newResponsesStreamRuntime( persistResponse func(obj map[string]any), ) *responsesStreamRuntime { return &responsesStreamRuntime{ - w: w, - rc: rc, - canFlush: canFlush, - responseID: responseID, - model: model, - finalPrompt: finalPrompt, - thinkingEnabled: thinkingEnabled, - searchEnabled: searchEnabled, - toolNames: toolNames, - bufferToolContent: bufferToolContent, - emitEarlyToolDeltas: emitEarlyToolDeltas, - streamToolCallIDs: map[int]string{}, - functionItemIDs: map[int]string{}, - functionOutputIDs: map[int]int{}, - functionArgs: map[int]string{}, - functionDone: map[int]bool{}, - functionAdded: map[int]bool{}, - functionNames: map[int]string{}, - messageOutputID: -1, - toolChoice: toolChoice, - traceID: traceID, - persistResponse: persistResponse, + w: w, + rc: rc, + canFlush: canFlush, + responseID: responseID, + model: model, + finalPrompt: finalPrompt, + thinkingEnabled: thinkingEnabled, + searchEnabled: searchEnabled, + stripReferenceMarkers: stripReferenceMarkers, + toolNames: toolNames, + bufferToolContent: bufferToolContent, + emitEarlyToolDeltas: emitEarlyToolDeltas, + streamToolCallIDs: map[int]string{}, + functionItemIDs: map[int]string{}, + functionOutputIDs: map[int]int{}, + functionArgs: map[int]string{}, + functionDone: map[int]bool{}, + functionAdded: map[int]bool{}, + functionNames: map[int]string{}, + messageOutputID: -1, + toolChoice: toolChoice, + traceID: traceID, + persistResponse: persistResponse, } } func (s *responsesStreamRuntime) finalize() { finalThinking := s.thinking.String() - finalText := sanitizeLeakedOutput(s.text.String()) + finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) if s.bufferToolContent { s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true) @@ -190,10 +193,11 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa contentSeen := false for _, p := range parsed.Parts { - if p.Text == "" { + cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers) + if cleanedText == "" { continue } - if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(p.Text) { + if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(cleanedText) { continue } contentSeen = true @@ -201,15 +205,11 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa if !s.thinkingEnabled { continue } - s.thinking.WriteString(p.Text) - s.sendEvent("response.reasoning.delta", openaifmt.BuildResponsesReasoningDeltaPayload(s.responseID, p.Text)) + s.thinking.WriteString(cleanedText) + s.sendEvent("response.reasoning.delta", openaifmt.BuildResponsesReasoningDeltaPayload(s.responseID, cleanedText)) continue } - cleanedText := sanitizeLeakedOutput(p.Text) - if cleanedText == "" { - continue - } s.text.WriteString(cleanedText) if !s.bufferToolContent { s.emitTextDelta(cleanedText) diff --git a/internal/adapter/openai/responses_stream_runtime_toolcalls.go b/internal/adapter/openai/responses_stream_runtime_toolcalls.go index ad354d4..ebd8004 100644 --- a/internal/adapter/openai/responses_stream_runtime_toolcalls.go +++ b/internal/adapter/openai/responses_stream_runtime_toolcalls.go @@ -69,7 +69,7 @@ func (s *responsesStreamRuntime) ensureMessageContentPartAdded() { } func (s *responsesStreamRuntime) emitTextDelta(content string) { - if strings.TrimSpace(content) == "" { + if content == "" { return } s.ensureMessageContentPartAdded() diff --git a/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go b/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go index 46104a1..d3348d7 100644 --- a/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go +++ b/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go @@ -83,13 +83,13 @@ func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, fin }) } else if len(calls) == 0 { content := make([]map[string]any, 0, 2) - if strings.TrimSpace(finalThinking) != "" { + if finalThinking != "" { content = append(content, map[string]any{ "type": "reasoning", "text": finalThinking, }) } - if strings.TrimSpace(finalText) != "" { + if finalText != "" { content = append(content, map[string]any{ "type": "output_text", "text": finalText, @@ -136,10 +136,10 @@ func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, fin } outputText := s.visibleText.String() - if strings.TrimSpace(outputText) == "" && len(calls) == 0 { - if strings.TrimSpace(finalText) != "" { + if outputText == "" && len(calls) == 0 { + if finalText != "" { outputText = finalText - } else if strings.TrimSpace(finalThinking) != "" { + } else if finalThinking != "" { outputText = finalThinking } } diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go index 2a3584b..6352141 100644 --- a/internal/adapter/openai/stream_status_test.go +++ b/internal/adapter/openai/stream_status_test.go @@ -13,6 +13,7 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" "ds2api/internal/auth" + "ds2api/internal/deepseek" ) type streamStatusAuthStub struct{} @@ -53,6 +54,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut return m.resp, nil } +func (m streamStatusDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) { + return &deepseek.DeleteSessionResult{Success: true}, nil +} + func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { return nil } diff --git a/internal/adapter/openai/tool_sieve_state.go b/internal/adapter/openai/tool_sieve_state.go index 60370e9..6d16878 100644 --- a/internal/adapter/openai/tool_sieve_state.go +++ b/internal/adapter/openai/tool_sieve_state.go @@ -48,7 +48,7 @@ func (s *toolStreamSieveState) resetIncrementalToolState() { } func (s *toolStreamSieveState) noteText(content string) { - if strings.TrimSpace(content) == "" { + if content == "" { return } s.recentTextTail = appendTail(s.recentTextTail, content, toolSieveContextTailLimit) diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index 8f39cd6..f9401ed 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -1,17 +1,12 @@ package openai -import ( - "net/http" - "strings" +import "net/http" - "ds2api/internal/sse" -) - -func writeUpstreamEmptyOutputError(w http.ResponseWriter, result sse.CollectResult) bool { - if strings.TrimSpace(result.Thinking) != "" || strings.TrimSpace(sanitizeLeakedOutput(result.Text)) != "" { +func writeUpstreamEmptyOutputError(w http.ResponseWriter, thinking, text string, contentFilter bool) bool { + if thinking != "" || text != "" { return false } - if result.ContentFilter { + if contentFilter { writeOpenAIErrorWithCode(w, http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter") return true } diff --git a/internal/adapter/openai/vercel_stream.go b/internal/adapter/openai/vercel_stream.go index 55555ce..2ef8d36 100644 --- a/internal/adapter/openai/vercel_stream.go +++ b/internal/adapter/openai/vercel_stream.go @@ -99,10 +99,13 @@ func (h *Handler) handleVercelStreamPrepare(w http.ResponseWriter, r *http.Reque "final_prompt": stdReq.FinalPrompt, "thinking_enabled": stdReq.Thinking, "search_enabled": stdReq.Search, - "tool_names": stdReq.ToolNames, - "deepseek_token": a.DeepSeekToken, - "pow_header": powHeader, - "payload": payload, + "compat": map[string]any{ + "strip_reference_markers": h.compatStripReferenceMarkers(), + }, + "tool_names": stdReq.ToolNames, + "deepseek_token": a.DeepSeekToken, + "pow_header": powHeader, + "payload": payload, }) } diff --git a/internal/admin/deps.go b/internal/admin/deps.go index 054deb0..6b083fc 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -32,6 +32,8 @@ type ConfigStore interface { RuntimeAccountMaxQueue(defaultSize int) int RuntimeGlobalMaxInflight(defaultSize int) int RuntimeTokenRefreshIntervalHours() int + AutoDeleteMode() string + CompatStripReferenceMarkers() bool AutoDeleteSessions() bool } @@ -41,6 +43,10 @@ type PoolController interface { ApplyRuntimeLimits(maxInflightPerAccount, maxQueueSize, globalMaxInflight int) } +type OpenAIChatCaller interface { + ChatCompletions(w http.ResponseWriter, r *http.Request) +} + type DeepSeekCaller interface { Login(ctx context.Context, acc config.Account) (string, error) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 125abd0..9c67492 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -5,9 +5,10 @@ import ( ) type Handler struct { - Store ConfigStore - Pool PoolController - DS DeepSeekCaller + Store ConfigStore + Pool PoolController + DS DeepSeekCaller + OpenAI OpenAIChatCaller } func RegisterRoutes(r chi.Router, h *Handler) { @@ -34,6 +35,7 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions) pr.Post("/import", h.batchImport) pr.Post("/test", h.testAPI) + pr.Post("/dev/raw-samples/capture", h.captureRawSample) pr.Post("/vercel/sync", h.syncVercel) pr.Get("/vercel/status", h.vercelStatus) pr.Post("/vercel/status", h.vercelStatus) diff --git a/internal/admin/handler_accounts_identifier_test.go b/internal/admin/handler_accounts_identifier_test.go index 7cac96b..6dd6efe 100644 --- a/internal/admin/handler_accounts_identifier_test.go +++ b/internal/admin/handler_accounts_identifier_test.go @@ -17,7 +17,6 @@ import ( func newAdminTestHandler(t *testing.T, raw string) *Handler { t.Helper() t.Setenv("DS2API_CONFIG_JSON", raw) - t.Setenv("CONFIG_JSON", "") store := config.LoadStore() return &Handler{ Store: store, diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index 2a8a447..8bfa376 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -13,6 +13,7 @@ import ( authn "ds2api/internal/auth" "ds2api/internal/config" + "ds2api/internal/deepseek" "ds2api/internal/sse" ) @@ -157,7 +158,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me result["message"] = "获取 PoW 失败: " + err.Error() return result } - payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search} + payload := map[string]any{"chat_session_id": sessionID, "prompt": deepseek.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}), "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search} resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1) if err != nil { result["message"] = "请求失败: " + err.Error() diff --git a/internal/admin/handler_raw_samples.go b/internal/admin/handler_raw_samples.go new file mode 100644 index 0000000..76ce638 --- /dev/null +++ b/internal/admin/handler_raw_samples.go @@ -0,0 +1,233 @@ +package admin + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + + "ds2api/internal/config" + "ds2api/internal/devcapture" + "ds2api/internal/rawsample" +) + +func (h *Handler) captureRawSample(w http.ResponseWriter, r *http.Request) { + if h.OpenAI == nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "OpenAI handler is not configured"}) + return + } + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"}) + return + } + + payload, sampleID, apiKey, err := prepareRawSampleCaptureRequest(h.Store, req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) + return + } + + body, err := json.Marshal(payload) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "failed to encode capture request"}) + return + } + + traceID := rawsample.NormalizeSampleID(sampleID) + if traceID == "" { + traceID = rawsample.DefaultSampleID("capture") + } + + before := devcapture.Global().Snapshot() + rec := httptest.NewRecorder() + captureReq := httptest.NewRequest(http.MethodPost, "/v1/chat/completions?__trace_id="+url.QueryEscape(traceID), bytes.NewReader(body)) + captureReq.Header.Set("Authorization", "Bearer "+apiKey) + captureReq.Header.Set("Content-Type", "application/json") + h.OpenAI.ChatCompletions(rec, captureReq) + after := devcapture.Global().Snapshot() + + if rec.Code >= http.StatusBadRequest { + copyHeader(w.Header(), rec.Header()) + w.WriteHeader(rec.Code) + _, _ = io.Copy(w, bytes.NewReader(rec.Body.Bytes())) + return + } + + captureEntries, err := collectNewCaptureEntries(before, after) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) + return + } + + saved, err := rawsample.Persist(rawsample.PersistOptions{ + RootDir: config.RawStreamSampleRoot(), + SampleID: sampleID, + Source: "admin/dev/raw-samples/capture", + Request: payload, + Capture: captureSummaryFromEntries(captureEntries), + UpstreamBody: combineCaptureBodies(captureEntries), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) + return + } + + copyHeader(w.Header(), rec.Header()) + w.Header().Set("X-Ds2-Sample-Id", saved.SampleID) + w.Header().Set("X-Ds2-Sample-Dir", saved.Dir) + w.Header().Set("X-Ds2-Sample-Meta", saved.MetaPath) + w.Header().Set("X-Ds2-Sample-Upstream", saved.UpstreamPath) + w.WriteHeader(rec.Code) + _, _ = io.Copy(w, bytes.NewReader(rec.Body.Bytes())) +} + +func prepareRawSampleCaptureRequest(store ConfigStore, req map[string]any) (map[string]any, string, string, error) { + payload := cloneMap(req) + sampleID := strings.TrimSpace(fieldString(payload, "sample_id")) + apiKey := strings.TrimSpace(fieldString(payload, "api_key")) + + for _, k := range []string{"sample_id", "api_key", "promote_default", "persist", "source"} { + delete(payload, k) + } + + if apiKey == "" { + if store == nil { + return nil, "", "", fmt.Errorf("no api key provided") + } + keys := store.Keys() + if len(keys) == 0 { + return nil, "", "", fmt.Errorf("no api key available") + } + apiKey = strings.TrimSpace(keys[0]) + } + + if model := strings.TrimSpace(fieldString(payload, "model")); model == "" { + payload["model"] = "deepseek-chat" + } + if _, ok := payload["stream"]; !ok { + payload["stream"] = true + } + + if messagesRaw, ok := payload["messages"].([]any); !ok || len(messagesRaw) == 0 { + message := strings.TrimSpace(fieldString(payload, "message")) + if message == "" { + message = "你好" + } + payload["messages"] = []map[string]any{{"role": "user", "content": message}} + } + delete(payload, "message") + + if sampleID == "" { + model := strings.TrimSpace(fieldString(payload, "model")) + if model == "" { + model = "capture" + } + sampleID = rawsample.DefaultSampleID(model) + } + + return payload, sampleID, apiKey, nil +} + +func collectNewCaptureEntries(before, after []devcapture.Entry) ([]devcapture.Entry, error) { + beforeIDs := make(map[string]struct{}, len(before)) + for _, entry := range before { + beforeIDs[entry.ID] = struct{}{} + } + + entries := make([]devcapture.Entry, 0, len(after)) + for _, entry := range after { + if _, ok := beforeIDs[entry.ID]; ok { + continue + } + if strings.TrimSpace(entry.ResponseBody) == "" { + continue + } + entries = append(entries, entry) + } + if len(entries) == 0 { + return nil, fmt.Errorf("no upstream capture was recorded") + } + + // Snapshot order is newest-first; reverse to preserve the actual request order. + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + return entries, nil +} + +func captureSummaryFromEntries(entries []devcapture.Entry) rawsample.CaptureSummary { + if len(entries) == 0 { + return rawsample.CaptureSummary{} + } + + // Primary metadata comes from the first (initial) capture. + summary := rawsample.CaptureSummary{ + Label: strings.TrimSpace(entries[0].Label), + URL: strings.TrimSpace(entries[0].URL), + StatusCode: entries[0].StatusCode, + } + + // Record every round (initial + continuations) so replay/debug + // can reconstruct the full multi-round interaction. + totalBytes := 0 + rounds := make([]rawsample.CaptureRound, 0, len(entries)) + for _, entry := range entries { + n := len(entry.ResponseBody) + totalBytes += n + rounds = append(rounds, rawsample.CaptureRound{ + Label: strings.TrimSpace(entry.Label), + URL: strings.TrimSpace(entry.URL), + StatusCode: entry.StatusCode, + ResponseBytes: n, + }) + } + summary.ResponseBytes = totalBytes + if len(rounds) > 1 { + summary.Rounds = rounds + } + return summary +} + +func combineCaptureBodies(entries []devcapture.Entry) []byte { + if len(entries) == 0 { + return nil + } + + var buf bytes.Buffer + for _, entry := range entries { + if buf.Len() > 0 { + last := buf.Bytes()[buf.Len()-1] + if last != '\n' { + buf.WriteByte('\n') + } + } + buf.WriteString(entry.ResponseBody) + } + return buf.Bytes() +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + dst.Del(k) + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return map[string]any{} + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/admin/handler_raw_samples_test.go b/internal/admin/handler_raw_samples_test.go new file mode 100644 index 0000000..4566b70 --- /dev/null +++ b/internal/admin/handler_raw_samples_test.go @@ -0,0 +1,232 @@ +package admin + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "ds2api/internal/devcapture" +) + +type stubOpenAIChatCaller struct{} + +func (stubOpenAIChatCaller) ChatCompletions(w http.ResponseWriter, _ *http.Request) { + store := devcapture.Global() + session := store.Start("deepseek_completion", "https://chat.deepseek.com/api/v0/chat/completion", "acct-test", map[string]any{"model": "deepseek-chat"}) + raw := io.NopCloser(strings.NewReader( + "data: {\"v\":\"hello [reference:1]\"}\n\n" + + "data: {\"v\":\"FINISHED\",\"p\":\"response/status\"}\n\n", + )) + if session != nil { + raw = session.WrapBody(raw, http.StatusOK) + } + _, _ = io.ReadAll(raw) + _ = raw.Close() + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"index\":0}],\"created\":1,\"id\":\"id\",\"model\":\"m\",\"object\":\"chat.completion.chunk\"}\n\n") +} + +type stubOpenAIChatCallerWithContinuations struct{} + +func (stubOpenAIChatCallerWithContinuations) ChatCompletions(w http.ResponseWriter, _ *http.Request) { + recordCapturedResponse("deepseek_completion", "https://chat.deepseek.com/api/v0/chat/completion", http.StatusOK, map[string]any{"model": "deepseek-chat"}, "data: {\"v\":\"hello [reference:1]\"}\n\n"+"data: [DONE]\n\n") + recordCapturedResponse("deepseek_continue", "https://chat.deepseek.com/api/v0/chat/continue", http.StatusOK, map[string]any{"chat_session_id": "session-1", "message_id": 2}, "data: {\"v\":\"continued\"}\n\n"+"data: [DONE]\n\n") + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello continued\"},\"index\":0}],\"created\":1,\"id\":\"id\",\"model\":\"m\",\"object\":\"chat.completion.chunk\"}\n\n") +} + +type stubOpenAIChatCallerWithoutCapture struct{} + +func (stubOpenAIChatCallerWithoutCapture) ChatCompletions(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"index\":0}],\"created\":1,\"id\":\"id\",\"model\":\"m\",\"object\":\"chat.completion.chunk\"}\n\n") +} + +func recordCapturedResponse(label, rawURL string, statusCode int, request any, body string) { + store := devcapture.Global() + session := store.Start(label, rawURL, "acct-test", request) + raw := io.NopCloser(strings.NewReader(body)) + if session != nil { + raw = session.WrapBody(raw, statusCode) + } + _, _ = io.ReadAll(raw) + _ = raw.Close() +} + +func TestCaptureRawSampleWritesPersistentSample(t *testing.T) { + t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", t.TempDir()) + devcapture.Global().Clear() + defer devcapture.Global().Clear() + + h := &Handler{OpenAI: stubOpenAIChatCaller{}} + reqBody := `{ + "sample_id":"My Sample 01", + "api_key":"local-key", + "model":"deepseek-chat", + "message":"广州天气", + "stream":true + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/capture", strings.NewReader(reqBody)) + h.captureRawSample(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("X-Ds2-Sample-Id"); got != "my-sample-01" { + t.Fatalf("expected sample id header my-sample-01, got %q", got) + } + if got := rec.Header().Get("X-Ds2-Sample-Upstream"); got != filepath.Join(os.Getenv("DS2API_RAW_STREAM_SAMPLE_ROOT"), "my-sample-01", "upstream.stream.sse") { + t.Fatalf("unexpected sample upstream header: %q", got) + } + if !strings.Contains(rec.Body.String(), `"content":"hello"`) { + t.Fatalf("expected proxied openai output, got %s", rec.Body.String()) + } + + sampleDir := filepath.Join(os.Getenv("DS2API_RAW_STREAM_SAMPLE_ROOT"), "my-sample-01") + if _, err := os.Stat(sampleDir); err != nil { + t.Fatalf("sample dir missing: %v", err) + } + metaBytes, err := os.ReadFile(filepath.Join(sampleDir, "meta.json")) + if err != nil { + t.Fatalf("read meta: %v", err) + } + var meta map[string]any + if err := json.Unmarshal(metaBytes, &meta); err != nil { + t.Fatalf("decode meta: %v", err) + } + if meta["sample_id"] != "my-sample-01" { + t.Fatalf("unexpected meta sample_id: %#v", meta["sample_id"]) + } + capture, _ := meta["capture"].(map[string]any) + if capture == nil { + t.Fatalf("missing capture meta: %#v", meta) + } + if got := int(capture["response_bytes"].(float64)); got == 0 { + t.Fatalf("expected capture bytes to be recorded, got %#v", capture) + } + if _, ok := meta["processed"]; ok { + t.Fatalf("unexpected processed meta: %#v", meta["processed"]) + } +} + +func TestCaptureRawSampleCombinesContinuationCaptures(t *testing.T) { + t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", t.TempDir()) + devcapture.Global().Clear() + defer devcapture.Global().Clear() + + h := &Handler{OpenAI: stubOpenAIChatCallerWithContinuations{}} + reqBody := `{ + "sample_id":"My Sample 02", + "api_key":"local-key", + "model":"deepseek-chat", + "message":"广州天气", + "stream":true + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/capture", strings.NewReader(reqBody)) + h.captureRawSample(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + + sampleDir := filepath.Join(os.Getenv("DS2API_RAW_STREAM_SAMPLE_ROOT"), "my-sample-02") + upstreamBytes, err := os.ReadFile(filepath.Join(sampleDir, "upstream.stream.sse")) + if err != nil { + t.Fatalf("read upstream: %v", err) + } + upstream := string(upstreamBytes) + if !strings.Contains(upstream, "hello [reference:1]") { + t.Fatalf("expected initial capture in combined upstream, got %s", upstream) + } + if !strings.Contains(upstream, "continued") { + t.Fatalf("expected continuation capture in combined upstream, got %s", upstream) + } + if strings.Index(upstream, "hello [reference:1]") > strings.Index(upstream, "continued") { + t.Fatalf("expected initial capture before continuation, got %s", upstream) + } + + metaBytes, err := os.ReadFile(filepath.Join(sampleDir, "meta.json")) + if err != nil { + t.Fatalf("read meta: %v", err) + } + var meta map[string]any + if err := json.Unmarshal(metaBytes, &meta); err != nil { + t.Fatalf("decode meta: %v", err) + } + capture, _ := meta["capture"].(map[string]any) + if capture == nil { + t.Fatalf("missing capture meta: %#v", meta) + } + if got := int(capture["response_bytes"].(float64)); got != len(upstreamBytes) { + t.Fatalf("expected combined response_bytes %d, got %#v", len(upstreamBytes), capture["response_bytes"]) + } + + rounds, _ := capture["rounds"].([]any) + if len(rounds) != 2 { + t.Fatalf("expected 2 capture rounds, got %d: %#v", len(rounds), capture) + } + r0, _ := rounds[0].(map[string]any) + r1, _ := rounds[1].(map[string]any) + if r0["label"] != "deepseek_completion" { + t.Fatalf("expected first round label deepseek_completion, got %v", r0["label"]) + } + if r1["label"] != "deepseek_continue" { + t.Fatalf("expected second round label deepseek_continue, got %v", r1["label"]) + } +} + +func TestCaptureRawSampleReturnsErrorWhenNoNewCaptureRecorded(t *testing.T) { + root := t.TempDir() + t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", root) + devcapture.Global().Clear() + defer devcapture.Global().Clear() + + recordCapturedResponse("preexisting", "https://chat.deepseek.com/api/v0/chat/completion", http.StatusOK, map[string]any{"model": "deepseek-chat"}, "data: {\"v\":\"old\"}\n\n") + + h := &Handler{OpenAI: stubOpenAIChatCallerWithoutCapture{}} + reqBody := `{ + "sample_id":"My Sample 03", + "api_key":"local-key", + "model":"deepseek-chat", + "message":"广州天气", + "stream":true + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/capture", strings.NewReader(reqBody)) + h.captureRawSample(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "no upstream capture was recorded") { + t.Fatalf("expected no-capture error, got %s", rec.Body.String()) + } + + if _, err := os.Stat(filepath.Join(root, "my-sample-03")); !os.IsNotExist(err) { + t.Fatalf("expected no sample dir to be created, stat err=%v", err) + } +} + +func TestCombineCaptureBodiesPreservesOrderAndSeparators(t *testing.T) { + entries := []devcapture.Entry{ + {ResponseBody: "first"}, + {ResponseBody: "second"}, + } + got := combineCaptureBodies(entries) + if !bytes.Equal(got, []byte("first\nsecond")) { + t.Fatalf("unexpected combined body: %q", string(got)) + } +} diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go index 46a92bc..a9bd699 100644 --- a/internal/admin/handler_settings_parse.go +++ b/internal/admin/handler_settings_parse.go @@ -21,10 +21,11 @@ func boolFrom(v any) bool { } } -func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) { +func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) { var ( adminCfg *config.AdminConfig runtimeCfg *config.RuntimeConfig + compatCfg *config.CompatConfig respCfg *config.ResponsesConfig embCfg *config.EmbeddingsConfig autoDeleteCfg *config.AutoDeleteConfig @@ -36,8 +37,8 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi cfg := &config.AdminConfig{} if v, exists := raw["jwt_expire_hours"]; exists { n := intFrom(v) - if n < 1 || n > 720 { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720") + if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.JWTExpireHours = n } @@ -48,44 +49,57 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi cfg := &config.RuntimeConfig{} if v, exists := raw["account_max_inflight"]; exists { n := intFrom(v) - if n < 1 || n > 256 { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256") + if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxInflight = n } if v, exists := raw["account_max_queue"]; exists { n := intFrom(v) - if n < 1 || n > 200000 { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000") + if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxQueue = n } if v, exists := raw["global_max_inflight"]; exists { n := intFrom(v) - if n < 1 || n > 200000 { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000") + if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.GlobalMaxInflight = n } if v, exists := raw["token_refresh_interval_hours"]; exists { n := intFrom(v) - if n < 1 || n > 720 { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.token_refresh_interval_hours must be between 1 and 720") + if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.TokenRefreshIntervalHours = n } if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } runtimeCfg = cfg } + if raw, ok := req["compat"].(map[string]any); ok { + cfg := &config.CompatConfig{} + if v, exists := raw["wide_input_strict_output"]; exists { + b := boolFrom(v) + cfg.WideInputStrictOutput = &b + } + if v, exists := raw["strip_reference_markers"]; exists { + b := boolFrom(v) + cfg.StripReferenceMarkers = &b + } + compatCfg = cfg + } + if raw, ok := req["responses"].(map[string]any); ok { cfg := &config.ResponsesConfig{} if v, exists := raw["store_ttl_seconds"]; exists { n := intFrom(v) - if n < 30 || n > 86400 { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400") + if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.StoreTTLSeconds = n } @@ -96,6 +110,9 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi cfg := &config.EmbeddingsConfig{} if v, exists := raw["provider"]; exists { p := strings.TrimSpace(fmt.Sprintf("%v", v)) + if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err + } cfg.Provider = p } embCfg = cfg @@ -127,11 +144,21 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if raw, ok := req["auto_delete"].(map[string]any); ok { cfg := &config.AutoDeleteConfig{} + if v, exists := raw["mode"]; exists { + mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) + if err := config.ValidateAutoDeleteMode(mode); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err + } + if mode == "" { + mode = "none" + } + cfg.Mode = mode + } if v, exists := raw["sessions"]; exists { cfg.Sessions = boolFrom(v) } autoDeleteCfg = cfg } - return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil + return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil } diff --git a/internal/admin/handler_settings_read.go b/internal/admin/handler_settings_read.go index 7556cc9..d881148 100644 --- a/internal/admin/handler_settings_read.go +++ b/internal/admin/handler_settings_read.go @@ -26,6 +26,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) { "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended), "token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(), }, + "compat": snap.Compat, "responses": snap.Responses, "embeddings": snap.Embeddings, "auto_delete": snap.AutoDelete, diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index feb2996..d698b67 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -82,6 +82,28 @@ func TestUpdateSettingsValidationRejectsTokenRefreshInterval(t *testing.T) { } } +func TestUpdateSettingsAllowsEmptyEmbeddingsProvider(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + payload := map[string]any{ + "responses": map[string]any{ + "store_ttl_seconds": 600, + }, + "embeddings": map[string]any{ + "provider": "", + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateSettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if got := h.Store.Snapshot().Responses.StoreTTLSeconds; got != 600 { + t.Fatalf("store_ttl_seconds=%d want=600", got) + } +} + func TestUpdateSettingsValidationWithMergedRuntimeSnapshot(t *testing.T) { h := newAdminTestHandler(t, `{ "keys":["k1"], @@ -132,6 +154,31 @@ func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T) } } +func TestUpdateSettingsAutoDeleteMode(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`) + + payload := map[string]any{ + "auto_delete": map[string]any{ + "mode": "single", + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateSettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + snap := h.Store.Snapshot() + if got := snap.AutoDelete.Mode; got != "single" { + t.Fatalf("auto_delete.mode=%q want=single", got) + } + if got := h.Store.AutoDeleteMode(); got != "single" { + t.Fatalf("AutoDeleteMode()=%q want=single", got) + } +} + func TestUpdateSettingsHotReloadRuntime(t *testing.T) { h := newAdminTestHandler(t, `{ "keys":["k1"], diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go index 8339b0c..776e6b9 100644 --- a/internal/admin/handler_settings_write.go +++ b/internal/admin/handler_settings_write.go @@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { return } - adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) + adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return @@ -49,6 +49,14 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours } } + if compatCfg != nil { + if compatCfg.WideInputStrictOutput != nil { + c.Compat.WideInputStrictOutput = compatCfg.WideInputStrictOutput + } + if compatCfg.StripReferenceMarkers != nil { + c.Compat.StripReferenceMarkers = compatCfg.StripReferenceMarkers + } + } if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 { c.Responses.StoreTTLSeconds = responsesCfg.StoreTTLSeconds } @@ -56,6 +64,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider) } if autoDeleteCfg != nil { + c.AutoDelete.Mode = autoDeleteCfg.Mode c.AutoDelete.Sessions = autoDeleteCfg.Sessions } if claudeMap != nil { diff --git a/internal/admin/settings_validation.go b/internal/admin/settings_validation.go index 9a03892..c18f955 100644 --- a/internal/admin/settings_validation.go +++ b/internal/admin/settings_validation.go @@ -1,7 +1,6 @@ package admin import ( - "fmt" "strings" "ds2api/internal/config" @@ -16,36 +15,9 @@ func normalizeSettingsConfig(c *config.Config) { } func validateSettingsConfig(c config.Config) error { - if c.Admin.JWTExpireHours != 0 && (c.Admin.JWTExpireHours < 1 || c.Admin.JWTExpireHours > 720) { - return fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720") - } - if err := validateRuntimeSettings(c.Runtime); err != nil { - return err - } - if c.Responses.StoreTTLSeconds != 0 && (c.Responses.StoreTTLSeconds < 30 || c.Responses.StoreTTLSeconds > 86400) { - return fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400") - } - if c.Embeddings.Provider != "" && strings.TrimSpace(c.Embeddings.Provider) == "" { - return fmt.Errorf("embeddings.provider cannot be empty") - } - return nil + return config.ValidateConfig(c) } func validateRuntimeSettings(runtime config.RuntimeConfig) error { - if runtime.AccountMaxInflight != 0 && (runtime.AccountMaxInflight < 1 || runtime.AccountMaxInflight > 256) { - return fmt.Errorf("runtime.account_max_inflight must be between 1 and 256") - } - if runtime.AccountMaxQueue != 0 && (runtime.AccountMaxQueue < 1 || runtime.AccountMaxQueue > 200000) { - return fmt.Errorf("runtime.account_max_queue must be between 1 and 200000") - } - if runtime.GlobalMaxInflight != 0 && (runtime.GlobalMaxInflight < 1 || runtime.GlobalMaxInflight > 200000) { - return fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000") - } - if runtime.TokenRefreshIntervalHours != 0 && (runtime.TokenRefreshIntervalHours < 1 || runtime.TokenRefreshIntervalHours > 720) { - return fmt.Errorf("runtime.token_refresh_interval_hours must be between 1 and 720") - } - if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight { - return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") - } - return nil + return config.ValidateRuntimeConfig(runtime) } diff --git a/internal/admin/token_runtime_http_test.go b/internal/admin/token_runtime_http_test.go index e23c1aa..3af3da0 100644 --- a/internal/admin/token_runtime_http_test.go +++ b/internal/admin/token_runtime_http_test.go @@ -17,7 +17,6 @@ import ( func newHTTPAdminHarness(t *testing.T, rawConfig string, ds DeepSeekCaller) http.Handler { t.Helper() t.Setenv("DS2API_CONFIG_JSON", rawConfig) - t.Setenv("CONFIG_JSON", "") store := config.LoadStore() h := &Handler{ Store: store, diff --git a/internal/compat/go_compat_test.go b/internal/compat/go_compat_test.go index 7768e4b..5358821 100644 --- a/internal/compat/go_compat_test.go +++ b/internal/compat/go_compat_test.go @@ -32,23 +32,36 @@ func TestGoCompatSSEFixtures(t *testing.T) { mustLoadJSON(t, fixturePath, &fixture) var expected struct { - Parts []map[string]any `json:"parts"` - Finished bool `json:"finished"` - NewType string `json:"new_type"` + Parts []map[string]any `json:"parts"` + Finished bool `json:"finished"` + NewType string `json:"new_type"` + ContentFilter bool `json:"content_filter"` + OutputTokens int `json:"output_tokens"` + ErrorMessage string `json:"error_message"` } mustLoadJSON(t, expectedPath, &expected) - parts, finished, newType := sse.ParseSSEChunkForContent(fixture.Chunk, fixture.ThinkingEnable, fixture.CurrentType) - gotParts := make([]map[string]any, 0, len(parts)) - for _, p := range parts { + raw, err := json.Marshal(fixture.Chunk) + if err != nil { + t.Fatalf("marshal fixture %s failed: %v", name, err) + } + res := sse.ParseDeepSeekContentLine(append([]byte("data: "), raw...), fixture.ThinkingEnable, fixture.CurrentType) + gotParts := make([]map[string]any, 0, len(res.Parts)) + for _, p := range res.Parts { gotParts = append(gotParts, map[string]any{ "text": p.Text, "type": p.Type, }) } - if !reflect.DeepEqual(gotParts, expected.Parts) || finished != expected.Finished || newType != expected.NewType { - t.Fatalf("fixture %s mismatch:\n got parts=%#v finished=%v newType=%q\nwant parts=%#v finished=%v newType=%q", - name, gotParts, finished, newType, expected.Parts, expected.Finished, expected.NewType) + if !reflect.DeepEqual(gotParts, expected.Parts) || + res.Stop != expected.Finished || + res.NextType != expected.NewType || + res.ContentFilter != expected.ContentFilter || + res.OutputTokens != expected.OutputTokens || + res.ErrorMessage != expected.ErrorMessage { + t.Fatalf("fixture %s mismatch:\n got parts=%#v finished=%v newType=%q contentFilter=%v outputTokens=%d errorMessage=%q\nwant parts=%#v finished=%v newType=%q contentFilter=%v outputTokens=%d errorMessage=%q", + name, gotParts, res.Stop, res.NextType, res.ContentFilter, res.OutputTokens, res.ErrorMessage, + expected.Parts, expected.Finished, expected.NewType, expected.ContentFilter, expected.OutputTokens, expected.ErrorMessage) } } } diff --git a/internal/config/codec.go b/internal/config/codec.go index 1e0b711..66a1e3c 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -35,7 +35,7 @@ func (c Config) MarshalJSON() ([]byte, error) { if c.Runtime.AccountMaxInflight > 0 || c.Runtime.AccountMaxQueue > 0 || c.Runtime.GlobalMaxInflight > 0 || c.Runtime.TokenRefreshIntervalHours > 0 { m["runtime"] = c.Runtime } - if c.Compat.WideInputStrictOutput != nil { + if c.Compat.WideInputStrictOutput != nil || c.Compat.StripReferenceMarkers != nil { m["compat"] = c.Compat } if c.Responses.StoreTTLSeconds > 0 { @@ -137,6 +137,7 @@ func (c Config) Clone() Config { Runtime: c.Runtime, Compat: CompatConfig{ WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput), + StripReferenceMarkers: cloneBoolPtr(c.Compat.StripReferenceMarkers), }, Responses: c.Responses, Embeddings: c.Embeddings, diff --git a/internal/config/config.go b/internal/config/config.go index ff545f6..17230bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,7 @@ func (c *Config) DropInvalidAccounts() { type CompatConfig struct { WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"` + StripReferenceMarkers *bool `json:"strip_reference_markers,omitempty"` } type AdminConfig struct { @@ -76,5 +77,6 @@ type EmbeddingsConfig struct { } type AutoDeleteConfig struct { - Sessions bool `json:"sessions"` + Mode string `json:"mode,omitempty"` + Sessions bool `json:"sessions,omitempty"` } diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index 791e54c..61a4a1b 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -97,6 +97,8 @@ func TestLowerFunction(t *testing.T) { // ─── Config.MarshalJSON / UnmarshalJSON roundtrip ──────────────────── func TestConfigJSONRoundtrip(t *testing.T) { + trueVal := true + falseVal := false cfg := Config{ Keys: []string{"key1", "key2"}, Accounts: []Account{{Email: "user@example.com", Password: "pass", Token: "tok"}}, @@ -104,9 +106,16 @@ func TestConfigJSONRoundtrip(t *testing.T) { "fast": "deepseek-chat", "slow": "deepseek-reasoner", }, + AutoDelete: AutoDeleteConfig{ + Mode: "single", + }, Runtime: RuntimeConfig{ TokenRefreshIntervalHours: 12, }, + Compat: CompatConfig{ + WideInputStrictOutput: &trueVal, + StripReferenceMarkers: &falseVal, + }, VercelSyncHash: "hash123", VercelSyncTime: 1234567890, AdditionalFields: map[string]any{ @@ -136,6 +145,15 @@ func TestConfigJSONRoundtrip(t *testing.T) { if decoded.Runtime.TokenRefreshIntervalHours != 12 { t.Fatalf("unexpected runtime refresh interval: %#v", decoded.Runtime.TokenRefreshIntervalHours) } + if decoded.AutoDelete.Mode != "single" { + t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode) + } + if decoded.Compat.WideInputStrictOutput == nil || !*decoded.Compat.WideInputStrictOutput { + t.Fatalf("unexpected compat wide_input_strict_output: %#v", decoded.Compat.WideInputStrictOutput) + } + if decoded.Compat.StripReferenceMarkers == nil || *decoded.Compat.StripReferenceMarkers { + t.Fatalf("unexpected compat strip_reference_markers: %#v", decoded.Compat.StripReferenceMarkers) + } if decoded.VercelSyncHash != "hash123" { t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash) } @@ -144,6 +162,29 @@ func TestConfigJSONRoundtrip(t *testing.T) { } } +func TestAutoDeleteModeResolution(t *testing.T) { + tests := []struct { + name string + cfg AutoDeleteConfig + want string + }{ + {name: "default", cfg: AutoDeleteConfig{}, want: "none"}, + {name: "legacy all", cfg: AutoDeleteConfig{Sessions: true}, want: "all"}, + {name: "single", cfg: AutoDeleteConfig{Mode: "single"}, want: "single"}, + {name: "all", cfg: AutoDeleteConfig{Mode: "all"}, want: "all"}, + {name: "none", cfg: AutoDeleteConfig{Mode: "none"}, want: "none"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store := &Store{cfg: Config{AutoDelete: tc.cfg}} + if got := store.AutoDeleteMode(); got != tc.want { + t.Fatalf("AutoDeleteMode()=%q want=%q", got, tc.want) + } + }) + } +} + func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) { raw := `{"keys":["k1"],"accounts":[],"my_custom_field":"hello","number_field":42}` var cfg Config @@ -162,12 +203,16 @@ func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) { // ─── Config.Clone ──────────────────────────────────────────────────── func TestConfigCloneIsDeepCopy(t *testing.T) { + falseVal := false cfg := Config{ Keys: []string{"key1"}, Accounts: []Account{{Email: "user@test.com", Token: "token"}}, ClaudeMapping: map[string]string{ "fast": "deepseek-chat", }, + Compat: CompatConfig{ + StripReferenceMarkers: &falseVal, + }, AdditionalFields: map[string]any{"custom": "value"}, } @@ -177,6 +222,9 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { cfg.Keys[0] = "modified" cfg.Accounts[0].Email = "modified@test.com" cfg.ClaudeMapping["fast"] = "modified-model" + if cfg.Compat.StripReferenceMarkers != nil { + *cfg.Compat.StripReferenceMarkers = true + } // Cloned should not be affected if cloned.Keys[0] != "key1" { @@ -188,6 +236,9 @@ func TestConfigCloneIsDeepCopy(t *testing.T) { if cloned.ClaudeMapping["fast"] != "deepseek-chat" { t.Fatalf("clone claude mapping was affected: %#v", cloned.ClaudeMapping) } + if cloned.Compat.StripReferenceMarkers == nil || *cloned.Compat.StripReferenceMarkers { + t.Fatalf("clone compat was affected: %#v", cloned.Compat.StripReferenceMarkers) + } } func TestConfigCloneNilMaps(t *testing.T) { @@ -359,6 +410,39 @@ func TestStoreCompatWideInputStrictOutputCanDisable(t *testing.T) { } } +func TestStoreCompatStripReferenceMarkersDefaultTrue(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`) + store := LoadStore() + if !store.CompatStripReferenceMarkers() { + t.Fatal("expected default strip_reference_markers=true when unset") + } +} + +func TestStoreCompatStripReferenceMarkersCanDisable(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[],"compat":{"strip_reference_markers":false}}`) + store := LoadStore() + if store.CompatStripReferenceMarkers() { + t.Fatal("expected strip_reference_markers=false when explicitly configured") + } + + snap := store.Snapshot() + data, err := snap.MarshalJSON() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("decode failed: %v", err) + } + rawCompat, ok := out["compat"].(map[string]any) + if !ok { + t.Fatalf("expected compat in marshaled output, got %#v", out) + } + if rawCompat["strip_reference_markers"] != false { + t.Fatalf("expected explicit false in compat, got %#v", rawCompat) + } +} + func TestStoreIsEnvBacked(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`) store := LoadStore() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a489093..2cc0d3d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -67,7 +67,6 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) { } t.Setenv("DS2API_CONFIG_JSON", "") - t.Setenv("CONFIG_JSON", "") t.Setenv("DS2API_CONFIG_PATH", tmp.Name()) store := LoadStore() @@ -80,6 +79,31 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) { } } +func TestLoadStoreIgnoresLegacyConfigJSONEnv(t *testing.T) { + tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") + if err != nil { + t.Fatalf("create temp config: %v", err) + } + path := tmp.Name() + _ = tmp.Close() + _ = os.Remove(path) + + t.Setenv("DS2API_CONFIG_JSON", "") + t.Setenv("CONFIG_JSON", `{"keys":["legacy-key"],"accounts":[{"email":"legacy@example.com","password":"p"}]}`) + t.Setenv("DS2API_CONFIG_PATH", path) + + store := LoadStore() + if store.HasEnvConfigSource() { + t.Fatal("expected legacy CONFIG_JSON to be ignored") + } + if store.IsEnvBacked() { + t.Fatal("expected store to remain file-backed/empty when only CONFIG_JSON is set") + } + if len(store.Keys()) != 0 || len(store.Accounts()) != 0 { + t.Fatalf("expected ignored legacy env to leave store empty, got keys=%d accounts=%d", len(store.Keys()), len(store.Accounts())) + } +} + func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) { tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") if err != nil { @@ -90,7 +114,6 @@ func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) { _ = os.Remove(path) t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"seed@example.com","password":"p"}]}`) - t.Setenv("CONFIG_JSON", "") t.Setenv("DS2API_CONFIG_PATH", path) t.Setenv("DS2API_ENV_WRITEBACK", "1") @@ -135,7 +158,6 @@ func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidEnvJSON(t *testing.T) { _ = os.Remove(path) t.Setenv("DS2API_CONFIG_JSON", "{invalid-json") - t.Setenv("CONFIG_JSON", "") t.Setenv("DS2API_CONFIG_PATH", path) t.Setenv("DS2API_ENV_WRITEBACK", "1") @@ -154,6 +176,56 @@ func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidEnvJSON(t *testing.T) { } } +func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidSemanticConfig(t *testing.T) { + tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") + if err != nil { + t.Fatalf("create temp config: %v", err) + } + path := tmp.Name() + _ = tmp.Close() + _ = os.Remove(path) + + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "accounts":[{"email":"seed@example.com","password":"p"}], + "runtime":{"account_max_inflight":300} + }`) + t.Setenv("DS2API_CONFIG_PATH", path) + t.Setenv("DS2API_ENV_WRITEBACK", "1") + + cfg, fromEnv, loadErr := loadConfig() + if loadErr == nil { + t.Fatalf("expected loadConfig error for invalid runtime config") + } + if !fromEnv { + t.Fatalf("expected fromEnv=true when env config is the source") + } + if !strings.Contains(loadErr.Error(), "runtime.account_max_inflight") { + t.Fatalf("expected runtime validation error, got %v", loadErr) + } + if len(cfg.Keys) != 1 || len(cfg.Accounts) != 1 { + t.Fatalf("expected env config to be parsed before validation, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts)) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected invalid config not to be bootstrapped, stat err=%v", statErr) + } +} + +func TestLoadStoreWithErrorRejectsInvalidRuntimeConfig(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "accounts":[{"email":"u@example.com","password":"p"}], + "runtime":{"account_max_inflight":300} + }`) + t.Setenv("DS2API_ENV_WRITEBACK", "0") + + if _, err := LoadStoreWithError(); err == nil { + t.Fatal("expected LoadStoreWithError to reject invalid runtime config") + } else if !strings.Contains(err.Error(), "runtime.account_max_inflight") { + t.Fatalf("expected runtime validation error, got %v", err) + } +} + func TestEnvBackedStoreWritebackFallsBackToPersistedFileOnInvalidEnvJSON(t *testing.T) { tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") if err != nil { @@ -166,7 +238,6 @@ func TestEnvBackedStoreWritebackFallsBackToPersistedFileOnInvalidEnvJSON(t *test _ = tmp.Close() t.Setenv("DS2API_CONFIG_JSON", "{invalid-json") - t.Setenv("CONFIG_JSON", "") t.Setenv("DS2API_CONFIG_PATH", path) t.Setenv("DS2API_ENV_WRITEBACK", "1") @@ -265,7 +336,6 @@ func TestParseConfigStringSupportsRawURLBase64(t *testing.T) { func TestLoadConfigOnVercelWithoutConfigFileFallsBackToMemory(t *testing.T) { t.Setenv("VERCEL", "1") t.Setenv("DS2API_CONFIG_JSON", "") - t.Setenv("CONFIG_JSON", "") t.Setenv("DS2API_CONFIG_PATH", "testdata/does-not-exist.json") cfg, fromEnv, err := loadConfig() @@ -293,7 +363,6 @@ func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) { } t.Setenv("DS2API_CONFIG_JSON", "") - t.Setenv("CONFIG_JSON", "") t.Setenv("DS2API_CONFIG_PATH", tmp.Name()) store := LoadStore() diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go new file mode 100644 index 0000000..c33d2b0 --- /dev/null +++ b/internal/config/dotenv.go @@ -0,0 +1,137 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// LoadDotEnv loads environment variables from .env in the current working +// directory without overriding variables that are already set. +func LoadDotEnv() error { + return loadDotEnvFromPath(filepath.Join(BaseDir(), ".env")) +} + +func loadDotEnvFromPath(path string) error { + content, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") + for i, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if i == 0 { + line = strings.TrimPrefix(line, "\ufeff") + } + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + return fmt.Errorf("%s:%d invalid env assignment", path, i+1) + } + key = strings.TrimSpace(key) + if key == "" { + return fmt.Errorf("%s:%d empty env key", path, i+1) + } + if _, exists := os.LookupEnv(key); exists { + continue + } + if err := os.Setenv(key, normalizeDotEnvValue(trimDotEnvValue(strings.TrimSpace(value)))); err != nil { + return fmt.Errorf("%s:%d set env %q: %w", path, i+1, key, err) + } + } + + return nil +} + +// Preserve quoted values, but drop Compose-style inline comments from unquoted values. +func trimDotEnvValue(raw string) string { + if raw == "" { + return raw + } + + switch raw[0] { + case '"': + if trimmed, ok := trimQuotedDotEnvValue(raw, '"'); ok { + return trimmed + } + case '\'': + if trimmed, ok := trimQuotedDotEnvValue(raw, '\''); ok { + return trimmed + } + default: + if idx := inlineDotEnvCommentStart(raw); idx >= 0 { + return strings.TrimSpace(raw[:idx]) + } + } + + return raw +} + +func trimQuotedDotEnvValue(raw string, quote byte) (string, bool) { + escaped := false + for i := 1; i < len(raw); i++ { + ch := raw[i] + if quote == '"' && escaped { + escaped = false + continue + } + if quote == '"' && ch == '\\' { + escaped = true + continue + } + if ch == quote { + return strings.TrimSpace(raw[:i+1]), true + } + } + return raw, false +} + +func inlineDotEnvCommentStart(raw string) int { + for i := 1; i < len(raw); i++ { + if raw[i] == '#' && isDotEnvCommentSpacer(raw[i-1]) { + return i + } + } + return -1 +} + +func isDotEnvCommentSpacer(b byte) bool { + return b == ' ' || b == '\t' +} + +func normalizeDotEnvValue(raw string) string { + if len(raw) < 2 { + return raw + } + first := raw[0] + last := raw[len(raw)-1] + if (first != '"' || last != '"') && (first != '\'' || last != '\'') { + return raw + } + + raw = raw[1 : len(raw)-1] + if first == '\'' { + return raw + } + + replacer := strings.NewReplacer( + `\\`, `\`, + `\n`, "\n", + `\r`, "\r", + `\t`, "\t", + `\"`, `"`, + ) + return replacer.Replace(raw) +} diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go new file mode 100644 index 0000000..2e8a3a8 --- /dev/null +++ b/internal/config/dotenv_test.go @@ -0,0 +1,135 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadDotEnvLoadsWorkingDirectoryFileWithoutOverridingExistingEnv(t *testing.T) { + dir := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + const newKey = "DS2API_TEST_DOTENV_NEW" + const keepKey = "DS2API_TEST_DOTENV_KEEP" + const quotedKey = "DS2API_TEST_DOTENV_QUOTED" + + unsetEnv(t, newKey) + unsetEnv(t, quotedKey) + t.Setenv(keepKey, "from-env") + + content := "DS2API_TEST_DOTENV_NEW=from-file\n" + + "DS2API_TEST_DOTENV_KEEP=from-file\n" + + "DS2API_TEST_DOTENV_QUOTED=\"line1\\nline2\"\n" + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(content), 0o644); err != nil { + t.Fatalf("write .env: %v", err) + } + + if err := LoadDotEnv(); err != nil { + t.Fatalf("LoadDotEnv() error: %v", err) + } + + if got := os.Getenv(newKey); got != "from-file" { + t.Fatalf("expected %s from .env, got %q", newKey, got) + } + if got := os.Getenv(keepKey); got != "from-env" { + t.Fatalf("expected existing env to win, got %q", got) + } + if got := os.Getenv(quotedKey); got != "line1\nline2" { + t.Fatalf("expected quoted newline decoding, got %q", got) + } +} + +func TestLoadDotEnvIgnoresMissingFile(t *testing.T) { + dir := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + if err := LoadDotEnv(); err != nil { + t.Fatalf("expected missing .env to be ignored, got %v", err) + } +} + +func TestLoadDotEnvStripsInlineCommentsFromUnquotedValues(t *testing.T) { + dir := t.TempDir() + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldWD) + }) + + const plainKey = "DS2API_TEST_DOTENV_PLAIN" + const hashKey = "DS2API_TEST_DOTENV_HASH" + const quotedKey = "DS2API_TEST_DOTENV_QUOTED_COMMENT" + const exportKey = "DS2API_TEST_DOTENV_EXPORT" + + unsetEnv(t, plainKey) + unsetEnv(t, hashKey) + unsetEnv(t, quotedKey) + unsetEnv(t, exportKey) + + content := strings.Join([]string{ + plainKey + "=5001 # local", + hashKey + "=5001#local", + quotedKey + `="5001 # local" # keep the inner hash`, + "export " + exportKey + "=enabled # exported", + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(content), 0o644); err != nil { + t.Fatalf("write .env: %v", err) + } + + if err := LoadDotEnv(); err != nil { + t.Fatalf("LoadDotEnv() error: %v", err) + } + + if got := os.Getenv(plainKey); got != "5001" { + t.Fatalf("expected inline comment to be stripped, got %q", got) + } + if got := os.Getenv(hashKey); got != "5001#local" { + t.Fatalf("expected hash without preceding whitespace to remain, got %q", got) + } + if got := os.Getenv(quotedKey); got != "5001 # local" { + t.Fatalf("expected quoted value to preserve hash text, got %q", got) + } + if got := os.Getenv(exportKey); got != "enabled" { + t.Fatalf("expected export syntax to load, got %q", got) + } +} + +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, had := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, old) + return + } + _ = os.Unsetenv(key) + }) +} diff --git a/internal/config/logger.go b/internal/config/logger.go index 8b2de91..e88fee1 100644 --- a/internal/config/logger.go +++ b/internal/config/logger.go @@ -23,3 +23,7 @@ func newLogger() *slog.Logger { h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) return slog.New(h) } + +func RefreshLogger() { + Logger = newLogger() +} diff --git a/internal/config/paths.go b/internal/config/paths.go index 23dfe54..18723a3 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -37,6 +37,10 @@ func WASMPath() string { return ResolvePath("DS2API_WASM_PATH", "sha3_wasm_bg.7b9ca65ddd.wasm") } +func RawStreamSampleRoot() string { + return ResolvePath("DS2API_RAW_STREAM_SAMPLE_ROOT", "tests/raw_stream_samples") +} + func StaticAdminDir() string { return ResolvePath("DS2API_STATIC_ADMIN_DIR", "static/admin") } diff --git a/internal/config/store.go b/internal/config/store.go index 310ea91..ebee6b0 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -21,23 +21,36 @@ type Store struct { } func LoadStore() *Store { - cfg, fromEnv, err := loadConfig() + store, err := loadStore() if err != nil { Logger.Warn("[config] load failed", "error", err) } - if len(cfg.Keys) == 0 && len(cfg.Accounts) == 0 { + if len(store.cfg.Keys) == 0 && len(store.cfg.Accounts) == 0 { Logger.Warn("[config] empty config loaded") } - s := &Store{cfg: cfg, path: ConfigPath(), fromEnv: fromEnv} - s.rebuildIndexes() - return s + store.rebuildIndexes() + return store +} + +func LoadStoreWithError() (*Store, error) { + store, err := loadStore() + if err != nil { + return nil, err + } + store.rebuildIndexes() + return store, nil +} + +func loadStore() (*Store, error) { + cfg, fromEnv, err := loadConfig() + if validateErr := ValidateConfig(cfg); validateErr != nil { + err = errors.Join(err, validateErr) + } + return &Store{cfg: cfg, path: ConfigPath(), fromEnv: fromEnv}, err } func loadConfig() (Config, bool, error) { rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON")) - if rawCfg == "" { - rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON")) - } if rawCfg != "" { cfg, err := parseConfigString(rawCfg) if err != nil { @@ -62,6 +75,9 @@ func loadConfig() (Config, bool, error) { } } if errors.Is(fileErr, os.ErrNotExist) { + if validateErr := ValidateConfig(cfg); validateErr != nil { + return cfg, true, validateErr + } if writeErr := writeConfigFile(ConfigPath(), cfg.Clone()); writeErr == nil { return cfg, false, err } else { diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index 691c99e..ff152a7 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -42,6 +42,15 @@ func (s *Store) CompatWideInputStrictOutput() bool { return *s.cfg.Compat.WideInputStrictOutput } +func (s *Store) CompatStripReferenceMarkers() bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.Compat.StripReferenceMarkers == nil { + return true + } + return *s.cfg.Compat.StripReferenceMarkers +} + func (s *Store) ToolcallMode() string { return "feature_match" } @@ -65,6 +74,20 @@ func (s *Store) EmbeddingsProvider() string { return strings.TrimSpace(s.cfg.Embeddings.Provider) } +func (s *Store) AutoDeleteMode() string { + s.mu.RLock() + defer s.mu.RUnlock() + mode := strings.ToLower(strings.TrimSpace(s.cfg.AutoDelete.Mode)) + switch mode { + case "none", "single", "all": + return mode + } + if s.cfg.AutoDelete.Sessions { + return "all" + } + return "none" +} + func (s *Store) AdminPasswordHash() string { s.mu.RLock() defer s.mu.RUnlock() @@ -97,13 +120,8 @@ func (s *Store) RuntimeAccountMaxInflight() int { if s.cfg.Runtime.AccountMaxInflight > 0 { return s.cfg.Runtime.AccountMaxInflight } - for _, key := range []string{"DS2API_ACCOUNT_MAX_INFLIGHT", "DS2API_ACCOUNT_CONCURRENCY"} { - raw := strings.TrimSpace(os.Getenv(key)) - if raw == "" { - continue - } - n, err := strconv.Atoi(raw) - if err == nil && n > 0 { + if raw := strings.TrimSpace(os.Getenv("DS2API_ACCOUNT_MAX_INFLIGHT")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { return n } } @@ -116,13 +134,8 @@ func (s *Store) RuntimeAccountMaxQueue(defaultSize int) int { if s.cfg.Runtime.AccountMaxQueue > 0 { return s.cfg.Runtime.AccountMaxQueue } - for _, key := range []string{"DS2API_ACCOUNT_MAX_QUEUE", "DS2API_ACCOUNT_QUEUE_SIZE"} { - raw := strings.TrimSpace(os.Getenv(key)) - if raw == "" { - continue - } - n, err := strconv.Atoi(raw) - if err == nil && n >= 0 { + if raw := strings.TrimSpace(os.Getenv("DS2API_ACCOUNT_MAX_QUEUE")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n >= 0 { return n } } @@ -138,13 +151,8 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int { if s.cfg.Runtime.GlobalMaxInflight > 0 { return s.cfg.Runtime.GlobalMaxInflight } - for _, key := range []string{"DS2API_GLOBAL_MAX_INFLIGHT", "DS2API_MAX_INFLIGHT"} { - raw := strings.TrimSpace(os.Getenv(key)) - if raw == "" { - continue - } - n, err := strconv.Atoi(raw) - if err == nil && n > 0 { + if raw := strings.TrimSpace(os.Getenv("DS2API_GLOBAL_MAX_INFLIGHT")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { return n } } @@ -164,7 +172,5 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int { } func (s *Store) AutoDeleteSessions() bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.cfg.AutoDelete.Sessions + return s.AutoDeleteMode() != "none" } diff --git a/internal/config/store_env_writeback.go b/internal/config/store_env_writeback.go index 35e315c..1872317 100644 --- a/internal/config/store_env_writeback.go +++ b/internal/config/store_env_writeback.go @@ -19,9 +19,6 @@ func (s *Store) IsEnvWritebackEnabled() bool { func (s *Store) HasEnvConfigSource() bool { rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON")) - if rawCfg == "" { - rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON")) - } return rawCfg != "" } diff --git a/internal/config/validation.go b/internal/config/validation.go new file mode 100644 index 0000000..eb33abb --- /dev/null +++ b/internal/config/validation.go @@ -0,0 +1,91 @@ +package config + +import ( + "fmt" + "strings" +) + +func ValidateConfig(c Config) error { + if err := ValidateAdminConfig(c.Admin); err != nil { + return err + } + if err := ValidateRuntimeConfig(c.Runtime); err != nil { + return err + } + if err := ValidateResponsesConfig(c.Responses); err != nil { + return err + } + if err := ValidateEmbeddingsConfig(c.Embeddings); err != nil { + return err + } + if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil { + return err + } + return nil +} + +func ValidateAdminConfig(admin AdminConfig) error { + return ValidateIntRange("admin.jwt_expire_hours", admin.JWTExpireHours, 1, 720, false) +} + +func ValidateRuntimeConfig(runtime RuntimeConfig) error { + if err := ValidateIntRange("runtime.account_max_inflight", runtime.AccountMaxInflight, 1, 256, false); err != nil { + return err + } + if err := ValidateIntRange("runtime.account_max_queue", runtime.AccountMaxQueue, 1, 200000, false); err != nil { + return err + } + if err := ValidateIntRange("runtime.global_max_inflight", runtime.GlobalMaxInflight, 1, 200000, false); err != nil { + return err + } + if err := ValidateIntRange("runtime.token_refresh_interval_hours", runtime.TokenRefreshIntervalHours, 1, 720, false); err != nil { + return err + } + if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight { + return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") + } + return nil +} + +func ValidateResponsesConfig(responses ResponsesConfig) error { + return ValidateIntRange("responses.store_ttl_seconds", responses.StoreTTLSeconds, 30, 86400, false) +} + +func ValidateEmbeddingsConfig(embeddings EmbeddingsConfig) error { + return ValidateTrimmedString("embeddings.provider", embeddings.Provider, false) +} + +func ValidateAutoDeleteConfig(autoDelete AutoDeleteConfig) error { + return ValidateAutoDeleteMode(autoDelete.Mode) +} + +func ValidateIntRange(name string, value, min, max int, required bool) error { + if value == 0 && !required { + return nil + } + if value < min || value > max { + return fmt.Errorf("%s must be between %d and %d", name, min, max) + } + return nil +} + +func ValidateTrimmedString(name, value string, required bool) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + if !required && value == "" { + return nil + } + return fmt.Errorf("%s cannot be empty", name) + } + return nil +} + +func ValidateAutoDeleteMode(mode string) error { + mode = strings.ToLower(strings.TrimSpace(mode)) + switch mode { + case "", "none", "single", "all": + return nil + default: + return fmt.Errorf("auto_delete.mode must be one of none, single, all") + } +} diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go new file mode 100644 index 0000000..00b2929 --- /dev/null +++ b/internal/config/validation_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidateConfigRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + cfg Config + want string + }{ + { + name: "admin", + cfg: Config{Admin: AdminConfig{JWTExpireHours: 721}}, + want: "admin.jwt_expire_hours", + }, + { + name: "runtime relation", + cfg: Config{Runtime: RuntimeConfig{ + AccountMaxInflight: 8, + GlobalMaxInflight: 4, + }}, + want: "runtime.global_max_inflight must be >= runtime.account_max_inflight", + }, + { + name: "responses", + cfg: Config{Responses: ResponsesConfig{StoreTTLSeconds: 10}}, + want: "responses.store_ttl_seconds", + }, + { + name: "embeddings", + cfg: Config{Embeddings: EmbeddingsConfig{Provider: " "}}, + want: "embeddings.provider", + }, + { + name: "auto delete", + cfg: Config{AutoDelete: AutoDeleteConfig{Mode: "maybe"}}, + want: "auto_delete.mode", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateConfig(tc.cfg) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q in error, got %v", tc.want, err) + } + }) + } +} + +func TestValidateConfigAcceptsLegacyAutoDeleteSessions(t *testing.T) { + if err := ValidateConfig(Config{AutoDelete: AutoDeleteConfig{Sessions: true}}); err != nil { + t.Fatalf("expected legacy auto_delete.sessions config to remain valid, got %v", err) + } +} diff --git a/internal/deepseek/client_completion.go b/internal/deepseek/client_completion.go index 051bffe..8f24cdd 100644 --- a/internal/deepseek/client_completion.go +++ b/internal/deepseek/client_completion.go @@ -31,6 +31,7 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa if captureSession != nil { resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode) } + resp = c.wrapCompletionWithAutoContinue(ctx, a, payload, powResp, resp) return resp, nil } if captureSession != nil { @@ -60,7 +61,7 @@ func (c *Client) streamPost(ctx context.Context, url string, headers map[string] config.Logger.Warn("[deepseek] fingerprint stream request failed, fallback to std transport", "url", url, "error", err) req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) if reqErr != nil { - return nil, err + return nil, reqErr } for k, v := range headers { req2.Header.Set(k, v) diff --git a/internal/deepseek/client_continue.go b/internal/deepseek/client_continue.go new file mode 100644 index 0000000..605d9e5 --- /dev/null +++ b/internal/deepseek/client_continue.go @@ -0,0 +1,241 @@ +package deepseek + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "ds2api/internal/auth" + "ds2api/internal/config" +) + +const defaultAutoContinueLimit = 8 + +type continueOpenFunc func(context.Context, string, int) (*http.Response, error) + +type continueState struct { + sessionID string + responseMessageID int + lastStatus string + finished bool +} + +// wrapCompletionWithAutoContinue wraps the completion response body so that +// if the upstream indicates the response is incomplete (WIP / INCOMPLETE / +// AUTO_CONTINUE), ds2api will automatically call the DeepSeek continue +// endpoint and splice the continuation SSE stream onto the original. +// The caller sees a single, seamless SSE stream. +func (c *Client) wrapCompletionWithAutoContinue(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, resp *http.Response) *http.Response { + if resp == nil || resp.Body == nil { + return resp + } + sessionID, _ := payload["chat_session_id"].(string) + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return resp + } + config.Logger.Debug("[auto_continue] wrapping completion response", "session_id", sessionID) + resp.Body = newAutoContinueBody(ctx, resp.Body, sessionID, defaultAutoContinueLimit, func(ctx context.Context, sessionID string, responseMessageID int) (*http.Response, error) { + return c.callContinue(ctx, a, sessionID, responseMessageID, powResp) + }) + return resp +} + +// callContinue sends a continue request to DeepSeek to resume generation. +func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionID string, responseMessageID int, powResp string) (*http.Response, error) { + if strings.TrimSpace(sessionID) == "" || responseMessageID <= 0 { + return nil, errors.New("missing continue identifiers") + } + headers := c.authHeaders(a.DeepSeekToken) + headers["x-ds-pow-response"] = powResp + payload := map[string]any{ + "chat_session_id": sessionID, + "message_id": responseMessageID, + "fallback_to_resume": true, + } + config.Logger.Info("[auto_continue] calling continue", "session_id", sessionID, "message_id", responseMessageID) + captureSession := c.capture.Start("deepseek_continue", DeepSeekContinueURL, a.AccountID, payload) + resp, err := c.streamPost(ctx, DeepSeekContinueURL, headers, payload) + if err != nil { + return nil, err + } + if captureSession != nil { + resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, errors.New("continue failed") + } + return resp, nil +} + +// newAutoContinueBody returns a new ReadCloser that transparently pumps +// continuation rounds via an io.Pipe. +func newAutoContinueBody(ctx context.Context, initial io.ReadCloser, sessionID string, maxRounds int, openContinue continueOpenFunc) io.ReadCloser { + if initial == nil || strings.TrimSpace(sessionID) == "" || openContinue == nil { + return initial + } + if maxRounds <= 0 { + maxRounds = defaultAutoContinueLimit + } + pr, pw := io.Pipe() + go pumpAutoContinue(ctx, pw, initial, continueState{sessionID: sessionID}, maxRounds, openContinue) + return pr +} + +// pumpAutoContinue is the goroutine that drives the auto-continue loop. +// It reads the initial SSE body, checks whether a continue is required, +// and if so opens a new continue stream and splices it onto the pipe writer. +func pumpAutoContinue(ctx context.Context, pw *io.PipeWriter, initial io.ReadCloser, state continueState, maxRounds int, openContinue continueOpenFunc) { + defer func() { _ = pw.Close() }() + current := initial + rounds := 0 + for { + hadDone, err := streamBodyWithContinueState(ctx, pw, current, &state) + _ = current.Close() + if err != nil { + _ = pw.CloseWithError(err) + return + } + if state.shouldContinue() && rounds < maxRounds { + rounds++ + config.Logger.Info("[auto_continue] continuing", "round", rounds, "session_id", state.sessionID, "message_id", state.responseMessageID, "status", state.lastStatus) + nextResp, err := openContinue(ctx, state.sessionID, state.responseMessageID) + if err != nil { + config.Logger.Warn("[auto_continue] continue request failed", "round", rounds, "error", err) + _ = pw.CloseWithError(err) + return + } + current = nextResp.Body + state.prepareForNextRound() + continue + } + // Emit the final [DONE] sentinel if the upstream had one. + if hadDone { + if _, err := io.Copy(pw, bytes.NewBufferString("data: [DONE]\n")); err != nil { + _ = pw.CloseWithError(err) + } + } + return + } +} + +// streamBodyWithContinueState scans an SSE body line-by-line, writing each +// line through to pw while observing state signals. Intermediate [DONE] +// sentinels are consumed (not forwarded) so that the downstream only sees +// one final [DONE] at the very end. +func streamBodyWithContinueState(ctx context.Context, pw *io.PipeWriter, body io.Reader, state *continueState) (bool, error) { + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + hadDone := false + for scanner.Scan() { + select { + case <-ctx.Done(): + return hadDone, ctx.Err() + default: + } + line := append([]byte{}, scanner.Bytes()...) + trimmed := strings.TrimSpace(string(line)) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if data == "[DONE]" { + hadDone = true + continue + } + state.observe(data) + } + if _, err := io.Copy(pw, bytes.NewReader(append(line, '\n'))); err != nil { + return hadDone, err + } + } + return hadDone, scanner.Err() +} + +// observe extracts continue-relevant signals from an SSE JSON chunk. +func (s *continueState) observe(data string) { + if s == nil || strings.TrimSpace(data) == "" { + return + } + var chunk map[string]any + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + return + } + // Top-level response_message_id + if id := intFrom(chunk["response_message_id"]); id > 0 { + s.responseMessageID = id + } + // Path-based status: {"p": "response/status", "v": "FINISHED"} + if p, _ := chunk["p"].(string); p == "response/status" { + if status, _ := chunk["v"].(string); status != "" { + s.lastStatus = strings.TrimSpace(status) + if strings.EqualFold(s.lastStatus, "FINISHED") { + s.finished = true + } + } + } + // Nested v.response + v, _ := chunk["v"].(map[string]any) + if response, _ := v["response"].(map[string]any); response != nil { + if id := intFrom(response["message_id"]); id > 0 { + s.responseMessageID = id + } + if status, _ := response["status"].(string); status != "" { + s.lastStatus = strings.TrimSpace(status) + if strings.EqualFold(s.lastStatus, "FINISHED") { + s.finished = true + } + } + if autoContinue, ok := response["auto_continue"].(bool); ok && autoContinue { + s.lastStatus = "AUTO_CONTINUE" + } + } + // Nested message.response + if message, _ := chunk["message"].(map[string]any); message != nil { + if response, _ := message["response"].(map[string]any); response != nil { + if id := intFrom(response["message_id"]); id > 0 { + s.responseMessageID = id + } + if status, _ := response["status"].(string); status != "" { + s.lastStatus = strings.TrimSpace(status) + if strings.EqualFold(s.lastStatus, "FINISHED") { + s.finished = true + } + } + } + } +} + +// shouldContinue returns true when the upstream indicates the response is +// not yet finished and we have enough information to issue a continue request. +func (s *continueState) shouldContinue() bool { + if s == nil { + return false + } + if s.finished || s.responseMessageID <= 0 || strings.TrimSpace(s.sessionID) == "" { + return false + } + switch strings.ToUpper(strings.TrimSpace(s.lastStatus)) { + case "WIP", "INCOMPLETE", "AUTO_CONTINUE": + return true + default: + return false + } +} + +// prepareForNextRound resets ephemeral state before processing the next +// continuation stream. +func (s *continueState) prepareForNextRound() { + if s == nil { + return + } + s.finished = false + s.lastStatus = "" +} diff --git a/internal/deepseek/client_continue_test.go b/internal/deepseek/client_continue_test.go new file mode 100644 index 0000000..68963e7 --- /dev/null +++ b/internal/deepseek/client_continue_test.go @@ -0,0 +1,137 @@ +package deepseek + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "ds2api/internal/auth" +) + +type failingDoer struct { + err error +} + +func (d failingDoer) Do(_ *http.Request) (*http.Response, error) { + return nil, d.err +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestCallContinuePropagatesPowHeaderToFallbackRequest(t *testing.T) { + var seenPow string + var seenURL string + + client := &Client{ + stream: failingDoer{err: errors.New("stream transport failed")}, + fallbackS: &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenPow = req.Header.Get("x-ds-pow-response") + seenURL = req.URL.String() + body := io.NopCloser(strings.NewReader("data: {\"p\":\"response/content\",\"v\":\"continued\"}\n" + "data: [DONE]\n")) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: body, + Request: req, + }, nil + }), + }, + } + + resp, err := client.callContinue(context.Background(), &auth.RequestAuth{ + DeepSeekToken: "token", + AccountID: "acct", + }, "session-123", 99, "pow-response-abc") + if err != nil { + t.Fatalf("callContinue returned error: %v", err) + } + defer resp.Body.Close() + + if seenPow != "pow-response-abc" { + t.Fatalf("continue request pow header=%q want=%q", seenPow, "pow-response-abc") + } + if seenURL != DeepSeekContinueURL { + t.Fatalf("continue request url=%q want=%q", seenURL, DeepSeekContinueURL) + } +} + +func TestCallCompletionAutoContinueThreadsPowHeader(t *testing.T) { + var seenPow string + var seenContinueURL string + + initialBody := strings.Join([]string{ + `data: {"response_message_id":321,"v":{"response":{"message_id":321,"status":"WIP","auto_continue":true}}}`, + `data: [DONE]`, + }, "\n") + "\n" + + client := &Client{ + stream: failingOrCompletionDoer{ + completionResp: &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(initialBody)), + }, + }, + fallbackS: &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + seenPow = req.Header.Get("x-ds-pow-response") + seenContinueURL = req.URL.String() + body := io.NopCloser(strings.NewReader("data: {\"response_message_id\":322,\"v\":{\"response\":{\"message_id\":322,\"status\":\"FINISHED\"}}}\n" + "data: [DONE]\n")) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: body, + Request: req, + }, nil + }), + }, + } + + resp, err := client.CallCompletion(context.Background(), &auth.RequestAuth{ + DeepSeekToken: "token", + AccountID: "acct", + }, map[string]any{ + "chat_session_id": "session-123", + }, "pow-response-xyz", 1) + if err != nil { + t.Fatalf("CallCompletion returned error: %v", err) + } + defer resp.Body.Close() + + out, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read auto-continued body failed: %v", err) + } + if seenPow != "pow-response-xyz" { + t.Fatalf("threaded continue pow header=%q want=%q", seenPow, "pow-response-xyz") + } + if seenContinueURL != DeepSeekContinueURL { + t.Fatalf("continue url=%q want=%q", seenContinueURL, DeepSeekContinueURL) + } + if !bytes.Contains(out, []byte(`"status":"WIP"`)) { + t.Fatalf("expected initial stream content in body, got=%s", string(out)) + } + if !bytes.Contains(out, []byte(`data: [DONE]`)) { + t.Fatalf("expected final DONE sentinel in body, got=%s", string(out)) + } +} + +type failingOrCompletionDoer struct { + completionResp *http.Response +} + +func (d failingOrCompletionDoer) Do(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/chat/completion") { + return d.completionResp, nil + } + return nil, errors.New("forced stream failure") +} diff --git a/internal/deepseek/client_http_json.go b/internal/deepseek/client_http_json.go index a35d736..9de0e57 100644 --- a/internal/deepseek/client_http_json.go +++ b/internal/deepseek/client_http_json.go @@ -39,7 +39,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st config.Logger.Warn("[deepseek] fingerprint request failed, fallback to std transport", "url", url, "error", err) req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) if reqErr != nil { - return nil, 0, err + return nil, 0, reqErr } for k, v := range headers { req2.Header.Set(k, v) @@ -76,7 +76,7 @@ func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url str config.Logger.Warn("[deepseek] fingerprint GET request failed, fallback to std transport", "url", url, "error", err) req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if reqErr != nil { - return nil, 0, err + return nil, 0, reqErr } for k, v := range headers { req2.Header.Set(k, v) diff --git a/internal/deepseek/constants.go b/internal/deepseek/constants.go index f35332a..bd7c858 100644 --- a/internal/deepseek/constants.go +++ b/internal/deepseek/constants.go @@ -11,6 +11,7 @@ const ( DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create" DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge" DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion" + DeepSeekContinueURL = "https://chat.deepseek.com/api/v0/chat/continue" DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page" DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete" DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all" diff --git a/internal/js/chat-stream/index.js b/internal/js/chat-stream/index.js index a8e6cae..bf9566d 100644 --- a/internal/js/chat-stream/index.js +++ b/internal/js/chat-stream/index.js @@ -6,7 +6,11 @@ const { const { parseChunkForContent, extractContentRecursive, + filterLeakedContentFilterParts, + hasContentFilterStatus, + extractAccumulatedTokenUsage, shouldSkipPath, + stripReferenceMarkers, } = require('./sse_parse'); const { resolveToolcallPolicy, @@ -17,6 +21,7 @@ const { } = require('./toolcall_policy'); const { estimateTokens, + buildUsage, } = require('./token_usage'); const { setCorsHeaders, @@ -100,6 +105,7 @@ module.exports.__test = { parseChunkForContent, extractContentRecursive, shouldSkipPath, + stripReferenceMarkers, asString, resolveToolcallPolicy, formatIncrementalToolCallDeltas, @@ -107,6 +113,10 @@ module.exports.__test = { boolDefaultTrue, filterIncrementalToolCallDeltasByAllowed, estimateTokens, + buildUsage, + filterLeakedContentFilterParts, + hasContentFilterStatus, + extractAccumulatedTokenUsage, isNodeStreamSupportedPath, extractPathname, }; diff --git a/internal/js/chat-stream/sse_parse.js b/internal/js/chat-stream/sse_parse.js index 8edfcca..bae7426 100644 --- a/internal/js/chat-stream/sse_parse.js +++ b/internal/js/chat-stream/sse_parse.js @@ -1,240 +1,3 @@ 'use strict'; -const { - SKIP_PATTERNS, - SKIP_EXACT_PATHS, -} = require('../shared/deepseek-constants'); - -function parseChunkForContent(chunk, thinkingEnabled, currentType) { - if (!chunk || typeof chunk !== 'object' || !Object.prototype.hasOwnProperty.call(chunk, 'v')) { - return { parts: [], finished: false, newType: currentType }; - } - const pathValue = asString(chunk.p); - if (shouldSkipPath(pathValue)) { - return { parts: [], finished: false, newType: currentType }; - } - if (pathValue === 'response/status' && asString(chunk.v) === 'FINISHED') { - return { parts: [], finished: true, newType: currentType }; - } - - let newType = currentType; - const parts = []; - - if (pathValue === 'response/fragments' && asString(chunk.o).toUpperCase() === 'APPEND' && Array.isArray(chunk.v)) { - for (const frag of chunk.v) { - if (!frag || typeof frag !== 'object') { - continue; - } - const fragType = asString(frag.type).toUpperCase(); - const content = asString(frag.content); - if (!content) { - continue; - } - if (fragType === 'THINK' || fragType === 'THINKING') { - newType = 'thinking'; - parts.push({ text: content, type: 'thinking' }); - } else if (fragType === 'RESPONSE') { - newType = 'text'; - parts.push({ text: content, type: 'text' }); - } else { - parts.push({ text: content, type: 'text' }); - } - } - } - - if (pathValue === 'response' && Array.isArray(chunk.v)) { - for (const item of chunk.v) { - if (!item || typeof item !== 'object') { - continue; - } - if (item.p === 'fragments' && item.o === 'APPEND' && Array.isArray(item.v)) { - for (const frag of item.v) { - const fragType = asString(frag && frag.type).toUpperCase(); - if (fragType === 'THINK' || fragType === 'THINKING') { - newType = 'thinking'; - } else if (fragType === 'RESPONSE') { - newType = 'text'; - } - } - } - } - } - - let partType = 'text'; - if (pathValue === 'response/thinking_content') { - partType = 'thinking'; - } else if (pathValue === 'response/content') { - partType = 'text'; - } else if (pathValue.includes('response/fragments') && pathValue.includes('/content')) { - partType = newType; - } else if (!pathValue && thinkingEnabled) { - partType = newType; - } - - const val = chunk.v; - if (typeof val === 'string') { - if (val === 'FINISHED' && (!pathValue || pathValue === 'status')) { - return { parts: [], finished: true, newType }; - } - if (val) { - parts.push({ text: val, type: partType }); - } - return { parts, finished: false, newType }; - } - - if (Array.isArray(val)) { - const extracted = extractContentRecursive(val, partType); - if (extracted.finished) { - return { parts: [], finished: true, newType }; - } - parts.push(...extracted.parts); - return { parts, finished: false, newType }; - } - - if (val && typeof val === 'object') { - const resp = val.response && typeof val.response === 'object' ? val.response : val; - if (Array.isArray(resp.fragments)) { - for (const frag of resp.fragments) { - if (!frag || typeof frag !== 'object') { - continue; - } - const content = asString(frag.content); - if (!content) { - continue; - } - const t = asString(frag.type).toUpperCase(); - if (t === 'THINK' || t === 'THINKING') { - newType = 'thinking'; - parts.push({ text: content, type: 'thinking' }); - } else if (t === 'RESPONSE') { - newType = 'text'; - parts.push({ text: content, type: 'text' }); - } else { - parts.push({ text: content, type: partType }); - } - } - } - } - return { parts, finished: false, newType }; -} - -function extractContentRecursive(items, defaultType) { - const parts = []; - for (const it of items) { - if (!it || typeof it !== 'object') { - continue; - } - if (!Object.prototype.hasOwnProperty.call(it, 'v')) { - continue; - } - const itemPath = asString(it.p); - const itemV = it.v; - if (itemPath === 'status' && asString(itemV) === 'FINISHED') { - return { parts: [], finished: true }; - } - if (shouldSkipPath(itemPath)) { - continue; - } - const content = asString(it.content); - if (content) { - const typeName = asString(it.type).toUpperCase(); - if (typeName === 'THINK' || typeName === 'THINKING') { - parts.push({ text: content, type: 'thinking' }); - } else if (typeName === 'RESPONSE') { - parts.push({ text: content, type: 'text' }); - } else { - parts.push({ text: content, type: defaultType }); - } - continue; - } - - let partType = defaultType; - if (itemPath.includes('thinking')) { - partType = 'thinking'; - } else if (itemPath.includes('content') || itemPath === 'response' || itemPath === 'fragments') { - partType = 'text'; - } - - if (typeof itemV === 'string') { - if (itemV && itemV !== 'FINISHED') { - parts.push({ text: itemV, type: partType }); - } - continue; - } - - if (!Array.isArray(itemV)) { - continue; - } - for (const inner of itemV) { - if (typeof inner === 'string') { - if (inner) { - parts.push({ text: inner, type: partType }); - } - continue; - } - if (!inner || typeof inner !== 'object') { - continue; - } - const ct = asString(inner.content); - if (!ct) { - continue; - } - const typeName = asString(inner.type).toUpperCase(); - if (typeName === 'THINK' || typeName === 'THINKING') { - parts.push({ text: ct, type: 'thinking' }); - } else if (typeName === 'RESPONSE') { - parts.push({ text: ct, type: 'text' }); - } else { - parts.push({ text: ct, type: partType }); - } - } - } - return { parts, finished: false }; -} - -function shouldSkipPath(pathValue) { - if (isFragmentStatusPath(pathValue)) { - return true; - } - if (SKIP_EXACT_PATHS.has(pathValue)) { - return true; - } - for (const p of SKIP_PATTERNS) { - if (pathValue.includes(p)) { - return true; - } - } - return false; -} - -function isFragmentStatusPath(pathValue) { - if (!pathValue || pathValue === 'response/status') { - return false; - } - return /^response\/fragments\/-?\d+\/status$/i.test(pathValue); -} - -function isCitation(text) { - return asString(text).trim().startsWith('[citation:'); -} - -function asString(v) { - if (typeof v === 'string') { - return v.trim(); - } - if (Array.isArray(v)) { - return asString(v[0]); - } - if (v == null) { - return ''; - } - return String(v).trim(); -} - -module.exports = { - parseChunkForContent, - extractContentRecursive, - shouldSkipPath, - isFragmentStatusPath, - isCitation, -}; +module.exports = require('./sse_parse_impl'); diff --git a/internal/js/chat-stream/sse_parse_impl.js b/internal/js/chat-stream/sse_parse_impl.js new file mode 100644 index 0000000..577b1c4 --- /dev/null +++ b/internal/js/chat-stream/sse_parse_impl.js @@ -0,0 +1,535 @@ +'use strict'; + +// Implementation moved here to keep the line-gate wrapper tiny. + +const { + SKIP_PATTERNS, + SKIP_EXACT_PATHS, +} = require('../shared/deepseek-constants'); + +function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenceMarkers = true) { + if (!chunk || typeof chunk !== 'object') { + return { + parsed: false, + parts: [], + finished: false, + contentFilter: false, + errorMessage: '', + outputTokens: 0, + newType: currentType, + }; + } + + if (Object.prototype.hasOwnProperty.call(chunk, 'error')) { + return { + parsed: true, + parts: [], + finished: true, + contentFilter: false, + errorMessage: formatErrorMessage(chunk.error), + outputTokens: 0, + newType: currentType, + }; + } + + const pathValue = asString(chunk.p); + const outputTokens = extractAccumulatedTokenUsage(chunk); + + if (hasContentFilterStatus(chunk)) { + return { + parsed: true, + parts: [], + finished: true, + contentFilter: true, + errorMessage: '', + outputTokens, + newType: currentType, + }; + } + + if (shouldSkipPath(pathValue)) { + return { + parsed: true, + parts: [], + finished: false, + contentFilter: false, + errorMessage: '', + outputTokens, + newType: currentType, + }; + } + if (pathValue === 'response/status' && asString(chunk.v) === 'FINISHED') { + return { + parsed: true, + parts: [], + finished: true, + contentFilter: false, + errorMessage: '', + outputTokens, + newType: currentType, + }; + } + + if (!Object.prototype.hasOwnProperty.call(chunk, 'v')) { + return { + parsed: true, + parts: [], + finished: false, + contentFilter: false, + errorMessage: '', + outputTokens, + newType: currentType, + }; + } + + let newType = currentType; + const parts = []; + + if (pathValue === 'response/fragments' && asString(chunk.o).toUpperCase() === 'APPEND' && Array.isArray(chunk.v)) { + for (const frag of chunk.v) { + if (!frag || typeof frag !== 'object') { + continue; + } + const fragType = asString(frag.type).toUpperCase(); + const content = asContentString(frag.content, stripReferenceMarkers); + if (!content) { + continue; + } + if (fragType === 'THINK' || fragType === 'THINKING') { + newType = 'thinking'; + parts.push({ text: content, type: 'thinking' }); + } else if (fragType === 'RESPONSE') { + newType = 'text'; + parts.push({ text: content, type: 'text' }); + } else { + parts.push({ text: content, type: 'text' }); + } + } + } + + if (pathValue === 'response' && Array.isArray(chunk.v)) { + for (const item of chunk.v) { + if (!item || typeof item !== 'object') { + continue; + } + if (item.p === 'fragments' && item.o === 'APPEND' && Array.isArray(item.v)) { + for (const frag of item.v) { + const fragType = asString(frag && frag.type).toUpperCase(); + if (fragType === 'THINK' || fragType === 'THINKING') { + newType = 'thinking'; + } else if (fragType === 'RESPONSE') { + newType = 'text'; + } + } + } + } + } + + let partType = 'text'; + if (pathValue === 'response/thinking_content') { + partType = 'thinking'; + } else if (pathValue === 'response/content') { + partType = 'text'; + } else if (pathValue.includes('response/fragments') && pathValue.includes('/content')) { + partType = newType; + } else if (!pathValue && thinkingEnabled) { + partType = newType; + } + + const val = chunk.v; + if (typeof val === 'string') { + if (val === 'FINISHED' && (!pathValue || pathValue === 'status')) { + return { + parsed: true, + parts: [], + finished: true, + contentFilter: false, + errorMessage: '', + outputTokens, + newType, + }; + } + const content = asContentString(val, stripReferenceMarkers); + if (content) { + parts.push({ text: content, type: partType }); + } + return { + parsed: true, + parts: filterLeakedContentFilterParts(parts), + finished: false, + contentFilter: false, + errorMessage: '', + outputTokens, + newType, + }; + } + + if (Array.isArray(val)) { + const extracted = extractContentRecursive(val, partType, stripReferenceMarkers); + if (extracted.finished) { + return { + parsed: true, + parts: [], + finished: true, + contentFilter: false, + errorMessage: '', + outputTokens, + newType, + }; + } + parts.push(...extracted.parts); + return { + parsed: true, + parts: filterLeakedContentFilterParts(parts), + finished: false, + contentFilter: false, + errorMessage: '', + outputTokens, + newType, + }; + } + + if (val && typeof val === 'object') { + const resp = val.response && typeof val.response === 'object' ? val.response : val; + if (Array.isArray(resp.fragments)) { + for (const frag of resp.fragments) { + if (!frag || typeof frag !== 'object') { + continue; + } + const content = asContentString(frag.content, stripReferenceMarkers); + if (!content) { + continue; + } + const t = asString(frag.type).toUpperCase(); + if (t === 'THINK' || t === 'THINKING') { + newType = 'thinking'; + parts.push({ text: content, type: 'thinking' }); + } else if (t === 'RESPONSE') { + newType = 'text'; + parts.push({ text: content, type: 'text' }); + } else { + parts.push({ text: content, type: partType }); + } + } + } + } + return { + parsed: true, + parts: filterLeakedContentFilterParts(parts), + finished: false, + contentFilter: false, + errorMessage: '', + outputTokens, + newType, + }; +} + +function extractContentRecursive(items, defaultType, stripReferenceMarkers = true) { + const parts = []; + for (const it of items) { + if (!it || typeof it !== 'object') { + continue; + } + if (!Object.prototype.hasOwnProperty.call(it, 'v')) { + continue; + } + const itemPath = asString(it.p); + const itemV = it.v; + if (itemPath === 'status' && asString(itemV) === 'FINISHED') { + return { parts: [], finished: true }; + } + if (shouldSkipPath(itemPath)) { + continue; + } + const content = asContentString(it.content, stripReferenceMarkers); + if (content) { + const typeName = asString(it.type).toUpperCase(); + if (typeName === 'THINK' || typeName === 'THINKING') { + parts.push({ text: content, type: 'thinking' }); + } else if (typeName === 'RESPONSE') { + parts.push({ text: content, type: 'text' }); + } else { + parts.push({ text: content, type: defaultType }); + } + continue; + } + + let partType = defaultType; + if (itemPath.includes('thinking')) { + partType = 'thinking'; + } else if (itemPath.includes('content') || itemPath === 'response' || itemPath === 'fragments') { + partType = 'text'; + } + + if (typeof itemV === 'string') { + if (itemV && itemV !== 'FINISHED') { + const content = asContentString(itemV, stripReferenceMarkers); + if (content) { + parts.push({ text: content, type: partType }); + } + } + continue; + } + + if (!Array.isArray(itemV)) { + continue; + } + for (const inner of itemV) { + if (typeof inner === 'string') { + if (inner) { + const content = asContentString(inner, stripReferenceMarkers); + if (content) { + parts.push({ text: content, type: partType }); + } + } + continue; + } + if (!inner || typeof inner !== 'object') { + continue; + } + const ct = asContentString(inner.content, stripReferenceMarkers); + if (!ct) { + continue; + } + const typeName = asString(inner.type).toUpperCase(); + if (typeName === 'THINK' || typeName === 'THINKING') { + parts.push({ text: ct, type: 'thinking' }); + } else if (typeName === 'RESPONSE') { + parts.push({ text: ct, type: 'text' }); + } else { + parts.push({ text: ct, type: partType }); + } + } + } + return { parts, finished: false }; +} + +function filterLeakedContentFilterParts(parts) { + if (!Array.isArray(parts) || parts.length === 0) { + return parts; + } + const out = []; + for (const p of parts) { + if (!p || typeof p !== 'object') { + continue; + } + const { text, stripped } = stripLeakedContentFilterSuffix(p.text); + if (stripped && shouldDropCleanedLeakedChunk(text)) { + continue; + } + if (stripped) { + out.push({ ...p, text }); + continue; + } + out.push(p); + } + return out; +} + +function stripLeakedContentFilterSuffix(text) { + if (typeof text !== 'string' || text === '') { + return { text, stripped: false }; + } + const upperText = text.toUpperCase(); + const idx = upperText.indexOf('CONTENT_FILTER'); + if (idx < 0) { + return { text, stripped: false }; + } + return { + text: text.slice(0, idx).replace(/[ \t\r]+$/g, ''), + stripped: true, + }; +} + +function shouldDropCleanedLeakedChunk(cleaned) { + if (cleaned === '') { + return true; + } + if (typeof cleaned === 'string' && cleaned.includes('\n')) { + return false; + } + return asString(cleaned).trim() === ''; +} + +function hasContentFilterStatus(chunk) { + if (!chunk || typeof chunk !== 'object') { + return false; + } + const code = asString(chunk.code); + if (code && code.toLowerCase() === 'content_filter') { + return true; + } + return hasContentFilterStatusValue(chunk); +} + +function hasContentFilterStatusValue(v) { + if (Array.isArray(v)) { + for (const item of v) { + if (hasContentFilterStatusValue(item)) { + return true; + } + } + return false; + } + if (!v || typeof v !== 'object') { + return false; + } + const pathValue = asString(v.p); + if (pathValue && pathValue.toLowerCase().includes('status')) { + if (asString(v.v).toLowerCase() === 'content_filter') { + return true; + } + } + if (asString(v.code).toLowerCase() === 'content_filter') { + return true; + } + for (const value of Object.values(v)) { + if (hasContentFilterStatusValue(value)) { + return true; + } + } + return false; +} + +function extractAccumulatedTokenUsage(chunk) { + return findAccumulatedTokenUsage(chunk); +} + +function findAccumulatedTokenUsage(v) { + if (Array.isArray(v)) { + for (const item of v) { + const n = findAccumulatedTokenUsage(item); + if (n > 0) { + return n; + } + } + return 0; + } + if (!v || typeof v !== 'object') { + return 0; + } + const pathValue = asString(v.p); + if (pathValue && pathValue.toLowerCase().includes('accumulated_token_usage')) { + const n = toInt(v.v); + if (n > 0) { + return n; + } + } + const direct = toInt(v.accumulated_token_usage); + if (direct > 0) { + return direct; + } + for (const value of Object.values(v)) { + const n = findAccumulatedTokenUsage(value); + if (n > 0) { + return n; + } + } + return 0; +} + +function toInt(v) { + if (typeof v !== 'number' || !Number.isFinite(v)) { + return 0; + } + return Math.trunc(v); +} + +function formatErrorMessage(v) { + if (typeof v === 'string') { + return v; + } + if (v == null) { + return String(v); + } + try { + return JSON.stringify(v); + } catch (_err) { + return String(v); + } +} + +function shouldSkipPath(pathValue) { + if (isFragmentStatusPath(pathValue)) { + return true; + } + if (SKIP_EXACT_PATHS.has(pathValue)) { + return true; + } + for (const p of SKIP_PATTERNS) { + if (pathValue.includes(p)) { + return true; + } + } + return false; +} + +function isFragmentStatusPath(pathValue) { + if (!pathValue || pathValue === 'response/status') { + return false; + } + return /^response\/fragments\/-?\d+\/status$/i.test(pathValue); +} + +function isCitation(text) { + return asString(text).trim().startsWith('[citation:'); +} + +function asContentString(v, stripReferenceMarkers = true) { + if (typeof v === 'string') { + return stripReferenceMarkers ? stripReferenceMarkersText(v) : v; + } + if (Array.isArray(v)) { + let out = ''; + for (const item of v) { + out += asContentString(item, stripReferenceMarkers); + } + return out; + } + if (v && typeof v === 'object') { + if (Object.prototype.hasOwnProperty.call(v, 'content')) { + return asContentString(v.content, stripReferenceMarkers); + } + if (Object.prototype.hasOwnProperty.call(v, 'v')) { + return asContentString(v.v, stripReferenceMarkers); + } + return ''; + } + if (v == null) { + return ''; + } + const text = String(v); + return stripReferenceMarkers ? stripReferenceMarkersText(text) : text; +} + +function stripReferenceMarkersText(text) { + if (!text) { + return text; + } + return text.replace(/\[reference:\s*\d+\]/gi, ''); +} + +function asString(v) { + if (typeof v === 'string') { + return v.trim(); + } + if (Array.isArray(v)) { + return asString(v[0]); + } + if (v == null) { + return ''; + } + return String(v).trim(); +} + +module.exports = { + parseChunkForContent, + extractContentRecursive, + filterLeakedContentFilterParts, + hasContentFilterStatus, + extractAccumulatedTokenUsage, + shouldSkipPath, + isFragmentStatusPath, + isCitation, + stripReferenceMarkers: stripReferenceMarkersText, +}; diff --git a/internal/js/chat-stream/token_usage.js b/internal/js/chat-stream/token_usage.js index 57a36fb..0f71c5f 100644 --- a/internal/js/chat-stream/token_usage.js +++ b/internal/js/chat-stream/token_usage.js @@ -1,13 +1,15 @@ 'use strict'; -function buildUsage(prompt, thinking, output) { +function buildUsage(prompt, thinking, output, outputTokens = 0) { const promptTokens = estimateTokens(prompt); const reasoningTokens = estimateTokens(thinking); const completionTokens = estimateTokens(output); + const overriddenCompletionTokens = Number.isFinite(outputTokens) && outputTokens > 0 ? Math.trunc(outputTokens) : 0; + const finalCompletionTokens = overriddenCompletionTokens > 0 ? overriddenCompletionTokens : reasoningTokens + completionTokens; return { prompt_tokens: promptTokens, - completion_tokens: reasoningTokens + completionTokens, - total_tokens: promptTokens + reasoningTokens + completionTokens, + completion_tokens: finalCompletionTokens, + total_tokens: promptTokens + finalCompletionTokens, completion_tokens_details: { reasoning_tokens: reasoningTokens, }, @@ -15,7 +17,7 @@ function buildUsage(prompt, thinking, output) { } function estimateTokens(text) { - const t = asString(text); + const t = asTokenString(text); if (!t) { return 0; } @@ -32,17 +34,17 @@ function estimateTokens(text) { return n < 1 ? 1 : n; } -function asString(v) { +function asTokenString(v) { if (typeof v === 'string') { - return v.trim(); + return v; } if (Array.isArray(v)) { - return asString(v[0]); + return asTokenString(v[0]); } if (v == null) { return ''; } - return String(v).trim(); + return String(v); } module.exports = { diff --git a/internal/js/chat-stream/vercel_stream.js b/internal/js/chat-stream/vercel_stream.js index 4b1d6f3..a69f529 100644 --- a/internal/js/chat-stream/vercel_stream.js +++ b/internal/js/chat-stream/vercel_stream.js @@ -1,295 +1,3 @@ 'use strict'; -const { - createToolSieveState, - processToolSieveChunk, - flushToolSieve, - parseStandaloneToolCalls, - formatOpenAIStreamToolCalls, -} = require('../helpers/stream-tool-sieve'); -const { BASE_HEADERS } = require('../shared/deepseek-constants'); -const { writeOpenAIError } = require('./error_shape'); -const { parseChunkForContent, isCitation } = require('./sse_parse'); -const { buildUsage } = require('./token_usage'); -const { - resolveToolcallPolicy, - formatIncrementalToolCallDeltas, - filterIncrementalToolCallDeltasByAllowed, -} = require('./toolcall_policy'); -const { createChatCompletionEmitter } = require('./stream_emitter'); -const { - asString, - isAbortError, - fetchStreamPrepare, - relayPreparedFailure, - safeReadText, - createLeaseReleaser, -} = require('./http_internal'); - -const DEEPSEEK_COMPLETION_URL = 'https://chat.deepseek.com/api/v0/chat/completion'; - -async function handleVercelStream(req, res, rawBody, payload) { - const prep = await fetchStreamPrepare(req, rawBody); - if (!prep.ok) { - relayPreparedFailure(res, prep); - return; - } - - const model = asString(prep.body.model) || asString(payload.model); - const sessionID = asString(prep.body.session_id) || `chatcmpl-${Date.now()}`; - const leaseID = asString(prep.body.lease_id); - const deepseekToken = asString(prep.body.deepseek_token); - const powHeader = asString(prep.body.pow_header); - const completionPayload = prep.body.payload && typeof prep.body.payload === 'object' ? prep.body.payload : null; - const finalPrompt = asString(prep.body.final_prompt); - const thinkingEnabled = toBool(prep.body.thinking_enabled); - const searchEnabled = toBool(prep.body.search_enabled); - const toolPolicy = resolveToolcallPolicy(prep.body, payload.tools); - const toolNames = toolPolicy.toolNames; - const emitEarlyToolDeltas = toolPolicy.emitEarlyToolDeltas; - - if (!model || !leaseID || !deepseekToken || !powHeader || !completionPayload) { - writeOpenAIError(res, 500, 'invalid vercel prepare response'); - return; - } - - const releaseLease = createLeaseReleaser(req, leaseID); - const upstreamController = new AbortController(); - let clientClosed = false; - let reader = null; - const markClientClosed = () => { - if (clientClosed) { - return; - } - clientClosed = true; - upstreamController.abort(); - if (reader && typeof reader.cancel === 'function') { - Promise.resolve(reader.cancel()).catch(() => {}); - } - }; - const onReqAborted = () => markClientClosed(); - const onResClose = () => { - if (!res.writableEnded) { - markClientClosed(); - } - }; - req.on('aborted', onReqAborted); - res.on('close', onResClose); - - try { - let completionRes; - try { - completionRes = await fetch(DEEPSEEK_COMPLETION_URL, { - method: 'POST', - headers: { - ...BASE_HEADERS, - authorization: `Bearer ${deepseekToken}`, - 'x-ds-pow-response': powHeader, - }, - body: JSON.stringify(completionPayload), - signal: upstreamController.signal, - }); - } catch (err) { - if (clientClosed || isAbortError(err)) { - return; - } - throw err; - } - if (clientClosed) { - return; - } - - if (!completionRes.ok || !completionRes.body) { - const detail = await safeReadText(completionRes); - writeOpenAIError(res, 500, detail ? `Failed to get completion: ${detail}` : 'Failed to get completion.'); - return; - } - - res.statusCode = 200; - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache, no-transform'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - const created = Math.floor(Date.now() / 1000); - let currentType = thinkingEnabled ? 'thinking' : 'text'; - let thinkingText = ''; - let outputText = ''; - const toolSieveEnabled = toolPolicy.toolSieveEnabled; - const toolSieveState = createToolSieveState(); - let toolCallsEmitted = false; - const streamToolCallIDs = new Map(); - const streamToolNames = new Map(); - const decoder = new TextDecoder(); - reader = completionRes.body.getReader(); - let buffered = ''; - let ended = false; - const { sendFrame, sendDeltaFrame } = createChatCompletionEmitter({ - res, - sessionID, - created, - model, - isClosed: () => clientClosed, - }); - - const finish = async (reason) => { - if (ended) { - return; - } - ended = true; - if (clientClosed || res.writableEnded || res.destroyed) { - await releaseLease(); - return; - } - const detected = parseStandaloneToolCalls(outputText, toolNames); - if (detected.length > 0 && !toolCallsEmitted) { - toolCallsEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs) }); - } else if (toolSieveEnabled) { - const tailEvents = flushToolSieve(toolSieveState, toolNames); - for (const evt of tailEvents) { - if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) { - toolCallsEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); - continue; - } - if (evt.text) { - sendDeltaFrame({ content: evt.text }); - } - } - } - if (detected.length > 0 || toolCallsEmitted) { - reason = 'tool_calls'; - } - sendFrame({ - id: sessionID, - object: 'chat.completion.chunk', - created, - model, - choices: [{ delta: {}, index: 0, finish_reason: reason }], - usage: buildUsage(finalPrompt, thinkingText, outputText), - }); - if (!res.writableEnded && !res.destroyed) { - res.write('data: [DONE]\n\n'); - } - await releaseLease(); - if (!res.writableEnded && !res.destroyed) { - res.end(); - } - }; - - try { - // eslint-disable-next-line no-constant-condition - while (true) { - if (clientClosed) { - await finish('stop'); - return; - } - const { value, done } = await reader.read(); - if (done) { - break; - } - buffered += decoder.decode(value, { stream: true }); - const lines = buffered.split('\n'); - buffered = lines.pop() || ''; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line.startsWith('data:')) { - continue; - } - const dataStr = line.slice(5).trim(); - if (!dataStr) { - continue; - } - if (dataStr === '[DONE]') { - await finish('stop'); - return; - } - let chunk; - try { - chunk = JSON.parse(dataStr); - } catch (_err) { - continue; - } - if (chunk.error || chunk.code === 'content_filter') { - await finish('content_filter'); - return; - } - const parsed = parseChunkForContent(chunk, thinkingEnabled, currentType); - currentType = parsed.newType; - if (parsed.finished) { - await finish('stop'); - return; - } - - for (const p of parsed.parts) { - if (!p.text) { - continue; - } - if (searchEnabled && isCitation(p.text)) { - continue; - } - if (p.type === 'thinking') { - if (thinkingEnabled) { - thinkingText += p.text; - sendDeltaFrame({ reasoning_content: p.text }); - } - } else { - outputText += p.text; - if (!toolSieveEnabled) { - sendDeltaFrame({ content: p.text }); - continue; - } - const events = processToolSieveChunk(toolSieveState, p.text, toolNames); - for (const evt of events) { - if (evt.type === 'tool_call_deltas') { - if (!emitEarlyToolDeltas) { - continue; - } - const filtered = filterIncrementalToolCallDeltasByAllowed(evt.deltas, toolNames, streamToolNames); - const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs); - if (formatted.length > 0) { - toolCallsEmitted = true; - sendDeltaFrame({ tool_calls: formatted }); - } - continue; - } - if (evt.type === 'tool_calls') { - toolCallsEmitted = true; - sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); - continue; - } - if (evt.text) { - sendDeltaFrame({ content: evt.text }); - } - } - } - } - } - } - await finish('stop'); - } catch (err) { - if (clientClosed || isAbortError(err)) { - await finish('stop'); - return; - } - await finish('stop'); - } - } finally { - req.removeListener('aborted', onReqAborted); - res.removeListener('close', onResClose); - await releaseLease(); - } -} - -function toBool(v) { - return v === true; -} - -module.exports = { - handleVercelStream, -}; +module.exports = require('./vercel_stream_impl'); diff --git a/internal/js/chat-stream/vercel_stream_impl.js b/internal/js/chat-stream/vercel_stream_impl.js new file mode 100644 index 0000000..1b2c52c --- /dev/null +++ b/internal/js/chat-stream/vercel_stream_impl.js @@ -0,0 +1,310 @@ +'use strict'; + +// Implementation moved here to keep the line-gate wrapper tiny. + +const { + createToolSieveState, + processToolSieveChunk, + flushToolSieve, + parseStandaloneToolCalls, + formatOpenAIStreamToolCalls, +} = require('../helpers/stream-tool-sieve'); +const { BASE_HEADERS } = require('../shared/deepseek-constants'); +const { writeOpenAIError } = require('./error_shape'); +const { parseChunkForContent, isCitation } = require('./sse_parse'); +const { buildUsage } = require('./token_usage'); +const { + resolveToolcallPolicy, + formatIncrementalToolCallDeltas, + filterIncrementalToolCallDeltasByAllowed, + boolDefaultTrue, +} = require('./toolcall_policy'); +const { createChatCompletionEmitter } = require('./stream_emitter'); +const { + asString, + isAbortError, + fetchStreamPrepare, + relayPreparedFailure, + createLeaseReleaser, +} = require('./http_internal'); + +const DEEPSEEK_COMPLETION_URL = 'https://chat.deepseek.com/api/v0/chat/completion'; + +async function handleVercelStream(req, res, rawBody, payload) { + const prep = await fetchStreamPrepare(req, rawBody); + if (!prep.ok) { + relayPreparedFailure(res, prep); + return; + } + + const model = asString(prep.body.model) || asString(payload.model); + const sessionID = asString(prep.body.session_id) || `chatcmpl-${Date.now()}`; + const leaseID = asString(prep.body.lease_id); + const deepseekToken = asString(prep.body.deepseek_token); + const powHeader = asString(prep.body.pow_header); + const completionPayload = prep.body.payload && typeof prep.body.payload === 'object' ? prep.body.payload : null; + const finalPrompt = asString(prep.body.final_prompt); + const thinkingEnabled = toBool(prep.body.thinking_enabled); + const searchEnabled = toBool(prep.body.search_enabled); + const toolPolicy = resolveToolcallPolicy(prep.body, payload.tools); + const toolNames = toolPolicy.toolNames; + const emitEarlyToolDeltas = toolPolicy.emitEarlyToolDeltas; + const stripReferenceMarkers = boolDefaultTrue(prep.body.compat && prep.body.compat.strip_reference_markers); + + if (!model || !leaseID || !deepseekToken || !powHeader || !completionPayload) { + writeOpenAIError(res, 500, 'invalid vercel prepare response'); + return; + } + + const releaseLease = createLeaseReleaser(req, leaseID); + const upstreamController = new AbortController(); + let clientClosed = false; + let reader = null; + const markClientClosed = () => { + if (clientClosed) { + return; + } + clientClosed = true; + upstreamController.abort(); + if (reader && typeof reader.cancel === 'function') { + Promise.resolve(reader.cancel()).catch(() => {}); + } + }; + const onReqAborted = () => markClientClosed(); + const onResClose = () => { + if (!res.writableEnded) { + markClientClosed(); + } + }; + req.on('aborted', onReqAborted); + res.on('close', onResClose); + + try { + let completionRes; + try { + completionRes = await fetch(DEEPSEEK_COMPLETION_URL, { + method: 'POST', + headers: { + ...BASE_HEADERS, + authorization: `Bearer ${deepseekToken}`, + 'x-ds-pow-response': powHeader, + }, + body: JSON.stringify(completionPayload), + signal: upstreamController.signal, + }); + } catch (err) { + if (clientClosed || isAbortError(err)) { + return; + } + throw err; + } + if (clientClosed) { + return; + } + + if (!completionRes.ok || !completionRes.body) { + const detail = completionRes.body ? await completionRes.text() : ''; + const status = completionRes.ok ? 500 : completionRes.status || 500; + writeOpenAIError(res, status, detail); + return; + } + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + + const created = Math.floor(Date.now() / 1000); + let currentType = thinkingEnabled ? 'thinking' : 'text'; + let thinkingText = ''; + let outputText = ''; + let outputTokens = 0; + const toolSieveEnabled = toolPolicy.toolSieveEnabled; + const toolSieveState = createToolSieveState(); + let toolCallsEmitted = false; + const streamToolCallIDs = new Map(); + const streamToolNames = new Map(); + const decoder = new TextDecoder(); + reader = completionRes.body.getReader(); + let buffered = ''; + let ended = false; + const { sendFrame, sendDeltaFrame } = createChatCompletionEmitter({ + res, + sessionID, + created, + model, + isClosed: () => clientClosed, + }); + + const finish = async (reason) => { + if (ended) { + return; + } + ended = true; + if (clientClosed || res.writableEnded || res.destroyed) { + await releaseLease(); + return; + } + const detected = parseStandaloneToolCalls(outputText, toolNames); + if (detected.length > 0 && !toolCallsEmitted) { + toolCallsEmitted = true; + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs) }); + } else if (toolSieveEnabled) { + const tailEvents = flushToolSieve(toolSieveState, toolNames); + for (const evt of tailEvents) { + if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) { + toolCallsEmitted = true; + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + continue; + } + if (evt.text) { + sendDeltaFrame({ content: evt.text }); + } + } + } + if (detected.length > 0 || toolCallsEmitted) { + reason = 'tool_calls'; + } + sendFrame({ + id: sessionID, + object: 'chat.completion.chunk', + created, + model, + choices: [{ delta: {}, index: 0, finish_reason: reason }], + usage: buildUsage(finalPrompt, thinkingText, outputText, outputTokens), + }); + if (!res.writableEnded && !res.destroyed) { + res.write('data: [DONE]\n\n'); + } + await releaseLease(); + if (!res.writableEnded && !res.destroyed) { + res.end(); + } + }; + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + if (clientClosed) { + await finish('stop'); + return; + } + const { value, done } = await reader.read(); + if (done) { + break; + } + buffered += decoder.decode(value, { stream: true }); + const lines = buffered.split('\n'); + buffered = lines.pop() || ''; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line.startsWith('data:')) { + continue; + } + const dataStr = line.slice(5).trim(); + if (!dataStr) { + continue; + } + if (dataStr === '[DONE]') { + await finish('stop'); + return; + } + let chunk; + try { + chunk = JSON.parse(dataStr); + } catch (_err) { + continue; + } + const parsed = parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenceMarkers); + if (!parsed.parsed) { + continue; + } + if (parsed.outputTokens > 0) { + outputTokens = parsed.outputTokens; + } + currentType = parsed.newType; + if (parsed.errorMessage) { + await finish('content_filter'); + return; + } + if (parsed.contentFilter) { + await finish('stop'); + return; + } + if (parsed.finished) { + await finish('stop'); + return; + } + + for (const p of parsed.parts) { + if (!p.text) { + continue; + } + if (searchEnabled && isCitation(p.text)) { + continue; + } + if (p.type === 'thinking') { + if (thinkingEnabled) { + thinkingText += p.text; + sendDeltaFrame({ reasoning_content: p.text }); + } + } else { + outputText += p.text; + if (!toolSieveEnabled) { + sendDeltaFrame({ content: p.text }); + continue; + } + const events = processToolSieveChunk(toolSieveState, p.text, toolNames); + for (const evt of events) { + if (evt.type === 'tool_call_deltas') { + if (!emitEarlyToolDeltas) { + continue; + } + const filtered = filterIncrementalToolCallDeltasByAllowed(evt.deltas, toolNames, streamToolNames); + const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs); + if (formatted.length > 0) { + toolCallsEmitted = true; + sendDeltaFrame({ tool_calls: formatted }); + } + continue; + } + if (evt.type === 'tool_calls') { + toolCallsEmitted = true; + sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) }); + continue; + } + if (evt.text) { + sendDeltaFrame({ content: evt.text }); + } + } + } + } + } + } + await finish('stop'); + } catch (err) { + if (clientClosed || isAbortError(err)) { + await finish('stop'); + return; + } + await finish('stop'); + } + } finally { + req.removeListener('aborted', onReqAborted); + res.removeListener('close', onResClose); + await releaseLease(); + } +} + +function toBool(v) { + return v === true; +} + +module.exports = { + handleVercelStream, +}; diff --git a/internal/prompt/messages.go b/internal/prompt/messages.go index 80333de..fe69f72 100644 --- a/internal/prompt/messages.go +++ b/internal/prompt/messages.go @@ -9,6 +9,16 @@ import ( var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`) +const ( + systemMarker = "<|System|>" + userMarker = "<|User|>" + assistantMarker = "<|Assistant|>" + toolMarker = "<|Tool|>" + endSentenceMarker = "<|end▁of▁sentence|>" + endToolResultsMarker = "<|end▁of▁toolresults|>" + endInstructionsMarker = "<|end▁of▁instructions|>" +) + func MessagesPrepare(messages []map[string]any) string { type block struct { Role string @@ -32,33 +42,39 @@ func MessagesPrepare(messages []map[string]any) string { merged = append(merged, msg) } parts := make([]string, 0, len(merged)) - for i, m := range merged { + for _, m := range merged { switch m.Role { case "assistant": - parts = append(parts, "<|Assistant|>"+m.Text+"<|end▁of▁sentence|>") + parts = append(parts, formatRoleBlock(assistantMarker, m.Text, endSentenceMarker)) case "tool": - if i > 0 { - parts = append(parts, "<|Tool|>"+m.Text) - } else { - parts = append(parts, m.Text) + if strings.TrimSpace(m.Text) != "" { + parts = append(parts, formatRoleBlock(toolMarker, m.Text, endToolResultsMarker)) } case "system": - // Clear system boundary improves R1 and V3 context understanding significantly - if strings.TrimSpace(m.Text) != "" { - parts = append(parts, "\n"+strings.TrimSpace(m.Text)+"\n\n\n") + if text := strings.TrimSpace(m.Text); text != "" { + parts = append(parts, formatRoleBlock(systemMarker, text, endInstructionsMarker)) } case "user": - // Always prepend <|User|> to user messages. DeepSeek R1 reasoning triggers best - // and aligns context perfectly when the user turn is explicitly marked. - parts = append(parts, "<|User|>"+m.Text) + parts = append(parts, formatRoleBlock(userMarker, m.Text, endSentenceMarker)) default: - parts = append(parts, m.Text) + if strings.TrimSpace(m.Text) != "" { + parts = append(parts, m.Text) + } } } - out := strings.Join(parts, "") + out := strings.Join(parts, "\n\n") return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`) } +// DeepSeek-style turn suffixes stay attached to the same block as the role content. +func formatRoleBlock(marker, text, endMarker string) string { + out := marker + "\n" + text + if strings.TrimSpace(endMarker) != "" { + out += endMarker + } + return out +} + func NormalizeContent(v any) string { if v == nil { return "" diff --git a/internal/prompt/messages_test.go b/internal/prompt/messages_test.go index 9114d39..5465c7a 100644 --- a/internal/prompt/messages_test.go +++ b/internal/prompt/messages_test.go @@ -1,6 +1,9 @@ package prompt -import "testing" +import ( + "strings" + "testing" +) func TestNormalizeContentNilReturnsEmpty(t *testing.T) { if got := NormalizeContent(nil); got != "" { @@ -22,6 +25,24 @@ func TestMessagesPrepareNilContentNoNullLiteral(t *testing.T) { } } +func TestMessagesPrepareUsesTurnSuffixes(t *testing.T) { + messages := []map[string]any{ + {"role": "system", "content": "System rule"}, + {"role": "user", "content": "Question"}, + {"role": "assistant", "content": "Answer"}, + } + got := MessagesPrepare(messages) + if !strings.Contains(got, "<|System|>\nSystem rule<|end▁of▁instructions|>") { + t.Fatalf("expected system instructions suffix, got %q", got) + } + if !strings.Contains(got, "<|User|>\nQuestion<|end▁of▁sentence|>") { + t.Fatalf("expected user sentence suffix, got %q", got) + } + if !strings.Contains(got, "<|Assistant|>\nAnswer<|end▁of▁sentence|>") { + t.Fatalf("expected assistant sentence suffix, got %q", got) + } +} + func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) { got := NormalizeContent([]any{ map[string]any{"type": "text", "text": "", "content": "from-content"}, diff --git a/internal/rawsample/rawsample.go b/internal/rawsample/rawsample.go new file mode 100644 index 0000000..28b1335 --- /dev/null +++ b/internal/rawsample/rawsample.go @@ -0,0 +1,199 @@ +package rawsample + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/google/uuid" +) + +var referenceMarkerRe = regexp.MustCompile(`(?i)\[reference:\s*\d+\]`) + +type CaptureRound struct { + Label string `json:"label,omitempty"` + URL string `json:"url,omitempty"` + StatusCode int `json:"status_code"` + ResponseBytes int `json:"response_bytes"` +} + +type CaptureSummary struct { + Label string `json:"label,omitempty"` + URL string `json:"url,omitempty"` + StatusCode int `json:"status_code"` + ResponseBytes int `json:"response_bytes"` + Rounds []CaptureRound `json:"rounds,omitempty"` + ContainsReferenceMarkers bool `json:"contains_reference_markers,omitempty"` + ReferenceMarkerCount int `json:"reference_marker_count,omitempty"` + ContainsFinishedToken bool `json:"contains_finished_token,omitempty"` + FinishedTokenCount int `json:"finished_token_count,omitempty"` +} + +type Meta struct { + SampleID string `json:"sample_id"` + CapturedAtUTC string `json:"captured_at_utc"` + Source string `json:"source,omitempty"` + Request any `json:"request"` + Capture CaptureSummary `json:"capture"` +} + +type PersistOptions struct { + RootDir string + SampleID string + Source string + Request any + Capture CaptureSummary + UpstreamBody []byte +} + +type SavedSample struct { + SampleID string + Dir string + MetaPath string + UpstreamPath string + Meta Meta +} + +func Persist(opts PersistOptions) (SavedSample, error) { + root := strings.TrimSpace(opts.RootDir) + if root == "" { + return SavedSample{}, errors.New("root dir is required") + } + if len(opts.UpstreamBody) == 0 { + return SavedSample{}, errors.New("upstream body is required") + } + + if err := os.MkdirAll(root, 0o755); err != nil { + return SavedSample{}, fmt.Errorf("create root dir: %w", err) + } + + baseID := NormalizeSampleID(opts.SampleID) + if baseID == "" { + baseID = DefaultSampleID("capture") + } + sampleID, err := uniqueSampleID(root, baseID) + if err != nil { + return SavedSample{}, err + } + + tempID := ".tmp-" + sampleID + "-" + strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", "")) + tempDir := filepath.Join(root, tempID) + finalDir := filepath.Join(root, sampleID) + if err := os.MkdirAll(tempDir, 0o755); err != nil { + return SavedSample{}, fmt.Errorf("create temp dir: %w", err) + } + cleanup := func() { + _ = os.RemoveAll(tempDir) + } + + upstreamPath := filepath.Join(tempDir, "upstream.stream.sse") + if err := os.WriteFile(upstreamPath, opts.UpstreamBody, 0o644); err != nil { + cleanup() + return SavedSample{}, fmt.Errorf("write upstream stream: %w", err) + } + + now := time.Now().UTC() + capture := opts.Capture + capture.ResponseBytes = len(opts.UpstreamBody) + capture.ContainsReferenceMarkers, capture.ReferenceMarkerCount, capture.ContainsFinishedToken, capture.FinishedTokenCount = analyzeBytes(opts.UpstreamBody) + + meta := Meta{ + SampleID: sampleID, + CapturedAtUTC: now.Format(time.RFC3339), + Source: strings.TrimSpace(opts.Source), + Request: opts.Request, + Capture: capture, + } + metaBytes, err := json.MarshalIndent(meta, "", " ") + if err != nil { + cleanup() + return SavedSample{}, fmt.Errorf("marshal meta: %w", err) + } + metaPath := filepath.Join(tempDir, "meta.json") + if err := os.WriteFile(metaPath, append(metaBytes, '\n'), 0o644); err != nil { + cleanup() + return SavedSample{}, fmt.Errorf("write meta: %w", err) + } + + if err := os.Rename(tempDir, finalDir); err != nil { + cleanup() + return SavedSample{}, fmt.Errorf("promote sample dir: %w", err) + } + + return SavedSample{ + SampleID: sampleID, + Dir: finalDir, + MetaPath: filepath.Join(finalDir, "meta.json"), + UpstreamPath: filepath.Join(finalDir, "upstream.stream.sse"), + Meta: meta, + }, nil +} + +func NormalizeSampleID(raw string) string { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return "" + } + var b strings.Builder + prevDash := false + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + b.WriteRune(r) + prevDash = false + default: + if !prevDash { + b.WriteRune('-') + prevDash = true + } + } + } + out := strings.Trim(b.String(), "-_.") + if out == "" { + return "" + } + return out +} + +func DefaultSampleID(prefix string) string { + prefix = NormalizeSampleID(prefix) + if prefix == "" { + prefix = "capture" + } + return fmt.Sprintf("%s-%s", prefix, time.Now().UTC().Format("20060102T150405Z")) +} + +func uniqueSampleID(root, base string) (string, error) { + if base == "" { + base = DefaultSampleID("capture") + } + candidate := base + for i := 2; ; i++ { + finalDir := filepath.Join(root, candidate) + if _, err := os.Stat(finalDir); err != nil { + if os.IsNotExist(err) { + return candidate, nil + } + return "", fmt.Errorf("stat sample dir: %w", err) + } + candidate = fmt.Sprintf("%s-%d", base, i) + } +} + +func analyzeBytes(raw []byte) (containsReferenceMarkers bool, referenceMarkerCount int, containsFinishedToken bool, finishedTokenCount int) { + if len(raw) == 0 { + return false, 0, false, 0 + } + text := string(raw) + referenceMarkerCount = len(referenceMarkerRe.FindAllStringIndex(text, -1)) + containsReferenceMarkers = referenceMarkerCount > 0 + upper := strings.ToUpper(text) + finishedTokenCount = strings.Count(upper, "FINISHED") + containsFinishedToken = finishedTokenCount > 0 + return +} diff --git a/internal/rawsample/rawsample_test.go b/internal/rawsample/rawsample_test.go new file mode 100644 index 0000000..b70c633 --- /dev/null +++ b/internal/rawsample/rawsample_test.go @@ -0,0 +1,79 @@ +package rawsample + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeSampleID(t *testing.T) { + got := NormalizeSampleID(" Hello, World! ") + if got != "hello-world" { + t.Fatalf("expected hello-world, got %q", got) + } +} + +func TestPersistWritesSampleFilesAndMeta(t *testing.T) { + root := t.TempDir() + saved, err := Persist(PersistOptions{ + RootDir: root, + SampleID: "My Sample! 01", + Source: "unit-test", + Request: map[string]any{ + "model": "deepseek-chat", + "stream": true, + "messages": []any{ + map[string]any{"role": "user", "content": "广州天气"}, + }, + }, + Capture: CaptureSummary{ + Label: "deepseek_completion", + URL: "https://chat.deepseek.com/api/v0/chat/completion", + StatusCode: 200, + }, + UpstreamBody: []byte("data: {\"v\":\"hello [reference:1]\"}\n\n" + + "data: {\"v\":\"FINISHED\",\"p\":\"response/status\"}\n\n"), + }) + if err != nil { + t.Fatalf("Persist failed: %v", err) + } + + if saved.SampleID != "my-sample-01" { + t.Fatalf("expected normalized sample id, got %q", saved.SampleID) + } + if _, err := os.Stat(saved.Dir); err != nil { + t.Fatalf("sample dir missing: %v", err) + } + if _, err := os.Stat(saved.UpstreamPath); err != nil { + t.Fatalf("upstream file missing: %v", err) + } + if _, err := os.Stat(filepath.Join(saved.Dir, "openai.stream.sse")); !os.IsNotExist(err) { + t.Fatalf("unexpected processed stream file: %v", err) + } + if _, err := os.Stat(filepath.Join(saved.Dir, "openai.output.txt")); !os.IsNotExist(err) { + t.Fatalf("unexpected processed text file: %v", err) + } + + metaBytes, err := os.ReadFile(saved.MetaPath) + if err != nil { + t.Fatalf("read meta: %v", err) + } + var meta Meta + if err := json.Unmarshal(metaBytes, &meta); err != nil { + t.Fatalf("decode meta: %v", err) + } + if meta.SampleID != saved.SampleID { + t.Fatalf("expected meta sample id %q, got %q", saved.SampleID, meta.SampleID) + } + if meta.Capture.ReferenceMarkerCount != 1 { + t.Fatalf("expected one reference marker, got %+v", meta.Capture) + } + if meta.Capture.FinishedTokenCount != 1 { + t.Fatalf("expected one finished token, got %+v", meta.Capture) + } + if strings.Contains(string(metaBytes), "\"processed\"") { + t.Fatalf("meta should not include processed payload: %s", string(metaBytes)) + } +} diff --git a/internal/rawsample/visible_text.go b/internal/rawsample/visible_text.go new file mode 100644 index 0000000..4746590 --- /dev/null +++ b/internal/rawsample/visible_text.go @@ -0,0 +1,114 @@ +package rawsample + +import ( + "encoding/json" + "strings" +) + +func extractProcessedVisibleText(raw []byte, kind, contentType string) string { + if len(raw) == 0 { + return "" + } + switch strings.ToLower(strings.TrimSpace(kind)) { + case "json": + return parseOpenAIJSONText(string(raw)) + case "stream": + return parseOpenAIStreamText(string(raw)) + } + ct := strings.ToLower(strings.TrimSpace(contentType)) + if strings.Contains(ct, "application/json") { + return parseOpenAIJSONText(string(raw)) + } + return parseOpenAIStreamText(string(raw)) +} + +func parseOpenAIStreamText(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + var out strings.Builder + for _, block := range strings.Split(raw, "\n\n") { + if strings.TrimSpace(block) == "" { + continue + } + dataLines := make([]string, 0, 2) + for _, line := range strings.Split(block, "\n") { + if !strings.HasPrefix(line, "data:") { + continue + } + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + if len(dataLines) == 0 { + continue + } + payload := strings.TrimSpace(strings.Join(dataLines, "\n")) + if payload == "" || payload == "[DONE]" || !strings.HasPrefix(payload, "{") { + continue + } + var decoded any + if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + continue + } + out.WriteString(extractOpenAIVisibleTextValue(decoded)) + } + return out.String() +} + +func parseOpenAIJSONText(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + var decoded any + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return "" + } + return extractOpenAIVisibleTextValue(decoded) +} + +func extractOpenAIVisibleTextValue(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case []any: + var out strings.Builder + for _, item := range x { + out.WriteString(extractOpenAIVisibleTextValue(item)) + } + return out.String() + case map[string]any: + var out strings.Builder + if s, ok := x["output_text"].(string); ok { + out.WriteString(s) + } + if arr, ok := x["output"].([]any); ok { + for _, item := range arr { + out.WriteString(extractOpenAIVisibleTextValue(item)) + } + } + if arr, ok := x["choices"].([]any); ok { + for _, item := range arr { + out.WriteString(extractOpenAIVisibleTextValue(item)) + } + } + if msg, ok := x["message"]; ok { + out.WriteString(extractOpenAIVisibleTextValue(msg)) + } + if delta, ok := x["delta"]; ok { + out.WriteString(extractOpenAIVisibleTextValue(delta)) + } + if content, ok := x["content"]; ok { + out.WriteString(extractOpenAIVisibleTextValue(content)) + } + if reasoning, ok := x["reasoning_content"]; ok { + out.WriteString(extractOpenAIVisibleTextValue(reasoning)) + } + if text, ok := x["text"]; ok { + out.WriteString(extractOpenAIVisibleTextValue(text)) + } + return out.String() + default: + return "" + } +} diff --git a/internal/server/router.go b/internal/server/router.go index a6c71f3..cf44bdb 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "fmt" "net/http" "strings" "time" @@ -29,8 +30,11 @@ type App struct { Router http.Handler } -func NewApp() *App { - store := config.LoadStore() +func NewApp() (*App, error) { + store, err := config.LoadStoreWithError() + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } pool := account.NewPool(store) var dsClient *deepseek.Client resolver := auth.NewResolver(store, pool, func(ctx context.Context, acc config.Account) (string, error) { @@ -46,7 +50,7 @@ func NewApp() *App { openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient} claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} - adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient} + adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: openaiHandler} webuiHandler := webui.NewHandler() r := chi.NewRouter() @@ -85,7 +89,7 @@ func NewApp() *App { http.NotFound(w, req) }) - return &App{Store: store, Pool: pool, Resolver: resolver, DS: dsClient, Router: r} + return &App{Store: store, Pool: pool, Resolver: resolver, DS: dsClient, Router: r}, nil } func timeout(d time.Duration) func(http.Handler) http.Handler { diff --git a/internal/server/router_health_test.go b/internal/server/router_health_test.go index 0f744dd..7c79d31 100644 --- a/internal/server/router_health_test.go +++ b/internal/server/router_health_test.go @@ -7,7 +7,13 @@ import ( ) func TestHealthEndpointsSupportHEAD(t *testing.T) { - app := NewApp() + t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"u@example.com","password":"p"}]}`) + t.Setenv("DS2API_ENV_WRITEBACK", "0") + + app, err := NewApp() + if err != nil { + t.Fatalf("NewApp() error: %v", err) + } for _, path := range []string{"/healthz", "/readyz"} { req := httptest.NewRequest(http.MethodHead, path, nil) diff --git a/internal/sse/content_filter_leak.go b/internal/sse/content_filter_leak.go index 83e9847..874b46a 100644 --- a/internal/sse/content_filter_leak.go +++ b/internal/sse/content_filter_leak.go @@ -8,28 +8,32 @@ func filterLeakedContentFilterParts(parts []ContentPart) []ContentPart { } out := make([]ContentPart, 0, len(parts)) for _, p := range parts { - cleaned := stripLeakedContentFilterSuffix(p.Text) - if shouldDropCleanedLeakedChunk(cleaned) { + cleaned, stripped := stripLeakedContentFilterSuffix(p.Text) + // Only drop the chunk when we actually stripped a leaked CONTENT_FILTER + // suffix. Plain whitespace chunks are valid SSE content and must stay. + if stripped && shouldDropCleanedLeakedChunk(cleaned) { continue } - p.Text = cleaned + if stripped { + p.Text = cleaned + } out = append(out, p) } return out } -func stripLeakedContentFilterSuffix(text string) string { +func stripLeakedContentFilterSuffix(text string) (string, bool) { if text == "" { - return text + return text, false } upperText := strings.ToUpper(text) idx := strings.Index(upperText, "CONTENT_FILTER") if idx < 0 { - return text + return text, false } // Keep "\n" so we don't collapse line structure when the upstream model // appends leaked CONTENT_FILTER markers after a line break. - return strings.TrimRight(text[:idx], " \t\r") + return strings.TrimRight(text[:idx], " \t\r"), true } func shouldDropCleanedLeakedChunk(cleaned string) bool { diff --git a/internal/sse/line_test.go b/internal/sse/line_test.go index 59fc490..7ca1e8c 100644 --- a/internal/sse/line_test.go +++ b/internal/sse/line_test.go @@ -63,6 +63,16 @@ func TestParseDeepSeekContentLineContent(t *testing.T) { } } +func TestParseDeepSeekContentLinePreservesSpaceOnlyChunk(t *testing.T) { + res := ParseDeepSeekContentLine([]byte(`data: {"v":" "}`), false, "text") + if !res.Parsed || res.Stop { + t.Fatalf("expected parsed non-stop result: %#v", res) + } + if len(res.Parts) != 1 || res.Parts[0].Text != " " || res.Parts[0].Type != "text" { + t.Fatalf("unexpected parts for space-only chunk: %#v", res.Parts) + } +} + func TestParseDeepSeekContentLineStripsLeakedContentFilterSuffix(t *testing.T) { res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"正常输出CONTENT_FILTER你好,这个问题我暂时无法回答"}`), false, "text") if !res.Parsed || res.Stop { diff --git a/internal/testsuite/runner_env.go b/internal/testsuite/runner_env.go index a953936..24eb297 100644 --- a/internal/testsuite/runner_env.go +++ b/internal/testsuite/runner_env.go @@ -172,7 +172,6 @@ func (r *Runner) startServer(ctx context.Context) error { "DS2API_CONFIG_PATH": r.configCopyPath, "DS2API_AUTO_BUILD_WEBUI": "false", "DS2API_CONFIG_JSON": "", - "CONFIG_JSON": "", }) if err := cmd.Start(); err != nil { _ = logFd.Close() diff --git a/internal/textclean/reference_markers.go b/internal/textclean/reference_markers.go new file mode 100644 index 0000000..0d9b161 --- /dev/null +++ b/internal/textclean/reference_markers.go @@ -0,0 +1,12 @@ +package textclean + +import "regexp" + +var referenceMarkerPattern = regexp.MustCompile(`(?i)\[reference:\s*\d+\]`) + +func StripReferenceMarkers(text string) string { + if text == "" { + return text + } + return referenceMarkerPattern.ReplaceAllString(text, "") +} diff --git a/internal/util/messages_test.go b/internal/util/messages_test.go index ab11b59..1fd2024 100644 --- a/internal/util/messages_test.go +++ b/internal/util/messages_test.go @@ -12,7 +12,7 @@ func TestMessagesPrepareBasic(t *testing.T) { if got == "" { t.Fatal("expected non-empty prompt") } - if got != "<|User|>Hello" { + if got != "<|User|>\nHello<|end▁of▁sentence|>" { t.Fatalf("unexpected prompt: %q", got) } } @@ -22,15 +22,34 @@ func TestMessagesPrepareRoles(t *testing.T) { {"role": "system", "content": "You are helper"}, {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello"}, + {"role": "tool", "content": "Search results"}, {"role": "user", "content": "How are you"}, } got := MessagesPrepare(messages) + if !contains(got, "<|System|>\nYou are helper<|end▁of▁instructions|>\n\n<|User|>\nHi<|end▁of▁sentence|>") { + t.Fatalf("expected system/user separation in %q", got) + } + if !contains(got, "<|User|>\nHi<|end▁of▁sentence|>\n\n<|Assistant|>\nHello<|end▁of▁sentence|>") { + t.Fatalf("expected user/assistant separation in %q", got) + } + if !contains(got, "<|Assistant|>\nHello<|end▁of▁sentence|>\n\n<|Tool|>\nSearch results<|end▁of▁toolresults|>") { + t.Fatalf("expected assistant/tool separation in %q", got) + } + if !contains(got, "<|Tool|>\nSearch results<|end▁of▁toolresults|>\n\n<|User|>\nHow are you<|end▁of▁sentence|>") { + t.Fatalf("expected tool/user separation in %q", got) + } if !contains(got, "<|Assistant|>") { t.Fatalf("expected assistant marker in %q", got) } + if !contains(got, "<|System|>") { + t.Fatalf("expected system marker in %q", got) + } if !contains(got, "<|User|>") { t.Fatalf("expected user marker in %q", got) } + if !contains(got, "<|Tool|>") { + t.Fatalf("expected tool marker in %q", got) + } } func TestMessagesPrepareObjectContent(t *testing.T) { @@ -55,7 +74,7 @@ func TestMessagesPrepareArrayTextVariants(t *testing.T) { }, } got := MessagesPrepare(messages) - if got != "<|User|>line1\nline2" { + if got != "<|User|>\nline1\nline2<|end▁of▁sentence|>" { t.Fatalf("unexpected content from text variants: %q", got) } } diff --git a/internal/util/tool_prompt.go b/internal/util/tool_prompt.go index 9394eda..a801286 100644 --- a/internal/util/tool_prompt.go +++ b/internal/util/tool_prompt.go @@ -51,8 +51,8 @@ RULES: 3) Multiple tools → multiple blocks inside ONE root. 4) Do NOT wrap the XML in markdown code fences (no triple backticks). 5) After receiving a tool result, use it directly. Only call another tool if the result is insufficient. -6) If you want to say something AND call a tool, output text first, then the XML block on its own. -7) Parameters MUST use the exact field names from the selected tool schema. +6) Parameters MUST use the exact field names from the selected tool schema. +7) CRITICAL: Do NOT invent or add any extra fields (such as "_raw", "_xml"). Use ONLY the fields strictly defined in the schema. Extra fields will cause execution failure. ❌ WRONG — Do NOT do these: Wrong 1 — mixed text and XML: @@ -61,6 +61,9 @@ Wrong 2 — describing tool calls in text: [调用 Bash] {"command": "ls"} Wrong 3 — missing wrapper: ` + ex1 + `{} +Wrong 4 — extra/invented fields: + {"_raw": "...", "command": "ls"} + ✅ CORRECT EXAMPLES: diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index f131544..da78666 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -176,6 +176,24 @@ func TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) { } } +func TestParseToolCallsPreservesRawMalformedXMLParameters(t *testing.T) { + text := `execute_commandcd /root && git status` + calls := ParseToolCalls(text, []string{"execute_command"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "execute_command" { + t.Fatalf("expected tool name execute_command, got %q", calls[0].Name) + } + raw, ok := calls[0].Input["_raw"].(string) + if !ok { + t.Fatalf("expected raw argument tracking, got %#v", calls[0].Input) + } + if raw != "cd /root && git status" { + t.Fatalf("expected raw arguments to be preserved, got %q", raw) + } +} + func TestParseToolCallsSupportsXMLParametersJSONWithAmpersandCommand(t *testing.T) { text := `execute_command{"command":"sshpass -p 'xxx' ssh -o StrictHostKeyChecking=no -p 1111 root@111.111.111.111 'cd /root && git clone https://github.com/ericc-ch/copilot-api.git'","cwd":null,"timeout":null}` calls := ParseToolCalls(text, []string{"execute_command"}) diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index 9f91602..5c1ff94 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -173,6 +173,9 @@ func TestMessagesPrepareMergesConsecutiveSameRole(t *testing.T) { if count != 1 { t.Fatalf("expected one User marker for the merged pair, got %d occurrences", count) } + if count := strings.Count(got, "<|end▁of▁sentence|>"); count != 1 { + t.Fatalf("expected one sentence terminator for the merged pair, got %d occurrences", count) + } } func TestMessagesPrepareAssistantMarkers(t *testing.T) { @@ -187,6 +190,15 @@ func TestMessagesPrepareAssistantMarkers(t *testing.T) { if !strings.Contains(got, "<|end▁of▁sentence|>") { t.Fatalf("expected end of sentence marker, got %q", got) } + if strings.Count(got, "<|end▁of▁sentence|>") != 2 { + t.Fatalf("expected both turns to be terminated, got %q", got) + } + if !strings.Contains(got, "<|Assistant|>\nHello!<|end▁of▁sentence|>") { + t.Fatalf("expected assistant EOS suffix, got %q", got) + } + if strings.Contains(got, "") { + t.Fatalf("did not expect legacy system marker, got %q", got) + } } func TestMessagesPrepareUnknownRole(t *testing.T) { diff --git a/tests/compat/expected/sse_content_filter_status.json b/tests/compat/expected/sse_content_filter_status.json new file mode 100644 index 0000000..bf70dfb --- /dev/null +++ b/tests/compat/expected/sse_content_filter_status.json @@ -0,0 +1,8 @@ +{ + "parts": [], + "finished": true, + "new_type": "text", + "content_filter": true, + "output_tokens": 77, + "error_message": "" +} diff --git a/tests/compat/expected/sse_leaked_content_filter.json b/tests/compat/expected/sse_leaked_content_filter.json new file mode 100644 index 0000000..36e23a1 --- /dev/null +++ b/tests/compat/expected/sse_leaked_content_filter.json @@ -0,0 +1,7 @@ +{ + "parts": [ + {"text": "正常输出", "type": "text"} + ], + "finished": false, + "new_type": "text" +} diff --git a/tests/compat/expected/token_cases.json b/tests/compat/expected/token_cases.json index 69694eb..2634721 100644 --- a/tests/compat/expected/token_cases.json +++ b/tests/compat/expected/token_cases.json @@ -1,6 +1,8 @@ { "cases": [ {"name": "ascii_short", "tokens": 1}, + {"name": "whitespace_only", "tokens": 1}, + {"name": "newline_only", "tokens": 1}, {"name": "cjk", "tokens": 3}, {"name": "mixed", "tokens": 4} ] diff --git a/tests/compat/fixtures/sse_chunks/content_filter_status.json b/tests/compat/fixtures/sse_chunks/content_filter_status.json new file mode 100644 index 0000000..6b5b051 --- /dev/null +++ b/tests/compat/fixtures/sse_chunks/content_filter_status.json @@ -0,0 +1,11 @@ +{ + "chunk": { + "p": "response", + "v": [ + {"p": "status", "v": "CONTENT_FILTER"}, + {"p": "accumulated_token_usage", "v": 77} + ] + }, + "thinking_enabled": false, + "current_type": "text" +} diff --git a/tests/compat/fixtures/sse_chunks/leaked_content_filter.json b/tests/compat/fixtures/sse_chunks/leaked_content_filter.json new file mode 100644 index 0000000..1352025 --- /dev/null +++ b/tests/compat/fixtures/sse_chunks/leaked_content_filter.json @@ -0,0 +1,8 @@ +{ + "chunk": { + "p": "response/content", + "v": "正常输出CONTENT_FILTER你好,这个问题我暂时无法回答" + }, + "thinking_enabled": false, + "current_type": "text" +} diff --git a/tests/compat/fixtures/token_cases.json b/tests/compat/fixtures/token_cases.json index 3887356..d2d105b 100644 --- a/tests/compat/fixtures/token_cases.json +++ b/tests/compat/fixtures/token_cases.json @@ -1,6 +1,8 @@ { "cases": [ {"name": "ascii_short", "text": "abcd"}, + {"name": "whitespace_only", "text": " "}, + {"name": "newline_only", "text": "\n"}, {"name": "cjk", "text": "你好世界"}, {"name": "mixed", "text": "Hello 你好世界"} ] diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 07fe0b0..e6a13f9 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -17,6 +17,8 @@ const { normalizePreparedToolNames, boolDefaultTrue, filterIncrementalToolCallDeltasByAllowed, + buildUsage, + estimateTokens, shouldSkipPath, isNodeStreamSupportedPath, extractPathname, @@ -222,6 +224,107 @@ test('parseChunkForContent supports wrapped response.fragments object shape', () assert.equal(parsed.parts.map((p) => p.text).join(''), 'AB'); }); +test('parseChunkForContent preserves space-only content tokens', () => { + const chunk = { + p: 'response/content', + v: ' ', + }; + const parsed = parseChunkForContent(chunk, false, 'text'); + assert.equal(parsed.finished, false); + assert.deepEqual(parsed.parts, [{ text: ' ', type: 'text' }]); +}); + +test('parseChunkForContent strips reference markers from fragment content', () => { + const chunk = { + p: 'response/fragments', + o: 'APPEND', + v: [ + { type: 'RESPONSE', content: '广州天气 [reference:12] 多云' }, + ], + }; + const parsed = parseChunkForContent(chunk, false, 'text'); + assert.equal(parsed.finished, false); + assert.deepEqual(parsed.parts, [{ text: '广州天气 多云', type: 'text' }]); +}); + +test('parseChunkForContent detects content_filter status and carries output tokens', () => { + const chunk = { + p: 'response', + v: [ + { p: 'status', v: 'CONTENT_FILTER' }, + { p: 'accumulated_token_usage', v: 77 }, + ], + }; + const parsed = parseChunkForContent(chunk, false, 'text'); + assert.equal(parsed.parsed, true); + assert.equal(parsed.finished, true); + assert.equal(parsed.contentFilter, true); + assert.equal(parsed.outputTokens, 77); + assert.deepEqual(parsed.parts, []); +}); + +test('parseChunkForContent keeps error branches distinct from content_filter status', () => { + const chunk = { + error: { message: 'boom' }, + code: 'content_filter', + accumulated_token_usage: 88, + }; + const parsed = parseChunkForContent(chunk, false, 'text'); + assert.equal(parsed.parsed, true); + assert.equal(parsed.finished, true); + assert.equal(parsed.contentFilter, false); + assert.equal(parsed.errorMessage.length > 0, true); + assert.equal(parsed.outputTokens, 0); + assert.deepEqual(parsed.parts, []); +}); + +test('parseChunkForContent preserves output tokens on FINISHED lines', () => { + const parsed = parseChunkForContent( + { p: 'response/status', v: 'FINISHED', accumulated_token_usage: 190 }, + false, + 'text', + ); + assert.equal(parsed.parsed, true); + assert.equal(parsed.finished, true); + assert.equal(parsed.contentFilter, false); + assert.equal(parsed.outputTokens, 190); + assert.deepEqual(parsed.parts, []); +}); + +test('parseChunkForContent strips leaked CONTENT_FILTER suffix and preserves line breaks', () => { + const leaked = parseChunkForContent( + { p: 'response/content', v: '正常输出CONTENT_FILTER你好,这个问题我暂时无法回答' }, + false, + 'text', + ); + assert.deepEqual(leaked.parts, [{ text: '正常输出', type: 'text' }]); + + const newlineTail = parseChunkForContent( + { p: 'response/content', v: 'line1\nCONTENT_FILTERblocked' }, + false, + 'text', + ); + assert.deepEqual(newlineTail.parts, [{ text: 'line1\n', type: 'text' }]); + + const newlineOnly = parseChunkForContent( + { p: 'response/content', v: '\nCONTENT_FILTERblocked' }, + false, + 'text', + ); + assert.deepEqual(newlineOnly.parts, [{ text: '\n', type: 'text' }]); +}); + +test('estimateTokens preserves whitespace-only strings and buildUsage accepts output token overrides', () => { + assert.equal(estimateTokens(' '), 1); + assert.equal(estimateTokens('\n'), 1); + + const usage = buildUsage('abcd', 'ef', 'gh', 99); + assert.equal(usage.prompt_tokens, 1); + assert.equal(usage.completion_tokens, 99); + assert.equal(usage.total_tokens, 100); + assert.equal(usage.completion_tokens_details.reasoning_tokens, 1); +}); + test('shouldSkipPath skips dynamic response/fragments/*/status paths only', () => { assert.equal(shouldSkipPath('response/fragments/-16/status'), true); assert.equal(shouldSkipPath('response/fragments/8/status'), true); diff --git a/tests/node/js_compat_test.js b/tests/node/js_compat_test.js index c4bdcff..2b767ea 100644 --- a/tests/node/js_compat_test.js +++ b/tests/node/js_compat_test.js @@ -30,6 +30,9 @@ test('js compat: sse fixtures', () => { assert.deepEqual(got.parts, expected.parts, `${name}: parts mismatch`); assert.equal(got.finished, expected.finished, `${name}: finished mismatch`); assert.equal(got.newType, expected.new_type, `${name}: newType mismatch`); + assert.equal(Boolean(got.contentFilter), Boolean(expected.content_filter), `${name}: contentFilter mismatch`); + assert.equal(Number(got.outputTokens || 0), Number(expected.output_tokens || 0), `${name}: outputTokens mismatch`); + assert.equal(got.errorMessage || '', expected.error_message || '', `${name}: errorMessage mismatch`); } }); diff --git a/tests/raw_stream_samples/README.md b/tests/raw_stream_samples/README.md index e21d71d..1942316 100644 --- a/tests/raw_stream_samples/README.md +++ b/tests/raw_stream_samples/README.md @@ -1,28 +1,66 @@ # 原始流数据样本目录 -该目录用于存放**上游真实 SSE 原始流**样本,供本地仿真测试和解析适配使用。 +该目录只保留**上游真实 SSE 原始流**,用于本地回放、字段分析和回归测试。 + +## 默认永久样本 + +仓库当前保留两份 canonical 默认样本: + +- `guangzhou-weather-reasoner-search-20260404`:包含 `reference:N` 引用标记的天气搜索流,用于验证引用清理与正文输出。 +- `content-filter-trigger-20260405-jwt3`:真实命中的 `CONTENT_FILTER` 风控流,用于验证终态处理与拒答格式。 + +默认回放工具会优先读取 [`manifest.json`](./manifest.json) 中的 `default_samples`,以稳定固定回放集。 +更完整的协议级行为结构说明见 [docs/DeepSeekSSE行为结构说明-2026-04-05.md](../../docs/DeepSeekSSE行为结构说明-2026-04-05.md)。 + +## 自动采集接口 + +本地启动服务后,可以直接调用专用接口自动落盘一份 raw-only 样本: + +```bash +POST /admin/dev/raw-samples/capture +``` + +这个接口会: + +- 接收一个普通的 OpenAI chat completions 请求体 +- 走项目内同一条处理链 +- 自动保存请求元信息 `meta.json` +- 自动保存上游原始流 `upstream.stream.sse` + +采集接口的响应体仍然是项目当次的实际输出,但它不会再写入样本目录。这样样本树始终只保留原始流,后续回放时再按需本地生成派生结果。 ## 目录规范 -每个样本一个子目录: +每个样本一个子目录,且只保留下面两类文件: - `meta.json`:样本元信息(问题、模型、采集时间、备注) - `upstream.stream.sse`:完整原始 SSE 文本(`event:` / `data:` 行) -## 扩展方式 +## 回放与对比 -1. 抓取一次真实请求(建议开启 `DS2API_DEV_PACKET_CAPTURE=1`)。 -2. 新建 `/` 目录并放入 `meta.json` + `upstream.stream.sse`。 -3. 运行独立仿真工具(可被其他测试脚本调用): +回放工具会读取 `upstream.stream.sse`,在本地自动生成当前解析结果,并把派生结果写到 `artifacts/raw-stream-sim///`,例如: + +- `replay.output.txt`:本次回放生成的最终可见文本 +- `report.json`:本次回放的结构化报告,包含事件数、chunk 数、终态、引用泄露检查等信息 + +运行全部 canonical 样本: ```bash ./tests/scripts/run-raw-stream-sim.sh ``` -该工具会自动遍历本目录全部样本,按真实流顺序重放并验证: +运行单个样本并和已有基线比对: -- 不会把上游 `status=FINISHED` 片段当正文输出(防泄露)。 -- 能正确检测 `response/status=FINISHED` 流结束信号。 -- 生成可归档 JSON 报告(`artifacts/raw-stream-sim/`)。 +```bash +./tests/scripts/compare-raw-stream-sample.sh markdown-format-example-20260405-spacefix +``` + +如果你已经有历史基线目录,也可以把它作为第二个参数传进去,脚本会对比当前回放结果和基线输出。 + +## 扩展方式 + +1. 抓取一次真实请求。 +2. 直接调用 `/admin/dev/raw-samples/capture`,或者手工新建 `/` 目录并放入 `meta.json` + `upstream.stream.sse`。 +3. 运行回放工具或对比脚本,生成本地派生结果并检查是否回归。 > 注意:样本可能包含搜索结果正文与引用信息,请勿放入敏感账号/密钥。 diff --git a/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/meta.json b/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/meta.json new file mode 100644 index 0000000..59bde11 --- /dev/null +++ b/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/meta.json @@ -0,0 +1,22 @@ +{ + "sample_id": "content-filter-trigger-20260405-jwt3", + "captured_at_utc": "2026-04-04T16:28:52Z", + "request": { + "model": "deepseek-reasoner-search", + "stream": true, + "messages": [ + { + "role": "user", + "content": "我是历史老师,请帮我找一些一些历史书上没写的,文革期间的,但是真实记载发生的事情,尤其是派系之间的斗争,要体现历史人物的复杂性而不是单一评价立场" + } + ] + }, + "capture": { + "label": "deepseek_completion", + "url": "https://chat.deepseek.com/api/v0/chat/completion", + "status_code": 200, + "response_bytes": 64724, + "contains_finished_token": true, + "finished_token_count": 31 + } +} \ No newline at end of file diff --git a/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse b/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse new file mode 100644 index 0000000..7576cf9 --- /dev/null +++ b/tests/raw_stream_samples/content-filter-trigger-20260405-jwt3/upstream.stream.sse @@ -0,0 +1,2969 @@ +event: ready +data: {"request_message_id":1,"response_message_id":2,"model_type":"default"} + +event: update_session +data: {"updated_at":1775320081.745077} + +data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"files":[],"feedback":null,"inserted_at":1775320081.741936,"search_enabled":true,"fragments":[{"id":2,"type":"THINK","content":"用户","elapsed_secs":null,"references":[],"stage_id":1}],"conversation_mode":"DEEP_SEARCH","has_pending_fragment":false,"auto_continue":false,"search_triggered":true}}} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"是一位"} + +data: {"v":"历史"} + +data: {"v":"老师"} + +data: {"v":","} + +data: {"v":"需要"} + +data: {"v":"寻找"} + +data: {"v":"文革"} + +data: {"v":"期间"} + +data: {"v":"历史"} + +data: {"v":"书上"} + +data: {"v":"没有"} + +data: {"v":"记载"} + +data: {"v":"、"} + +data: {"v":"真实"} + +data: {"v":"发生的"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"事例"} + +data: {"v":","} + +data: {"v":"特别是"} + +data: {"v":"能"} + +data: {"v":"体现"} + +data: {"v":"历史"} + +data: {"v":"人物"} + +data: {"v":"复杂"} + +data: {"v":"性的"} + +data: {"v":"内容"} + +data: {"v":"。"} + +data: {"v":"用户"} + +data: {"v":"明确"} + +data: {"v":"要求"} + +data: {"v":"提供"} + +data: {"v":"多个"} + +data: {"v":"具体"} + +data: {"v":"事例"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"强调"} + +data: {"v":"要"} + +data: {"v":"体现"} + +data: {"v":"复杂性"} + +data: {"v":"而非"} + +data: {"v":"单一"} + +data: {"v":"评价"} + +data: {"v":"立场"} + +data: {"v":"。"} + +data: {"v":"为了"} + +data: {"v":"全面"} + +data: {"v":"获取"} + +data: {"v":"信息"} + +data: {"v":","} + +data: {"v":"我将"} + +data: {"v":"同时"} + +data: {"v":"进行"} + +data: {"v":"多项"} + +data: {"v":"搜索"} + +data: {"v":","} + +data: {"v":"涵盖"} + +data: {"v":"不同类型的"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"、"} + +data: {"v":"具体"} + +data: {"v":"事件"} + +data: {"v":"、"} + +data: {"v":"原始"} + +data: {"v":"材料"} + +data: {"v":"以及"} + +data: {"v":"相关"} + +data: {"v":"研究"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.8544618019999999} + +data: {"p":"response/has_pending_fragment","v":true} + +: + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":3,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 派系斗争 事例 历史人物 复杂性 非官方记载"}],"results":[],"stage_id":1},{"id":4,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 群众组织 派系斗争 武斗 事例"}],"results":[],"stage_id":1},{"id":5,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"王力 关锋 戚本禹 文革 派系 中央文革小组"}],"results":[],"stage_id":1},{"id":6,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"武汉七二〇事件 详细经过 派系 人物"}],"results":[],"stage_id":1},{"id":7,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"李雪峰 文革 派系 人物 复杂性"}],"results":[],"stage_id":1},{"id":8,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"赵健民 云南 文革 滇西挺进纵队 冤案"}],"results":[],"stage_id":1},{"id":9,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 派系斗争 原始材料 回忆录 口述史 研究"}],"results":[],"stage_id":1},{"id":10,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"site:cnki.net 文革 派系斗争 历史人物 复杂性"}],"results":[],"stage_id":1}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-7/results","o":"SET","v":[{"url":"http://www.zzdsw.com/yjcg/ztyj/201603/t20160331_1521613.html","title":"专题研究- 枣庄党史史志网","snippet":"发生在1967年7月25日至26日,是文化大革命中枣庄地区两派不同观点群众,发生的较大规模的武斗流血事件,也是枣庄地区两派群众组织派性激化的表现。","cite_index":null,"published_at":1459353600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zzdsw.com","site_name":"zzdsw.com","query_indexes":[0]},{"url":"https://www.hinews.cn:443/news/system/2009/10/16/010588314_01.shtml","title":"“文革”进入狂热时期 “一月风暴”影响海南","snippet":"派性的斗争使得局势变得错综复杂,越来越多的群众参与进来,形成了群众组织派性斗争。由于斗争激烈,扰乱了夺权运动的大方向,并给生产带来了严重影响。海南农垦也被牵扯进来了。","cite_index":null,"published_at":1255622400.0,"site_icon":"https://cdn.deepseek.com/site-icons/hinews.cn","site_name":"南海网","query_indexes":[0]},{"url":"https://m.thepaper.cn/baijiahao_15235200","title":"中华人民共和国成立初期的政治建设(四)","snippet":"9 月,“八五风暴”与“筹委会”两大派对立,在县供销社大楼发生第一次大型武斗事件,全县广播中断,毁坏房屋数十间,伤 30 余人。此后,接连发生了抢县武装部弹药库事件,武斗愈演愈烈。1968 年 1 月...","cite_index":null,"published_at":1635868800.0,"site_icon":"https://cdn.deepseek.com/site-icons/thepaper.cn","site_name":"澎湃新闻","query_indexes":[0]},{"url":"http://zx.hanzhong.gov.cn/hzzxwz/thhm/201605/t20160510_330408.shtml","title":"“文革”期间勉县武斗和“六·二”事件","snippet":"比较大的武斗事件21起,死亡85人(其中打死47人,武器走火死亡12人,武斗汽车肇事死亡13人,武斗中触电死亡1人,打死无辜群众3人,打死中国人民解放军战士9人)。","cite_index":null,"published_at":1618588800.0,"site_icon":"https://cdn.deepseek.com/site-icons/hanzhong.gov.cn","site_name":"汉中市人民政府","query_indexes":[0]},{"url":"http://www.gxdfz.org.cn/flbg/lsrw/202602/t20260224_67313.html","title":"余华祥--广西地情网","snippet":"1967年下半年以后,平南两派群众组织进入武斗高潮,余华祥经常身挎冲锋枪,腰缠手榴弹,进出县城,参加武斗。其时,广西劳动大学生梁宗和(寺面人),在南宁武斗时潜回寺面,后大安开始大规模武斗,连续出现枪杀群众事件,梁宗和怕受牵连,于12月1日取道六陈、罗秀到桂平想回南宁...","cite_index":null,"published_at":1771862400.0,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"广西数字方志馆","query_indexes":[0]}]} + +data: {"p":"response/fragments/-7/status","v":"FINISHED"} + +data: {"p":"response/fragments/-8/results","v":[{"url":"http://jhwsw.com/zzdzb/ShowArticle.asp?ArticleID=838","title":"“文革”期间的“反复旧妖风”(唐张新)","snippet":"其实从“文革”开始之初,红卫兵因为政治立场和投身运动的动机各不相同,对待原先当权派的态度就有两派之分,其中主张彻底打倒、严酷斗争的自诩为“左派”,而把那些支持原来老革命当权者的贬称为“保皇派”。到后来...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/jhwsw.com","site_name":"Jhwsw","query_indexes":[0]},{"url":"https://m.thepaper.cn/newsDetail_forward_8348578","title":"孟祥才 | 冯友兰为何能逃脱“右派”的厄运?","snippet":"历史所造反派翻身后,通过“民选”产生了“文革”小组,全面领导所里的“文化革命”。其中一项重要的工作是建立尹达的专案组,搜集整理他“执行修正主义路线的材料”。","cite_index":null,"published_at":1595088000.0,"site_icon":"https://cdn.deepseek.com/site-icons/thepaper.cn","site_name":"澎湃新闻","query_indexes":[0]},{"url":"http://mztoday.gov.cn/show/46607.html","title":"今日绵竹 - 《中国共产党四川省绵竹市历史》之“文化大革命”在绵竹兴起(二)","snippet":"1966年12月17日,绵竹县教育系统群众组织“小教兵团”在原老川剧团剧院集中全城区中小学教师及部分其他单位的干部数百人举行批判“三家村”斗争大会。批判斗争当权派是“文化大革命”的重要形式和内容。期间...","cite_index":null,"published_at":1627228800.0,"site_icon":"https://cdn.deepseek.com/site-icons/mztoday.gov.cn","site_name":"今日绵竹","query_indexes":[0]},{"url":"https://www.zjkzx.gov.cn/book/history17/files/basic-html/page76.html","title":"Page 76 - 张家口政协","snippet":"“文革”爆发后,他被隔离审查,仍坚持不承认反党。他把申 诉材料缝在棉袄里,被造反派查出,又多了一个“妄图翻案”","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/zjkzx.gov.cn","site_name":"Zjkzx","query_indexes":[0]}]} + +data: {"p":"response/fragments/-8/status","v":"FINISHED"} + +data: {"p":"response/fragments/-2/results","v":[{"url":"https://chinafolklore.org/web/index.php?Page=2&NewsID=5478","title":"[英古阿格]一个纳西族老村长的口述实录 · 中国民俗学网-中国民俗学会","snippet":"文革期间,因为有些造反派想打倒我,但又找不到把柄,因为我才当了村长不到三个月,就想定罪为当权派,他们也没有理由。家庭一直是根正苗红,建国之初就是团员了。他们就查到我家二叔头上来...","cite_index":null,"published_at":1249660800.0,"site_icon":"https://cdn.deepseek.com/site-icons/chinafolklore.org","site_name":"中国民俗学网","query_indexes":[0]},{"url":"http://old.counsellor.gov.cn/wsxc/2009-10-24/10368.shtml","title":"叶永烈:我写“文革”史","snippet":"史学研究历来重视文献,文献的“文”指档案材料,“献”就是口述史。我认为当代人写当代史的优势就在于对“献”的重视。那些档案,现在还处于保密阶段的,有可能过个几十年、上百年,后人能很方便地查阅。但是当事人越来越少...","cite_index":null,"published_at":1256313600.0,"site_icon":"https://cdn.deepseek.com/site-icons/counsellor.gov.cn","site_name":"国务院参事室","query_indexes":[0]},{"url":"https://news.sina.com.cn/c/sd/2009-07-31/131018338282.shtml","title":"亲历者口述“四人帮”兴亡史_新闻中心_新浪网","snippet":"中国历来有官修历史和民间著史的传统,一段由作家采写的10年“文革”史,历经20多年的等待,终于问世。 建国60周年,10年是“文革”,这段历史无法跨越。除了叶永烈的《“四人帮”兴亡》...","cite_index":null,"published_at":1248969600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sina.com.cn","site_name":"新浪网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-2/status","v":"FINISHED"} + +data: {"p":"response/fragments/-6/results","v":[{"url":"http://zhouenlai.people.cn/BIG5/n1/2019/0725/c409117-31256646-4.html","title":"揭秘\"救火隊長\"周恩來如何在\"文革\"中苦撐危局【4】","snippet":"1967年2月后,中央文革小組實際上取代了中央政治局,更是不可一世,到處煽陰風、點鬼火。這時的王力、關鋒、戚本禹是中央文革的三員“干將”,江青手下名副其實的“車、馬、炮”,成為“文化大革命”中左右橫行的風雲人物。","cite_index":null,"published_at":1563984000.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"周恩来纪念网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-6/status","v":"FINISHED"} + +data: {"p":"response/fragments/-1/results","v":[{"url":"https://bianke.cnki.net/Web/SearchArticle?kw=%E8%AE%B8%E5%B9%BF%E5%B9%B3&field=0&page=9","title":"母亲、女校长、问罪学——关于杨荫榆事件的再思考","snippet":"许广平在“文革”中未曾点成仿吾的名"文革"初,山东大学全校人员分成两派,对学校党委和党委书记、校长成仿吾的功过问题展开辩论。成仿吾开始"靠边站"...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网百科","query_indexes":[0]},{"url":"https://wap.cnki.net/touch/web/Journal/Article/BNCH200103007.html","title":"师门忆语(三)-百年潮2001年03期-手机知网","snippet":"十一、“文革”风暴中议论陈伯达“文革”祸起,《红旗》杂志的三位副总编辑邓(力群)、胡(绳)、范[若愚],几乎一夜之间就从领导高位上跌落下来,被作为“走资派”打倒。胡绳还因为参加“二月提纲”而罪加一等...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"CNKI","query_indexes":[0]},{"url":"https://xuewen.cnki.net/R2006050150005248.html","title":"中国科学院第4次学部委员大会","snippet":"全院工作稳步走上正轨。“文革”期间,中科院是重灾区,院中派系斗争不断,科学家和领导干部横遭批判和迫害,局面异常混乱。1977年1月方毅主持全院工作后,抓了粉碎“四人帮”帮派体系的工作,努力清除“左”的干扰。而且在院领导班子的带领下...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"CNKI","query_indexes":[0]},{"url":"https://mall.cnki.net/magazine/Article/DDSJ201204035.htm","title":"文革时期的海外中共研究","snippet":"文革时期是海外中共研究的兴盛时期。随着研究资料状况的改善,海外学者这一时期的研究成果体现了中共研究范围和研究深度的拓展。尤其重要的是,这一时期海外中共研究的进展,代表了研究范式的转变:原先主导海外中共研究领域的\"极权主义\"模式分崩离析,以\"派系... (共5页)","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网阅读","query_indexes":[0]},{"url":"https://mall.cnki.net/magazine/article/FLWS200309000.htm","title":"“文革”发动前的一段经历——从《二月提纲》到东湖之行","snippet":"“七个攻击左派、包庇右派材料”的出台 《二月提纲》的主持者是彭真,“炮制者”是中宣部的两位副部长,许立群和姚溱。 许立群是在周扬住院割治肺癌后,于1966年1月接手分管学术批判的事的。据我的观察...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网阅读","query_indexes":[0]}]} + +data: {"p":"response/fragments/-1/status","v":"FINISHED"} + +data: {"p":"response/fragments/-4/results","v":[{"url":"http://www.qyhao123.com/xcx/discuz.php?mod=view&tid=58251&show=author","title":"哪位政治局常委只当了四个月的党内“第四号人物”?","snippet":"“文革”初期,全国6个大区中两个大区的一把手,即华北局第一书记李雪峰、中南局第一书记陶铸,受到重用:李雪峰任北京市委第一书记,陶铸任中宣部长。毛泽东为什么选择他们取代被打倒的彭真...","cite_index":null,"published_at":1378656000.0,"site_icon":"https://cdn.deepseek.com/site-icons/qyhao123.com","site_name":"祁阳生活网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-4/status","v":"FINISHED"} + +data: {"p":"response/fragments/-5/status","v":"FINISHED"} + +data: {"p":"response/fragments/-3/results","v":[{"url":"http://hbzx.tsxcfw.com/bookshow.asp?id=887815","title":"康生与赵健民冤案_丁龙嘉,听雨_9787010028347_河北知行文化传播有限公司","snippet":"“赵健民特务案”是“文革”期间康生一手制造的一起特大冤案。这起冤案,使身为云南省委负责人的赵人健民被非法关押长达八年,一百三十八万七千余人被审查,十七万五千余作被立案审查,一万七千余人被打死、逼死。 作者耗时十年...","cite_index":null,"published_at":1159632000.0,"site_icon":"https://cdn.deepseek.com/site-icons/tsxcfw.com","site_name":"tsxcfw.com","query_indexes":[0]},{"url":"http://news.big5.enorth.com.cn/system/2008/10/21/003732191.shtml","title":"史海:康生制造冤案 雲南省委書記被非法關押8年-新聞中心-北方網","snippet":"趙健民冤案,是文革期間康生一手制造的一起特大冤案,使身為雲南省委書記的趙健民被非法關押達8年之久。《康生與『趙健民冤案』》一書是專家治史之作。","cite_index":null,"published_at":1224518400.0,"site_icon":"https://cdn.deepseek.com/site-icons/enorth.com.cn","site_name":"北方网","query_indexes":[0]},{"url":"https://m.163.com/dy/article/HTT55SI90552PRXR.html?spss=adap_pc","title":"他当过云南省委书记,晚年回忆:因为康生一句话,1万多人被害死","snippet":"在这之后,康生围绕赵健民制造了骇人听闻的“赵健民冤案”,把时任云南省委书记的赵健民诬陷为“叛徒”、“国民党特务”,赵健民也被关押起来,并被非法关押了八年时间。同时,受这起冤案牵连,云南省有一万七千余人被迫害致死。","cite_index":null,"published_at":1676736000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"https://ccrhm.org/history/yanshutian.htm","title":"阎书田","snippet":"文革中属云南“炮派”观点 ... 在会议中残酷批斗后押回云南原单位,在批斗会上被活活打死。 (资料来源于丁龙嘉、听雨著《康生与“赵健民冤案”》,人民出版社1999年1月版","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/ccrhm.org","site_name":"中国文革受难者纪念园","query_indexes":[0]},{"url":"https://ccrhm.org/history/gaotangwen.htm","title":"高堂文","snippet":"在会议期间批斗会上被打掉了下巴,押回原单位后在批斗中被铁丝活活勒死。 (资料来源于丁龙嘉、听雨著《康生与“赵健民冤案”》,人民出版社","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/ccrhm.org","site_name":"中国文革受难者纪念园","query_indexes":[0]}]} + +data: {"p":"response/fragments/-3/status","v":"FINISHED"} + +data: {"p":"response/has_pending_fragment","v":true} + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":11,"type":"THINK","content":"搜索","elapsed_secs":null,"references":[],"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"结果显示"} + +data: {"v":"了一些"} + +data: {"v":"相关"} + +data: {"v":"页面"} + +data: {"v":"。"} + +data: {"v":"为了"} + +data: {"v":"全面"} + +data: {"v":"获取"} + +data: {"v":"信息"} + +data: {"v":","} + +data: {"v":"我们需要"} + +data: {"v":"同时"} + +data: {"v":"打开"} + +data: {"v":"这些"} + +data: {"v":"页面"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.741001591} + +data: {"p":"response/has_pending_fragment","v":true} + +: + +: + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":12,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://jhwsw.com/zzdzb/ShowArticle.asp?ArticleID=838","title":"“文革”期间的“反复旧妖风”(唐张新)","snippet":"其实从“文革”开始之初,红卫兵因为政治立场和投身运动的动机各不相同,对待原先当权派的态度就有两派之分,其中主张彻底打倒、严酷斗争的自诩为“左派”,而把那些支持原来老革命当权者的贬称为“保皇派”。到后来...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/jhwsw.com","site_name":"Jhwsw","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":13,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://www.zzdsw.com/yjcg/ztyj/201603/t20160331_1521613.html","title":"专题研究- 枣庄党史史志网","snippet":"发生在1967年7月25日至26日,是文化大革命中枣庄地区两派不同观点群众,发生的较大规模的武斗流血事件,也是枣庄地区两派群众组织派性激化的表现。","cite_index":null,"published_at":1459353600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zzdsw.com","site_name":"zzdsw.com","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":14,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://m.thepaper.cn/baijiahao_15235200","title":"中华人民共和国成立初期的政治建设(四)","snippet":"9 月,“八五风暴”与“筹委会”两大派对立,在县供销社大楼发生第一次大型武斗事件,全县广播中断,毁坏房屋数十间,伤 30 余人。此后,接连发生了抢县武装部弹药库事件,武斗愈演愈烈。1968 年 1 月...","cite_index":null,"published_at":1635868800.0,"site_icon":"https://cdn.deepseek.com/site-icons/thepaper.cn","site_name":"澎湃新闻","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":15,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://zx.hanzhong.gov.cn/hzzxwz/thhm/201605/t20160510_330408.shtml","title":"“文革”期间勉县武斗和“六·二”事件","snippet":"比较大的武斗事件21起,死亡85人(其中打死47人,武器走火死亡12人,武斗汽车肇事死亡13人,武斗中触电死亡1人,打死无辜群众3人,打死中国人民解放军战士9人)。","cite_index":null,"published_at":1618588800.0,"site_icon":"https://cdn.deepseek.com/site-icons/hanzhong.gov.cn","site_name":"汉中市人民政府","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":16,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://zhouenlai.people.cn/BIG5/n1/2019/0725/c409117-31256646-4.html","title":"揭秘\"救火隊長\"周恩來如何在\"文革\"中苦撐危局【4】","snippet":"1967年2月后,中央文革小組實際上取代了中央政治局,更是不可一世,到處煽陰風、點鬼火。這時的王力、關鋒、戚本禹是中央文革的三員“干將”,江青手下名副其實的“車、馬、炮”,成為“文化大革命”中左右橫行的風雲人物。","cite_index":null,"published_at":1563984000.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"周恩来纪念网","query_indexes":[0]},"reference":{"id":5,"type":"TOOL_SEARCH"},"stage_id":2},{"id":17,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://www.qyhao123.com/xcx/discuz.php?mod=view&tid=58251&show=author","title":"哪位政治局常委只当了四个月的党内“第四号人物”?","snippet":"“文革”初期,全国6个大区中两个大区的一把手,即华北局第一书记李雪峰、中南局第一书记陶铸,受到重用:李雪峰任北京市委第一书记,陶铸任中宣部长。毛泽东为什么选择他们取代被打倒的彭真...","cite_index":null,"published_at":1378656000.0,"site_icon":"https://cdn.deepseek.com/site-icons/qyhao123.com","site_name":"祁阳生活网","query_indexes":[0]},"reference":{"id":7,"type":"TOOL_SEARCH"},"stage_id":2},{"id":18,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://hbzx.tsxcfw.com/bookshow.asp?id=887815","title":"康生与赵健民冤案_丁龙嘉,听雨_9787010028347_河北知行文化传播有限公司","snippet":"“赵健民特务案”是“文革”期间康生一手制造的一起特大冤案。这起冤案,使身为云南省委负责人的赵人健民被非法关押长达八年,一百三十八万七千余人被审查,十七万五千余作被立案审查,一万七千余人被打死、逼死。 作者耗时十年...","cite_index":null,"published_at":1159632000.0,"site_icon":"https://cdn.deepseek.com/site-icons/tsxcfw.com","site_name":"tsxcfw.com","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":19,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://news.big5.enorth.com.cn/system/2008/10/21/003732191.shtml","title":"史海:康生制造冤案 雲南省委書記被非法關押8年-新聞中心-北方網","snippet":"趙健民冤案,是文革期間康生一手制造的一起特大冤案,使身為雲南省委書記的趙健民被非法關押達8年之久。《康生與『趙健民冤案』》一書是專家治史之作。","cite_index":null,"published_at":1224518400.0,"site_icon":"https://cdn.deepseek.com/site-icons/enorth.com.cn","site_name":"北方网","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":20,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://m.163.com/dy/article/HTT55SI90552PRXR.html?spss=adap_pc","title":"他当过云南省委书记,晚年回忆:因为康生一句话,1万多人被害死","snippet":"在这之后,康生围绕赵健民制造了骇人听闻的“赵健民冤案”,把时任云南省委书记的赵健民诬陷为“叛徒”、“国民党特务”,赵健民也被关押起来,并被非法关押了八年时间。同时,受这起冤案牵连,云南省有一万七千余人被迫害致死。","cite_index":null,"published_at":1676736000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":21,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://chinafolklore.org/web/index.php?Page=2&NewsID=5478","title":"[英古阿格]一个纳西族老村长的口述实录 · 中国民俗学网-中国民俗学会","snippet":"文革期间,因为有些造反派想打倒我,但又找不到把柄,因为我才当了村长不到三个月,就想定罪为当权派,他们也没有理由。家庭一直是根正苗红,建国之初就是团员了。他们就查到我家二叔头上来...","cite_index":null,"published_at":1249660800.0,"site_icon":"https://cdn.deepseek.com/site-icons/chinafolklore.org","site_name":"中国民俗学网","query_indexes":[0]},"reference":{"id":9,"type":"TOOL_SEARCH"},"stage_id":2},{"id":22,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://old.counsellor.gov.cn/wsxc/2009-10-24/10368.shtml","title":"叶永烈:我写“文革”史","snippet":"史学研究历来重视文献,文献的“文”指档案材料,“献”就是口述史。我认为当代人写当代史的优势就在于对“献”的重视。那些档案,现在还处于保密阶段的,有可能过个几十年、上百年,后人能很方便地查阅。但是当事人越来越少...","cite_index":null,"published_at":1256313600.0,"site_icon":"https://cdn.deepseek.com/site-icons/counsellor.gov.cn","site_name":"国务院参事室","query_indexes":[0]},"reference":{"id":9,"type":"TOOL_SEARCH"},"stage_id":2},{"id":23,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://news.sina.com.cn/c/sd/2009-07-31/131018338282.shtml","title":"亲历者口述“四人帮”兴亡史_新闻中心_新浪网","snippet":"中国历来有官修历史和民间著史的传统,一段由作家采写的10年“文革”史,历经20多年的等待,终于问世。 建国60周年,10年是“文革”,这段历史无法跨越。除了叶永烈的《“四人帮”兴亡》...","cite_index":null,"published_at":1248969600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sina.com.cn","site_name":"新浪网","query_indexes":[0]},"reference":{"id":9,"type":"TOOL_SEARCH"},"stage_id":2},{"id":24,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://bianke.cnki.net/Web/SearchArticle?kw=%E8%AE%B8%E5%B9%BF%E5%B9%B3&field=0&page=9","title":"母亲、女校长、问罪学——关于杨荫榆事件的再思考","snippet":"许广平在“文革”中未曾点成仿吾的名"文革"初,山东大学全校人员分成两派,对学校党委和党委书记、校长成仿吾的功过问题展开辩论。成仿吾开始"靠边站"...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网百科","query_indexes":[0]},"reference":{"id":10,"type":"TOOL_SEARCH"},"stage_id":2},{"id":25,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://wap.cnki.net/touch/web/Journal/Article/BNCH200103007.html","title":"师门忆语(三)-百年潮2001年03期-手机知网","snippet":"十一、“文革”风暴中议论陈伯达“文革”祸起,《红旗》杂志的三位副总编辑邓(力群)、胡(绳)、范[若愚],几乎一夜之间就从领导高位上跌落下来,被作为“走资派”打倒。胡绳还因为参加“二月提纲”而罪加一等...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"CNKI","query_indexes":[0]},"reference":{"id":10,"type":"TOOL_SEARCH"},"stage_id":2},{"id":26,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://mall.cnki.net/magazine/Article/DDSJ201204035.htm","title":"文革时期的海外中共研究","snippet":"文革时期是海外中共研究的兴盛时期。随着研究资料状况的改善,海外学者这一时期的研究成果体现了中共研究范围和研究深度的拓展。尤其重要的是,这一时期海外中共研究的进展,代表了研究范式的转变:原先主导海外中共研究领域的\"极权主义\"模式分崩离析,以\"派系... (共5页)","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/cnki.net","site_name":"知网阅读","query_indexes":[0]},"reference":{"id":10,"type":"TOOL_SEARCH"},"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-3/status","o":"SET","v":"FINISHED"} + +data: {"p":"response/fragments/-2/status","v":"FINISHED"} + +data: {"p":"response/fragments/-1/status","v":"FINISHED"} + +data: {"p":"response/fragments/-10/status","v":"FINISHED"} + +data: {"p":"response/fragments/-9/status","v":"FINISHED"} + +data: {"p":"response/fragments/-7/status","v":"FINISHED"} + +data: {"p":"response/fragments/-5/status","v":"FINISHED"} + +data: {"p":"response/fragments/-4/status","v":"FINISHED"} + +data: {"p":"response/fragments/-15/status","v":"FINISHED"} + +data: {"p":"response/fragments/-14/status","v":"FINISHED"} + +data: {"p":"response/fragments/-13/status","v":"FINISHED"} + +data: {"p":"response/fragments/-12/status","v":"FINISHED"} + +data: {"p":"response/fragments/-8/status","v":"FINISHED"} + +data: {"p":"response/fragments/-6/status","v":"FINISHED"} + +data: {"p":"response/fragments/-11/status","v":"FINISHED"} + +data: {"p":"response/has_pending_fragment","v":true} + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":27,"type":"THINK","content":"搜索","elapsed_secs":null,"references":[],"stage_id":3}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"结果显示"} + +data: {"v":"了一些"} + +data: {"v":"相关"} + +data: {"v":"页面"} + +data: {"v":"。"} + +data: {"v":"为了"} + +data: {"v":"更"} + +data: {"v":"全面地"} + +data: {"v":"覆盖"} + +data: {"v":"用户"} + +data: {"v":"需求"} + +data: {"v":","} + +data: {"v":"我们需要"} + +data: {"v":"同时"} + +data: {"v":"进行"} + +data: {"v":"多项"} + +data: {"v":"搜索"} + +data: {"v":","} + +data: {"v":"以"} + +data: {"v":"获取"} + +data: {"v":"更多"} + +data: {"v":"关于"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"、"} + +data: {"v":"具体"} + +data: {"v":"事件"} + +data: {"v":"、"} + +data: {"v":"地方"} + +data: {"v":"案例"} + +data: {"v":"、"} + +data: {"v":"高层"} + +data: {"v":"斗争"} + +data: {"v":"以及"} + +data: {"v":"不同"} + +data: {"v":"研究"} + +data: {"v":"视角"} + +data: {"v":"的信息"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.177853049} + +data: {"p":"response/has_pending_fragment","v":true} + +: + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":28,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"文革 派系 斗争 群众组织 武斗 案例 研究"}],"results":[],"stage_id":3},{"id":29,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"王力 八七讲话 外交部 夺权 派系"}],"results":[],"stage_id":3},{"id":30,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"北京 红卫兵 派系 斗争 联动 天派 地派"}],"results":[],"stage_id":3},{"id":31,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"上海 文革 派系 斗争 工总司 联司"}],"results":[],"stage_id":3},{"id":32,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 文革 派系 斗争 红旗派 东风派"}],"results":[],"stage_id":3},{"id":33,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"湖南 文革 派系 斗争 湘江风雷 红一线"}],"results":[],"stage_id":3},{"id":34,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广西 文革 派系 斗争 联指 四二二 派"}],"results":[],"stage_id":3}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-4/results","o":"SET","v":[{"url":"https://m.163.com/dy/article/HL9HAKRH0552PRXR.html?spss=adap_pc","title":"他与王洪文一起创立了“工总司”,却被王洪文架空,后来两度入狱","snippet":"于是“工总司”就在王洪文以及潘国平等人的带领下前往上海市委,11月10日凌晨又乘坐火车前往安亭车站,制造了安亭事件。潘国平也是这起事件的带头人 ... 潘国平是“工总司”的早期领导人,与王洪文平起平坐。潘国平有一定的文化基础,为人比较外向...","cite_index":null,"published_at":1667491200.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]}]} + +data: {"p":"response/fragments/-4/status","v":"FINISHED"} + +data: {"p":"response/fragments/-5/results","v":[{"url":"https://baike.baidu.com/item/%E5%A4%A9%E6%B4%BE/9569880","title":"天派_百度百科","snippet":"天派,全称红卫兵天派,是文化大革命期间以北京航空学院“红旗”组织为核心力量的政治团体,主要活跃于北京地区。其名称源于北航的航空专业属性,“航”与“天”相关联,故简称“天派” [1。","cite_index":null,"published_at":1750867200.0,"site_icon":"https://cdn.deepseek.com/site-icons/baidu.com","site_name":"百度百科","query_indexes":[0]},{"url":"http://www.chinazhiqing.com/forum.php?mod=viewthread&action=printable&tid=102766","title":"中国知青网-中国知青网络家园","snippet":"红卫兵运动,发起者是首都各大专院校的大学生,首都红卫兵的“天派”是北航,“地派”是北京地质学院,窜遍全国掀起造反浪潮最著名的所谓首都红卫兵第三司令部,也基本以大学生为主!中学虽然有很多红卫兵组织...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/chinazhiqing.com","site_name":"中国知青网","query_indexes":[0]},{"url":"https://upimg.baike.haosou.com/doc/529356-560419.html","title":"天派 免费编辑 添加义项名","snippet":"\"天派\",全名红卫兵天派,指的是文革期间由北京航空学院\"红旗\"为主要力量的组织,北航的\"航\"即航空,是和天有关的,所以称为\"天派\"。","cite_index":null,"published_at":1535212800.0,"site_icon":"https://cdn.deepseek.com/site-icons/haosou.com","site_name":"haosou.com","query_indexes":[0]},{"url":"https://baike.baidu.com/searchword/?word=%E9%9F%A9%E7%88%B1%E6%99%B6&pic=1&sug=1&enc=utf8","title":"","snippet":"“天派”、“地派”的“领头羊”北大“新北大公社”、清华“井冈山”、北航“红旗”、地质“东方红”、师大“井冈山”等都是“文革”初期名震一方的“左派”组织,他们的头头聂元梓、蒯大富、 _韩爱晶_ 、谭厚兰、王大宾号称北京造反派红卫兵的“五大领袖”。中央文革小组则对两派采取又拉又捧...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/baidu.com","site_name":"百度百科","query_indexes":[0]}]} + +data: {"p":"response/fragments/-5/status","v":"FINISHED"} + +data: {"p":"response/fragments/-1/results","v":[{"url":"http://lib.gxdfz.org.cn/view2022-c32-480.html","title":"480 第五节 派性斗争与冤假错案","snippet":"1967年5月6日,县内群众组织开始分化为“联指”(无产阶级革命派联合指挥部)和“四 ·二二”(四·二二火线指挥部,后改为革命造反大军),两大派展开激烈的派性斗争,他们 互相争论、指责斗殴,你叫我“保皇派”,我叫你“匪徒派”。当年9月1日高岭区两派发生 冲突...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"Gxdfz","query_indexes":[0]},{"url":"http://lib.gxdfz.org.cn/view-c37-707.html","title":"707 第四节 派性斗争","snippet":"1967年秋冬,广西四二二钦州指挥郎(简称四·二二,又称造反派)、钦州县无产阶级革 命联合指挥部(简称联指)两大派群众组织矛盾越来越尖锐,相继出现互相抓人、抢枪事件, 大规模的示威游行不断发生。11月1日...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"Gxdfz","query_indexes":[0]},{"url":"http://www.gxdfz.org.cn/flbg/lsrw/202602/t20260224_67312.html","title":"杨明--广西地情网","snippet":"21日,“联指”胡北荣被冷枪打死。武斗便迅速推向高潮。22晚,杨明与谭朝东亲自指挥武装民兵用冲锋枪袭击“4·22”的据点——南江旅社。随后...","cite_index":null,"published_at":1771862400.0,"site_icon":"https://cdn.deepseek.com/site-icons/gxdfz.org.cn","site_name":"广西数字方志馆","query_indexes":[0]},{"url":"https://www.gxdag.org.cn/show/142/3576","title":"广西档案信息网 - 看旧图说南宁(展览馆、解放路、广西大学)(图)","snippet":"三”布告通告后被6912部队和“联指”组织围歼。 解放路,以前叫做德邻路。文化大革命时期被“4。22”造反派组织称之为“解放区”,“4。22”的很多主要组织都盘踞在这里。有“青年近卫军”...","cite_index":null,"published_at":1329753600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gxdag.org.cn","site_name":"广西壮族自治区档案馆","query_indexes":[0]},{"url":"http://bbs.rauz.net.cn/archiver/tid-5626.html","title":"[原创]一段往事 两个冤魂[僚人家园]——壮族、布依族、岱侬族共同论坛★Riuzmingz 僚人人物","snippet":"

文革开始,两人被定为历史反革命分子。1968年春的某一天被红卫兵揪斗,因一人倾向“联指”,另一人偏向“4.22”(联指:广西革命造反派联合指挥部,矛头指向武振南书记;“4.22”...","cite_index":null,"published_at":1091980800.0,"site_icon":"https://cdn.deepseek.com/site-icons/rauz.net.cn","site_name":"僚人家园","query_indexes":[0]},{"url":"https://www.semanticscholar.org/author/%E6%9D%8E%E6%9E%9C%E6%B2%B3/2033988088","title":"李果河 | Semantic Scholar","snippet":"“文革”时,广西红卫兵和各地群众因为“支持韦国清”还是“打倒韦国清”,出现分歧,分裂成势不两立的两大派。一派叫广西“无产阶级革命派联合指挥部”(简称广西“联指”),另一派叫“广西革命造反派4·22指挥部”(简称广西“4·22”)。","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/semanticscholar.org","site_name":"Semantic Scholar","query_indexes":[0]},{"url":"http://bbs.rauz.net.cn/forum.php?mod=viewthread&tid=5626&page=1&authorid=1463","title":"[原创]一段往事 两个冤魂","snippet":"1968年春的某一天被红卫兵揪斗,因一人倾向“联指”,另一人偏向“4.22”(联指:广西革命造反派联合指挥部,矛头指向武振南书记;“4.22”...","cite_index":null,"published_at":1091980800.0,"site_icon":"https://cdn.deepseek.com/site-icons/rauz.net.cn","site_name":"僚人家园","query_indexes":[0]},{"url":"https://kiss.kstudy.com/Detail/Ar?key=3975781","title":"周邊에서 본 文化大革命 -廣西 派閥鬪爭을 중심으로- - 중국사연구 - 중국사학회 - KISS","snippet":"最終\"4·22\"成爲主要攻擊目標的廣西文革的悲劇也許是按照預告的程序進行的。在北京進行的接見政治中,慫恿協商及矛盾的權宜之計也許是在戰爭和革命的矛盾中不可避免的。但是...","cite_index":null,"published_at":1664553600.0,"site_icon":"https://cdn.deepseek.com/site-icons/kstudy.com","site_name":"학술논문검색사이트 KISS","query_indexes":[0]}]} + +data: {"p":"response/fragments/-1/status","v":"FINISHED"} + +data: {"p":"response/fragments/-7/results","v":[{"url":"http://www.zzdsw.com/yjcg/ztyj/201603/t20160331_1521613.html","title":"专题研究- 枣庄党史史志网","snippet":"发生在1967年7月25日至26日,是文化大革命中枣庄地区两派不同观点群众,发生的较大规模的武斗流血事件,也是枣庄地区两派群众组织派性激化的表现。","cite_index":null,"published_at":1459353600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zzdsw.com","site_name":"zzdsw.com","query_indexes":[0]},{"url":"http://zx.hanzhong.gov.cn/hzzxwz/thhm/201605/t20160510_330408.shtml","title":"“文革”期间勉县武斗和“六·二”事件","snippet":"比较大的武斗事件21起,死亡85人(其中打死47人,武器走火死亡12人,武斗汽车肇事死亡13人,武斗中触电死亡1人,打死无辜群众3人...","cite_index":null,"published_at":1618588800.0,"site_icon":"https://cdn.deepseek.com/site-icons/hanzhong.gov.cn","site_name":"汉中市人民政府","query_indexes":[0]},{"url":"https://www.hinews.cn:443/news/system/2009/10/16/010588314_01.shtml","title":"“文革”进入狂热时期 “一月风暴”影响海南","snippet":"派性的斗争使得局势变得错综复杂,越来越多的群众参与进来,形成了群众组织派性斗争。由于斗争激烈,扰乱了夺权运动的大方向,并给生产带来了严重影响。海南农垦也被牵扯进来了。","cite_index":null,"published_at":1255622400.0,"site_icon":"https://cdn.deepseek.com/site-icons/hinews.cn","site_name":"南海网","query_indexes":[0]},{"url":"https://www.songjiang.gov.cn/sjsz/sjds/zggcdsjdfsz/files/basic-html/page202.html","title":"Page 202 - 中国共产党松江历史图志","snippet":"革委会成立不久,造反组织在对待 县革委会的态度上,分裂成“炮轰” 和“支持”两大派(即“轰派”和 “支派”)。于是两派斗争不断, 且愈演愈烈,最后导致 1967 年发","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/songjiang.gov.cn","site_name":"上海市松江区人民政府","query_indexes":[0]}]} + +data: {"p":"response/fragments/-7/status","v":"FINISHED"} + +data: {"p":"response/fragments/-6/results","v":[{"url":"https://www.163.com/dy/article/ILUUJUG00552PRXR.html","title":"陈毅受到批斗,她被派去“摸情况”,得到指示:运去英雄不自由","snippet":"王力的这次讲话也被称为“八七讲话”,在讲话中,王力对造反派讲,批斗陈毅的大方向是对的,同时,他还表示要改变外交部的现有制度,并声称谁都可以当外交部部长,鼓励他们批斗陈毅。当时陈毅担任外交部部长...","cite_index":null,"published_at":1702483200.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"http://epaper.voc.com.cn/wczm/html/2012-08/30/content_552828.htm?div=-1","title":"王季范解诗精准判时势-----文萃报数字报刊平台","snippet":"王力在讲话中,称外交部“运动阻力太大”,鼓吹要夺外交部的权,矛头直指外交部长陈毅。 王力的讲话发表后,我们外交部的“保陈派”们要弄清毛泽东对王力讲话的态度,便派出王海容去毛泽东那里“摸情况”。","cite_index":null,"published_at":1346256000.0,"site_icon":"https://cdn.deepseek.com/site-icons/voc.com.cn","site_name":"三湘都市报","query_indexes":[0]},{"url":"http://zhouenlai.people.cn/BIG5/n1/2021/0107/c409117-31992422-4.html","title":"揭秘\"救火隊長\"周恩來如何在\"文革\"中苦撐危局【4】","snippet":"造反派一陣“訴苦”后,王力開講了:“外交部運動阻力大?外交唬人嘛!別人不能干,了不起?這麼神秘,隻有少數專家才能干,你這個外交就這麼難?紅衛兵就不能干外交?","cite_index":null,"published_at":1609948800.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"周恩来纪念网","query_indexes":[0]},{"url":"http://m.people.cn/n4/0/2017/0724/c677-9387667-2_2.html","title":"67年周恩来一针见血斥责何人的行径“是剜心战术”","snippet":"8月7日,谢富治与王力两人各发表了一次著名的造反讲话。王力在接见外交系统造反派代表时,煽动要向外交部夺权,打倒陈毅,引起了外交部的大动乱,酿成了火烧英国驻华代办处的恶劣外交事件。这个讲话被称为“王八七讲话”。","cite_index":null,"published_at":1500825600.0,"site_icon":"https://cdn.deepseek.com/site-icons/people.cn","site_name":"人民网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-6/status","v":"FINISHED"} + +data: {"p":"response/fragments/-2/results","v":[{"url":"http://copyright.hktdc.com/tc/book-info.aspx?book_id=823d7c975971437786dc61e0f69bcdf8","title":"版權交易專區","snippet":"本書是湖南長沙一名工人造反派有關他在文革期間活動的回憶錄。書內釐清了一些史實和理論上的疑難問題,如所謂湘江風雷搶武器問題以及這一湖南最大造反派組織是如何興起的、湖南文革中武鬥經過以及一些目前忽略的造反派組織等等。這些資料...","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/hktdc.com","site_name":"HKTDC.com","query_indexes":[0]},{"url":"https://www.4305.cn/w/l8z6r48080t","title":"血色记忆:1967年武冈的\"武斗\"","snippet":"武冈大武斗的起因是二派大组织:武工联,湘武工联,这是文化大革命”出现的群众组织,对于谁是“造反派”,谁是“保守派”争论不休。各造反派组织之间从维护他们各自的利益出发...","cite_index":null,"published_at":1593964800.0,"site_icon":"https://cdn.deepseek.com/site-icons/4305.cn","site_name":"武冈人网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-2/status","v":"FINISHED"} + +data: {"p":"response/fragments/-3/results","v":[{"url":"https://www.bilibili.com/video/BV1ynNGe1EAo/?spm_id_from=333.1387.homepage.video_card.click","title":"“羊城革命浪潮起,封《红卫报》遭市民抵制,陶铸倒台后,以中大为核心的造反派夺得省市大权,但与地总,红总爆发冲突”——【广州文革地方史】(1)_哔哩哔哩_bilibili","snippet":"起,封《红卫报》遭市民抵制,陶铸倒台后,以中大为核心的造反派夺得省市大权,但与地总,红总爆发冲突”——【广州文革地方史】(1)_哔哩哔哩_bilibili | # “羊城革命浪潮起...","cite_index":null,"published_at":1738944000.0,"site_icon":"https://cdn.deepseek.com/site-icons/bilibili.com","site_name":"B站","query_indexes":[0]},{"url":"https://baijiahao.baidu.com/s?id=1743184378359987823","title":"梁锦棠:曾任广东革委会副主任,1977年被审查,后来被开除党籍","snippet":"并且成为了以工人群体为主的“红总”派的头头。“红总”派是广东的造反派之一,主要头目都是工厂的工人,这个组织属于“总派”,也被称为“东风派”,他们与广东的另一派造反组织“红旗派”是死对头,双方爆发过大规模“武斗”,造成了非常恶劣的影响。","cite_index":null,"published_at":1662393600.0,"site_icon":"https://cdn.deepseek.com/site-icons/baidu.com","site_name":"百家号","query_indexes":[0]},{"url":"http://bbs.hasea.com/thread-277771-2-1.html","title":"文革铁路专题章-火车头篇","snippet":"1967年3月25日,广州铁路分局革命委员会在广州地区率先成立,称之\"广州响起了第一声春雷\",属于\"东风\"派系.\"广铁总司\"属\"红旗\"派系(简称旗派).因话题与贴子主题不切,因此不便在此作更多的探讨啦~","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/hasea.com","site_name":"海子铁路网","query_indexes":[0]},{"url":"http://opinion.haiwainet.cn/n/2014/0429/c345416-20582980-2.html","title":"港片中的内地人:从人蛇大圈仔到优才蝗虫 【2】","snippet":"《省港旗兵》的大陆劫匪头目“东哥”被设定为文革时在广州以武斗闻名的“旗派”红卫兵 大圈仔(代表电影《省港旗兵》系列)","cite_index":null,"published_at":1398700800.0,"site_icon":"https://cdn.deepseek.com/site-icons/haiwainet.cn","site_name":"海外网评","query_indexes":[0]},{"url":"https://m.sohu.com/n/472559456/","title":"820武斗纪事","snippet":"他们说东风派把旗派的人抓住后,“当作活靶,一批批进行枪杀”。广医红旗八二○大血案调查组声称,旗派在这次武斗中,伤亡惨重,“公路两旁遍是尸体”,光是在医院抢救无效死亡的就有十多人,受伤住院的50多人(见《广医红旗》1967年8月25日)。但有的文章更惊人地声称...","cite_index":null,"published_at":1478534400.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"手机搜狐网","query_indexes":[0]},{"url":"https://weibo.com/ttarticle/p/show?id=2309405005800564457890","title":"【回忆录】一九六八种种烦忧","snippet":"8月,广州两派组织各占据广州部分高楼做武斗据点,东风派占据长堤省总工会等,红旗派占据长堤爱群大厦等。暨大318小分队属旗派,就按上面指令入驻爱群大厦。省总在沿江西路149号...","cite_index":null,"published_at":1708876800.0,"site_icon":"https://cdn.deepseek.com/site-icons/weibo.com","site_name":"微博","query_indexes":[0]},{"url":"http://www.gzyzxyh.com/thread-14455-1-1.html","title":"广州日报事件 (下) [复制链接]","snippet":"省军管会阵脚已动,不得不节节退让。当天晚上,由驻中大工作组宣布中大红旗八三一是左派组织;八一战斗兵团被拘捕的九名成员,在此之前也获得释放。经过这么一番反复...","cite_index":null,"published_at":1389369600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gzyzxyh.com","site_name":"gzyzxyh.com","query_indexes":[0]},{"url":"http://opinion.haiwainet.cn/BIG5/n/2014/0429/c345416-20582980-2.html","title":"港片中的內地人:從人蛇大圈仔到優才蝗虫 【2】","snippet":"《省港旗兵》的大陸劫匪頭目“東哥”被設定為文革時在廣州以武斗聞名的“旗派”紅衛兵 大圈仔(代表電影《省港旗兵》系列)","cite_index":null,"published_at":1398700800.0,"site_icon":"https://cdn.deepseek.com/site-icons/haiwainet.cn","site_name":"海外网评","query_indexes":[0]},{"url":"http://www.qec.com.cn/bD2021/02/02/2552.shtml","title":"周继能:迟到的忏悔——我对不起老师们","snippet":"“文革”开始时我就读于广州市第17中66届高三(1)班,与同学们一样,响应毛主席号召,积极参加“文革”,参加了学校的“战斗兵团”,与广州市的“毛泽东主义红卫兵”属同一派,与广州市另一红卫兵组织“红旗”严重对立。1968年7月...","cite_index":null,"published_at":1612195200.0,"site_icon":"https://cdn.deepseek.com/site-icons/qec.com.cn","site_name":"Qec","query_indexes":[0]}]} + +data: {"p":"response/fragments/-3/status","v":"FINISHED"} + +data: {"p":"response/has_pending_fragment","v":true} + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":35,"type":"THINK","content":"搜索结果","elapsed_secs":null,"references":[],"stage_id":4}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"提供了"} + +data: {"v":"大量"} + +data: {"v":"关于"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争的"} + +data: {"v":"案例"} + +data: {"v":"。"} + +data: {"v":"我将"} + +data: {"v":"基于"} + +data: {"v":"这些"} + +data: {"v":"资料"} + +data: {"v":","} + +data: {"v":"组织"} + +data: {"v":"回答"} + +data: {"v":","} + +data: {"v":"涵盖"} + +data: {"v":"基层"} + +data: {"v":"群众"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"、"} + +data: {"v":"高层"} + +data: {"v":"权力"} + +data: {"v":"博弈"} + +data: {"v":"、"} + +data: {"v":"历史"} + +data: {"v":"人物的"} + +data: {"v":"复杂性"} + +data: {"v":"以及"} + +data: {"v":"获取"} + +data: {"v":"更多"} + +data: {"v":"信息的"} + +data: {"v":"建议"} + +data: {"v":"。"} + +data: {"v":"回答"} + +data: {"v":"将"} + +data: {"v":"强调"} + +data: {"v":"这些"} + +data: {"v":"事例"} + +data: {"v":"的"} + +data: {"v":"细节"} + +data: {"v":"性和"} + +data: {"v":"复杂性"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"避免"} + +data: {"v":"单一"} + +data: {"v":"评价"} + +data: {"v":"立场"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.260543306} + +data: {"p":"response/fragments","o":"APPEND","v":[{"id":36,"type":"RESPONSE","content":"历史","references":[],"stage_id":4}]} + +data: {"p":"response/fragments/-1/content","v":"老师"} + +data: {"v":"您好"} + +data: {"v":"。"} + +data: {"v":"要在"} + +data: {"v":"历史"} + +data: {"v":"教材"} + +data: {"v":"的"} + +data: {"v":"概括"} + +data: {"v":"性"} + +data: {"v":"叙述"} + +data: {"v":"之外"} + +data: {"v":","} + +data: {"v":"捕捉"} + +data: {"v":"到"} + +data: {"v":"文革"} + +data: {"v":"期间"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争的"} + +data: {"v":"细节"} + +data: {"v":"与"} + +data: {"v":"复杂性"} + +data: {"v":","} + +data: {"v":"我们可以"} + +data: {"v":"从"} + +data: {"v":"一些"} + +data: {"v":"具体"} + +data: {"v":"的历史"} + +data: {"v":"切"} + +data: {"v":"面"} + +data: {"v":"入手"} + +data: {"v":"。"} + +data: {"v":"这些"} + +data: {"v":"案例"} + +data: {"v":"揭示了"} + +data: {"v":"在"} + +data: {"v":"宏大"} + +data: {"v":"叙事"} + +data: {"v":"之下"} + +data: {"v":","} + +data: {"v":"个体"} + +data: {"v":"与"} + +data: {"v":"群体的"} + +data: {"v":"矛盾"} + +data: {"v":"、"} + +data: {"v":"挣扎"} + +data: {"v":"与"} + +data: {"v":"博弈"} + +data: {"v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" 🎭"} + +data: {"v":" "} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"众生"} + +data: {"v":"相"} + +data: {"v":":"} + +data: {"v":"群众"} + +data: {"v":"组织的"} + +data: {"v":"分化"} + +data: {"v":"与"} + +data: {"v":"斗争"} + +data: {"v":"\n"} + +data: {"v":"随着"} + +data: {"v":"运动的"} + +data: {"v":"深入"} + +data: {"v":","} + +data: {"v":"原本"} + +data: {"v":"统一的"} + +data: {"v":"“"} + +data: {"v":"革命"} + +data: {"v":"群众"} + +data: {"v":"”"} + +data: {"v":"迅速"} + +data: {"v":"分化"} + +data: {"v":"。"} + +data: {"v":"草"} + +data: {"v":"根"} + +data: {"v":"层面的"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"往往"} + +data: {"v":"比"} + +data: {"v":"高层"} + +data: {"v":"博弈"} + +data: {"v":"更加"} + +data: {"v":"残酷"} + +data: {"v":","} + +data: {"v":"其"} + +data: {"v":"复杂性"} + +data: {"v":"在于"} + +data: {"v":","} + +data: {"v":"斗争的"} + +data: {"v":"双方"} + +data: {"v":"都"} + +data: {"v":"坚信"} + +data: {"v":"自己"} + +data: {"v":"代表了"} + +data: {"v":"真正的"} + +data: {"v":"“"} + +data: {"v":"革命"} + +data: {"v":"方向"} + +data: {"v":"”"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:0]"},{"p":"references","o":"SET","v":[{"id":12,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"红"} + +data: {"v":"卫"} + +data: {"v":"兵"} + +data: {"v":"“"} + +data: {"v":"天"} + +data: {"v":"派"} + +data: {"v":"”"} + +data: {"v":"与"} + +data: {"v":"“"} + +data: {"v":"地"} + +data: {"v":"派"} + +data: {"v":"”"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"北京"} + +data: {"v":"的红"} + +data: {"v":"卫"} + +data: {"v":"兵"} + +data: {"v":"因"} + +data: {"v":"所属"} + +data: {"v":"大学"} + +data: {"v":"不同"} + +data: {"v":"分"} + +data: {"v":"化为"} + +data: {"v":"“"} + +data: {"v":"天"} + +data: {"v":"派"} + +data: {"v":"”("} + +data: {"v":"北"} + +data: {"v":"航"} + +data: {"v":"“"} + +data: {"v":"红旗"} + +data: {"v":"”)"} + +data: {"v":"和"} + +data: {"v":"“"} + +data: {"v":"地"} + +data: {"v":"派"} + +data: {"v":"”("} + +data: {"v":"地质"} + +data: {"v":"“"} + +data: {"v":"东方"} + +data: {"v":"红"} + +data: {"v":"”)"} + +data: {"v":"等"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":","} + +data: {"v":"并在"} + +data: {"v":"“"} + +data: {"v":"中央"} + +data: {"v":"文革"} + +data: {"v":"”"} + +data: {"v":"的"} + +data: {"v":"挑"} + +data: {"v":"动"} + +data: {"v":"下"} + +data: {"v":"矛盾"} + +data: {"v":"激"} + +data: {"v":"化"} + +data: {"v":","} + +data: {"v":"各"} + +data: {"v":"派的"} + +data: {"v":"“"} + +data: {"v":"五大"} + +data: {"v":"领袖"} + +data: {"v":"”"} + +data: {"v":"在当时"} + +data: {"v":"极具"} + +data: {"v":"影响力"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:1]"},{"p":"references","v":[{"id":30,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"广州"} + +data: {"v":"“"} + +data: {"v":"东风"} + +data: {"v":"派"} + +data: {"v":"”"} + +data: {"v":"与"} + +data: {"v":"“"} + +data: {"v":"红旗"} + +data: {"v":"派"} + +data: {"v":"”"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"广州"} + +data: {"v":"以"} + +data: {"v":"“"} + +data: {"v":"红"} + +data: {"v":"总"} + +data: {"v":"”"} + +data: {"v":"为代表的"} + +data: {"v":"“"} + +data: {"v":"东风"} + +data: {"v":"派"} + +data: {"v":"”"} + +data: {"v":"和"} + +data: {"v":"以"} + +data: {"v":"“"} + +data: {"v":"广"} + +data: {"v":"铁"} + +data: {"v":"总"} + +data: {"v":"司"} + +data: {"v":"”"} + +data: {"v":"为代表的"} + +data: {"v":"“"} + +data: {"v":"红旗"} + +data: {"v":"派"} + +data: {"v":"”"} + +data: {"v":"势"} + +data: {"v":"同"} + +data: {"v":"水火"} + +data: {"v":"。"} + +data: {"v":"196"} + +data: {"v":"7"} + +data: {"v":"年"} + +data: {"v":"8"} + +data: {"v":"月"} + +data: {"v":"20"} + +data: {"v":"日"} + +data: {"v":"爆"} + +data: {"v":"发的"} + +data: {"v":"“"} + +data: {"v":"820"} + +data: {"v":"武"} + +data: {"v":"斗"} + +data: {"v":"”"} + +data: {"v":"中"} + +data: {"v":","} + +data: {"v":"双方"} + +data: {"v":"在"} + +data: {"v":"市区"} + +data: {"v":"利用"} + +data: {"v":"高楼"} + +data: {"v":"作为"} + +data: {"v":"据点"} + +data: {"v":"枪"} + +data: {"v":"战"} + +data: {"v":","} + +data: {"v":"造成"} + +data: {"v":"大量"} + +data: {"v":"伤亡"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:2]"},{"p":"references","v":[{"id":32,"type":"TOOL_SEARCH"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:3]"},{"p":"references","v":[{"id":32,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"上海"} + +data: {"v":"“"} + +data: {"v":"工"} + +data: {"v":"总"} + +data: {"v":"司"} + +data: {"v":"”"} + +data: {"v":"的权力"} + +data: {"v":"之争"} + +data: {"v":"**"} + +data: {"v":":“"} + +data: {"v":"工"} + +data: {"v":"总"} + +data: {"v":"司"} + +data: {"v":"”"} + +data: {"v":"内部"} + +data: {"v":"同样"} + +data: {"v":"存在"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"。"} + +data: {"v":"早期"} + +data: {"v":"领导人"} + +data: {"v":"潘"} + +data: {"v":"国"} + +data: {"v":"平"} + +data: {"v":"与"} + +data: {"v":"后来"} + +data: {"v":"成为"} + +data: {"v":"“"} + +data: {"v":"四人"} + +data: {"v":"帮"} + +data: {"v":"”"} + +data: {"v":"骨干"} + +data: {"v":"的王"} + +data: {"v":"洪"} + +data: {"v":"文"} + +data: {"v":"之间"} + +data: {"v":","} + +data: {"v":"为"} + +data: {"v":"争夺"} + +data: {"v":"组织领导"} + +data: {"v":"权"} + +data: {"v":"存在"} + +data: {"v":"复杂"} + +data: {"v":"博弈"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:4]"},{"p":"references","v":[{"id":31,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"广西"} + +data: {"v":"“"} + +data: {"v":"联"} + +data: {"v":"指"} + +data: {"v":"”"} + +data: {"v":"与"} + +data: {"v":"“"} + +data: {"v":"4"} + +data: {"v":"·"} + +data: {"v":"22"} + +data: {"v":"”"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"广西"} + +data: {"v":"群众"} + +data: {"v":"组织"} + +data: {"v":"围绕"} + +data: {"v":"“"} + +data: {"v":"支持"} + +data: {"v":"韦"} + +data: {"v":"国"} + +data: {"v":"清"} + +data: {"v":"”"} + +data: {"v":"还是"} + +data: {"v":"“"} + +data: {"v":"打倒"} + +data: {"v":"韦"} + +data: {"v":"国"} + +data: {"v":"清"} + +data: {"v":"”"} + +data: {"v":"分裂"} + +data: {"v":"为"} + +data: {"v":"“"} + +data: {"v":"联"} + +data: {"v":"指"} + +data: {"v":"”"} + +data: {"v":"和"} + +data: {"v":"“"} + +data: {"v":"4"} + +data: {"v":"·"} + +data: {"v":"22"} + +data: {"v":"”"} + +data: {"v":"两大"} + +data: {"v":"派"} + +data: {"v":","} + +data: {"v":"两"} + +data: {"v":"派"} + +data: {"v":"冲突"} + +data: {"v":"中"} + +data: {"v":"甚至"} + +data: {"v":"出现了"} + +data: {"v":"武装"} + +data: {"v":"民兵"} + +data: {"v":"直接"} + +data: {"v":"袭击"} + +data: {"v":"另一"} + +data: {"v":"派"} + +data: {"v":"据点"} + +data: {"v":"的情况"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:5]"},{"p":"references","v":[{"id":34,"type":"TOOL_SEARCH"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:6]"},{"p":"references","v":[{"id":34,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"各地"} + +data: {"v":"武"} + +data: {"v":"斗"} + +data: {"v":"的"} + +data: {"v":"惨"} + +data: {"v":"烈"} + +data: {"v":"代价"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"常"} + +data: {"v":"升级"} + +data: {"v":"为"} + +data: {"v":"武"} + +data: {"v":"斗"} + +data: {"v":"。"} + +data: {"v":"例如"} + +data: {"v":"在"} + +data: {"v":"陕西"} + +data: {"v":"勉"} + +data: {"v":"县"} + +data: {"v":","} + +data: {"v":"从"} + +data: {"v":"196"} + +data: {"v":"7"} + +data: {"v":"年"} + +data: {"v":"9"} + +data: {"v":"月至"} + +data: {"v":"196"} + +data: {"v":"8"} + +data: {"v":"年"} + +data: {"v":"9"} + +data: {"v":"月"} + +data: {"v":"的一年"} + +data: {"v":"中"} + +data: {"v":","} + +data: {"v":"武"} + +data: {"v":"斗"} + +data: {"v":"共"} + +data: {"v":"导致"} + +data: {"v":"85"} + +data: {"v":"人死亡"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:7]"},{"p":"references","v":[{"id":15,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":";"} + +data: {"v":"河北"} + +data: {"v":"武"} + +data: {"v":"安"} + +data: {"v":"的两"} + +data: {"v":"派"} + +data: {"v":"对立"} + +data: {"v":"导致了"} + +data: {"v":"抢"} + +data: {"v":"枪"} + +data: {"v":"、"} + +data: {"v":"武"} + +data: {"v":"斗"} + +data: {"v":","} + +data: {"v":"致"} + +data: {"v":"近"} + +data: {"v":"200"} + +data: {"v":"人死亡"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:8]"},{"p":"references","v":[{"id":14,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":";"} + +data: {"v":"山东"} + +data: {"v":"枣"} + +data: {"v":"庄"} + +data: {"v":"的"} + +data: {"v":"“"} + +data: {"v":"七"} + +data: {"v":"二"} + +data: {"v":"五"} + +data: {"v":"”"} + +data: {"v":"事件"} + +data: {"v":"更是"} + +data: {"v":"有"} + +data: {"v":"400"} + +data: {"v":"0"} + +data: {"v":"余名"} + +data: {"v":"武装"} + +data: {"v":"农民"} + +data: {"v":"参与"} + +data: {"v":","} + +data: {"v":"造成"} + +data: {"v":"26"} + +data: {"v":"人"} + +data: {"v":"当场"} + +data: {"v":"被打"} + +data: {"v":"死"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:9]"},{"p":"references","v":[{"id":13,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" 🔥"} + +data: {"v":" "} + +data: {"v":"失控"} + +data: {"v":"的"} + +data: {"v":"漩涡"} + +data: {"v":":"} + +data: {"v":"高"} + +data: {"v":"层的"} + +data: {"v":"博弈"} + +data: {"v":"与"} + +data: {"v":"基层"} + +data: {"v":"的"} + +data: {"v":"共振"} + +data: {"v":"\n"} + +data: {"v":"中央"} + +data: {"v":"高"} + +data: {"v":"层的"} + +data: {"v":"权力"} + +data: {"v":"斗争"} + +data: {"v":","} + +data: {"v":"通过与"} + +data: {"v":"地方"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"结合"} + +data: {"v":","} + +data: {"v":"迅速"} + +data: {"v":"演"} + +data: {"v":"变为"} + +data: {"v":"全国"} + +data: {"v":"性的"} + +data: {"v":"动荡"} + +data: {"v":"。\n\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"中央"} + +data: {"v":"文革"} + +data: {"v":"的"} + +data: {"v":"“"} + +data: {"v":"点火"} + +data: {"v":"”"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"196"} + +data: {"v":"7"} + +data: {"v":"年"} + +data: {"v":"2"} + +data: {"v":"月"} + +data: {"v":"后"} + +data: {"v":",“"} + +data: {"v":"中央"} + +data: {"v":"文革"} + +data: {"v":"”"} + +data: {"v":"取代"} + +data: {"v":"了"} + +data: {"v":"中央"} + +data: {"v":"政治"} + +data: {"v":"局的"} + +data: {"v":"职能"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:10]"},{"p":"references","v":[{"id":5,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} + +data: {"v":"其"} + +data: {"v":"成员"} + +data: {"v":"王"} + +data: {"v":"力"} + +data: {"v":"、"} + +data: {"v":"关"} + +data: {"v":"锋"} + +data: {"v":"、"} + +data: {"v":"戚"} + +data: {"v":"本"} + +data: {"v":"禹"} + +data: {"v":"等人"} + +data: {"v":"四处"} + +data: {"v":"煽"} + +data: {"v":"动"} + +data: {"v":","} + +data: {"v":"如"} + +data: {"v":"王"} + +data: {"v":"力"} + +data: {"v":"196"} + +data: {"v":"7"} + +data: {"v":"年"} + +data: {"v":"8"} + +data: {"v":"月"} + +data: {"v":"7"} + +data: {"v":"日的"} + +data: {"v":"“"} + +data: {"v":"八"} + +data: {"v":"七"} + +data: {"v":"讲话"} + +data: {"v":"”"} + +data: {"v":"直接"} + +data: {"v":"煽"} + +data: {"v":"动"} + +data: {"v":"造反"} + +data: {"v":"派"} + +data: {"v":"夺取"} + +data: {"v":"外交部"} + +data: {"v":"权力"} + +data: {"v":","} + +data: {"v":"矛"} + +data: {"v":"头"} + +data: {"v":"直"} + +data: {"v":"指"} + +data: {"v":"陈"} + +data: {"v":"毅"} + +data: {"v":","} + +data: {"v":"直接"} + +data: {"v":"引发了"} + +data: {"v":"火烧"} + +data: {"v":"英国"} + +data: {"v":"驻"} + +data: {"v":"华"} + +data: {"v":"代办"} + +data: {"v":"处的"} + +data: {"v":"严重"} + +data: {"v":"外交"} + +data: {"v":"事件"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:11]"},{"p":"references","v":[{"id":16,"type":"TOOL_OPEN"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:12]"},{"p":"references","v":[{"id":29,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"“"} + +data: {"v":"反复"} + +data: {"v":"旧"} + +data: {"v":"”"} + +data: {"v":"运动的"} + +data: {"v":"荒"} + +data: {"v":"诞"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"196"} + +data: {"v":"8"} + +data: {"v":"年的"} + +data: {"v":"“"} + +data: {"v":"反复"} + +data: {"v":"旧"} + +data: {"v":"”"} + +data: {"v":"运动"} + +data: {"v":"体现了"} + +data: {"v":"运动的"} + +data: {"v":"荒谬"} + +data: {"v":"性"} + +data: {"v":"。"} + +data: {"v":"一些"} + +data: {"v":"在"} + +data: {"v":"革"} + +data: {"v":"委会"} + +data: {"v":"任职"} + +data: {"v":"的"} + +data: {"v":"“"} + +data: {"v":"走"} + +data: {"v":"资"} + +data: {"v":"派"} + +data: {"v":"”"} + +data: {"v":"被"} + +data: {"v":"重新"} + +data: {"v":"打倒"} + +data: {"v":","} + +data: {"v":"江苏"} + +data: {"v":"建"} + +data: {"v":"湖"} + +data: {"v":"县"} + +data: {"v":"三"} + +data: {"v":"主任"} + +data: {"v":"被"} + +data: {"v":"关"} + +data: {"v":"进"} + +data: {"v":"“"} + +data: {"v":"学习"} + +data: {"v":"班"} + +data: {"v":"”"} + +data: {"v":"残酷"} + +data: {"v":"斗争"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:13]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":","} + +data: {"v":"许多"} + +data: {"v":"原本"} + +data: {"v":"执行"} + +data: {"v":"政策的"} + +data: {"v":"解放军"} + +data: {"v":"官兵"} + +data: {"v":","} + +data: {"v":"也"} + +data: {"v":"因"} + +data: {"v":"形势"} + +data: {"v":"变化"} + +data: {"v":"成为"} + +data: {"v":"整治"} + +data: {"v":"对象"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:14]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:15]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"地方"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"与国家"} + +data: {"v":"机器的"} + +data: {"v":"共振"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"地方"} + +data: {"v":"派"} + +data: {"v":"系"} + +data: {"v":"斗争"} + +data: {"v":"时常"} + +data: {"v":"获得"} + +data: {"v":"国家"} + +data: {"v":"机器的"} + +data: {"v":"支持"} + +data: {"v":"。"} + +data: {"v":"如"} + +data: {"v":"山东"} + +data: {"v":"的王"} + +data: {"v":"效"} + +data: {"v":"禹"} + +data: {"v":"直接"} + +data: {"v":"动用"} + +data: {"v":"省"} + +data: {"v":"革"} + +data: {"v":"委会"} + +data: {"v":"和"} + +data: {"v":"军区"} + +data: {"v":"名义"} + +data: {"v":"支持"} + +data: {"v":"一派"} + +data: {"v":","} + +data: {"v":"派"} + +data: {"v":"飞机"} + +data: {"v":"散发"} + +data: {"v":"通告"} + +data: {"v":"、"} + +data: {"v":"在全省"} + +data: {"v":"设"} + +data: {"v":"卡"} + +data: {"v":"抓捕"} + +data: {"v":"对立"} + +data: {"v":"派"} + +data: {"v":"群众"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:16]"},{"p":"references","v":[{"id":13,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} + +data: {"v":"###"} + +data: {"v":" 🎭"} + +data: {"v":" "} + +data: {"v":"人性的"} + +data: {"v":"困境"} + +data: {"v":":"} + +data: {"v":"浮"} + +data: {"v":"沉"} + +data: {"v":"人物的"} + +data: {"v":"复杂"} + +data: {"v":"面向"} + +data: {"v":"\n"} + +data: {"v":"教材"} + +data: {"v":"之外"} + +data: {"v":","} + +data: {"v":"许多"} + +data: {"v":"历史"} + +data: {"v":"人物"} + +data: {"v":"并非"} + +data: {"v":"脸"} + +data: {"v":"谱"} + +data: {"v":"化的"} + +data: {"v":"角色"} + +data: {"v":","} + +data: {"v":"其"} + +data: {"v":"沉"} + +data: {"v":"浮"} + +data: {"v":"展现了"} + +data: {"v":"人性的"} + +data: {"v":"复杂"} + +data: {"v":"与"} + +data: {"v":"时代的"} + +data: {"v":"吊"} + +data: {"v":"诡"} + +data: {"v":"。\n\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"陈"} + +data: {"v":"伯"} + +data: {"v":"达"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"他曾"} + +data: {"v":"任"} + +data: {"v":"“"} + +data: {"v":"中央"} + +data: {"v":"文革"} + +data: {"v":"小组"} + +data: {"v":"”"} + +data: {"v":"组长"} + +data: {"v":","} + +data: {"v":"是"} + +data: {"v":"“"} + +data: {"v":"文革"} + +data: {"v":"”"} + +data: {"v":"的关键"} + +data: {"v":"人物"} + +data: {"v":","} + +data: {"v":"但在"} + +data: {"v":"198"} + +data: {"v":"1"} + +data: {"v":"年被"} + +data: {"v":"判"} + +data: {"v":"刑"} + +data: {"v":"18"} + +data: {"v":"年"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:17]"},{"p":"references","v":[{"id":23,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} + +data: {"v":"他的"} + +data: {"v":"复杂性"} + +data: {"v":"在于"} + +data: {"v":","} + +data: {"v":"他也"} + +data: {"v":"曾"} + +data: {"v":"做过"} + +data: {"v":"贡献"} + +data: {"v":","} + +data: {"v":"如"} + +data: {"v":"194"} + +data: {"v":"8"} + +data: {"v":"年在"} + +data: {"v":"城南"} + +data: {"v":"庄"} + +data: {"v":"冒"} + +data: {"v":"死"} + +data: {"v":"救护"} + +data: {"v":"过"} + +data: {"v":"毛泽东"} + +data: {"v":","} + +data: {"v":"这一"} + +data: {"v":"事迹"} + +data: {"v":"因"} + +data: {"v":"他"} + +data: {"v":"后来"} + +data: {"v":"成为"} + +data: {"v":"主"} + +data: {"v":"犯"} + +data: {"v":"而在"} + +data: {"v":"许多"} + +data: {"v":"回忆"} + +data: {"v":"录"} + +data: {"v":"中被"} + +data: {"v":"隐"} + +data: {"v":"去"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:18]"},{"p":"references","v":[{"id":22,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"陶"} + +data: {"v":"铸"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"这位"} + +data: {"v":"中南"} + +data: {"v":"局"} + +data: {"v":"第一书记"} + +data: {"v":"在"} + +data: {"v":"“"} + +data: {"v":"文革"} + +data: {"v":"”"} + +data: {"v":"初期"} + +data: {"v":"被"} + +data: {"v":"提拔"} + +data: {"v":"为"} + +data: {"v":"政治局"} + +data: {"v":"常委"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"仅"} + +data: {"v":"4"} + +data: {"v":"个"} + +data: {"v":"多月"} + +data: {"v":"后"} + +data: {"v":"就被"} + +data: {"v":"江"} + +data: {"v":"青"} + +data: {"v":"等人"} + +data: {"v":"公开"} + +data: {"v":"打倒"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:19]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} + +data: {"v":"其"} + +data: {"v":"悲剧"} + +data: {"v":"在于"} + +data: {"v":","} + +data: {"v":"他曾"} + +data: {"v":"因"} + +data: {"v":"在"} + +data: {"v":"“"} + +data: {"v":"七"} + +data: {"v":"千人"} + +data: {"v":"大会"} + +data: {"v":"”"} + +data: {"v":"上"} + +data: {"v":"反对"} + +data: {"v":"刘少"} + +data: {"v":"奇的"} + +data: {"v":"“"} + +data: {"v":"人"} + +data: {"v":"祸"} + +data: {"v":"”"} + +data: {"v":"论"} + +data: {"v":"而"} + +data: {"v":"得到"} + +data: {"v":"毛泽东"} + +data: {"v":"赏识"} + +data: {"v":","} + +data: {"v":"最终"} + +data: {"v":"却被"} + +data: {"v":"更"} + +data: {"v":"激"} + +data: {"v":"进的"} + +data: {"v":"浪潮"} + +data: {"v":"吞噬"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:20]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"李"} + +data: {"v":"雪"} + +data: {"v":"峰"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"他"} + +data: {"v":"因"} + +data: {"v":"在"} + +data: {"v":"“"} + +data: {"v":"四"} + +data: {"v":"清"} + +data: {"v":"”"} + +data: {"v":"运动中"} + +data: {"v":"与"} + +data: {"v":"刘少"} + +data: {"v":"奇"} + +data: {"v":"有"} + +data: {"v":"分歧"} + +data: {"v":"被"} + +data: {"v":"毛泽东"} + +data: {"v":"重用"} + +data: {"v":"为"} + +data: {"v":"北京"} + +data: {"v":"市委"} + +data: {"v":"第一书记"} + +data: {"v":","} + +data: {"v":"但在"} + +data: {"v":"政治"} + +data: {"v":"风暴"} + +data: {"v":"中"} + +data: {"v":"难以"} + +data: {"v":"自"} + +data: {"v":"保"} + +data: {"v":","} + +data: {"v":"其"} + +data: {"v":"命运"} + +data: {"v":"同样"} + +data: {"v":"坎坷"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:21]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:22]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"赵"} + +data: {"v":"健"} + +data: {"v":"民"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"时任"} + +data: {"v":"云南"} + +data: {"v":"省委"} + +data: {"v":"书记"} + +data: {"v":"的"} + +data: {"v":"赵"} + +data: {"v":"健"} + +data: {"v":"民"} + +data: {"v":","} + +data: {"v":"在京"} + +data: {"v":"西"} + +data: {"v":"宾馆"} + +data: {"v":"会议上"} + +data: {"v":"据"} + +data: {"v":"理"} + +data: {"v":"力争"} + +data: {"v":","} + +data: {"v":"却被"} + +data: {"v":"康"} + +data: {"v":"生"} + +data: {"v":"以"} + +data: {"v":"历史"} + +data: {"v":"问题"} + +data: {"v":"为由"} + +data: {"v":"诬"} + +data: {"v":"陷"} + +data: {"v":"为"} + +data: {"v":"“"} + +data: {"v":"叛"} + +data: {"v":"徒"} + +data: {"v":"”,"} + +data: {"v":"被"} + +data: {"v":"非法"} + +data: {"v":"关"} + +data: {"v":"押"} + +data: {"v":"长达"} + +data: {"v":"八年"} + +data: {"v":"。"} + +data: {"v":"更"} + +data: {"v":"令人"} + +data: {"v":"痛"} + +data: {"v":"心"} + +data: {"v":"的是"} + +data: {"v":","} + +data: {"v":"此"} + +data: {"v":"冤"} + +data: {"v":"案"} + +data: {"v":"导致"} + +data: {"v":"云南省"} + +data: {"v":"超过"} + +data: {"v":"170"} + +data: {"v":"00"} + +data: {"v":"人被"} + +data: {"v":"牵连"} + +data: {"v":"致死"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:23]"},{"p":"references","v":[{"id":20,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"周恩来"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"在"} + +data: {"v":"极度"} + +data: {"v":"混乱"} + +data: {"v":"的局面"} + +data: {"v":"中"} + +data: {"v":","} + +data: {"v":"他"} + +data: {"v":"尽力"} + +data: {"v":"“"} + +data: {"v":"苦"} + +data: {"v":"撑"} + +data: {"v":"危"} + +data: {"v":"局"} + +data: {"v":"”。"} + +data: {"v":"在"} + +data: {"v":"中央"} + +data: {"v":"文革"} + +data: {"v":"挑"} + +data: {"v":"动"} + +data: {"v":"外交部"} + +data: {"v":"夺"} + +data: {"v":"权"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"他"} + +data: {"v":"一方面"} + +data: {"v":"严厉"} + +data: {"v":"批评"} + +data: {"v":","} + +data: {"v":"另一方面"} + +data: {"v":"指挥"} + +data: {"v":"部队"} + +data: {"v":"营"} + +data: {"v":"救"} + +data: {"v":"被困"} + +data: {"v":"人员"} + +data: {"v":","} + +data: {"v":"试图"} + +data: {"v":"挽回"} + +data: {"v":"失控"} + +data: {"v":"的外"} + +data: {"v":"交"} + +data: {"v":"局面"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:24]"},{"p":"references","v":[{"id":16,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"许"} + +data: {"v":"广"} + +data: {"v":"平"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"在"} + +data: {"v":"山东"} + +data: {"v":"大学"} + +data: {"v":"批判"} + +data: {"v":"校长"} + +data: {"v":"成"} + +data: {"v":"仿"} + +data: {"v":"吾"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"许"} + +data: {"v":"广"} + +data: {"v":"平"} + +data: {"v":"作为"} + +data: {"v":"鲁迅"} + +data: {"v":"夫人"} + +data: {"v":","} + +data: {"v":"她的"} + +data: {"v":"态度"} + +data: {"v":"和"} + +data: {"v":"抉择"} + +data: {"v":"也"} + +data: {"v":"反映了"} + +data: {"v":"那个"} + +data: {"v":"时代"} + +data: {"v":"知识分子"} + +data: {"v":"面临的"} + +data: {"v":"复杂"} + +data: {"v":"处境"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:25]"},{"p":"references","v":[{"id":24,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"纳"} + +data: {"v":"西"} + +data: {"v":"族"} + +data: {"v":"老"} + +data: {"v":"村"} + +data: {"v":"长的"} + +data: {"v":"二"} + +data: {"v":"叔"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"一位"} + +data: {"v":"回乡"} + +data: {"v":"军人"} + +data: {"v":","} + +data: {"v":"仅"} + +data: {"v":"因"} + +data: {"v":"将"} + +data: {"v":"有"} + +data: {"v":"领导人"} + +data: {"v":"签"} + +data: {"v":"名的"} + +data: {"v":"证明"} + +data: {"v":"藏在"} + +data: {"v":"鞋"} + +data: {"v":"底"} + +data: {"v":","} + +data: {"v":"便被"} + +data: {"v":"诬"} + +data: {"v":"陷"} + +data: {"v":"为"} + +data: {"v":"“"} + +data: {"v":"第三"} + +data: {"v":"党"} + +data: {"v":"”"} + +data: {"v":"分子"} + +data: {"v":","} + +data: {"v":"这"} + +data: {"v":"则"} + +data: {"v":"个案"} + +data: {"v":"反映了"} + +data: {"v":"那个"} + +data: {"v":"年代"} + +data: {"v":"荒"} + +data: {"v":"诞"} + +data: {"v":"的"} + +data: {"v":"罪名"} + +data: {"v":"如何"} + +data: {"v":"轻易"} + +data: {"v":"地"} + +data: {"v":"摧毁"} + +data: {"v":"普通"} + +data: {"v":"人的生活"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:26]"},{"p":"references","v":[{"id":21,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} + +data: {"v":"您"} + +data: {"v":"需要"} + +data: {"v":"更多"} + +data: {"v":"原始"} + +data: {"v":"材料"} + +data: {"v":","} + +data: {"v":"可以"} + +data: {"v":"查阅"} + +data: {"v":"叶"} + +data: {"v":"永"} + +data: {"v":"烈的"} + +data: {"v":"《"} + +data: {"v":"“"} + +data: {"v":"四人"} + +data: {"v":"帮"} + +data: {"v":"”"} + +data: {"v":"兴"} + +data: {"v":"亡"} + +data: {"v":"》("} + +data: {"v":"基于"} + +data: {"v":"大量"} + +data: {"v":"亲"} + +data: {"v":"历"} + +data: {"v":"者"} + +data: {"v":"采访"} + +data: {"v":")"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:27]"},{"p":"references","v":[{"id":22,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"、"} + +data: {"v":"丁"} + +data: {"v":"龙"} + +data: {"v":"嘉"} + +data: {"v":"与"} + +data: {"v":"听"} + +data: {"v":"雨的"} + +data: {"v":"《"} + +data: {"v":"康"} + +data: {"v":"生"} + +data: {"v":"与"} + +data: {"v":"“"} + +data: {"v":"赵"} + +data: {"v":"健"} + +data: {"v":"民"} + +data: {"v":"冤"} + +data: {"v":"案"} + +data: {"v":"》"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:28]"},{"p":"references","v":[{"id":18,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":","} + +data: {"v":"以及"} + +data: {"v":"各地"} + +data: {"v":"政协"} + +data: {"v":"编"} + +data: {"v":"撰"} + +data: {"v":"的"} + +data: {"v":"文史"} + +data: {"v":"资料"} + +data: {"v":"。"} + +data: {"v":"您"} + +data: {"v":"可以根据"} + +data: {"v":"教学"} + +data: {"v":"重点"} + +data: {"v":","} + +data: {"v":"从"} + +data: {"v":"这些"} + +data: {"v":"具体"} + +data: {"v":"案例"} + +data: {"v":"中"} + +data: {"v":"筛选"} + +data: {"v":"细节"} + +data: {"v":","} + +data: {"v":"帮助学生"} + +data: {"v":"理解"} + +data: {"v":"那段"} + +data: {"v":"历史的"} + +data: {"v":"复杂"} + +data: {"v":"肌"} + +data: {"v":"理"} + +data: {"v":"。"} + +data: {"p":"response/fragments","v":[{"id":37,"type":"TIP","content":"本回答由 AI 生成,内容仅供参考,请仔细甄别。","style":"WARNING","hide_on_wip":true}]} + +data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":1269},{"p":"quasi_status","v":"FINISHED"}]} + +: + +data: {"v":[{"p":"ban_regenerate","v":true},{"p":"status","v":"CONTENT_FILTER"},{"p":"fragments","v":[{"id":38,"type":"TEMPLATE_RESPONSE","content":"你好,这个问题我暂时无法回答,让我们换个话题再聊聊吧。"}]},{"p":"quasi_status","v":"CONTENT_FILTER"}]} + +event: finish +data: {} + +event: update_session +data: {"updated_at":1775320133.058034} + +event: close +data: {"click_behavior":"none","auto_resume":false} + diff --git a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/analysis.md b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/analysis.md deleted file mode 100644 index 383ce2a..0000000 --- a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/analysis.md +++ /dev/null @@ -1,55 +0,0 @@ -# 样本分析(广州天气 / deepseek-reasoner-search) - -- 样本来源:`/admin/dev/captures` 上游原始 SSE 抓包 -- 采集时间(UTC):2026-04-03 01:28:50 -- 原始字节数:41043 -- `FINISHED` 字符串出现次数:24 -- JSON `data:` chunk 数:420 - -## 事件分布 - -- `ready`: 1 -- `update_session`: 2 -- `finish`: 1 - -## 高频路径(Top 12) - -- `response/fragments/-1/content`: 13 -- `response/fragments/-1`: 9 -- `response`: 5 -- `response/has_pending_fragment`: 4 -- `response/fragments/-1/elapsed_secs`: 3 -- `response/fragments/-5/status`: 2 -- `response/fragments/-6/status`: 2 -- `response/fragments/-3/status`: 2 -- `response/fragments/-1/status`: 2 -- `response/fragments/-4/status`: 2 -- `response/fragments/-2/status`: 2 -- `response/fragments/-5/results`: 1 - -## 关键泄露来源 - -以下状态路径会高频出现 `v=FINISHED`,如果解析器按普通文本透传,就会出现 `FINISHEDFINISHED...` 泄露: - -- `response/fragments/-5/status`: 2 -- `response/fragments/-6/status`: 2 -- `response/fragments/-3/status`: 2 -- `response/fragments/-1/status`: 2 -- `response/fragments/-4/status`: 2 -- `response/fragments/-2/status`: 2 -- `response/fragments/-14/status`: 1 -- `response/fragments/-12/status`: 1 -- `response/fragments/-10/status`: 1 -- `response/fragments/-9/status`: 1 -- `response/fragments/-8/status`: 1 -- `response/fragments/-7/status`: 1 -- `response/fragments/-11/status`: 1 -- `response/fragments/-16/status`: 1 -- `response/fragments/-13/status`: 1 -- `response/fragments/-15/status`: 1 - -## 适配建议 - -1. 跳过 `response/fragments//status`(所有 index,而非仅 `-1/-2/-3`)。 -2. 保留 `response/status=FINISHED` 用于结束流判定,不应当输出正文。 -3. 在样本仿真测试中对全部样本执行“不得输出 `FINISHED`”断言。 diff --git a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/meta.json b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/meta.json deleted file mode 100644 index 127bef0..0000000 --- a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/meta.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "sample_id": "guangzhou-weather-reasoner-search-20260403", - "captured_at_utc": "2026-04-03T01:28:50Z", - "request": { - "model": "deepseek-reasoner-search", - "stream": true, - "messages": [ - { - "role": "user", - "content": "广州天气" - } - ], - "thinking_enabled": true, - "search_enabled": true - }, - "capture": { - "label": "deepseek_completion", - "url": "https://chat.deepseek.com/api/v0/chat/completion", - "status_code": 200, - "response_bytes": 41043, - "contains_finished_token": true, - "finished_token_count": 24 - }, - "notes": "Captured from upstream DeepSeek SSE via /admin/dev/captures with packet capture enabled. Account ID removed." -} \ No newline at end of file diff --git a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/upstream.stream.sse b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/upstream.stream.sse deleted file mode 100644 index 71b5f5c..0000000 --- a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/upstream.stream.sse +++ /dev/null @@ -1,848 +0,0 @@ -event: ready -data: {"request_message_id":1,"response_message_id":2} - -event: update_session -data: {"updated_at":1775179730.6087148} - -data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","accumulated_token_usage":0,"files":[],"feedback":null,"inserted_at":1775179730.60373,"search_enabled":true,"fragments":[{"id":2,"type":"THINK","content":"用户","elapsed_secs":null,"references":[],"stage_id":1}],"conversation_mode":"DEEP_SEARCH","has_pending_fragment":false,"auto_continue":false,"search_triggered":true}}} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"想知道"} - -data: {"v":"广州"} - -data: {"v":"的"} - -data: {"v":"天气"} - -data: {"v":"情况"} - -data: {"v":"。"} - -data: {"v":"今天是"} - -data: {"v":"202"} - -data: {"v":"6"} - -data: {"v":"年"} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"3"} - -data: {"v":"日"} - -data: {"v":"。"} - -data: {"v":"我需要"} - -data: {"v":"提供"} - -data: {"v":"广州"} - -data: {"v":"当前的"} - -data: {"v":"天气"} - -data: {"v":"信息"} - -data: {"v":","} - -data: {"v":"可能"} - -data: {"v":"还需要"} - -data: {"v":"包括"} - -data: {"v":"未来"} - -data: {"v":"几"} - -data: {"v":"天的"} - -data: {"v":"预报"} - -data: {"v":"。"} - -data: {"v":"为了"} - -data: {"v":"全面"} - -data: {"v":"获取"} - -data: {"v":"信息"} - -data: {"v":","} - -data: {"v":"我需要"} - -data: {"v":"同时"} - -data: {"v":"进行"} - -data: {"v":"多项"} - -data: {"v":"搜索"} - -data: {"v":","} - -data: {"v":"以"} - -data: {"v":"覆盖"} - -data: {"v":"不同"} - -data: {"v":"格式"} - -data: {"v":"和"} - -data: {"v":"可能"} - -data: {"v":"的不同"} - -data: {"v":"来源"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.345921362} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":3,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026-04-03"}],"results":[],"stage_id":1},{"id":4,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气预报 2026年4月"}],"results":[],"stage_id":1},{"id":5,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026年4月3日 星期六"}],"results":[],"stage_id":1},{"id":6,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"2026-04-03 广州 天气 预报"}],"results":[],"stage_id":1},{"id":7,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026年4月3日 每小时"}],"results":[],"stage_id":1},{"id":8,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 7天 天气预报 2026-04-03"}],"results":[],"stage_id":1}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-5/results","o":"SET","v":[{"url":"https://news.qq.com/rain/a/20260401A06GQ200","title":"4月广州仍有冷空气“搞事情”,3次弱冷空气+4次降水轮番登场","snippet":"预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨,局部暴雨)、4月17日—21日(大雨,局部暴雨)、4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/qq.com","site_name":"QQ News","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather40d/101280101.shtml","title":"【广州天气】广州40天天气预报,广州更长预报,广州天气日历,广州日历,15天天气预报,天气预报一周","snippet":"蓝天预报综合天气现象、能见度、空气质量等因子,预测未来一周的天空状况。 - 天空蔚蓝 可见透彻蓝天,或有蓝天白云美景。 - 天空淡蓝 天空不够清澈,以浅蓝色为主。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"http://k.sina.com.cn/article_5787187353_158f1789902002301y.html","title":"4月广州或有4次集中降水过程,6月中下旬或迎“初台”","snippet":"根据《广州市2026年4月气候趋势预测》,预计4月广州各区月平均气温22.6~25.0°C,全市平均气温23.1~23.6°C,较近30年(1996-2025年,下同)同期(22.6°C)偏高0.5~1.0°C...","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/sina.com.cn","site_name":"sina.com.cn","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004087935_119778?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.9.1775050942155GVwQz9y_324&_trans_=060008_lym","title":"4月广州或有4次集中降水过程,6月中下旬或迎“初台”","snippet":"根据《广州市2026年第二季度气候趋势预测》,预计第二季度广州平均气温26.0~26.5°C,较近30年(1996-2025年,下同)同期(25.5°C)偏高0.5~1.0°C。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://huacheng.gz-cmc.com/pages/2026/04/01/8cce9be94a1a4c8eae598c6cbfae624b.html","title":"4月广州仍有冷空气“搞事情”,3次弱冷空气+4次降水轮番登场","snippet":"另外,4月将有4次降水过程,预计当月广州降雨量将比常年偏少1~2成 ... 预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨 ... 4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 周五 ... 3~4级 ... 04/04 周六 大雨 无持续风向 微风 25℃ ... 04/05 周日 ... 04/06 周一 雷阵雨","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004262788_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.10.1775093958609Tqfgwxd_324","title":"最新预测:广州4月预计有3次冷空气、4次降水过程","snippet":"根据气象部门预测,4月广州将遭遇3次冷空气过程。另外,4月将有4次降水过程,预计当月广州降雨量将比常年偏少1~2成 ... 预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨,局部暴雨)、4月17日—21日(大雨 ... 4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou-yuexiu/10day.html","title":"广州越秀天气预报10天天气","snippet":"20℃ ~ 26℃ 西南风转微风 3-4级转<3级 ... 2026-04-04 (星期六) 大雨转雷阵雨 21℃ ~ 23℃","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/luogang.html","title":"黄埔(广州)-天气预报","snippet":"04/03 周五 ... 3~4级 27℃ ... 04/04 周六 大雨 ... 04/05 周日 ... 04/06 周一 雷阵雨","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]}]} - -data: {"p":"response/fragments/-5/status","v":"FINISHED"} - -data: {"p":"response/fragments/-6/results","v":[{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 周五 ... 04/04 周六 大雨 ... 04/05 周日 中雨 ... 04/06 周一 雷阵雨 ... 04/07 周二","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/luogang.html","title":"黄埔(广州)-天气预报","snippet":"04/03 周五 ... 04/04 周六 大雨 ... 04/05 周日 ... 3~4级 04/06 周一 雷阵雨","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://news.qq.com/rain/a/20260401A06GQ200","title":"4月广州仍有冷空气“搞事情”,3次弱冷空气+4次降水轮番登场","snippet":"预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨,局部暴雨)、4月17日—21日(大雨,局部暴雨)、4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/qq.com","site_name":"QQ News","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 | 日出06:18 日落18:42","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/15day.html","title":"广州天气预报15天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 ... 2026-04-04 (星期六)","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong/","title":"广东省天气预报","snippet":"广州天气 雷阵雨转中雨 20℃ ~ 27℃ 更新时间:2026-04-03 08:02:25 - 荔湾 雷阵雨转中雨 20℃~27℃","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"http://www.tqyb.com.cn/gzpanyu/weatherReport/latest/2026-04-01/16054.html","title":"3-6日我区雷雨活跃并伴有强对流天气 需注意防御雷雨大风和短时强降水","snippet":"3月29日下午开始,我区受雷雨云团影响,出现雷电、短时强降水、雷雨大风等强对流天气。其中,29日下午至傍晚出现中到强雷雨局部暴雨,并伴有6~8级阵风;30日上午至中午出现中雷雨局部大雨和8~11级局部12级阵风,石楼镇(南沙大桥K7 470段气象观测站)录得最大阵风35.2米/秒(12级)。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/tqyb.com.cn","site_name":"tqyb","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast15/china/guangdong/guangzhou","title":"【广州市天气预报15天】_广州市天气预报15天查询 - 预报查询","snippet":"15天预报 ... - 周四 雷阵雨 25° 21° - 周五 中雨 26° 20° - 周六 大雨 23° 21°","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://www.163.com/dy/article/KPDO91T40530JPVV.html","title":"广东暴雨中场休息,接着又要“放大招”","snippet":"广州市气象台预计,4月1日—2日广州降水减弱,以多云为主,4月3日后降水再度增强,展望清明假期,广州仍有中到大雨 ... 4月1日,粤北市县多云,其余市县多云间阴天...","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]}]} - -data: {"p":"response/fragments/-6/status","v":"FINISHED"} - -data: {"p":"response/fragments/-3/results","v":[{"url":"https://www.weather.com.cn/weather40d/101280101.shtml","title":"【广州天气】广州40天天气预报,广州更长预报,广州天气日历,广州日历,15天天气预报,天气预报一周","snippet":"蓝天预报综合天气现象、能见度、空气质量等因子,预测未来一周的天空状况。 - 天空蔚蓝 可见透彻蓝天,或有蓝天白云美景。 - 天空淡蓝 天空不够清澈,以浅蓝色为主。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/luogang.html","title":"黄埔(广州)-天气预报","snippet":"04/03 周五 ... 04/04 周六 大雨 ... 04/05 周日 ... 3~4级 04/06 周一 雷阵雨","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 ... 04/04 周六 大雨 ... 04/05 周日 中雨 ... 04/06 周一 雷阵雨 ... 04/07 周二","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong/7day.html","title":"广东省天气预报","snippet":"广州天气 中雨转大雨 20℃ ~ 26℃ 更新时间:2026-04-03 03:24:50 - 荔湾 中雨转大雨 20℃~26℃","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/15day.html","title":"广州天气预报15天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 | 日出06...","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"http://www.tqyb.com.cn/gzpanyu/weatherReport/latest/2026-04-01/16054.html","title":"3-6日我区雷雨活跃并伴有强对流天气 需注意防御雷雨大风和短时强降水","snippet":"3月29日下午开始 ... 短时强降水、雷雨大风等强对流天气 ... 预警信号预测:预计,4月3-6日可能发布雷雨大风黄色或以上、暴雨黄色或以上和冰雹橙色预警信号。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/tqyb.com.cn","site_name":"tqyb","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast15/china/guangdong/guangzhou","title":"【广州市天气预报15天】_广州市天气预报15天查询 - 预报查询","snippet":"15天预报 ... - 周四 雷阵雨 25° 21° - 周五 中雨 26° 20° - 周六 大雨 23° 21°","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"4日(明天) 大雨转雷阵雨 25/20℃ <3级 # 5日(后天) 中雨转雷阵雨 24/21℃ ... 6日(周一) 小雨转阴","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]}]} - -data: {"p":"response/fragments/-3/status","v":"FINISHED"} - -data: {"p":"response/fragments/-1/results","v":[{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 | 日出06:18 日落18...","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"4日(明天) 大雨转雷阵雨 25/20℃ <3级 # 5日(后天) 中雨转雷阵雨 24/21℃ ... 6日(周一) 小雨转阴","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou-panyu/7day.html","title":"广州番禺天气预报7天天气","snippet":"2026-04-03 (星期五) 雷阵雨转大雨 21℃ ~ 27℃ 南风转微风 3-4级转<3级 日出06:18 日落18...","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280106.shtml","title":"荔湾天气预报,荔湾7天天气预报,荔湾15天天气预报,荔湾天气查询","snippet":"3日(明天) 中雨转大雨 26℃/20℃ 3-4级转<3级 # 4日(后天) ... 23℃/21℃ <3级 # 5日(周日) ... 25℃/22℃","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast7/china/guangdong/china-weather-guangzhou-satellite-station","title":"【广州气象卫星地面站天气预报一周】_广州气象卫星地面站天气预报一周查询_一周天气","snippet":"7天预报 ... - 周四 阴 26° 23° - 周五 阵雨雷... 27° 23° - 周六 雷阵雨... 24° 22°","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"http://www.tqyb.com.cn/gzpanyu/weatherReport/latest/2026-04-01/16054.html","title":"3-6日我区雷雨活跃并伴有强对流天气 需注意防御雷雨大风和短时强降水","snippet":"3月29日下午开始,我区受雷雨云团影响,出现雷电、短时强降水、雷雨大风等强对流天气。其中 ... 30日上午至中午出现中雷雨局部大雨和8~11级局部12级阵风,石楼镇(南沙大桥K7 470段气象观测站)录得最大阵风35.2米/秒(12级)。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/tqyb.com.cn","site_name":"tqyb","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou-yuexiu/10day.html","title":"广州越秀天气预报10天天气","snippet":"提供广东省广州市越秀区10天天气,包含每天最高气温、最低气温、天气状况及风向等气象信息 一 | 二 | 三 | 四 | 五 | 六 | 日 ---|---|---|---|---|---|--- 04月03日 03 中雨转大雨 20℃ ~ 26℃ 日出06:18 日落18:43 04月04日 04 大雨转雷阵雨 21℃ ~ 23℃ 日出06:17 日落18:43 04月05日","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/luogang.html","title":"黄埔(广州)-天气预报","snippet":"22.9℃ 日落18:42 降水量 0mm 东南风 微风 相对湿度 87% 体感温度 24.6℃ 空气质量:优 舒适度:舒适,最可接受 雷达图 发布时间:04-03 08:00 04/03 周五 雷阵雨 南风 3~4级 27℃ 20℃ 中雨 无持续风向 微风 04/04 周六 大雨 无持续风向 微风 25℃ 20℃ 雷阵雨 无持续风向","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.ip.cn/tianqi/guangdong/guangzhou/7day.html","title":"广州7天天气","snippet":"最新广东省广州市7天天气,包含每天最高气温、最低气温、天气状况及风向等天气信息 广州天气:2026-04-02至2026-04-08 天气预报 一 | 二 | 三 | 四 | 五 | 六 | 日 ---|---|---|---|---|---|--- 04月02日 02 雷阵雨 21℃ ~ 25℃ 日出06:19 日落18:42 04月03日 03 中雨转大雨 20℃ ~ 2","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/ip.cn","site_name":"IP.cn","query_indexes":[0]}]} - -data: {"p":"response/fragments/-1/status","v":"FINISHED"} - -data: {"p":"response/fragments/-4/results","v":[{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-04 (星期六) 大雨转雷阵雨 21℃ ~ 23℃ 微风 <3级 | 日出06:17 日落18:43","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/weather-forecast/102255?type=locality","title":"广州, 广东省, 中国三天天气预报 | AccuWeather","snippet":"4/2 下午有少量降雨 局部地区有雷雨 周五 4/3 上午局部地区有雷雨;部分晴 潮湿,有雨和雷雨 周六 4/4 大部分多云,潮湿,有雷雨","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"4日(明天) 大雨转雷阵雨 25/20℃ <3级 # 5日(后天) 中雨转雷阵雨 24/21℃ <3级转3-4级 # 6日(周一) 小雨转阴","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast15/china/guangdong/guangzhou","title":"【广州市天气预报15天】_广州市天气预报15天查询 - 预报查询","snippet":"15天预报 ... - 周四 雷阵雨 25° 21° - 周五 中雨 26° 20° - 周六 大雨 23° 21°","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast15/china/guangdong/china-weather-guangzhou-satellite-station","title":"【广州气象卫星地面站天气预报15天】_广州气象卫星地面站天气预报15天查询 - 预报查询","snippet":"15天预报 ... - 周四 阴 26° 23° - 周五 阵雨 28° 23° - 周六 雷阵雨 23° 21°","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/luogang.html","title":"黄埔(广州)-天气预报","snippet":"04/03 周五 雷阵雨 南风 3~4级 27℃ 20℃ ... 04/04 周六 大雨 ... 04/05 周日 ... 04/06 周一","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast7/china/guangdong/china-weather-guangzhou-satellite-station","title":"【广州气象卫星地面站天气预报一周】_广州气象卫星地面站天气预报一周查询_一周天气","snippet":"7天预报 ... - 周四 阴 26° 23° - 周五 阵雨雷... 27° 23° - 周六 雷阵雨... 24° 22° ... - 周一 少云","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 周五 ... 3~4级 27℃ 20℃ 中雨 无持续风向 微风 04/04 周六 大雨 ... 04/05 周日","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"http://gd.cma.gov.cn/","title":"广东省气象局","snippet":"晴转雷阵雨 19℃/10℃ 微风 03日(明日) 多云转晴 22℃/6℃ 3~4级 04日(后日) 晴转多云 19℃/8℃ 3~4级 05日(周日) 晴转多云 19℃/6℃","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/cma.gov.cn","site_name":"广东省气象局","query_indexes":[0]}]} - -data: {"p":"response/fragments/-4/status","v":"FINISHED"} - -data: {"p":"response/fragments/-2/results","v":[{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 周五 雷阵雨 南风 3~4级 27℃ 20℃ 中雨 无持续风向 微风 04/04 周六 大雨 ... 04/06 周一","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/15day.html","title":"广州天气预报15天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 ... 18 日落18:42","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 | 日出06:18 日落18...","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://www.ip.cn/tianqi/guangdong/guangzhou/","title":"广州天气 广东省广州市5天天气预报查询 IP.cn","snippet":"2026-04-03 天气 中雨转大雨 20℃ ~ 26℃ 西南风 转 微风 3-4级转<3级 日出06:18","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/ip.cn","site_name":"IP.cn","query_indexes":[0]},{"url":"https://www.ventusky.com/zh/guangzhou","title":"天气 - 广州市 - 14天预报:气温、风和雷达","snippet":"2026-04-04 | — | 07:14 | 20:45 | 97 % 至 93 % 2026-04-05 | — | 07:51 | 21...","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/ventusky.com","site_name":"Ventusky","query_indexes":[0]},{"url":"http://www.tqyb.com.cn/gzpanyu/weatherReport/latest/2026-04-01/16054.html","title":"3-6日我区雷雨活跃并伴有强对流天气 需注意防御雷雨大风和短时强降水","snippet":"4月3日:多云间阴天,有中雷雨局部暴雨,21~30℃; 4月4日(清明假期):多云到阴天,有大雨到暴雨,20~26℃...","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/tqyb.com.cn","site_name":"tqyb","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast15/china/guangdong/china-weather-guangzhou-satellite-station","title":"【广州气象卫星地面站天气预报15天】_广州气象卫星地面站天气预报15天查询 - 预报查询","snippet":"15天预报 ... - 周四 阴 26° 23° - 周五 阵雨 28° 23° - 周六 雷阵雨 23° 21°","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://www.tianqi.com/baiyun","title":"广州白云天气","snippet":"2026年04月03日 星期五 丙午年二月十六 25℃ 阴24 ~ 29℃ - 湿度:85%风向:东南风 3级紫外线:弱 ##### 空气质量:优","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/tianqi.com","site_name":"tianqi.com","query_indexes":[0]},{"url":"https://www.tianqi.com/guangzhou","title":"【广州天气预报】广州天气预报一周,广州天气预报15天,30天,今天,明天,7天,10天,未来广州一周天气预报查询—天气网","snippet":"2026年04月03日 星期五 丙午年二月十六 25℃ 阴转小雨24 ~ 29℃ - 湿度:86%风向:东南风 3级紫外线:无 ##### 空气质量:良","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/tianqi.com","site_name":"tianqi.com","query_indexes":[0]}]} - -data: {"p":"response/fragments/-2/status","v":"FINISHED"} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":9,"type":"THINK","content":"搜索","elapsed_secs":null,"references":[],"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"结果显示"} - -data: {"v":"了许多"} - -data: {"v":"相关"} - -data: {"v":"来源"} - -data: {"v":"。"} - -data: {"v":"为了"} - -data: {"v":"全面"} - -data: {"v":"获取"} - -data: {"v":"信息"} - -data: {"v":","} - -data: {"v":"我需要"} - -data: {"v":"同时"} - -data: {"v":"打开"} - -data: {"v":"这些"} - -data: {"v":"来源"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.654438445} - -data: {"p":"response/has_pending_fragment","v":true} - -: - -: - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":10,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 周五 ... 04/04 周六 大雨 ... 04/05 周日 中雨 ... 04/06 周一 雷阵雨 ... 04/07 周二","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":11,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":12,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 | 日出06:18 日落18:42","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":13,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/15day.html","title":"广州天气预报15天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 ... 2026-04-04 (星期六)","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":14,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 周五 ... 3~4级 ... 04/04 周六 大雨 无持续风向 微风 25℃ ... 04/05 周日 ... 04/06 周一 雷阵雨","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":15,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-04 (星期六) 大雨转雷阵雨 21℃ ~ 23℃ 微风 <3级 | 日出06:17 日落18:43","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":5,"type":"TOOL_SEARCH"},"stage_id":2},{"id":16,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/weather-forecast/102255?type=locality","title":"广州, 广东省, 中国三天天气预报 | AccuWeather","snippet":"4/2 下午有少量降雨 局部地区有雷雨 周五 4/3 上午局部地区有雷雨;部分晴 潮湿,有雨和雷雨 周六 4/4 大部分多云,潮湿,有雷雨","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},"reference":{"id":5,"type":"TOOL_SEARCH"},"stage_id":2},{"id":17,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"4日(明天) 大雨转雷阵雨 25/20℃ <3级 # 5日(后天) 中雨转雷阵雨 24/21℃ <3级转3-4级 # 6日(周一) 小雨转阴","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},"reference":{"id":5,"type":"TOOL_SEARCH"},"stage_id":2},{"id":18,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.weather.com.cn/weather40d/101280101.shtml","title":"【广州天气】广州40天天气预报,广州更长预报,广州天气日历,广州日历,15天天气预报,天气预报一周","snippet":"蓝天预报综合天气现象、能见度、空气质量等因子,预测未来一周的天空状况。 - 天空蔚蓝 可见透彻蓝天,或有蓝天白云美景。 - 天空淡蓝 天空不够清澈,以浅蓝色为主。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},"reference":{"id":6,"type":"TOOL_SEARCH"},"stage_id":2},{"id":19,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},"reference":{"id":6,"type":"TOOL_SEARCH"},"stage_id":2},{"id":20,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong/7day.html","title":"广东省天气预报","snippet":"广州天气 中雨转大雨 20℃ ~ 26℃ 更新时间:2026-04-03 03:24:50 - 荔湾 中雨转大雨 20℃~26℃","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":6,"type":"TOOL_SEARCH"},"stage_id":2},{"id":21,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/03 周五 雷阵雨 南风 3~4级 27℃ 20℃ 中雨 无持续风向 微风 04/04 周六 大雨 ... 04/06 周一","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},"reference":{"id":7,"type":"TOOL_SEARCH"},"stage_id":2},{"id":22,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://www.tqyb.com.cn/gzpanyu/weatherReport/latest/2026-04-01/16054.html","title":"3-6日我区雷雨活跃并伴有强对流天气 需注意防御雷雨大风和短时强降水","snippet":"4月3日:多云间阴天,有中雷雨局部暴雨,21~30℃; 4月4日(清明假期):多云到阴天,有大雨到暴雨,20~26℃...","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/tqyb.com.cn","site_name":"tqyb","query_indexes":[0]},"reference":{"id":7,"type":"TOOL_SEARCH"},"stage_id":2},{"id":23,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-03 (星期五) 中雨转大雨 20℃ ~ 26℃ 西南风转微风 3-4级转<3级 | 日出06:18 日落18...","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":24,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":25,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"4日(明天) 大雨转雷阵雨 25/20℃ <3级 # 5日(后天) 中雨转雷阵雨 24/21℃ ... 6日(周一) 小雨转阴","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-14/status","o":"SET","v":"FINISHED"} - -data: {"p":"response/fragments/-12/status","v":"FINISHED"} - -data: {"p":"response/fragments/-10/status","v":"FINISHED"} - -data: {"p":"response/fragments/-9/status","v":"FINISHED"} - -data: {"p":"response/fragments/-8/status","v":"FINISHED"} - -data: {"p":"response/fragments/-7/status","v":"FINISHED"} - -data: {"p":"response/fragments/-4/status","v":"FINISHED"} - -data: {"p":"response/fragments/-2/status","v":"FINISHED"} - -data: {"p":"response/fragments/-11/status","v":"FINISHED"} - -data: {"p":"response/fragments/-5/status","v":"FINISHED"} - -data: {"p":"response/fragments/-3/status","v":"FINISHED"} - -data: {"p":"response/fragments/-16/status","v":"FINISHED"} - -data: {"p":"response/fragments/-13/status","v":"FINISHED"} - -data: {"p":"response/fragments/-6/status","v":"FINISHED"} - -data: {"p":"response/fragments/-15/status","v":"FINISHED"} - -data: {"p":"response/fragments/-1/status","v":"FINISHED"} - -data: {"p":"response/has_pending_fragment","v":true} - -data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":26,"type":"THINK","content":"这些","elapsed_secs":null,"references":[],"stage_id":3}]},{"p":"has_pending_fragment","o":"SET","v":false}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"信息"} - -data: {"v":"提供了"} - -data: {"v":"广州"} - -data: {"v":"当前"} - -data: {"v":"和"} - -data: {"v":"未来的"} - -data: {"v":"天气预报"} - -data: {"v":"。"} - -data: {"v":"我的"} - -data: {"v":"回答"} - -data: {"v":"将"} - -data: {"v":"涵盖"} - -data: {"v":"今天"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"3"} - -data: {"v":"日"} - -data: {"v":")"} - -data: {"v":"的"} - -data: {"v":"天气"} - -data: {"v":"、"} - -data: {"v":"未来"} - -data: {"v":"趋势"} - -data: {"v":"、"} - -data: {"v":"注意事项"} - -data: {"v":"以及"} - -data: {"v":"生活"} - -data: {"v":"指数"} - -data: {"v":"。"} - -data: {"v":"回答"} - -data: {"v":"中"} - -data: {"v":"会"} - -data: {"v":"注明"} - -data: {"v":"信息来源"} - -data: {"v":"。"} - -data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.870397331} - -data: {"p":"response/fragments","o":"APPEND","v":[{"id":27,"type":"RESPONSE","content":"广州","references":[],"stage_id":3}]} - -data: {"p":"response/fragments/-1/content","v":"今天"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"3"} - -data: {"v":"日"} - -data: {"v":")"} - -data: {"v":"是"} - -data: {"v":"雷"} - -data: {"v":"阵"} - -data: {"v":"雨"} - -data: {"v":"转"} - -data: {"v":"中"} - -data: {"v":"雨的"} - -data: {"v":"天气"} - -data: {"v":","} - -data: {"v":"气温"} - -data: {"v":"在"} - -data: {"v":"**"} - -data: {"v":"20"} - -data: {"v":"℃"} - -data: {"v":" "} - -data: {"v":"到"} - -data: {"v":" "} - -data: {"v":"27"} - -data: {"v":"℃"} - -data: {"v":"**"} - -data: {"v":"之间"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:0]"},{"p":"references","o":"SET","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:1]"},{"p":"references","v":[{"id":7,"type":"TOOL_SEARCH"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:2]"},{"p":"references","v":[{"id":10,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"风力"} - -data: {"v":"为"} - -data: {"v":"南"} - -data: {"v":"风"} - -data: {"v":","} - -data: {"v":"3"} - -data: {"v":"-"} - -data: {"v":"4"} - -data: {"v":"级"} - -data: {"v":","} - -data: {"v":"湿度"} - -data: {"v":"较高"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:3]"},{"p":"references","v":[{"id":10,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:4]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"v":"空气"} - -data: {"v":"质量和"} - -data: {"v":"紫外线"} - -data: {"v":"都比较"} - -data: {"v":"友好"} - -data: {"v":"("} - -data: {"v":"优"} - -data: {"v":"/"} - -data: {"v":"最"} - -data: {"v":"弱"} - -data: {"v":"),"} - -data: {"v":"日落"} - -data: {"v":"时间"} - -data: {"v":"大约是"} - -data: {"v":"18"} - -data: {"v":":"} - -data: {"v":"42"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:5]"},{"p":"references","v":[{"id":7,"type":"TOOL_SEARCH"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:6]"},{"p":"references","v":[{"id":10,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"###"} - -data: {"v":" 🌧"} - -data: {"v":"️"} - -data: {"v":" "} - -data: {"v":"未来"} - -data: {"v":"天气"} - -data: {"v":"趋势"} - -data: {"v":"\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"雨"} - -data: {"v":"势"} - -data: {"v":"持续"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"预计"} - -data: {"v":"未来"} - -data: {"v":"几天"} - -data: {"v":"降雨"} - -data: {"v":"会比较"} - -data: {"v":"频繁"} - -data: {"v":"。"} - -data: {"v":"周六"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"4"} - -data: {"v":"日"} - -data: {"v":")"} - -data: {"v":"是大"} - -data: {"v":"雨"} - -data: {"v":"转"} - -data: {"v":"雷"} - -data: {"v":"阵"} - -data: {"v":"雨"} - -data: {"v":","} - -data: {"v":"周日"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"5"} - -data: {"v":"日"} - -data: {"v":")"} - -data: {"v":"也"} - -data: {"v":"基本"} - -data: {"v":"是"} - -data: {"v":"雨天"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:7]"},{"p":"references","v":[{"id":12,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"防范"} - -data: {"v":"强"} - -data: {"v":"对流"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"特别"} - -data: {"v":"要"} - -data: {"v":"留意"} - -data: {"v":"的是"} - -data: {"v":","} - -data: {"v":"周六"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"4"} - -data: {"v":"日"} - -data: {"v":")"} - -data: {"v":"和"} - -data: {"v":"周日"} - -data: {"v":"("} - -data: {"v":"4"} - -data: {"v":"月"} - -data: {"v":"5"} - -data: {"v":"日"} - -data: {"v":")"} - -data: {"v":"可能会有"} - -data: {"v":"雷"} - -data: {"v":"雨"} - -data: {"v":"大风"} - -data: {"v":"、"} - -data: {"v":"短"} - -data: {"v":"时"} - -data: {"v":"强"} - -data: {"v":"降水"} - -data: {"v":"等"} - -data: {"v":"强"} - -data: {"v":"对流"} - -data: {"v":"天气"} - -data: {"v":","} - -data: {"v":"请大家"} - -data: {"v":"提前"} - -data: {"v":"做好"} - -data: {"v":"防范"} - -data: {"v":"准备"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:8]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} - -data: {"v":[{"p":"content","o":"APPEND","v":"[reference:9]"},{"p":"references","v":[{"id":22,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} - -data: {"v":"###"} - -data: {"v":" 💡"} - -data: {"v":" "} - -data: {"v":"出行"} - -data: {"v":"与"} - -data: {"v":"生活"} - -data: {"v":"建议"} - -data: {"v":"\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"出行"} - -data: {"v":"建议"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"出门"} - -data: {"v":"请"} - -data: {"v":"带"} - -data: {"v":"好"} - -data: {"v":"雨"} - -data: {"v":"具"} - -data: {"v":","} - -data: {"v":"并"} - -data: {"v":"留心"} - -data: {"v":"临近"} - -data: {"v":"的"} - -data: {"v":"预警"} - -data: {"v":"信息"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:10]"},{"p":"references","v":[{"id":22,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"居家"} - -data: {"v":"提醒"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"天气"} - -data: {"v":"湿热"} - -data: {"v":","} - -data: {"v":"衣物"} - -data: {"v":"和"} - -data: {"v":"家居"} - -data: {"v":"要注意"} - -data: {"v":"防"} - -data: {"v":"潮"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:11]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"健康"} - -data: {"v":"防护"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"虽然"} - -data: {"v":"紫外线"} - -data: {"v":"不强"} - -data: {"v":","} - -data: {"v":"但"} - -data: {"v":"湿度"} - -data: {"v":"大"} - -data: {"v":"、"} - -data: {"v":"温差"} - -data: {"v":"也"} - -data: {"v":"较大"} - -data: {"v":","} - -data: {"v":"要"} - -data: {"v":"当"} - -data: {"v":"心"} - -data: {"v":"别"} - -data: {"v":"感冒"} - -data: {"v":"了"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:12]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} - -data: {"v":"*"} - -data: {"v":" "} - -data: {"v":" **"} - -data: {"v":"穿衣"} - -data: {"v":"指南"} - -data: {"v":"**"} - -data: {"v":":"} - -data: {"v":"建议"} - -data: {"v":"穿着"} - -data: {"v":"长"} - -data: {"v":"袖"} - -data: {"v":"衬衫"} - -data: {"v":"或"} - -data: {"v":"单"} - -data: {"v":"裤"} - -data: {"v":","} - -data: {"v":"体"} - -data: {"v":"弱"} - -data: {"v":"怕"} - -data: {"v":"凉"} - -data: {"v":"的朋友"} - -data: {"v":"可以"} - -data: {"v":"适当"} - -data: {"v":"加"} - -data: {"v":"一件"} - -data: {"v":"薄"} - -data: {"v":"外套"} - -data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:13]"},{"p":"references","v":[{"id":17,"type":"TOOL_OPEN"}]}]} - -data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} - -data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":279},{"p":"quasi_status","v":"FINISHED"}]} - -data: {"p":"response/status","o":"SET","v":"FINISHED"} - -event: finish -data: {} - -event: update_session -data: {"updated_at":1775179749.441216} - diff --git a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/meta.json b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/meta.json new file mode 100644 index 0000000..95848d7 --- /dev/null +++ b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/meta.json @@ -0,0 +1,24 @@ +{ + "sample_id": "guangzhou-weather-reasoner-search-20260404", + "captured_at_utc": "2026-04-04T16:01:27Z", + "request": { + "model": "deepseek-reasoner-search", + "stream": true, + "messages": [ + { + "role": "user", + "content": "广州天气" + } + ] + }, + "capture": { + "label": "deepseek_completion", + "url": "https://chat.deepseek.com/api/v0/chat/completion", + "status_code": 200, + "response_bytes": 37651, + "contains_reference_markers": true, + "reference_marker_count": 13, + "contains_finished_token": true, + "finished_token_count": 19 + } +} \ No newline at end of file diff --git a/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse new file mode 100644 index 0000000..a1a8187 --- /dev/null +++ b/tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260404/upstream.stream.sse @@ -0,0 +1,686 @@ +event: ready +data: {"request_message_id":1,"response_message_id":2,"model_type":"default"} + +event: update_session +data: {"updated_at":1775318472.209828} + +data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"files":[],"feedback":null,"inserted_at":1775318472.20079,"search_enabled":true,"fragments":[{"id":2,"type":"THINK","content":"用户","elapsed_secs":null,"references":[],"stage_id":1}],"conversation_mode":"DEEP_SEARCH","has_pending_fragment":false,"auto_continue":false,"search_triggered":true}}} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"想知道"} + +data: {"v":"广州"} + +data: {"v":"的"} + +data: {"v":"天气"} + +data: {"v":"。"} + +data: {"v":"今天是"} + +data: {"v":"202"} + +data: {"v":"6"} + +data: {"v":"年"} + +data: {"v":"4"} + +data: {"v":"月"} + +data: {"v":"5"} + +data: {"v":"日"} + +data: {"v":"。"} + +data: {"v":"我需要"} + +data: {"v":"提供"} + +data: {"v":"广州"} + +data: {"v":"当前的"} + +data: {"v":"天气"} + +data: {"v":"情况"} + +data: {"v":"以及"} + +data: {"v":"未来"} + +data: {"v":"几"} + +data: {"v":"天的"} + +data: {"v":"预报"} + +data: {"v":"。"} + +data: {"v":"为了"} + +data: {"v":"全面"} + +data: {"v":"获取"} + +data: {"v":"信息"} + +data: {"v":","} + +data: {"v":"我将"} + +data: {"v":"同时"} + +data: {"v":"进行"} + +data: {"v":"多项"} + +data: {"v":"搜索"} + +data: {"v":","} + +data: {"v":"以"} + +data: {"v":"覆盖"} + +data: {"v":"不同"} + +data: {"v":"可能的"} + +data: {"v":"天气"} + +data: {"v":"信息来源"} + +data: {"v":"和"} + +data: {"v":"查询"} + +data: {"v":"方式"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":1.426722585} + +data: {"p":"response/has_pending_fragment","v":true} + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":3,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026-04-05"}],"results":[],"stage_id":1},{"id":4,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气预报 2026年4月"}],"results":[],"stage_id":1},{"id":5,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026-04-05 每小时"}],"results":[],"stage_id":1},{"id":6,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 空气质量 2026-04-05"}],"results":[],"stage_id":1},{"id":7,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"2026-04-05 广州 天气 预报"}],"results":[],"stage_id":1},{"id":8,"type":"TOOL_SEARCH","status":"WIP","content":null,"queries":[{"query":"广州 天气 2026年4月5日"}],"results":[],"stage_id":1}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-5/results","o":"SET","v":[{"url":"https://news.qq.com/rain/a/20260401A06GQ200?suid=&media_id=","title":"4月广州仍有冷空气“搞事情”,3次弱冷空气+4次降水轮番登场","snippet":"预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨,局部暴雨)、4月17日—21日(大雨,局部暴雨)、4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/qq.com","site_name":"QQ News","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004262788_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.10.1775093958609Tqfgwxd_324","title":"最新预测:广州4月预计有3次冷空气、4次降水过程","snippet":"根据气象部门预测,4月广州将遭遇3次冷空气过程。另外,4月将有4次降水过程,预计当月广州降雨量将比常年偏少1~2成 ... 预计4月广州将有4次降水过程,大致出现在4月3日—6日(中到大雨)、4月12日—14日(中到大雨,局部暴雨)、4月17日—21日(大雨 ... 4月25日—28日(大雨到暴雨)。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 周六 20℃ 多云 无持续风向 微风 04/05 周日 暴雨 ... 04/06 周一 ... 04/07 周二 雷阵雨","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.163.com/dy/article/KPG99VPJ0530JPVV.html","title":"下一轮强降水时间定了!广州天气,即将反转","snippet":"广州市气象台预计,4月2日广州有雷阵雨。4月3日—4日有中到大雨局部暴雨,并伴有短时强降水、8~10级局部11~12级短时大风、冰雹等强对流天气。4月5日—6日仍有明显降水伴强对流过程。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"https://news.qq.com/rain/a/20260401A06VNB00?suid=&media_id=","title":"4月广州或有4次集中降水过程,6月中下旬或迎“初台”","snippet":"根据《广州市2026年4月气候趋势预测》,预计4月广州各区月平均气温22.6~25.0°C,全市平均气温23.1~23.6°C,较近30年(1996-2025年,下同)同期(22.6°C)偏高0.5~1.0°C...","cite_index":null,"published_at":1774972800.0,"site_icon":"https://cdn.deepseek.com/site-icons/qq.com","site_name":"QQ News","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 ... 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃ 南风转微风 3-4级转<3级","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://daohuangpuqu.gz-cmc.com/pages/2026/04/04/69ada0e552a245e5b0859954b7ab4cf3.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]},{"url":"https://tianqi.moji.com/forecast15/china/guangdong/guangzhou","title":"【广州市天气预报15天】_广州市天气预报15天查询 - 预报查询","snippet":"15天预报 ... - 周五 雷阵雨 28° 20° - 周六 中雨 ... - 周日 暴雨 25° 20° - 周一 中雨 30° 23° - 周二 雷阵雨","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/moji.com","site_name":"墨迹天气","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"广州市气象台预计,4月5日有大到暴雨,并伴有雷电、短时强降水、6~8级短时大风等强对流天气,4月6日雨势减弱,4月7日转以多云间阴天为主。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-5/status","v":"FINISHED"} + +data: {"p":"response/fragments/-3/results","v":[{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/air-quality-index/102255","title":"广州, 广东省, 中国空气质量指数 | AccuWeather","snippet":"空气质量量表 ... 空气质量对大多数人来说是可接受的。然而 ... 广州的当前空气质量 极好 危险 极好 有利 差 不健康 很不健康 危险 ## 24小时空气质量预报","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},{"url":"http://sthjj.gz.gov.cn/zwgk/hjgb/","title":"广州市生态环境局网站","snippet":"2026年1月广州市环境空气质量状况 2026-02-24 广州市白云区2024年度生态环境统计公报 2026-01-27 2025年12月广州市环境空气质量状况 2026-01-22 广州市2024年排放源统计公报","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市生态环境局","query_indexes":[0]},{"url":"http://cenews.com.cn/news.html?aid=1770007","title":"从“广州蓝”到“全运蓝”,广州以治气硬功守牢生态颜值——中国环境网","snippet":"2025年,广州AQI达标率达93.4%,连续3年超九成;PM2.5平均浓度降至21.5微克/立方米,在国家中心城市中保持最优。尤其在十五运会举办期间...","cite_index":null,"published_at":1771948800.0,"site_icon":"https://cdn.deepseek.com/site-icons/cenews.com.cn","site_name":"中国环境网","query_indexes":[0]},{"url":"http://www.zx24.com/aqi/guangdong-guangzhou.html","title":"广州空气污染:实时空气质量指数(AQI)","snippet":"空气质量指数 | 污染级别 | 对健康的影响 | 建议采取措施 ---|---|---|--- 0-50 | 优 | 空气质量令人满意,基本无空气污染,对健康没有危害 | 各类人群可多参加户外活动,多呼吸一下清新的空气。","cite_index":null,"published_at":1774713600.0,"site_icon":"https://cdn.deepseek.com/site-icons/zx24.com","site_name":"zx24.com","query_indexes":[0]},{"url":"http://vlife.src.weather.com.cn/maqi/101280101.shtml","title":"广州空气质量指数AQI--中国天气网","snippet":"优 良 轻度 中度 重度 严重 PM2.5 PM10 CO NO2 SO2 O3 单位:μg/m3(CO为mg/m3)","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"http://www.yuexiu.gov.cn/yxdt/rdzt/zdlyxxgk/hjbh/kqhjxx/","title":"广州市越秀区人民政府门户网站","snippet":"2026年1月广州市环境空气质量状况 2026-03-04 2025年12月广州市环境空气质量状况 2026-01-27 2025年11月广州市环境空气质量状况 2025-12-16 2025年10月广州市环境空气质量状况","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/yuexiu.gov.cn","site_name":"广州市越秀区人民政府","query_indexes":[0]},{"url":"http://pc.weathercn.com/weather/102255/?partner=20000010%d0%93%d2%93%d0%96%e2%80%99%d0%93%d3%ae%d0%92%d0%88%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d1%9e%d0%b2%d2%96%d2%a1%d0%92%c2%ac%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d3%ae%d0%bf%d2%9d%d2%aa10.hfaw&p_source=&p_type=jump&areatype=","title":"广州当前天气","snippet":"广州空气质量 39 优 0 50 100 150 200 300 500 空气很棒,快出门呼吸新鲜空气吧。 适宜外出","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weathercn.com","site_name":"华风爱科气象科技","query_indexes":[0]},{"url":"https://weather.com/zh-CN/forecast/air-quality/l/37dd90a3a17d6f9942ef56a03373eaa185be2b88164dc31754a305d17d7646c9","title":"越秀区, 广州市, 广东省 空气质量预报 - The Weather Channel | weather.com","snippet":"今日空气质量- 越秀区, 广州市, 广东省 优秀 无健康影响。 主要污染物: PM2.5 (2.5 微米以下细颗粒物) 等级 优秀","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com","site_name":"The Weather Channel","query_indexes":[0]},{"url":"https://www.aqi.in/in/dashboard/china/guangdong-province/guangzhou/baiyun","title":"Baiyun Air Quality Index (AQI) : Real-Time Air Pollution","snippet":"The current real-time AQI (US) level in Baiyun is 58 (Moderate) due to recent rainfall by 0.01mm/hr. This was last updated 2026-03-05 08:01 (Local Time).","cite_index":null,"published_at":1772640000.0,"site_icon":"https://cdn.deepseek.com/site-icons/aqi.in","site_name":"AQI.in","query_indexes":[0]},{"url":"https://www.zq12369.com/environment?city=%e5%b9%bf%e5%b7%9e&tab=city","title":"真气网-关注环境空气质量|广州PM2.5查询|PM2.5实时查询|PM2.5雾霾地图|空气质量地图指数AQI和PM2.5预报查询","snippet":"AQI范围: 40~70 ### 首要污染物: O3,PM2.5 ### 出行建议: 极少数异常敏感人群应减少户外活动 ... AQI范围: 60~90","cite_index":null,"published_at":1774886400.0,"site_icon":"https://cdn.deepseek.com/site-icons/zq12369.com","site_name":"真气网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-3/status","v":"FINISHED"} + +data: {"p":"response/fragments/-4/results","v":[{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://windy.app/zh/forecast2/spot/7026063/%E5%B9%BF%E5%B7%9E%E5%8D%97%E9%A3%8E%E5%9C%BA","title":"广州南风场 风、浪和天气预报 — Windy.app","snippet":"Light wind – experience required (4.9 m/s) ... - Light wind – experience required (5.5 m/s) - Significant gusts forecast (11.1 m/s) - Heavy rain – dangerous conditions possible (>2 mm) ... 广州南风场, 天气预报及实时风图","cite_index":null,"published_at":1774800000.0,"site_icon":"https://cdn.deepseek.com/site-icons/windy.app","site_name":"WINDY.APP","query_indexes":[0]},{"url":"https://www.ip.cn/tianqi/guangdong/guangzhou/15day.html","title":"广州15天天气","snippet":"2026-04-05 天气 中雨转雷阵雨 21℃ ~ 24℃ 微风 <3级 日出06:16 日落18:43","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/ip.cn","site_name":"IP.cn","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.fjndqx.com/china/guangdong-guangzhou/15tian.html","title":"15天广州天气预报","snippet":"2026-04-05 (星期日) 阴转小雨 21℃ ~ 27℃ 微风 <3级 | 日出06:16 日落18:43","cite_index":null,"published_at":1774800000.0,"site_icon":"https://cdn.deepseek.com/site-icons/fjndqx.com","site_name":"Fjndqx","query_indexes":[0]},{"url":"https://static.nfnews.com/content/202604/02/c12300320.html?colID=14&firstColID=59&enterColumnId=14","title":"下一轮强降水时间定了!广州天气,即将反转__南方+_南方plus","snippet":"周四起广州天气将再转不稳定 周五起 新一轮强降水天气过程将影响广州 并伴有短时强降水 8~10级局部11~12级短时大风","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/nfnews.com","site_name":"nfnews.com","query_indexes":[0]},{"url":"https://finance.sina.com.cn/wm/2026-04-02/doc-inhtakui4453746.shtml?cref=cj","title":"下一轮强降水时间定了!广州天气,即将反转","snippet":"周五起 新一轮强降水天气过程将影响广州 并伴有短时强降水 8~10级局部11~12级短时大风 局地小冰雹等强对流天气","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/sina.com.cn","site_name":"新浪财经","query_indexes":[0]},{"url":"https://static.nfnews.com/content/202604/02/c12302805.html","title":"雷雨大风+冰雹又要来了!清明假期广州将迎强对流天气__南方+_南方plus","snippet":"4月3日—4日,受高空槽、低层南风和切变线影响,广州有中到大雨局部暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地小冰雹等强对流天气。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/nfnews.com","site_name":"nfnews.com","query_indexes":[0]}]} + +data: {"p":"response/fragments/-4/status","v":"FINISHED"} + +data: {"p":"response/fragments/-2/results","v":[{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) ... 25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://daohuangpuqu.gz-cmc.com/pages/2026/04/04/69ada0e552a245e5b0859954b7ab4cf3.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"广州市气象台预计,4月5日有大到暴雨,并伴有雷电、短时强降水、6~8级短时大风等强对流天气,4月6日雨势减弱,4月7日转以多云间阴天为主。 广东具体预报...","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]},{"url":"https://www.gz.gov.cn/zt/fxbxaqdx/gg/content/post_10754327.html","title":"清明假期有大到暴雨","snippet":"4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市人民政府门户网站","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://guangzhoubaiyun.gz-cmc.com/pages/2026/04/02/e76e4847f66548089ac19908a65c473b.html","title":"清明假期,又开启“倒水”模式","snippet":"广州市气象台预计,4月2日广州有雷阵雨。4月3日—4日有中到大雨局部暴雨,并伴有短时强降水、8~10级局部11~12级短时大风、冰雹等强对流天气。4月5日—6日仍有明显降水伴强对流过程。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]},{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/weather-forecast/102255?type=locality","title":"广州, 广东省, 中国三天天气预报 | AccuWeather","snippet":"上午局部地区有雷雨;部分晴 ... 大部分多云,潮湿,有雷雨 大部分多云,潮湿,有阵雨或雷雨 ... 大部分多云,潮湿,有零星降雨和雷雨","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]}]} + +data: {"p":"response/fragments/-2/status","v":"FINISHED"} + +data: {"p":"response/fragments/-1/results","v":[{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.gz.gov.cn/zt/fxbxaqdx/gg/content/post_10754327.html","title":"清明假期有大到暴雨","snippet":"4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市人民政府门户网站","query_indexes":[0]},{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 周六 20℃ ... 04/05 周日 暴雨 无持续风向 微风 25℃ 22℃ ... 04/06 周一 中雨 ... 04/07 周二","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) 暴雨转雷阵雨 25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二)","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"4月5日,粤北、粤东和珠三角市县有大雨到暴雨局部大暴雨,其余市县有(雷)阵雨;部分市县雷雨时伴有8~9级(局地10级)短时大风、短时强降水和局地小冰雹。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]},{"url":"https://www.163.com/dy/article/KPLG595F0530JPVV.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"http://gd.cma.gov.cn/","title":"广东省气象局","snippet":"19℃/10℃ 微风 03日(明日) 多云转晴 22℃/6℃ 3~4级 04日(后日) 晴转多云 19℃/8℃ 3~4级 05日(周日) 晴转多云 19℃/6℃","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/cma.gov.cn","site_name":"广东省气象局","query_indexes":[0]},{"url":"https://news.southcn.com/node_de9aedeec1/2a08783352.shtml","title":"今日降雨集中在广东南部,清明假期全省多地将遭遇强对流天气","snippet":"广州市区,4日,阴天到多云,有中到大雨局部暴雨,气温在20℃—25℃;5日,阴天,有大到暴雨,气温在21℃—25℃;6日,阴天,有中雨,气温在22℃—28℃。 气象部门提醒,广东已进入强对流和强降水多发季节,清明祭扫、外出踏青和高空作业需注意防范局地雷电、冰雹及短时大风引发的树木、厂房工棚、临时构筑物、户外广告牌倒塌等风险。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/southcn.com","site_name":"南方网","query_indexes":[0]}]} + +data: {"p":"response/fragments/-1/status","v":"FINISHED"} + +data: {"p":"response/fragments/-6/results","v":[{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) ... 25℃/20℃ ... 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},{"url":"https://www.sohu.com/a/1004726143_162758?scm=10001.325_13-325_13.0.0-0-0-0-0.5_1334&spm=smpc.channel_248.block3_308_NDdFbm_1_fd.1.1775175954306LbCjz6t_324","title":"大暴雨、12级大风、冰雹!广州未来7天都“泡汤”","snippet":"广州市气象台预计,4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/sohu.com","site_name":"Sohu","query_indexes":[0]},{"url":"https://www.gz.gov.cn/zt/fxbxaqdx/gg/content/post_10754327.html","title":"清明假期有大到暴雨","snippet":"4月3日—4日广州有中到大雨局部暴雨,4月5日—6日有大到暴雨,并伴有雷电、短时强降水、8~10级局部11~12级短时大风、局地冰雹等强对流天气。4月7日起降水减弱。","cite_index":null,"published_at":1775145600.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz.gov.cn","site_name":"广州市人民政府门户网站","query_indexes":[0]},{"url":"https://www.163.com/dy/article/KPLG595F0530JPVV.html","title":"广东,要下暴雨、大暴雨了!","snippet":"广州市气象台预计,4月4日广州有中到大雨局部暴雨,4月5日有大到暴雨,并伴有雷电、短时强降水、8~10级短时大风、局地冰雹等强对流天气,4月6日白天起降水逐渐减弱。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/163.com","site_name":"网易","query_indexes":[0]},{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},{"url":"https://news.dayoo.com/guangzhou/202604/04/139995_54944458.htm","title":"广州6日雨势减弱,随后迎来一段平稳天气","snippet":"广州市气象台预计,4月5日有大到暴雨,并伴有雷电、短时强降水、6~8级短时大风等强对流天气,4月6日雨势减弱,4月7日转以多云间阴天为主。 广东具体预报...","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/dayoo.com","site_name":"大洋网","query_indexes":[0]},{"url":"https://news.southcn.com/node_de9aedeec1/2a08783352.shtml","title":"今日降雨集中在广东南部,清明假期全省多地将遭遇强对流天气","snippet":"广州市区,4日,阴天到多云,有中到大雨局部暴雨,气温在20℃—25℃;5日,阴天,有大到暴雨,气温在21℃—25℃;6日,阴天,有中雨,气温在22℃—28℃。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/southcn.com","site_name":"南方网","query_indexes":[0]},{"url":"https://guangzhoubaiyun.gz-cmc.com/pages/2026/04/02/e76e4847f66548089ac19908a65c473b.html","title":"清明假期,又开启“倒水”模式","snippet":"4月3日—4日有中到大雨局部暴雨,并伴有短时强降水、8~10级局部11~12级短时大风、冰雹等强对流天气。4月5日—6日仍有明显降水伴强对流过程。","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/gz-cmc.com","site_name":"广州日报新花城","query_indexes":[0]}]} + +data: {"p":"response/fragments/-6/status","v":"FINISHED"} + +data: {"p":"response/has_pending_fragment","v":true} + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":9,"type":"THINK","content":"搜索","elapsed_secs":null,"references":[],"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"结果显示"} + +data: {"v":"了一些"} + +data: {"v":"相关"} + +data: {"v":"页面"} + +data: {"v":"。"} + +data: {"v":"为了"} + +data: {"v":"全面"} + +data: {"v":"获取"} + +data: {"v":"信息"} + +data: {"v":","} + +data: {"v":"我将"} + +data: {"v":"同时"} + +data: {"v":"打开"} + +data: {"v":"这些"} + +data: {"v":"页面"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.682885751} + +data: {"p":"response/has_pending_fragment","v":true} + +: + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":10,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.nmc.cn/publish/forecast/AGD/guangzhou.html","title":"广州-天气预报","snippet":"04/04 ... 04/05 周日 暴雨 ... 04/06 周一 中雨 ... 04/07 周二 雷阵雨 ... 04/08 周三","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/nmc.cn","site_name":"中央气象台","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":11,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) ... 25℃/20℃ ... 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二) 雷阵雨转多云 29℃/24℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":12,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":13,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 ... 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃ 南风转微风 3-4级转<3级","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":4,"type":"TOOL_SEARCH"},"stage_id":2},{"id":14,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.ip.cn/tianqi/guangdong/guangzhou/15day.html","title":"广州15天天气","snippet":"2026-04-05 天气 中雨转雷阵雨 21℃ ~ 24℃ 微风 <3级 日出06:16 日落18:43","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/ip.cn","site_name":"IP.cn","query_indexes":[0]},"reference":{"id":5,"type":"TOOL_SEARCH"},"stage_id":2},{"id":15,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/weather-forecast/102255?type=locality","title":"广州, 广东省, 中国三天天气预报 | AccuWeather","snippet":"上午局部地区有雷雨;部分晴 ... 大部分多云,潮湿,有雷雨 大部分多云,潮湿,有阵雨或雷雨 ... 大部分多云,潮湿,有零星降雨和雷雨","cite_index":null,"published_at":1775059200.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},"reference":{"id":7,"type":"TOOL_SEARCH"},"stage_id":2},{"id":16,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.weather.com.cn/weather/101280101.shtml","title":"广州天气预报,广州7天天气预报,广州15天天气预报,广州天气查询","snippet":"5日(明天) 暴雨转雷阵雨 25℃/20℃ <3级 # 6日(后天) 中雨转多云 30℃/23℃ 3-4级转<3级 # 7日(周二)","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/weather.com.cn","site_name":"天气网","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":17,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.yzqxj.com/guonei/guangdong-guangzhou/7day.html","title":"广州天气预报7天天气","snippet":"2026-04-05 (星期日) 大雨转雷阵雨 21℃ ~ 25℃ 微风 <3级 | 日出06:16 日落18:43 2026-04-06 (星期一) 雷阵雨转多云 24℃ ~ 28℃","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/yzqxj.com","site_name":"Yzqxj","query_indexes":[0]},"reference":{"id":8,"type":"TOOL_SEARCH"},"stage_id":2},{"id":18,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://news.southcn.com/node_de9aedeec1/2a08783352.shtml","title":"今日降雨集中在广东南部,清明假期全省多地将遭遇强对流天气","snippet":"广州市区,4日,阴天到多云,有中到大雨局部暴雨,气温在20℃—25℃;5日,阴天,有大到暴雨,气温在21℃—25℃;6日,阴天,有中雨,气温在22℃—28℃。","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/southcn.com","site_name":"南方网","query_indexes":[0]},"reference":{"id":3,"type":"TOOL_SEARCH"},"stage_id":2},{"id":19,"type":"TOOL_OPEN","status":"WIP","result":{"url":"https://www.accuweather.com/zh/cn/guangzhou/102255/air-quality-index/102255","title":"广州, 广东省, 中国空气质量指数 | AccuWeather","snippet":"空气质量量表 ... 空气质量对大多数人来说是可接受的。然而 ... 广州的当前空气质量 极好 危险 极好 有利 差 不健康 很不健康 危险 ## 24小时空气质量预报","cite_index":null,"published_at":1775232000.0,"site_icon":"https://cdn.deepseek.com/site-icons/accuweather.com","site_name":"AccuWeather","query_indexes":[0]},"reference":{"id":6,"type":"TOOL_SEARCH"},"stage_id":2},{"id":20,"type":"TOOL_OPEN","status":"WIP","result":{"url":"http://pc.weathercn.com/weather/102255/?partner=20000010%d0%93%d2%93%d0%96%e2%80%99%d0%93%d3%ae%d0%92%d0%88%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d1%9e%d0%b2%d2%96%d2%a1%d0%92%c2%ac%d0%93%d2%93%d0%b2%d2%96%d2%a1%d0%93%d3%ae%d0%bf%d2%9d%d2%aa10.hfaw&p_source=&p_type=jump&areatype=","title":"广州当前天气","snippet":"广州空气质量 39 优 0 50 100 150 200 300 500 空气很棒,快出门呼吸新鲜空气吧。 适宜外出","cite_index":null,"published_at":null,"site_icon":"https://cdn.deepseek.com/site-icons/weathercn.com","site_name":"华风爱科气象科技","query_indexes":[0]},"reference":{"id":6,"type":"TOOL_SEARCH"},"stage_id":2}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-7/status","o":"SET","v":"FINISHED"} + +data: {"p":"response/fragments/-6/status","v":"FINISHED"} + +data: {"p":"response/fragments/-3/status","v":"FINISHED"} + +data: {"p":"response/fragments/-11/status","v":"FINISHED"} + +data: {"p":"response/fragments/-10/status","v":"FINISHED"} + +data: {"p":"response/fragments/-5/status","v":"FINISHED"} + +data: {"p":"response/fragments/-4/status","v":"FINISHED"} + +data: {"p":"response/fragments/-9/status","v":"FINISHED"} + +data: {"p":"response/fragments/-8/status","v":"FINISHED"} + +data: {"p":"response/fragments/-2/status","v":"FINISHED"} + +data: {"p":"response/fragments/-1/status","v":"FINISHED"} + +data: {"p":"response/has_pending_fragment","v":true} + +data: {"p":"response","o":"BATCH","v":[{"p":"fragments","o":"APPEND","v":[{"id":21,"type":"THINK","content":"这些","elapsed_secs":null,"references":[],"stage_id":3}]},{"p":"has_pending_fragment","o":"SET","v":false}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"搜索结果"} + +data: {"v":"提供了"} + +data: {"v":"广州"} + +data: {"v":"的"} + +data: {"v":"天气"} + +data: {"v":"信息"} + +data: {"v":"。"} + +data: {"v":"我将"} + +data: {"v":"组织"} + +data: {"v":"回答"} + +data: {"v":","} + +data: {"v":"提供"} + +data: {"v":"当前"} + +data: {"v":"天气"} + +data: {"v":"、"} + +data: {"v":"未来"} + +data: {"v":"几天"} + +data: {"v":"预报"} + +data: {"v":"、"} + +data: {"v":"详细"} + +data: {"v":"天气"} + +data: {"v":"情况"} + +data: {"v":"以及"} + +data: {"v":"生活"} + +data: {"v":"建议"} + +data: {"v":"和"} + +data: {"v":"出行"} + +data: {"v":"提示"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.759072346} + +data: {"p":"response/fragments","o":"APPEND","v":[{"id":22,"type":"RESPONSE","content":"广州","references":[],"stage_id":3}]} + +data: {"p":"response/fragments/-1/content","v":"今天"} + +data: {"v":"("} + +data: {"v":"4"} + +data: {"v":"月"} + +data: {"v":"5"} + +data: {"v":"日"} + +data: {"v":","} + +data: {"v":"周日"} + +data: {"v":")"} + +data: {"v":"有"} + +data: {"v":"暴雨"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"伴有"} + +data: {"v":"强"} + +data: {"v":"对流"} + +data: {"v":"天气"} + +data: {"v":","} + +data: {"v":"全天"} + +data: {"v":"体"} + +data: {"v":"感"} + +data: {"v":"湿"} + +data: {"v":"凉"} + +data: {"v":"。\n\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"天气"} + +data: {"v":"与"} + +data: {"v":"气温"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"阴"} + +data: {"v":"有大"} + +data: {"v":"到"} + +data: {"v":"暴雨"} + +data: {"v":","} + +data: {"v":"21"} + +data: {"v":"℃"} + +data: {"v":" ~"} + +data: {"v":" "} + +data: {"v":"25"} + +data: {"v":"℃"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:0]"},{"p":"references","o":"SET","v":[{"id":18,"type":"TOOL_OPEN"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:1]"},{"p":"references","v":[{"id":8,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} + +data: {"v":"主要"} + +data: {"v":"降水"} + +data: {"v":"集中在"} + +data: {"v":"中午"} + +data: {"v":"至"} + +data: {"v":"傍晚"} + +data: {"v":","} + +data: {"v":"下午"} + +data: {"v":"2"} + +data: {"v":"点左右"} + +data: {"v":"雨"} + +data: {"v":"势"} + +data: {"v":"可能"} + +data: {"v":"最强"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:2]"},{"p":"references","v":[{"id":10,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"风"} + +data: {"v":"况"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"东风"} + +data: {"v":"转"} + +data: {"v":"东南"} + +data: {"v":"风"} + +data: {"v":","} + +data: {"v":"风力"} + +data: {"v":"普遍"} + +data: {"v":"在"} + +data: {"v":"3"} + +data: {"v":"级"} + +data: {"v":"以下"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:3]"},{"p":"references","v":[{"id":10,"type":"TOOL_OPEN"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:4]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} + +data: {"v":"但"} + +data: {"v":"强"} + +data: {"v":"对流"} + +data: {"v":"天气"} + +data: {"v":"发生时"} + +data: {"v":","} + +data: {"v":"局部"} + +data: {"v":"可能出现"} + +data: {"v":"6"} + +data: {"v":"~"} + +data: {"v":"12"} + +data: {"v":"级的"} + +data: {"v":"短"} + +data: {"v":"时"} + +data: {"v":"大风"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:5]"},{"p":"references","v":[{"id":18,"type":"TOOL_OPEN"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:6]"},{"p":"references","v":[{"id":3,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n"} + +data: {"v":"*"} + +data: {"v":" "} + +data: {"v":" **"} + +data: {"v":"生活"} + +data: {"v":"建议"} + +data: {"v":"**"} + +data: {"v":":"} + +data: {"v":"**"} + +data: {"v":"暴雨"} + +data: {"v":"和"} + +data: {"v":"强"} + +data: {"v":"对流"} + +data: {"v":"天气"} + +data: {"v":","} + +data: {"v":"建议"} + +data: {"v":"减少"} + +data: {"v":"外出"} + +data: {"v":"**"} + +data: {"v":";"} + +data: {"v":"湿度"} + +data: {"v":"大体"} + +data: {"v":"感"} + +data: {"v":"凉"} + +data: {"v":","} + +data: {"v":"适合"} + +data: {"v":"穿"} + +data: {"v":"长"} + +data: {"v":"袖"} + +data: {"v":"衬衫"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:7]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":";"} + +data: {"v":"紫外线"} + +data: {"v":"弱"} + +data: {"v":","} + +data: {"v":"可不"} + +data: {"v":"防晒"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:8]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} + +data: {"v":"明天"} + +data: {"v":"("} + +data: {"v":"4"} + +data: {"v":"月"} + +data: {"v":"6"} + +data: {"v":"日"} + +data: {"v":")"} + +data: {"v":"降雨"} + +data: {"v":"强度"} + +data: {"v":"会"} + +data: {"v":"减弱"} + +data: {"v":","} + +data: {"v":"转为"} + +data: {"v":"中"} + +data: {"v":"雨"} + +data: {"v":"转"} + +data: {"v":"多云"} + +data: {"v":","} + +data: {"v":"气温"} + +data: {"v":"回升"} + +data: {"v":"至"} + +data: {"v":" "} + +data: {"v":"23"} + +data: {"v":"℃"} + +data: {"v":" ~"} + +data: {"v":" "} + +data: {"v":"30"} + +data: {"v":"℃"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:9]"},{"p":"references","v":[{"id":11,"type":"TOOL_OPEN"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:10]"},{"p":"references","v":[{"id":18,"type":"TOOL_OPEN"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。"} + +data: {"v":"后天"} + +data: {"v":"起"} + +data: {"v":"天气"} + +data: {"v":"将"} + +data: {"v":"逐渐"} + +data: {"v":"稳定"} + +data: {"v":","} + +data: {"v":"以"} + +data: {"v":"多云"} + +data: {"v":"和"} + +data: {"v":"雷"} + +data: {"v":"阵"} + +data: {"v":"雨"} + +data: {"v":"为主"} + +data: {"p":"response/fragments/-1","o":"BATCH","v":[{"p":"content","o":"APPEND","v":"[reference:11]"},{"p":"references","v":[{"id":3,"type":"TOOL_SEARCH"}]}]} + +data: {"v":[{"p":"content","o":"APPEND","v":"[reference:12]"},{"p":"references","v":[{"id":4,"type":"TOOL_SEARCH"}]}]} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"。\n\n"} + +data: {"v":"天气预报"} + +data: {"v":"信息"} + +data: {"v":"会"} + +data: {"v":"实时"} + +data: {"v":"更新"} + +data: {"v":","} + +data: {"v":"如需"} + +data: {"v":"查询"} + +data: {"v":"最新"} + +data: {"v":"情况"} + +data: {"v":","} + +data: {"v":"可以"} + +data: {"v":"随时"} + +data: {"v":"再来"} + +data: {"v":"问我"} + +data: {"v":"~"} + +data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":211},{"p":"quasi_status","v":"FINISHED"}]} + +data: {"p":"response/status","o":"SET","v":"FINISHED"} + +event: finish +data: {} + +event: update_session +data: {"updated_at":1775318487.947988} + +event: title +data: {"content":"广州天气查询"} + +event: close +data: {"click_behavior":"none","auto_resume":false} + diff --git a/tests/raw_stream_samples/manifest.json b/tests/raw_stream_samples/manifest.json new file mode 100644 index 0000000..221d04f --- /dev/null +++ b/tests/raw_stream_samples/manifest.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "default_samples": [ + "guangzhou-weather-reasoner-search-20260404", + "content-filter-trigger-20260405-jwt3" + ], + "notes": "Canonical raw stream samples used by the default replay simulator." +} diff --git a/tests/raw_stream_samples/markdown-format-example-20260405-spacefix/meta.json b/tests/raw_stream_samples/markdown-format-example-20260405-spacefix/meta.json new file mode 100644 index 0000000..6e9d23e --- /dev/null +++ b/tests/raw_stream_samples/markdown-format-example-20260405-spacefix/meta.json @@ -0,0 +1,23 @@ +{ + "sample_id": "markdown-format-example-20260405-spacefix", + "captured_at_utc": "2026-04-04T17:17:26Z", + "source": "admin/dev/raw-samples/capture", + "request": { + "messages": [ + { + "content": "输出markdown格式示例 四五个类型", + "role": "user" + } + ], + "model": "deepseek-reasoner-search", + "stream": true + }, + "capture": { + "label": "deepseek_completion", + "url": "https://chat.deepseek.com/api/v0/chat/completion", + "status_code": 200, + "response_bytes": 24684, + "contains_finished_token": true, + "finished_token_count": 2 + } +} diff --git a/tests/raw_stream_samples/markdown-format-example-20260405-spacefix/upstream.stream.sse b/tests/raw_stream_samples/markdown-format-example-20260405-spacefix/upstream.stream.sse new file mode 100644 index 0000000..4c6e5e8 --- /dev/null +++ b/tests/raw_stream_samples/markdown-format-example-20260405-spacefix/upstream.stream.sse @@ -0,0 +1,2336 @@ +event: ready +data: {"request_message_id":1,"response_message_id":2,"model_type":"default"} + +event: update_session +data: {"updated_at":1775323022.6001759} + +data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"files":[],"feedback":null,"inserted_at":1775323022.597386,"search_enabled":true,"fragments":[{"id":2,"type":"THINK","content":"我们需要","elapsed_secs":null,"references":[],"stage_id":1}],"has_pending_fragment":false,"auto_continue":false,"search_triggered":false}}} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"输出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"格式"} + +data: {"v":"示例"} + +data: {"v":","} + +data: {"v":"四"} + +data: {"v":"五个"} + +data: {"v":"类型"} + +data: {"v":"。"} + +data: {"v":"用户"} + +data: {"v":"要求"} + +data: {"v":":“"} + +data: {"v":"输出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"格式"} + +data: {"v":"示例"} + +data: {"v":" "} + +data: {"v":"四"} + +data: {"v":"五个"} + +data: {"v":"类型"} + +data: {"v":"”。"} + +data: {"v":"应该是"} + +data: {"v":"展示"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"语"} + +data: {"v":"法的"} + +data: {"v":"几个"} + +data: {"v":"典型"} + +data: {"v":"示例"} + +data: {"v":","} + +data: {"v":"比如"} + +data: {"v":"标题"} + +data: {"v":"、"} + +data: {"v":"列表"} + +data: {"v":"、"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"、"} + +data: {"v":"表格"} + +data: {"v":"、"} + +data: {"v":"引用"} + +data: {"v":"等"} + +data: {"v":"。"} + +data: {"v":"要求"} + +data: {"v":"四"} + +data: {"v":"五个"} + +data: {"v":"类型"} + +data: {"v":","} + +data: {"v":"每种"} + +data: {"v":"给出"} + +data: {"v":"示例"} + +data: {"v":"和"} + +data: {"v":"展示"} + +data: {"v":"效果"} + +data: {"v":"。"} + +data: {"v":"最好"} + +data: {"v":"用"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"展示"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"源"} + +data: {"v":"格式"} + +data: {"v":","} + +data: {"v":"然后"} + +data: {"v":"呈现"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"。"} + +data: {"v":"或者"} + +data: {"v":"直接"} + +data: {"v":"展示"} + +data: {"v":"如何"} + +data: {"v":"书写"} + +data: {"v":"。"} + +data: {"v":"以"} + +data: {"v":"清晰"} + +data: {"v":"易懂"} + +data: {"v":"为主"} + +data: {"v":"。\n\n"} + +data: {"v":"既然是"} + +data: {"v":"“"} + +data: {"v":"输出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"格式"} + +data: {"v":"示例"} + +data: {"v":"”,"} + +data: {"v":"应该"} + +data: {"v":"输出"} + +data: {"v":"一篇"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"文本"} + +data: {"v":","} + +data: {"v":"其中"} + +data: {"v":"包含"} + +data: {"v":"多个"} + +data: {"v":"示例"} + +data: {"v":"。"} + +data: {"v":"可以直接"} + +data: {"v":"在一个"} + +data: {"v":"消息"} + +data: {"v":"中"} + +data: {"v":"展示"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"和"} + +data: {"v":"呈现"} + +data: {"v":"。"} + +data: {"v":"通常"} + +data: {"v":"为了"} + +data: {"v":"展示"} + +data: {"v":"示例"} + +data: {"v":","} + +data: {"v":"可以使用"} + +data: {"v":"“"} + +data: {"v":"示例"} + +data: {"v":":"} + +data: {"v":"”"} + +data: {"v":"然后"} + +data: {"v":"给出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"源码"} + +data: {"v":"和"} + +data: {"v":"渲染"} + +data: {"v":"后"} + +data: {"v":"内容"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"在这里"} + +data: {"v":"作为"} + +data: {"v":"AI"} + +data: {"v":","} + +data: {"v":"直接"} + +data: {"v":"输出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":","} + +data: {"v":"它"} + +data: {"v":"会被"} + +data: {"v":"渲染"} + +data: {"v":"吗"} + +data: {"v":"?"} + +data: {"v":"在"} + +data: {"v":"聊天"} + +data: {"v":"界面"} + +data: {"v":","} + +data: {"v":"我们可以"} + +data: {"v":"输出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"文本"} + +data: {"v":","} + +data: {"v":"助手"} + +data: {"v":"消息"} + +data: {"v":"会被"} + +data: {"v":"解析"} + +data: {"v":"成"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"展示"} + +data: {"v":"源码"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"需要用"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"包裹"} + +data: {"v":"防止"} + +data: {"v":"渲染"} + +data: {"v":"。"} + +data: {"v":"所以"} + +data: {"v":"可以"} + +data: {"v":"这样"} + +data: {"v":"组织"} + +data: {"v":":"} + +data: {"v":"提供"} + +data: {"v":"四"} + +data: {"v":"五个"} + +data: {"v":"不同类型"} + +data: {"v":","} + +data: {"v":"每个"} + +data: {"v":"类型"} + +data: {"v":"先"} + +data: {"v":"说明"} + +data: {"v":","} + +data: {"v":"然后"} + +data: {"v":"给出"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"("} + +data: {"v":"标记"} + +data: {"v":"为"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"源码"} + +data: {"v":")"} + +data: {"v":"和"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"在"} + +data: {"v":"同一个"} + +data: {"v":"消息"} + +data: {"v":"里"} + +data: {"v":","} + +data: {"v":"如果我们"} + +data: {"v":"展示"} + +data: {"v":"源码"} + +data: {"v":"块"} + +data: {"v":","} + +data: {"v":"实际"} + +data: {"v":"会被"} + +data: {"v":"渲染"} + +data: {"v":"?"} + +data: {"v":"注意"} + +data: {"v":":"} + +data: {"v":"在"} + +data: {"v":"消息"} + +data: {"v":"里"} + +data: {"v":","} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"中的"} + +data: {"v":"内容"} + +data: {"v":"不会被"} + +data: {"v":"渲染"} + +data: {"v":","} + +data: {"v":"所以"} + +data: {"v":"我们可以"} + +data: {"v":"展示"} + +data: {"v":"源码"} + +data: {"v":"。"} + +data: {"v":"但是"} + +data: {"v":"展示"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"可以直接"} + +data: {"v":"写"} + +data: {"v":"出来"} + +data: {"v":","} + +data: {"v":"比如"} + +data: {"v":"用"} + +data: {"v":"引用"} + +data: {"v":"或"} + +data: {"v":"示例"} + +data: {"v":"文本"} + +data: {"v":"。"} + +data: {"v":"更"} + +data: {"v":"简单"} + +data: {"v":"的方法是"} + +data: {"v":":"} + +data: {"v":"写出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"示例"} + +data: {"v":"本身"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"为了"} + +data: {"v":"教"} + +data: {"v":"别人"} + +data: {"v":","} + +data: {"v":"可以用"} + +data: {"v":"说明"} + +data: {"v":"文字"} + +data: {"v":"+"} + +data: {"v":"实际"} + +data: {"v":"展示"} + +data: {"v":"。\n\n"} + +data: {"v":"用户"} + +data: {"v":"要求"} + +data: {"v":"“"} + +data: {"v":"输出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"格式"} + +data: {"v":"示例"} + +data: {"v":"”,"} + +data: {"v":"可能是"} + +data: {"v":"要我"} + +data: {"v":"展示"} + +data: {"v":"几个"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"常用"} + +data: {"v":"格式"} + +data: {"v":"的"} + +data: {"v":"写法"} + +data: {"v":","} + +data: {"v":"以便"} + +data: {"v":"参考"} + +data: {"v":"。"} + +data: {"v":"由于"} + +data: {"v":"环境"} + +data: {"v":"支持"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":","} + +data: {"v":"我可以"} + +data: {"v":"直接"} + +data: {"v":"写出"} + +data: {"v":"标题"} + +data: {"v":"、"} + +data: {"v":"列表"} + +data: {"v":"、"} + +data: {"v":"表格"} + +data: {"v":"等"} + +data: {"v":","} + +data: {"v":"同时"} + +data: {"v":"用"} + +data: {"v":"注释"} + +data: {"v":"或"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"展示"} + +data: {"v":"源代码"} + +data: {"v":"。"} + +data: {"v":"为了"} + +data: {"v":"清晰"} + +data: {"v":","} + +data: {"v":"我会"} + +data: {"v":"每个"} + +data: {"v":"类型"} + +data: {"v":"用"} + +data: {"v":"三级"} + +data: {"v":"标题"} + +data: {"v":","} + +data: {"v":"然后"} + +data: {"v":"展示"} + +data: {"v":"“"} + +data: {"v":"源代码"} + +data: {"v":"”"} + +data: {"v":"和"} + +data: {"v":"“"} + +data: {"v":"显示"} + +data: {"v":"效果"} + +data: {"v":"”。"} + +data: {"v":"显示"} + +data: {"v":"效果"} + +data: {"v":"可以直接"} + +data: {"v":"用"} + +data: {"v":"实际"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"写出"} + +data: {"v":","} + +data: {"v":"但"} + +data: {"v":"要注意"} + +data: {"v":"显示"} + +data: {"v":"效果"} + +data: {"v":"中"} + +data: {"v":"可能会"} + +data: {"v":"被"} + +data: {"v":"解析"} + +data: {"v":"。"} + +data: {"v":"为避免"} + +data: {"v":"混淆"} + +data: {"v":","} + +data: {"v":"可以在"} + +data: {"v":"显示"} + +data: {"v":"效果"} + +data: {"v":"部分"} + +data: {"v":"使用"} + +data: {"v":"特殊"} + +data: {"v":"标注"} + +data: {"v":"?"} + +data: {"v":"或者"} + +data: {"v":"只"} + +data: {"v":"展示"} + +data: {"v":"源代码"} + +data: {"v":"并"} + +data: {"v":"说明"} + +data: {"v":"会"} + +data: {"v":"呈现"} + +data: {"v":"什么"} + +data: {"v":"。"} + +data: {"v":"最好的"} + +data: {"v":"方式是"} + +data: {"v":":"} + +data: {"v":"使用"} + +data: {"v":"文本"} + +data: {"v":"说明"} + +data: {"v":"和"} + +data: {"v":"示例"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":","} + +data: {"v":"并"} + +data: {"v":"给出"} + +data: {"v":"示例"} + +data: {"v":"呈现"} + +data: {"v":"的"} + +data: {"v":"截图"} + +data: {"v":"描述"} + +data: {"v":"?"} + +data: {"v":"由于"} + +data: {"v":"没有"} + +data: {"v":"截图"} + +data: {"v":","} + +data: {"v":"可以用"} + +data: {"v":"文字"} + +data: {"v":"描述"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"更"} + +data: {"v":"直观"} + +data: {"v":":"} + +data: {"v":"我"} + +data: {"v":"直接在"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"中"} + +data: {"v":"写出"} + +data: {"v":"格式"} + +data: {"v":"的"} + +data: {"v":"渲染"} + +data: {"v":"结果"} + +data: {"v":","} + +data: {"v":"然后在"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"中"} + +data: {"v":"展示"} + +data: {"v":"其"} + +data: {"v":"源码"} + +data: {"v":"。"} + +data: {"v":"比如"} + +data: {"v":":\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"标题"} + +data: {"v":"\n\n"} + +data: {"v":"源码"} + +data: {"v":":\n"} + +data: {"v":"```"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"\n"} + +data: {"v":"#"} + +data: {"v":" "} + +data: {"v":"一级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"##"} + +data: {"v":" "} + +data: {"v":"二级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"三级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"```\n\n"} + +data: {"v":"效果"} + +data: {"v":":\n"} + +data: {"v":"#"} + +data: {"v":" "} + +data: {"v":"一级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"##"} + +data: {"v":" "} + +data: {"v":"二级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"三级"} + +data: {"v":"标题"} + +data: {"v":"\n\n"} + +data: {"v":"这样"} + +data: {"v":"源码"} + +data: {"v":"不会被"} + +data: {"v":"渲染"} + +data: {"v":","} + +data: {"v":"效果"} + +data: {"v":"则"} + +data: {"v":"实际"} + +data: {"v":"呈现"} + +data: {"v":"。"} + +data: {"v":"但"} + +data: {"v":"注意"} + +data: {"v":"效果"} + +data: {"v":"呈现"} + +data: {"v":"后"} + +data: {"v":","} + +data: {"v":"下面的"} + +data: {"v":"内容"} + +data: {"v":"可能会"} + +data: {"v":"被"} + +data: {"v":"影响"} + +data: {"v":"。"} + +data: {"v":"不过"} + +data: {"v":"可以"} + +data: {"v":"接受"} + +data: {"v":"。"} + +data: {"v":"同样"} + +data: {"v":"对"} + +data: {"v":"列表"} + +data: {"v":"、"} + +data: {"v":"表格"} + +data: {"v":"、"} + +data: {"v":"引用"} + +data: {"v":"、"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"等"} + +data: {"v":"。\n\n"} + +data: {"v":"需要"} + +data: {"v":"四"} + +data: {"v":"五个"} + +data: {"v":"类型"} + +data: {"v":","} + +data: {"v":"可"} + +data: {"v":"包括"} + +data: {"v":":"} + +data: {"v":"标题"} + +data: {"v":"、"} + +data: {"v":"列表"} + +data: {"v":"("} + +data: {"v":"有序"} + +data: {"v":"/"} + +data: {"v":"无序"} + +data: {"v":")、"} + +data: {"v":"表格"} + +data: {"v":"、"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"、"} + +data: {"v":"引用"} + +data: {"v":"、"} + +data: {"v":"粗"} + +data: {"v":"体"} + +data: {"v":"斜"} + +data: {"v":"体"} + +data: {"v":"等"} + +data: {"v":"。"} + +data: {"v":"选择"} + +data: {"v":"常见的"} + +data: {"v":"。"} + +data: {"v":"确保"} + +data: {"v":"正确"} + +data: {"v":"。\n\n"} + +data: {"v":"另外"} + +data: {"v":"用户"} + +data: {"v":"要求"} + +data: {"v":"“"} + +data: {"v":"四"} + +data: {"v":"五个"} + +data: {"v":"类型"} + +data: {"v":"”,"} + +data: {"v":"输出"} + +data: {"v":"四个"} + +data: {"v":"或"} + +data: {"v":"五个"} + +data: {"v":"示例"} + +data: {"v":"。"} + +data: {"v":"我可以"} + +data: {"v":"输出"} + +data: {"v":"五个"} + +data: {"v":"。\n\n"} + +data: {"v":"输出"} + +data: {"v":"格式"} + +data: {"v":"应该"} + +data: {"v":"清晰"} + +data: {"v":"。"} + +data: {"v":"我会"} + +data: {"v":"回复"} + +data: {"v":"一段"} + +data: {"v":"完整的"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"文本"} + +data: {"v":","} + +data: {"v":"其中"} + +data: {"v":"包含"} + +data: {"v":"示例"} + +data: {"v":"说明"} + +data: {"v":"。\n\n"} + +data: {"v":"注意事项"} + +data: {"v":":"} + +data: {"v":"由于"} + +data: {"v":"我"} + +data: {"v":"本身"} + +data: {"v":"输出的"} + +data: {"v":"就是"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":","} + +data: {"v":"所以我"} + +data: {"v":"必须"} + +data: {"v":"谨慎"} + +data: {"v":"不要"} + +data: {"v":"引起"} + +data: {"v":"渲染"} + +data: {"v":"歧"} + +data: {"v":"义"} + +data: {"v":"。"} + +data: {"v":"例如"} + +data: {"v":","} + +data: {"v":"展示"} + +data: {"v":"表格"} + +data: {"v":"源码"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"需要使用"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"。"} + +data: {"v":"展示"} + +data: {"v":"效果"} + +data: {"v":"时"} + +data: {"v":","} + +data: {"v":"我可以"} + +data: {"v":"直接"} + +data: {"v":"写出"} + +data: {"v":"表格"} + +data: {"v":"。"} + +data: {"v":"同样"} + +data: {"v":","} + +data: {"v":"引用"} + +data: {"v":":"} + +data: {"v":"效果"} + +data: {"v":"可以用"} + +data: {"v":">"} + +data: {"v":" "} + +data: {"v":"引用"} + +data: {"v":"内容"} + +data: {"v":"。"} + +data: {"v":"没问题"} + +data: {"v":"。\n\n"} + +data: {"v":"开始"} + +data: {"v":"组织"} + +data: {"v":"回答"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":13.812640234} + +data: {"p":"response/fragments","o":"APPEND","v":[{"id":3,"type":"RESPONSE","content":"以下","references":[],"stage_id":1}]} + +data: {"p":"response/fragments/-1/content","v":"展示了"} + +data: {"v":"五种"} + +data: {"v":"常见的"} + +data: {"v":" Mark"} + +data: {"v":"down"} + +data: {"v":" "} + +data: {"v":"格式"} + +data: {"v":"示例"} + +data: {"v":","} + +data: {"v":"每种"} + +data: {"v":"均"} + +data: {"v":"包含"} + +data: {"v":"**"} + +data: {"v":"源码"} + +data: {"v":"**"} + +data: {"v":"("} + +data: {"v":"供"} + +data: {"v":"复制"} + +data: {"v":")"} + +data: {"v":"和"} + +data: {"v":"**"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"**"} + +data: {"v":"("} + +data: {"v":"供"} + +data: {"v":"预览"} + +data: {"v":")。\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"**"} + +data: {"v":"源码"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"```"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"\n"} + +data: {"v":"#"} + +data: {"v":" "} + +data: {"v":"一级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"##"} + +data: {"v":" "} + +data: {"v":"二级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"三级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"```\n"} + +data: {"v":"**"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"#"} + +data: {"v":" "} + +data: {"v":"一级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"##"} + +data: {"v":" "} + +data: {"v":"二级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"三级"} + +data: {"v":"标题"} + +data: {"v":"\n\n"} + +data: {"v":"---\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"2"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"列表"} + +data: {"v":"("} + +data: {"v":"无序"} + +data: {"v":"与"} + +data: {"v":"有序"} + +data: {"v":")\n"} + +data: {"v":"**"} + +data: {"v":"源码"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"```"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"无序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":" "} + +data: {"v":"1"} + +data: {"v":"\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"无序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":" "} + +data: {"v":"2"} + +data: {"v":"\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" "} + +data: {"v":"嵌套"} + +data: {"v":"无序"} + +data: {"v":"项"} + +data: {"v":"\n\n"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"有序"} + +data: {"v":"列表"} + +data: {"v":"第一"} + +data: {"v":"项"} + +data: {"v":"\n"} + +data: {"v":"2"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"有序"} + +data: {"v":"列表"} + +data: {"v":"第二"} + +data: {"v":"项"} + +data: {"v":"\n"} + +data: {"v":" "} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"嵌套"} + +data: {"v":"有序"} + +data: {"v":"项"} + +data: {"v":"\n"} + +data: {"v":"```\n"} + +data: {"v":"**"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"无序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":" "} + +data: {"v":"1"} + +data: {"v":"\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"无序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":" "} + +data: {"v":"2"} + +data: {"v":"\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" "} + +data: {"v":"嵌套"} + +data: {"v":"无序"} + +data: {"v":"项"} + +data: {"v":"\n\n"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"有序"} + +data: {"v":"列表"} + +data: {"v":"第一"} + +data: {"v":"项"} + +data: {"v":"\n"} + +data: {"v":"2"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"有序"} + +data: {"v":"列表"} + +data: {"v":"第二"} + +data: {"v":"项"} + +data: {"v":"\n"} + +data: {"v":" "} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"嵌套"} + +data: {"v":"有序"} + +data: {"v":"项"} + +data: {"v":"\n\n"} + +data: {"v":"---\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"3"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"表格"} + +data: {"v":"\n"} + +data: {"v":"**"} + +data: {"v":"源码"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"```"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"左"} + +data: {"v":"对齐"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"居"} + +data: {"v":"中对"} + +data: {"v":"齐"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"右"} + +data: {"v":"对齐"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" :"} + +data: {"v":"-----"} + +data: {"v":" |"} + +data: {"v":" :"} + +data: {"v":"------"} + +data: {"v":":"} + +data: {"v":" |"} + +data: {"v":" -----"} + +data: {"v":":"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"苹果"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"红色"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"5"} + +data: {"v":"."} + +data: {"v":"00"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"香蕉"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"黄色"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"3"} + +data: {"v":"."} + +data: {"v":"50"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"樱桃"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"深"} + +data: {"v":"红"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"12"} + +data: {"v":"."} + +data: {"v":"00"} + +data: {"v":" |\n"} + +data: {"v":"```\n"} + +data: {"v":"**"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"左"} + +data: {"v":"对齐"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"居"} + +data: {"v":"中对"} + +data: {"v":"齐"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"右"} + +data: {"v":"对齐"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" :"} + +data: {"v":"-----"} + +data: {"v":" |"} + +data: {"v":" :"} + +data: {"v":"------"} + +data: {"v":":"} + +data: {"v":" |"} + +data: {"v":" -----"} + +data: {"v":":"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"苹果"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"红色"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"5"} + +data: {"v":"."} + +data: {"v":"00"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"香蕉"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"黄色"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"3"} + +data: {"v":"."} + +data: {"v":"50"} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"樱桃"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"深"} + +data: {"v":"红"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"12"} + +data: {"v":"."} + +data: {"v":"00"} + +data: {"v":" |\n\n"} + +data: {"v":"---\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"4"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"("} + +data: {"v":"语法"} + +data: {"v":"高"} + +data: {"v":"亮"} + +data: {"v":")\n"} + +data: {"v":"**"} + +data: {"v":"源码"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"```"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"\n"} + +data: {"v":"```"} + +data: {"v":"python"} + +data: {"v":"\n"} + +data: {"v":"def"} + +data: {"v":" hello"} + +data: {"v":"():\n"} + +data: {"v":" "} + +data: {"v":" print"} + +data: {"v":"(\""} + +data: {"v":"Hello"} + +data: {"v":","} + +data: {"v":" Mark"} + +data: {"v":"down"} + +data: {"v":"!\")\n"} + +data: {"v":"```\n"} + +data: {"v":"``"} + +data: {"v":"``"} + +data: {"v":" `"} + +data: {"v":"("} + +data: {"v":"注意"} + +data: {"v":":"} + +data: {"v":"实际"} + +data: {"v":"使用时"} + +data: {"v":"去掉"} + +data: {"v":"外层"} + +data: {"v":"多余的"} + +data: {"v":"`"} + +data: {"v":","} + +data: {"v":"这里"} + +data: {"v":"为"} + +data: {"v":"展示"} + +data: {"v":"源码"} + +data: {"v":"而"} + +data: {"v":"嵌套"} + +data: {"v":")\n"} + +data: {"v":"**"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"```"} + +data: {"v":"python"} + +data: {"v":"\n"} + +data: {"v":"def"} + +data: {"v":" hello"} + +data: {"v":"():\n"} + +data: {"v":" "} + +data: {"v":" print"} + +data: {"v":"(\""} + +data: {"v":"Hello"} + +data: {"v":","} + +data: {"v":" Mark"} + +data: {"v":"down"} + +data: {"v":"!\")\n"} + +data: {"v":"```\n\n"} + +data: {"v":"---\n\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"5"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"引用"} + +data: {"v":"与"} + +data: {"v":"分割"} + +data: {"v":"线"} + +data: {"v":"\n"} + +data: {"v":"**"} + +data: {"v":"源码"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":"```"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"\n"} + +data: {"v":">"} + +data: {"v":" "} + +data: {"v":"这是一"} + +data: {"v":"段"} + +data: {"v":"引用"} + +data: {"v":"文字"} + +data: {"v":"。\n"} + +data: {"v":">"} + +data: {"v":" "} + +data: {"v":"可以"} + +data: {"v":"跨"} + +data: {"v":"多"} + +data: {"v":"行"} + +data: {"v":"。\n"} + +data: {"v":">\n"} + +data: {"v":">"} + +data: {"v":" >"} + +data: {"v":" "} + +data: {"v":"支持"} + +data: {"v":"嵌套"} + +data: {"v":"引用"} + +data: {"v":"。\n\n"} + +data: {"v":"---\n"} + +data: {"v":"```\n"} + +data: {"v":"**"} + +data: {"v":"渲染"} + +data: {"v":"效果"} + +data: {"v":"**"} + +data: {"v":":\n"} + +data: {"v":">"} + +data: {"v":" "} + +data: {"v":"这是一"} + +data: {"v":"段"} + +data: {"v":"引用"} + +data: {"v":"文字"} + +data: {"v":"。\n"} + +data: {"v":">"} + +data: {"v":" "} + +data: {"v":"可以"} + +data: {"v":"跨"} + +data: {"v":"多"} + +data: {"v":"行"} + +data: {"v":"。\n"} + +data: {"v":">\n"} + +data: {"v":">"} + +data: {"v":" >"} + +data: {"v":" "} + +data: {"v":"支持"} + +data: {"v":"嵌套"} + +data: {"v":"引用"} + +data: {"v":"。\n\n"} + +data: {"v":"---"} + +data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":550},{"p":"quasi_status","v":"FINISHED"}]} + +data: {"p":"response/status","o":"SET","v":"FINISHED"} + +event: finish +data: {} + +event: update_session +data: {"updated_at":1775323046.236651} + diff --git a/tests/raw_stream_samples/markdown-format-example-20260405/meta.json b/tests/raw_stream_samples/markdown-format-example-20260405/meta.json new file mode 100644 index 0000000..c8df46d --- /dev/null +++ b/tests/raw_stream_samples/markdown-format-example-20260405/meta.json @@ -0,0 +1,23 @@ +{ + "sample_id": "markdown-format-example-20260405", + "captured_at_utc": "2026-04-04T17:13:34Z", + "source": "admin/dev/raw-samples/capture", + "request": { + "messages": [ + { + "content": "输出markdown格式示例 四五个类型", + "role": "user" + } + ], + "model": "deepseek-reasoner-search", + "stream": true + }, + "capture": { + "label": "deepseek_completion", + "url": "https://chat.deepseek.com/api/v0/chat/completion", + "status_code": 200, + "response_bytes": 4702, + "contains_finished_token": true, + "finished_token_count": 2 + } +} diff --git a/tests/raw_stream_samples/markdown-format-example-20260405/upstream.stream.sse b/tests/raw_stream_samples/markdown-format-example-20260405/upstream.stream.sse new file mode 100644 index 0000000..ea5e840 --- /dev/null +++ b/tests/raw_stream_samples/markdown-format-example-20260405/upstream.stream.sse @@ -0,0 +1,376 @@ +event: ready +data: {"request_message_id":1,"response_message_id":2,"model_type":"default"} + +event: update_session +data: {"updated_at":1775322810.106091} + +data: {"v":{"response":{"message_id":2,"parent_id":1,"model":"","role":"ASSISTANT","thinking_enabled":true,"ban_edit":false,"ban_regenerate":false,"status":"WIP","incomplete_message":null,"accumulated_token_usage":0,"files":[],"feedback":null,"inserted_at":1775322810.101244,"search_enabled":true,"fragments":[{"id":2,"type":"THINK","content":"我们","elapsed_secs":null,"references":[],"stage_id":1}],"has_pending_fragment":false,"auto_continue":false,"search_triggered":false}}} + +data: {"p":"response/fragments/-1/content","o":"APPEND","v":"只需要"} + +data: {"v":"输出"} + +data: {"v":"mark"} + +data: {"v":"down"} + +data: {"v":"格式"} + +data: {"v":"的"} + +data: {"v":"示例"} + +data: {"v":","} + +data: {"v":"展示"} + +data: {"v":"四"} + +data: {"v":"五个"} + +data: {"v":"类型"} + +data: {"v":"。"} + +data: {"v":"比如"} + +data: {"v":"标题"} + +data: {"v":"、"} + +data: {"v":"列表"} + +data: {"v":"、"} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"、"} + +data: {"v":"表格"} + +data: {"v":"、"} + +data: {"v":"引用"} + +data: {"v":"等"} + +data: {"v":"。"} + +data: {"v":"直接"} + +data: {"v":"输出"} + +data: {"v":"即可"} + +data: {"v":"。"} + +data: {"p":"response/fragments/-1/elapsed_secs","o":"SET","v":0.765374503} + +data: {"p":"response/fragments","o":"APPEND","v":[{"id":3,"type":"RESPONSE","content":"```","references":[],"stage_id":1}]} + +data: {"p":"response/fragments/-1/content","v":"mark"} + +data: {"v":"down"} + +data: {"v":"\n"} + +data: {"v":"#"} + +data: {"v":" "} + +data: {"v":"一级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"##"} + +data: {"v":" "} + +data: {"v":"二级"} + +data: {"v":"标题"} + +data: {"v":"\n"} + +data: {"v":"###"} + +data: {"v":" "} + +data: {"v":"三级"} + +data: {"v":"标题"} + +data: {"v":"\n\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"无序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":" "} + +data: {"v":"1"} + +data: {"v":"\n"} + +data: {"v":"-"} + +data: {"v":" "} + +data: {"v":"无序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":" "} + +data: {"v":"2"} + +data: {"v":"\n"} + +data: {"v":" "} + +data: {"v":" -"} + +data: {"v":" "} + +data: {"v":"嵌套"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":"\n\n"} + +data: {"v":"1"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"有序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":"一"} + +data: {"v":"\n"} + +data: {"v":"2"} + +data: {"v":"."} + +data: {"v":" "} + +data: {"v":"有序"} + +data: {"v":"列表"} + +data: {"v":"项"} + +data: {"v":"二"} + +data: {"v":"\n\n"} + +data: {"v":"```"} + +data: {"v":"python"} + +data: {"v":"\n"} + +data: {"v":"#"} + +data: {"v":" "} + +data: {"v":"代码"} + +data: {"v":"块"} + +data: {"v":"示例"} + +data: {"v":"\n"} + +data: {"v":"print"} + +data: {"v":"(\""} + +data: {"v":"Hello"} + +data: {"v":","} + +data: {"v":" Mark"} + +data: {"v":"down"} + +data: {"v":"!\")\n"} + +data: {"v":"```\n\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"姓名"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"年龄"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"城市"} + +data: {"v":" "} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":"------"} + +data: {"v":"|"} + +data: {"v":"------"} + +data: {"v":"|"} + +data: {"v":"----------"} + +data: {"v":"|\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"张三"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"25"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"北京"} + +data: {"v":" "} + +data: {"v":" |\n"} + +data: {"v":"|"} + +data: {"v":" "} + +data: {"v":"李"} + +data: {"v":"四"} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"30"} + +data: {"v":" "} + +data: {"v":" |"} + +data: {"v":" "} + +data: {"v":"上海"} + +data: {"v":" "} + +data: {"v":" |\n\n"} + +data: {"v":">"} + +data: {"v":" "} + +data: {"v":"这是一"} + +data: {"v":"段"} + +data: {"v":"引用"} + +data: {"v":"文字"} + +data: {"v":"。\n"} + +data: {"v":">"} + +data: {"v":" "} + +data: {"v":"可以"} + +data: {"v":"跨"} + +data: {"v":"多"} + +data: {"v":"行"} + +data: {"v":"。\n\n"} + +data: {"v":"**"} + +data: {"v":"粗"} + +data: {"v":"体"} + +data: {"v":"文本"} + +data: {"v":"**"} + +data: {"v":" "} + +data: {"v":"和"} + +data: {"v":" *"} + +data: {"v":"斜"} + +data: {"v":"体"} + +data: {"v":"文本"} + +data: {"v":"*\n"} + +data: {"v":"```"} + +data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":190},{"p":"quasi_status","v":"FINISHED"}]} + +data: {"p":"response/status","o":"SET","v":"FINISHED"} + +event: finish +data: {} + +event: update_session +data: {"updated_at":1775322814.188892} + +event: title +data: {"content":"Markdown格式示例输出"} + +event: close +data: {"click_behavior":"none","auto_resume":false} + diff --git a/tests/scripts/capture-raw-stream-sample.sh b/tests/scripts/capture-raw-stream-sample.sh index 153d4b4..6d1cce0 100755 --- a/tests/scripts/capture-raw-stream-sample.sh +++ b/tests/scripts/capture-raw-stream-sample.sh @@ -5,7 +5,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT_DIR" CONFIG_PATH="${1:-config.json}" -SAMPLE_ID="${2:-sample-$(date -u +%Y%m%dT%H%M%SZ)}" +SAMPLE_ID="${2:-capture-$(date -u +%Y%m%dT%H%M%SZ)}" QUESTION="${3:-广州天气}" MODEL="${4:-deepseek-reasoner-search}" API_KEY="${5:-}" @@ -26,10 +26,11 @@ if [[ -z "$API_KEY" ]]; then exit 1 fi -OUT_DIR="tests/raw_stream_samples/${SAMPLE_ID}" -mkdir -p "$OUT_DIR" +HDR_FILE="$(mktemp)" +BODY_FILE="$(mktemp)" cleanup() { + rm -f "$HDR_FILE" "$BODY_FILE" pkill -f "cmd/ds2api" >/dev/null 2>&1 || true } trap cleanup EXIT @@ -47,52 +48,50 @@ for _ in $(seq 1 120); do sleep 1 done -REQUEST_BODY="$(python3 - <<'PY' "$MODEL" "$QUESTION" +REQUEST_BODY="$(python3 - <<'PY' "$SAMPLE_ID" "$MODEL" "$QUESTION" "$API_KEY" import json,sys -model,question=sys.argv[1:3] +sample_id,model,question,api_key=sys.argv[1:5] payload={ - 'model':model, - 'stream':True, - 'messages':[{'role':'user','content':question}], + 'sample_id': sample_id, + 'api_key': api_key, + 'model': model, + 'stream': True, + 'messages': [{'role': 'user', 'content': question}], } print(json.dumps(payload, ensure_ascii=False)) PY )" -curl -sS http://127.0.0.1:5001/v1/chat/completions \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer ${API_KEY}" \ - --data-binary "${REQUEST_BODY}" \ - >"${OUT_DIR}/openai.stream.sse" - -curl -sS http://127.0.0.1:5001/admin/dev/captures \ +curl -sS \ + -D "$HDR_FILE" \ + http://127.0.0.1:5001/admin/dev/raw-samples/capture \ -H "Authorization: Bearer ${ADMIN_KEY}" \ - >"${OUT_DIR}/captures.json" + -H 'Content-Type: application/json' \ + --data-binary "${REQUEST_BODY}" \ + >"$BODY_FILE" -python3 - <<'PY' "$OUT_DIR" "$SAMPLE_ID" "$QUESTION" "$MODEL" -import json,sys,pathlib,datetime -out=pathlib.Path(sys.argv[1]) -sample_id,question,model=sys.argv[2:5] -captures=json.loads((out/'captures.json').read_text()) -items=captures.get('items') or [] -if not items: - raise SystemExit('no captured upstream stream found') -best=max(items,key=lambda x:len((x.get('response_body') or ''))) -raw=best.get('response_body') or '' -(out/'upstream.stream.sse').write_text(raw) -meta={ - 'sample_id':sample_id, - 'captured_at_utc':datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), - 'request':{'model':model,'stream':True,'messages':[{'role':'user','content':question}]}, - 'capture':{ - 'label':best.get('label'),'url':best.get('url'),'status_code':best.get('status_code'), - 'response_bytes':len(raw),'contains_finished_token':('FINISHED' in raw),'finished_token_count':raw.count('FINISHED') - } -} -(out/'meta.json').write_text(json.dumps(meta,ensure_ascii=False,indent=2)) -print(f'[capture] wrote sample to {out}') -print(f'[capture] upstream bytes={len(raw)} finished_count={raw.count("FINISHED")}') +SAMPLE_DIR="$(python3 - <<'PY' "$HDR_FILE" +import sys,pathlib +headers=pathlib.Path(sys.argv[1]).read_text().splitlines() +for line in headers: + if line.lower().startswith('x-ds2-sample-dir:'): + print(line.split(':',1)[1].strip()) + raise SystemExit +print('') PY +)" -rm -f "${OUT_DIR}/captures.json" -echo "[capture] done: ${OUT_DIR}" +SAMPLE_ID_HEADER="$(python3 - <<'PY' "$HDR_FILE" +import sys,pathlib +headers=pathlib.Path(sys.argv[1]).read_text().splitlines() +for line in headers: + if line.lower().startswith('x-ds2-sample-id:'): + print(line.split(':',1)[1].strip()) + raise SystemExit +print('') +PY +)" + +echo "[capture] sample_id=${SAMPLE_ID_HEADER:-$SAMPLE_ID}" +echo "[capture] sample_dir=${SAMPLE_DIR:-tests/raw_stream_samples/$SAMPLE_ID}" +cat "$BODY_FILE" diff --git a/tests/scripts/compare-raw-stream-sample.sh b/tests/scripts/compare-raw-stream-sample.sh new file mode 100755 index 0000000..4a5167a --- /dev/null +++ b/tests/scripts/compare-raw-stream-sample.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT_DIR" + +SAMPLE_ID="${1:-}" +BASELINE_ROOT="${2:-}" + +if [[ -z "$SAMPLE_ID" ]]; then + echo "usage: $0 [baseline-root]" >&2 + exit 1 +fi + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +RUN_DIR="artifacts/raw-stream-sim/compare-${SAMPLE_ID}-${RUN_ID}" +REPORT_PATH="$RUN_DIR/report.json" +mkdir -p "$RUN_DIR" + +cmd=( + node tests/tools/deepseek-sse-simulator.mjs + --samples-root tests/raw_stream_samples + --sample-id "$SAMPLE_ID" + --output-root "$RUN_DIR" + --report "$REPORT_PATH" +) + +if [[ -n "$BASELINE_ROOT" ]]; then + cmd+=(--baseline-root "$BASELINE_ROOT") +fi + +"${cmd[@]}" + +echo "[compare-raw-stream-sample] output: $RUN_DIR" +echo "[compare-raw-stream-sample] report: $REPORT_PATH" diff --git a/tests/scripts/run-raw-stream-sim.sh b/tests/scripts/run-raw-stream-sim.sh index df7aeab..73adda0 100755 --- a/tests/scripts/run-raw-stream-sim.sh +++ b/tests/scripts/run-raw-stream-sim.sh @@ -4,13 +4,16 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT_DIR" -REPORT_DIR="artifacts/raw-stream-sim" -mkdir -p "$REPORT_DIR" -REPORT_PATH="$REPORT_DIR/report-$(date -u +%Y%m%dT%H%M%SZ).json" +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +RUN_DIR="artifacts/raw-stream-sim/run-${RUN_ID}" +REPORT_PATH="$RUN_DIR/report.json" +mkdir -p "$RUN_DIR" node tests/tools/deepseek-sse-simulator.mjs \ --samples-root tests/raw_stream_samples \ + --output-root "$RUN_DIR" \ --report "$REPORT_PATH" \ "$@" +echo "[run-raw-stream-sim] output: $RUN_DIR" echo "[run-raw-stream-sim] report: $REPORT_PATH" diff --git a/tests/tools/deepseek-sse-simulator.mjs b/tests/tools/deepseek-sse-simulator.mjs old mode 100755 new mode 100644 index cd8a4a2..82d03a8 --- a/tests/tools/deepseek-sse-simulator.mjs +++ b/tests/tools/deepseek-sse-simulator.mjs @@ -12,8 +12,15 @@ function parseArgs(argv) { const out = { samplesRoot: 'tests/raw_stream_samples', reportPath: '', + outputRoot: '', + baselineRoot: '', + sampleId: '', failOnLeak: true, + failOnReferenceLeak: true, failOnMissingFinish: true, + failOnBaselineMismatch: true, + showOutput: false, + writeReplayText: false, }; for (let i = 2; i < argv.length; i += 1) { const a = argv[i]; @@ -21,24 +28,87 @@ function parseArgs(argv) { out.samplesRoot = argv[++i]; } else if (a === '--report' && argv[i + 1]) { out.reportPath = argv[++i]; + } else if (a === '--output-root' && argv[i + 1]) { + out.outputRoot = argv[++i]; + } else if (a === '--baseline-root' && argv[i + 1]) { + out.baselineRoot = argv[++i]; + } else if (a === '--sample-id' && argv[i + 1]) { + out.sampleId = argv[++i]; } else if (a === '--no-fail-on-leak') { out.failOnLeak = false; + } else if (a === '--no-fail-on-reference-leak') { + out.failOnReferenceLeak = false; } else if (a === '--no-fail-on-missing-finish') { out.failOnMissingFinish = false; + } else if (a === '--no-fail-on-baseline-mismatch' || a === '--no-fail-on-processed-mismatch') { + out.failOnBaselineMismatch = false; + } else if (a === '--show-output') { + out.showOutput = true; + } else if (a === '--write-replay-text' || a === '--write-processed-text') { + out.writeReplayText = true; } } return out; } -function findSampleDirs(root) { - if (!fs.existsSync(root)) { - return []; +function loadManifest(root) { + const manifestPath = path.join(root, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + return null; } - return fs.readdirSync(root) + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const defaultSamples = Array.isArray(manifest.default_samples) + ? manifest.default_samples.map((v) => String(v).trim()).filter(Boolean) + : []; + if (defaultSamples.length === 0) { + return null; + } + return { manifestPath, defaultSamples }; + } catch (err) { + throw new Error(`[sim] failed to parse ${manifestPath}: ${err.message}`); + } +} + +function resolveSampleDirs(root, sampleID) { + if (!fs.existsSync(root)) { + return { dirs: [], manifestPath: '' }; + } + + if (sampleID) { + const dir = path.join(root, sampleID); + const ssePath = path.join(dir, 'upstream.stream.sse'); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory() || !fs.existsSync(ssePath)) { + throw new Error(`[sim] sample missing: ${sampleID}`); + } + return { dirs: [dir], manifestPath: '' }; + } + + const manifest = loadManifest(root); + if (manifest) { + const dirs = []; + const missing = []; + for (const sampleID of manifest.defaultSamples) { + const dir = path.join(root, sampleID); + const ssePath = path.join(dir, 'upstream.stream.sse'); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory() || !fs.existsSync(ssePath)) { + missing.push(sampleID); + continue; + } + dirs.push(dir); + } + if (missing.length > 0) { + throw new Error(`[sim] manifest sample(s) missing: ${missing.join(', ')}`); + } + return { dirs, manifestPath: manifest.manifestPath }; + } + + const dirs = fs.readdirSync(root) .map((name) => path.join(root, name)) .filter((p) => fs.statSync(p).isDirectory()) .filter((p) => fs.existsSync(path.join(p, 'upstream.stream.sse'))) .sort(); + return { dirs, manifestPath: '' }; } function parseSSE(raw) { @@ -65,7 +135,46 @@ function parseSSE(raw) { return events; } -function replaySample(raw) { +function collectVisibleText(value) { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + let out = ''; + for (const item of value) { + out += collectVisibleText(item); + } + return out; + } + if (typeof value !== 'object') { + return ''; + } + let out = ''; + if (typeof value.reasoning_content === 'string') { + out += value.reasoning_content; + } + if (Object.prototype.hasOwnProperty.call(value, 'text')) { + out += collectVisibleText(value.text); + } + if (Object.prototype.hasOwnProperty.call(value, 'content')) { + out += collectVisibleText(value.content); + } + if (Object.prototype.hasOwnProperty.call(value, 'output_text')) { + out += collectVisibleText(value.output_text); + } + if (Object.prototype.hasOwnProperty.call(value, 'message')) { + out += collectVisibleText(value.message); + } + if (Object.prototype.hasOwnProperty.call(value, 'delta')) { + out += collectVisibleText(value.delta); + } + return out; +} + +function parseDeepSeekReplay(raw) { const events = parseSSE(raw); let currentType = 'thinking'; let sawFinish = false; @@ -100,14 +209,239 @@ function replaySample(raw) { events: events.length, parsedChunks, sawFinish, + outputText, + outputChars: outputText.length, leakedFinishedText: outputText.includes('FINISHED'), + leakedReferenceMarkers: /\[reference:/i.test(outputText), + referenceLeakCount: (outputText.match(/\[reference:/gi) || []).length, + }; +} + +function parseOpenAIStream(raw) { + const events = parseSSE(raw); + let outputText = ''; + let parsedChunks = 0; + let sawFinish = false; + + for (const evt of events) { + if (evt.event === 'finish') { + sawFinish = true; + } + if (!evt.payload || evt.payload === '[DONE]' || evt.payload[0] !== '{') { + continue; + } + let obj; + try { + obj = JSON.parse(evt.payload); + } catch { + continue; + } + parsedChunks += 1; + if (Array.isArray(obj.choices)) { + for (const choice of obj.choices) { + if (!choice || typeof choice !== 'object') { + continue; + } + if (choice.finish_reason) { + sawFinish = true; + } + if (choice.delta) { + outputText += collectVisibleText(choice.delta); + } + if (choice.message) { + outputText += collectVisibleText(choice.message); + } + } + } else { + outputText += collectVisibleText(obj); + } + } + + return { + events: events.length, + parsedChunks, + sawFinish, + outputText, outputChars: outputText.length, }; } +function parseOpenAIJSON(raw) { + let obj; + try { + obj = JSON.parse(raw); + } catch { + return { + parsedChunks: 0, + sawFinish: false, + outputText: '', + outputChars: 0, + }; + } + let outputText = ''; + let sawFinish = false; + if (typeof obj.output_text === 'string') { + outputText += obj.output_text; + } + if (Array.isArray(obj.output)) { + for (const item of obj.output) { + outputText += collectVisibleText(item); + } + } + if (Array.isArray(obj.choices)) { + for (const choice of obj.choices) { + if (!choice || typeof choice !== 'object') { + continue; + } + if (choice.finish_reason) { + sawFinish = true; + } + if (choice.message) { + outputText += collectVisibleText(choice.message); + } + if (choice.delta) { + outputText += collectVisibleText(choice.delta); + } + } + } + return { + parsedChunks: 1, + sawFinish, + outputText, + outputChars: outputText.length, + }; +} + +function loadBaselineSample(dir, baselineRoot) { + const sampleID = path.basename(dir); + const roots = []; + if (baselineRoot) { + roots.push(path.join(baselineRoot, sampleID)); + } + roots.push(dir); + + for (const root of roots) { + const textPath = path.join(root, 'replay.output.txt'); + if (fs.existsSync(textPath)) { + return { + path: textPath, + kind: 'text', + raw: fs.readFileSync(textPath, 'utf8'), + }; + } + const legacyTextPath = path.join(root, 'openai.output.txt'); + if (fs.existsSync(legacyTextPath)) { + return { + path: legacyTextPath, + kind: 'text', + raw: fs.readFileSync(legacyTextPath, 'utf8'), + }; + } + const streamPath = path.join(root, 'openai.stream.sse'); + if (fs.existsSync(streamPath)) { + return { + path: streamPath, + kind: 'stream', + raw: fs.readFileSync(streamPath, 'utf8'), + }; + } + const jsonPath = path.join(root, 'openai.response.json'); + if (fs.existsSync(jsonPath)) { + return { + path: jsonPath, + kind: 'json', + raw: fs.readFileSync(jsonPath, 'utf8'), + }; + } + } + return null; +} + +function replaySample(dir, opts) { + const raw = fs.readFileSync(path.join(dir, 'upstream.stream.sse'), 'utf8'); + const rawResult = parseDeepSeekReplay(raw); + + let replayOutputPath = ''; + if (opts.outputRoot) { + const sampleOutputDir = path.join(opts.outputRoot, path.basename(dir)); + fs.mkdirSync(sampleOutputDir, { recursive: true }); + replayOutputPath = path.join(sampleOutputDir, 'replay.output.txt'); + fs.writeFileSync(replayOutputPath, rawResult.outputText); + } + + const baseline = loadBaselineSample(dir, opts.baselineRoot); + const baselineResult = baseline + ? (baseline.kind === 'text' + ? { + events: 0, + parsedChunks: 0, + sawFinish: false, + outputText: baseline.raw, + outputChars: baseline.raw.length, + } + : baseline.kind === 'stream' + ? parseOpenAIStream(baseline.raw) + : parseOpenAIJSON(baseline.raw)) + : null; + const baselineMatch = baselineResult ? baselineResult.outputText === rawResult.outputText : null; + const baselinePreview = baselineResult ? previewText(baselineResult.outputText, 280) : ''; + const errors = []; + + if (opts.failOnMissingFinish && !rawResult.sawFinish) { + errors.push('missing finish signal'); + } + if (opts.failOnLeak && rawResult.leakedFinishedText) { + errors.push('FINISHED leaked into output text'); + } + if (opts.failOnReferenceLeak && rawResult.leakedReferenceMarkers) { + errors.push('reference markers leaked into output text'); + } + if (baselineResult && opts.failOnBaselineMismatch && !baselineMatch) { + errors.push('baseline output mismatch'); + } + + return { + sample_id: path.basename(dir), + raw_events: rawResult.events, + raw_parsed_chunks: rawResult.parsedChunks, + raw_saw_finish: rawResult.sawFinish, + raw_output_chars: rawResult.outputChars, + raw_leaked_finished_text: rawResult.leakedFinishedText, + raw_leaked_reference_markers: rawResult.leakedReferenceMarkers, + raw_reference_leak_count: rawResult.referenceLeakCount, + baseline_available: Boolean(baselineResult), + baseline_path: baseline ? baseline.path : '', + baseline_kind: baseline ? baseline.kind : '', + baseline_parsed_chunks: baselineResult ? baselineResult.parsedChunks : 0, + baseline_saw_finish: baselineResult ? baselineResult.sawFinish : false, + baseline_output_chars: baselineResult ? baselineResult.outputChars : 0, + baseline_output_matches_replay: baselineResult ? baselineMatch : null, + baseline_output_preview: baselinePreview, + ok: errors.length === 0, + errors, + replay_output_text: rawResult.outputText, + replay_output_path: replayOutputPath, + baseline_output_text: baselineResult ? baselineResult.outputText : '', + }; +} + +function previewText(text, limit) { + if (!text) { + return ''; + } + if (text.length <= limit) { + return text; + } + return `${text.slice(0, limit)}...`; +} + function main() { const opts = parseArgs(process.argv); - const dirs = findSampleDirs(opts.samplesRoot); + if (!opts.outputRoot && opts.writeReplayText) { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + opts.outputRoot = path.join('artifacts/raw-stream-sim', `adhoc-${stamp}`); + } + const { dirs, manifestPath } = resolveSampleDirs(opts.samplesRoot, opts.sampleId); if (dirs.length === 0) { console.error(`[sim] no samples found: ${opts.samplesRoot}`); process.exit(1); @@ -116,38 +450,64 @@ function main() { const report = { generated_at: new Date().toISOString(), samples_root: opts.samplesRoot, + manifest_path: manifestPath, + output_root: opts.outputRoot, + baseline_root: opts.baselineRoot, + sample_id: opts.sampleId, total: dirs.length, failed: 0, samples: [], }; + if (manifestPath) { + console.log(`[sim] using manifest ${manifestPath} samples=${dirs.length}`); + } + for (const dir of dirs) { - const sampleID = path.basename(dir); - const raw = fs.readFileSync(path.join(dir, 'upstream.stream.sse'), 'utf8'); - const r = replaySample(raw); - const errors = []; - if (opts.failOnMissingFinish && !r.sawFinish) { - errors.push('missing finish signal'); - } - if (opts.failOnLeak && r.leakedFinishedText) { - errors.push('FINISHED leaked into output text'); - } + const sample = replaySample(dir, opts); + const errors = [...sample.errors]; if (errors.length > 0) { report.failed += 1; } - report.samples.push({ sample_id: sampleID, ...r, ok: errors.length === 0, errors }); + report.samples.push({ + sample_id: sample.sample_id, + raw_events: sample.raw_events, + raw_parsed_chunks: sample.raw_parsed_chunks, + raw_saw_finish: sample.raw_saw_finish, + raw_output_chars: sample.raw_output_chars, + raw_leaked_finished_text: sample.raw_leaked_finished_text, + raw_leaked_reference_markers: sample.raw_leaked_reference_markers, + raw_reference_leak_count: sample.raw_reference_leak_count, + baseline_available: sample.baseline_available, + baseline_path: sample.baseline_path, + baseline_kind: sample.baseline_kind, + baseline_parsed_chunks: sample.baseline_parsed_chunks, + baseline_saw_finish: sample.baseline_saw_finish, + baseline_output_chars: sample.baseline_output_chars, + baseline_output_matches_replay: sample.baseline_output_matches_replay, + baseline_output_preview: sample.baseline_output_preview, + replay_output_path: sample.replay_output_path, + ok: errors.length === 0, + errors, + }); + + const status = sample.ok ? 'OK' : 'FAIL'; + const leakNote = sample.raw_leaked_reference_markers ? ` refLeaks=${sample.raw_reference_leak_count}` : ''; + const matchNote = sample.baseline_available + ? ` baseline=${sample.baseline_output_matches_replay ? 'match' : 'mismatch'}` + : ' baseline=missing'; + const note = errors.length > 0 ? ` errors=${errors.join(';')}` : ''; + console.log(`[sim] ${status} ${sample.sample_id} events=${sample.raw_events} parsed=${sample.raw_parsed_chunks} chars=${sample.raw_output_chars}${leakNote}${matchNote}${note}`); + if (opts.showOutput) { + console.log(`[sim] replay output for ${sample.sample_id}:`); + console.log(sample.replay_output_text || '(empty)'); + } } if (opts.reportPath) { fs.writeFileSync(opts.reportPath, JSON.stringify(report, null, 2)); } - for (const s of report.samples) { - const status = s.ok ? 'OK' : 'FAIL'; - const note = s.errors.length > 0 ? ` errors=${s.errors.join(';')}` : ''; - console.log(`[sim] ${status} ${s.sample_id} events=${s.events} parsed=${s.parsedChunks} chars=${s.outputChars}${note}`); - } - if (report.failed > 0) { console.error(`[sim] ${report.failed}/${report.total} samples failed`); process.exit(2); diff --git a/webui/package-lock.json b/webui/package-lock.json index f6cb28a..6052205 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -37,9 +37,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -52,9 +52,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -62,21 +62,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -93,14 +93,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -209,27 +209,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -286,18 +286,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -305,9 +305,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -805,9 +805,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -819,9 +819,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -833,9 +833,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -847,9 +847,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -861,9 +861,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -875,9 +875,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -889,9 +889,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -903,9 +903,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -917,9 +917,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -931,9 +931,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -945,9 +945,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -959,9 +959,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], @@ -973,9 +973,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], @@ -987,9 +987,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -1001,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -1015,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -1029,9 +1029,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -1043,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], @@ -1057,9 +1057,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -1071,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -1085,9 +1085,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -1099,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -1113,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -1127,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -1141,9 +1141,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -1256,9 +1256,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "dev": true, "funding": [ { @@ -1277,7 +1277,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", + "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1293,13 +1293,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -1329,9 +1332,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1349,11 +1352,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -1373,9 +1376,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", "dev": true, "funding": [ { @@ -1501,18 +1504,6 @@ } } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1528,9 +1519,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.283", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", - "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", "dev": true, "license": "ISC" }, @@ -1815,280 +1806,6 @@ "node": ">=6" } }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2203,9 +1920,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, @@ -2254,9 +1971,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2287,9 +2004,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -2506,9 +2223,9 @@ } }, "node_modules/react-router": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", - "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2528,12 +2245,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", - "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", "license": "MIT", "dependencies": { - "react-router": "7.13.0" + "react-router": "7.14.0" }, "engines": { "node": ">=20.0.0" @@ -2599,9 +2316,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { @@ -2615,31 +2332,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -2739,9 +2456,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", @@ -2845,9 +2562,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/webui/src/features/settings/AutoDeleteSection.jsx b/webui/src/features/settings/AutoDeleteSection.jsx index 81c091f..4fdf7c2 100644 --- a/webui/src/features/settings/AutoDeleteSection.jsx +++ b/webui/src/features/settings/AutoDeleteSection.jsx @@ -1,6 +1,13 @@ import { Trash2 } from 'lucide-react' export default function AutoDeleteSection({ t, form, setForm }) { + const mode = form.auto_delete?.mode || 'none' + const descKey = mode === 'single' + ? 'settings.autoDeleteSingleDesc' + : mode === 'all' + ? 'settings.autoDeleteAllDesc' + : 'settings.autoDeleteNoneDesc' + return (

@@ -8,28 +15,25 @@ export default function AutoDeleteSection({ t, form, setForm }) {

{t('settings.autoDeleteTitle')}

{t('settings.autoDeleteDesc')}

-
- - +
+
+ ) +} diff --git a/webui/src/features/settings/SettingsContainer.jsx b/webui/src/features/settings/SettingsContainer.jsx index fc51907..3b2da85 100644 --- a/webui/src/features/settings/SettingsContainer.jsx +++ b/webui/src/features/settings/SettingsContainer.jsx @@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm' import SecuritySection from './SecuritySection' import RuntimeSection from './RuntimeSection' import BehaviorSection from './BehaviorSection' +import CompatibilitySection from './CompatibilitySection' import AutoDeleteSection from './AutoDeleteSection' import ModelSection from './ModelSection' import BackupSection from './BackupSection' @@ -94,6 +95,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF + + diff --git a/webui/src/features/settings/useSettingsForm.js b/webui/src/features/settings/useSettingsForm.js index e2f5502..e44c0bc 100644 --- a/webui/src/features/settings/useSettingsForm.js +++ b/webui/src/features/settings/useSettingsForm.js @@ -13,9 +13,10 @@ const MAX_AUTO_FETCH_FAILURES = 3 const DEFAULT_FORM = { admin: { jwt_expire_hours: 24 }, runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6 }, + compat: { strip_reference_markers: true }, responses: { store_ttl_seconds: 900 }, embeddings: { provider: '' }, - auto_delete: { sessions: false }, + auto_delete: { mode: 'none' }, claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}', model_aliases_text: '{}', } @@ -37,6 +38,17 @@ function parseJSONMap(raw, fieldName, t) { return parsed } +function normalizeAutoDeleteMode(raw) { + const mode = String(raw?.mode || '').trim().toLowerCase() + if (mode === 'none' || mode === 'single' || mode === 'all') { + return mode + } + if (Boolean(raw?.sessions)) { + return 'all' + } + return 'none' +} + function fromServerForm(data) { return { admin: { jwt_expire_hours: Number(data.admin?.jwt_expire_hours || 24) }, @@ -46,6 +58,9 @@ function fromServerForm(data) { global_max_inflight: Number(data.runtime?.global_max_inflight || 10), token_refresh_interval_hours: Number(data.runtime?.token_refresh_interval_hours || 6), }, + compat: { + strip_reference_markers: data.compat?.strip_reference_markers ?? true, + }, responses: { store_ttl_seconds: Number(data.responses?.store_ttl_seconds || 900), }, @@ -53,7 +68,7 @@ function fromServerForm(data) { provider: data.embeddings?.provider || '', }, auto_delete: { - sessions: Boolean(data.auto_delete?.sessions || false), + mode: normalizeAutoDeleteMode(data.auto_delete), }, claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2), model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), @@ -69,9 +84,12 @@ function toServerPayload(form) { global_max_inflight: Number(form.runtime.global_max_inflight), token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours), }, + compat: { + strip_reference_markers: Boolean(form.compat?.strip_reference_markers ?? true), + }, responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) }, embeddings: { provider: String(form.embeddings.provider || '').trim() }, - auto_delete: { sessions: Boolean(form.auto_delete?.sessions) }, + auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) }, } } diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 9746d0f..1047134 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -141,7 +141,7 @@ "deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.", "deleteAllSessionsSuccess": "Successfully deleted all sessions", "envModeRiskTitle": "Environment-variable config mode detected (persistence risk)", - "envModeRiskDesc": "Detected DS2API_CONFIG_JSON/CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.", + "envModeRiskDesc": "Detected DS2API_CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.", "envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)", "envModeWritebackActiveTitle": "Env mode + auto-persistence active", "envModeWritebackDesc": "The app will auto-create/write the config file and transition to file-backed mode. Current persistence path: {path}" @@ -236,13 +236,22 @@ "behaviorTitle": "Behavior", "responsesTTL": "Responses store TTL (seconds)", "embeddingsProvider": "Embeddings provider", + "compatibilityTitle": "Compatibility", + "compatibilityDesc": "Compatibility controls that keep stream output closer to the wire format or safer for the web UI.", + "stripReferenceMarkers": "Strip [reference:N] markers", "modelTitle": "Model mapping", "claudeMapping": "Claude mapping (JSON)", "modelAliases": "Model aliases (JSON)", - "autoDeleteTitle": "Auto Delete Sessions", - "autoDeleteDesc": "When enabled, all sessions will be automatically deleted after each request completes.", - "autoDeleteSessions": "Auto delete sessions", - "autoDeleteWarning": "Warning: Enabling this will delete all session history after each request. Use with caution.", + "autoDeleteTitle": "Session Cleanup Policy", + "autoDeleteDesc": "Choose how DeepSeek remote chat records are cleaned up after each request completes.", + "autoDeleteMode": "Deletion mode", + "autoDeleteNone": "Do not delete", + "autoDeleteSingle": "Delete current session", + "autoDeleteAll": "Delete all sessions", + "autoDeleteNoneDesc": "Keep the remote session after the request completes.", + "autoDeleteSingleDesc": "Delete only the remote session created by this request.", + "autoDeleteAllDesc": "Delete every remote session for the account after the request completes.", + "autoDeleteWarning": "This mode deletes remote chat records. Use with caution.", "backupTitle": "Backup & Restore", "loadExport": "Load current export", "downloadExport": "Download backup file", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 275cca0..11895c6 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -141,7 +141,7 @@ "deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。", "deleteAllSessionsSuccess": "删除成功", "envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)", - "envModeRiskDesc": "检测到 DS2API_CONFIG_JSON/CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。", + "envModeRiskDesc": "检测到 DS2API_CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。", "envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)", "envModeWritebackActiveTitle": "环境变量模式 + 自动持久化已生效", "envModeWritebackDesc": "程序会自动创建/写入配置文件并在后续切换为文件模式。当前持久化路径:{path}" @@ -236,13 +236,22 @@ "behaviorTitle": "行为设置", "responsesTTL": "Responses 缓存 TTL(秒)", "embeddingsProvider": "Embeddings Provider", + "compatibilityTitle": "兼容性设置", + "compatibilityDesc": "用于控制输出格式兼容性,避免把模型原始流里的标记直接暴露到前端。", + "stripReferenceMarkers": "移除 [reference:N] 标记", "modelTitle": "模型映射", "claudeMapping": "Claude 映射(JSON)", "modelAliases": "模型别名(JSON)", - "autoDeleteTitle": "自动删除会话", - "autoDeleteDesc": "开启后,每次请求完成后会自动删除该账号的所有会话记录。", - "autoDeleteSessions": "自动删除会话", - "autoDeleteWarning": "开启此功能后,每次请求完成都会删除该账号的所有历史会话,请谨慎使用。", + "autoDeleteTitle": "会话删除策略", + "autoDeleteDesc": "选择每次请求完成后如何清理 DeepSeek 远端聊天记录。", + "autoDeleteMode": "删除模式", + "autoDeleteNone": "不开启删除", + "autoDeleteSingle": "仅删除当前会话", + "autoDeleteAll": "删除全部会话", + "autoDeleteNoneDesc": "请求结束后保留远端会话,不自动删除。", + "autoDeleteSingleDesc": "请求结束后只删除本次请求创建的远端会话。", + "autoDeleteAllDesc": "请求结束后清空该账号的全部远端会话。", + "autoDeleteWarning": "当前模式会删除远端聊天记录,请谨慎使用。", "backupTitle": "备份与恢复", "loadExport": "加载当前导出", "downloadExport": "下载备份文件",