Compare commits

...

33 Commits

Author SHA1 Message Date
CJACK.
d2c6445cfc Merge pull request #284 from CJackHwang/dev
非常大的更新(3.6.0)
2026-04-23 08:18:37 +08:00
CJACK.
b6fba47bcf feat: prepend strong instruction override to history prompt to ensure context adherence 2026-04-22 20:53:35 +00:00
CJACK.
e8d1aee7ad chore: update gitignore and documentation files 2026-04-22 20:23:32 +00:00
CJACK.
5cf56e7628 fix: reset tool call state between separate tool blocks to ensure unique IDs across stream segments 2026-04-22 20:10:06 +00:00
CJACK.
c291d333c4 feat: extract and inject assistant reasoning content into history split prompts 2026-04-22 19:56:28 +00:00
CJACK.
2788e20f05 feat: implement history split functionality to optimize context usage and add corresponding UI settings 2026-04-22 18:23:09 +00:00
CJACK.
f178000d69 docs: clarify config template synchronization and archive contents in README files 2026-04-22 17:36:18 +00:00
CJACK.
e840743295 refactor: centralize batch import templates and enable config file access in Vite 2026-04-22 17:30:39 +00:00
CJACK.
77484bf813 feat: add account editing functionality with UI modal and backend handler 2026-04-22 17:20:44 +00:00
CJACK.
f14969eca5 feat: implement API key metadata preservation and make chat history migration best-effort 2026-04-22 16:59:10 +00:00
CJACK.
fe8a6bd3cd refactor: improve chat history persistence reliability with metadata-only migration, error handling, and optimized file updates 2026-04-22 16:22:04 +00:00
CJACK.
797ab77873 Merge pull request #279 from CJackHwang/codex/add-api-key-name-handling-in-webui
feat(account): add structured API key and account name/remark support
2026-04-22 23:52:54 +08:00
CJACK.
8f09e3b381 feat: implement API key management with reconciliation and add update key endpoint 2026-04-22 15:51:43 +00:00
CJACK.
3a79b07d33 Merge pull request #282 from livesRan/fix/citation-link-mapping-pr
修复搜索场景下 citation 标签偶发未替换问题(FINISHED 后继续收集引用元数据)
2026-04-22 23:31:32 +08:00
CJACK.
df13f35f43 Merge pull request #281 from ouqiting/main
给webui新增“对话记录”
2026-04-22 23:30:59 +08:00
ouqiting
4422f989be fix: satisfy staticcheck QF1007 2026-04-22 20:28:08 +08:00
songguoliang
6052a8d1e2 修复搜索场景 citation 偶发未替换 2026-04-22 19:03:07 +08:00
ouqiting
f125c7ab83 增加“对话记录” 2026-04-22 15:17:10 +08:00
CJACK.
8ff923cd77 feat(account): add key/account name and remark metadata 2026-04-22 01:43:20 +08:00
CJACK.
e9a544cc53 Merge pull request #276 from CJackHwang/dev
Fix citation link mapping for duplicate URLs and unstable cite_index
2026-04-21 18:46:10 +08:00
CJACK.
d848d24a82 Initialize LICENSE file
更新为AGPL3.0
2026-04-21 11:53:38 +08:00
CJACK.
0a2fc42dad Update VERSION 2026-04-21 11:43:36 +08:00
CJACK.
e615f1710f Merge pull request #275 from livesRan/fix/citation-link-mapping-pr
修复重复 URL 且 cite_index 不稳定时 citation 映射不完整的问题
2026-04-21 11:42:41 +08:00
songguoliang
8f01aa224c 本次修复了搜索场景下 citation 标签未完全映射的问题。根因是 citation 顺序收集阶段对 URL 做了去重,导致当上游返回重复来源且 cite_index 缺失或不稳定时,位置索引被压缩,部分 [citation:x] 无法找到对应链接。修复后改为保留上游结果的原始顺序(包括重复 URL),从而保证按位置回填 citation 时不会丢号。 2026-04-21 10:52:17 +08:00
CJACK.
31e64ff31d Merge pull request #272 from CJackHwang/dev
Remove outdated architecture documentation and improve citation parsing
2026-04-20 18:38:26 +08:00
CJACK.
5984802df4 Merge pull request #273 from CJackHwang/codex/fix-citation-index-normalization-issue
Fix zero-based citation index normalization
2026-04-20 18:35:46 +08:00
CJACK.
e0ed4ba238 Handle one-based and zero-based citation indices safely 2026-04-20 18:29:58 +08:00
CJACK.
ae37654893 Fix zero-based citation index normalization 2026-04-20 18:18:00 +08:00
CJACK.
aa7f821151 Bump version from 3.5.0 to 3.5.1 2026-04-20 17:32:05 +08:00
CJACK.
f7426f9f04 Remove detailed backend architecture explanations
Removed detailed descriptions of routing, execution, adapter layers, tool calling, configuration, streaming capabilities, and observability enhancements.
2026-04-20 17:18:37 +08:00
CJACK.
787e034174 Merge pull request #271 from livesRan/citation注释解析
/v1/chat/completions 接口返回报文中出现了[citation:1][citation:2]等未解析的标签,本次改动将返…
2026-04-20 13:06:38 +08:00
songguoliang
d73f7b8b73 /v1/chat/completions 接口返回报文中出现了[citation:1][citation:2]等未解析的标签,本次改动将返回报文中的标签做了解析 2026-04-20 11:22:31 +08:00
CJACK
b8d844e2f6 docs: remove outdated 3.X architecture documentation from README files 2026-04-20 01:44:58 +08:00
88 changed files with 6466 additions and 398 deletions

5
.gitignore vendored
View File

@@ -62,3 +62,8 @@ CLAUDE.local.md
# Local tool bootstrap cache
.tmp/
# Chat history
data/
.codex
.roomodes

View File

@@ -31,7 +31,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl
| Base URL | `http://localhost:5001` or your deployment domain |
| Default Content-Type | `application/json` |
| Health probes | `GET /healthz`, `GET /readyz` |
| CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass`) |
| CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`) |
### 3.0 Adapter-Layer Notes
@@ -130,7 +130,8 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
| POST | `/admin/settings/password` | Admin | Update admin password and invalidate old JWTs |
| POST | `/admin/config/import` | Admin | Import config (merge/replace) |
| GET | `/admin/config/export` | Admin | Export full config (`config`/`json`/`base64`) |
| POST | `/admin/keys` | Admin | Add API key |
| POST | `/admin/keys` | Admin | Add API key (optional `name`/`remark`) |
| PUT | `/admin/keys/{key}` | Admin | Update API key metadata |
| DELETE | `/admin/keys/{key}` | Admin | Delete API key |
| GET | `/admin/proxies` | Admin | List proxies |
| POST | `/admin/proxies` | Admin | Add proxy |
@@ -139,6 +140,7 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
| POST | `/admin/proxies/test` | Admin | Test proxy connectivity |
| GET | `/admin/accounts` | Admin | Paginated account list |
| POST | `/admin/accounts` | Admin | Add account |
| PUT | `/admin/accounts/{identifier}` | Admin | Update account name/remark |
| DELETE | `/admin/accounts/{identifier}` | Admin | Delete account |
| PUT | `/admin/accounts/{identifier}/proxy` | Admin | Bind/unbind proxy for an account |
| GET | `/admin/queue/status` | Admin | Account queue status |
@@ -156,6 +158,10 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
| GET | `/admin/export` | Admin | Export config JSON/Base64 |
| GET | `/admin/dev/captures` | Admin | Read local packet-capture entries |
| DELETE | `/admin/dev/captures` | Admin | Clear local packet-capture entries |
| GET | `/admin/chat-history` | Admin | Read server-side conversation history |
| DELETE | `/admin/chat-history` | Admin | Clear server-side conversation history |
| DELETE | `/admin/chat-history/{id}` | Admin | Delete one server-side conversation entry |
| PUT | `/admin/chat-history/settings` | Admin | Update conversation history retention limit |
| GET | `/admin/version` | Admin | Check current version and latest Release |
---
@@ -643,11 +649,15 @@ Returns Vercel preconfiguration status.
### `GET /admin/config`
Returns sanitized config.
Returns sanitized config, including both `keys` and `api_keys`.
```json
{
"keys": ["k1", "k2"],
"api_keys": [
{"key": "k1", "name": "Primary", "remark": "Production"},
{"key": "k2", "name": "Backup", "remark": "Load test"}
],
"env_backed": false,
"env_source_present": true,
"env_writeback_enabled": true,
@@ -671,13 +681,18 @@ Returns sanitized config.
### `POST /admin/config`
Only updates `keys`, `accounts`, and `claude_mapping`.
Only updates `keys`, `api_keys`, `accounts`, and `claude_mapping`.
If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback.
**Request**:
```json
{
"keys": ["k1", "k2"],
"api_keys": [
{"key": "k1", "name": "Primary", "remark": "Production"},
{"key": "k2", "name": "Backup", "remark": "Load test"}
],
"accounts": [
{"email": "user@example.com", "password": "pwd", "token": ""}
],
@@ -737,7 +752,7 @@ Imports full config with:
The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`.
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.
Import accepts `keys`, `api_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`.
@@ -748,7 +763,17 @@ Exports full config in three forms: `config`, `json`, and `base64`.
### `POST /admin/keys`
```json
{"key": "new-api-key"}
{"key": "new-api-key", "name": "Primary", "remark": "Production"}
```
**Response**: `{"success": true, "total_keys": 3}`
### `PUT /admin/keys/{key}`
Updates the `name` / `remark` of the specified API key. The path `key` is read-only and cannot be changed.
```json
{"name": "Backup", "remark": "Load test"}
```
**Response**: `{"success": true, "total_keys": 3}`
@@ -819,6 +844,16 @@ Returned items also include `test_status`, usually `ok` or `failed`.
**Response**: `{"success": true, "total_accounts": 6}`
### `PUT /admin/accounts/{identifier}`
Updates the `name` / `remark` of the specified account. The path `identifier` can be email or mobile and cannot be changed.
```json
{"name": "Primary account", "remark": "Shared with the team"}
```
**Response**: `{"success": true, "total_accounts": 6}`
### `DELETE /admin/accounts/{identifier}`
`identifier` can be email, mobile, or the synthetic id for token-only accounts (`token:<hash>`).

52
API.md
View File

@@ -31,7 +31,7 @@
| Base URL | `http://localhost:5001` 或你的部署域名 |
| 默认 Content-Type | `application/json` |
| 健康检查 | `GET /healthz``GET /readyz` |
| CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass` |
| CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass` |
### 3.0 接口适配层说明
@@ -130,7 +130,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
| POST | `/admin/settings/password` | Admin | 更新 Admin 密码并使旧 JWT 失效 |
| POST | `/admin/config/import` | Admin | 导入配置merge/replace |
| GET | `/admin/config/export` | Admin | 导出完整配置(含 `config`/`json`/`base64` |
| POST | `/admin/keys` | Admin | 添加 API key |
| POST | `/admin/keys` | Admin | 添加 API key(可附 name/remark |
| PUT | `/admin/keys/{key}` | Admin | 更新 API key 备注信息 |
| DELETE | `/admin/keys/{key}` | Admin | 删除 API key |
| GET | `/admin/proxies` | Admin | 代理列表 |
| POST | `/admin/proxies` | Admin | 添加代理 |
@@ -139,6 +140,7 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
| POST | `/admin/proxies/test` | Admin | 测试代理连通性 |
| GET | `/admin/accounts` | Admin | 分页账号列表 |
| POST | `/admin/accounts` | Admin | 添加账号 |
| PUT | `/admin/accounts/{identifier}` | Admin | 更新账号 name/remark |
| DELETE | `/admin/accounts/{identifier}` | Admin | 删除账号 |
| PUT | `/admin/accounts/{identifier}/proxy` | Admin | 为账号绑定/解绑代理 |
| GET | `/admin/queue/status` | Admin | 账号队列状态 |
@@ -156,6 +158,10 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
| GET | `/admin/export` | Admin | 导出配置 JSON/Base64 |
| GET | `/admin/dev/captures` | Admin | 查看本地抓包记录 |
| DELETE | `/admin/dev/captures` | Admin | 清空本地抓包记录 |
| GET | `/admin/chat-history` | Admin | 查看服务器端对话记录 |
| DELETE | `/admin/chat-history` | Admin | 清空服务器端对话记录 |
| DELETE | `/admin/chat-history/{id}` | Admin | 删除单条服务器端对话记录 |
| PUT | `/admin/chat-history/settings` | Admin | 更新对话记录保留条数 |
| GET | `/admin/version` | Admin | 查询当前版本与最新 Release |
---
@@ -644,11 +650,15 @@ data: {"type":"message_stop"}
### `GET /admin/config`
返回脱敏后的配置。
返回脱敏后的配置,包含 `keys``api_keys`
```json
{
"keys": ["k1", "k2"],
"api_keys": [
{"key": "k1", "name": "主 Key", "remark": "生产流量"},
{"key": "k2", "name": "备用 Key", "remark": "压测"}
],
"env_backed": false,
"env_source_present": true,
"env_writeback_enabled": true,
@@ -672,13 +682,18 @@ data: {"type":"message_stop"}
### `POST /admin/config`
只更新 `keys``accounts``claude_mapping`
只更新 `keys``api_keys``accounts``claude_mapping`
如果同时发送 `api_keys``keys`,优先保留 `api_keys` 中的结构化 `name` / `remark``keys` 仅作为旧格式兼容回退。
**请求**
```json
{
"keys": ["k1", "k2"],
"api_keys": [
{"key": "k1", "name": "主 Key", "remark": "生产流量"},
{"key": "k2", "name": "备用 Key", "remark": "压测"}
],
"accounts": [
{"email": "user@example.com", "password": "pwd", "token": ""}
],
@@ -738,7 +753,7 @@ data: {"type":"message_stop"}
请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。
也支持在查询参数里传 `?mode=merge` / `?mode=replace`
导入时会接受 `keys``accounts``claude_mapping` / `claude_model_mapping``model_aliases``admin``runtime``responses``embeddings``auto_delete` 等字段;`toolcall` 相关字段会被忽略。
导入时会接受 `keys``api_keys``accounts``claude_mapping` / `claude_model_mapping``model_aliases``admin``runtime``responses``embeddings``auto_delete` 等字段;`toolcall` 相关字段会被忽略。
> `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。
@@ -746,10 +761,25 @@ data: {"type":"message_stop"}
导出完整配置,返回 `config``json``base64` 三种格式。
响应示例:
> 注:`_vercel_sync_hash` 和 `_vercel_sync_time` 为内部同步元数据字段,用于 Vercel 配置漂移检测。
### `POST /admin/keys`
```json
{"key": "new-api-key"}
{"key": "new-api-key", "name": "主 Key", "remark": "生产流量"}
```
**响应**`{"success": true, "total_keys": 3}`
### `PUT /admin/keys/{key}`
更新指定 API key 的 `name` / `remark`,路径参数中的 `key` 为只读标识,不可修改。
```json
{"name": "备用 Key", "remark": "压测"}
```
**响应**`{"success": true, "total_keys": 3}`
@@ -818,6 +848,16 @@ data: {"type":"message_stop"}
**响应**`{"success": true, "total_accounts": 6}`
### `PUT /admin/accounts/{identifier}`
更新指定账号的 `name` / `remark`。路径参数中的 `identifier` 可以是 email 或 mobile且不可修改。
```json
{"name": "主账号", "remark": "团队共享"}
```
**响应**`{"success": true, "total_accounts": 6}`
### `DELETE /admin/accounts/{identifier}`
`identifier` 可为 email、mobile或 token-only 账号的合成标识(`token:<hash>`)。

143
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -82,16 +82,6 @@ flowchart LR
- **前端**React 管理台(`webui/`),运行时托管静态构建产物
- **部署**本地运行、Docker、Vercel Serverless、Linux systemd
### 3.X 底层架构调整(相较旧版本)
- **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。
- **统一执行链路**Claude / Gemini 入口先经 `internal/translatorcliproxy` 做协议转换,再进入 `openai.ChatCompletions` 统一处理工具调用与流式语义,最后再转换回原协议响应。
- **适配器分层更清晰**`internal/adapter/{claude,gemini}` 负责入口/出口协议封装,`internal/adapter/openai` 负责核心执行DeepSeek 侧调用只保留在 OpenAI 内核中。
- **Tool Calling 双运行时对齐**Go 侧(`internal/toolcall`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义;当前以 XML/Markup 家族为主(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml 变体)。
- **配置与运行时设置解耦**:静态配置(`config`)与运行时策略(`settings`)通过 Admin API 分离管理,支持热更新和密码轮换失效旧 JWT。
- **流式能力升级**`/v1/responses` 与 `/v1/chat/completions` 共享更一致的工具调用增量输出策略,降低不同 SDK 下的行为差异。
- **可观测与可运维增强**`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。
## 核心能力
| 能力 | 说明 |
@@ -104,7 +94,7 @@ flowchart LR
| DeepSeek PoW | 纯 Go 高性能实现DeepSeekHashV1毫秒级响应 |
| Tool Calling | 防泄漏处理:非代码块高置信特征识别、`delta.tool_calls` 早发、结构化增量输出 |
| Admin API | 配置管理、运行时设置热更新、代理管理、账号测试 / 批量测试、会话清理、导入导出、Vercel 同步、版本检查 |
| WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式) |
| WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式,支持查看服务器端对话记录 |
| 运维探针 | `GET /healthz`(存活)、`GET /readyz`(就绪) |
## 平台兼容矩阵
@@ -185,6 +175,8 @@ cp config.example.json config.json
- 本地运行:直接读取 `config.json`
- Docker / Vercel由 `config.json` 生成 `DS2API_CONFIG_JSON`Base64注入环境变量也可以直接写原始 JSON
WebUI 管理台里的“全量配置模板”也直接复用同一份 `config.example.json`,所以更新示例文件后,前端模板会自动保持一致。
### 方式一:下载 Release 构建包
每次发布 Release 时GitHub Actions 会自动构建多平台二进制包:
@@ -281,8 +273,17 @@ go run ./cmd/ds2api
```json
{
"keys": ["your-api-key-1", "your-api-key-2"],
"api_keys": [
{
"key": "your-api-key-1",
"name": "主 Key",
"remark": "生产流量"
}
],
"accounts": [
{
"name": "账号 A",
"remark": "主账号",
"email": "user@example.com",
"password": "your-password"
},
@@ -330,7 +331,8 @@ go run ./cmd/ds2api
```
- `keys`API 访问密钥列表,客户端通过 `Authorization: Bearer <key>` 鉴权
- `accounts`DeepSeek 账号列表,支持 `email` `mobile` 登录
- `api_keys`:推荐使用的新结构化密钥列表,支持 `key` + `name` + `remark``keys` 仍兼容)
- `accounts`DeepSeek 账号列表,支持 `email` 或 `mobile` 登录;可额外填写 `name` / `remark` 便于管理
- `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token实际 token 仅在运行时内存中维护并自动刷新
- `model_aliases`:常见模型名(如 GPT/Codex/Claude到 DeepSeek 模型的映射
- `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出)
@@ -345,6 +347,8 @@ go run ./cmd/ds2api
### 环境变量
> 建议:长期维护请优先以 `config.json`(或其 Base64为单一配置源。环境变量仅保留部署必需项`DS2API_CONFIG_JSON` 主要用于 Vercel/无持久盘场景,后续可能进一步收敛。
| 变量 | 用途 | 默认值 |
| --- | --- | --- |
| `PORT` | 服务端口 | `5001` |
@@ -354,6 +358,7 @@ go run ./cmd/ds2api
| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT 过期小时数 | `24` |
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
| `DS2API_CONFIG_JSON` | 直接注入配置JSON 或 Base64 | — |
| `DS2API_CHAT_HISTORY_PATH` | 服务器端对话记录文件路径 | `data/chat_history.json` |
| `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on` | 关闭 |
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启Vercel 关闭 |
@@ -372,6 +377,15 @@ go run ./cmd/ds2api
> 提示:当检测到 `DS2API_CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。
#### 必填 / 可选(按部署方式)
- **所有部署都必填**`DS2API_ADMIN_KEY`
- **配置来源二选一(推荐前者)**
- `config.json` 文件(推荐,持久化更直观)
- `DS2API_CONFIG_JSON`(可选,适合 Vercel支持 JSON 或 Base64
- **仅在环境变量配置模式建议开启**`DS2API_ENV_WRITEBACK=1`(避免管理台改动重启后丢失)
- 其余环境变量均为可选调优项。
## 鉴权模式
调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式:
@@ -501,7 +515,7 @@ go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
- **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发)
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`+ `sha256sums.txt`
- **容器镜像发布**:仅推送到 GHCR`ghcr.io/cjackhwang/ds2api`
- **每个压缩包包含**`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback、配置示例、README、LICENSE
- **每个压缩包包含**`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback`config.example.json` 配置示例、README、LICENSE
## 免责声明

View File

@@ -80,16 +80,6 @@ For the full module-by-module architecture and directory responsibilities, see [
- **Frontend**: React admin panel (`webui/`), served as static build at runtime
- **Deployment**: local run, Docker, Vercel serverless, Linux systemd
### 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.
- **Cleaner adapter boundaries**: `internal/adapter/{claude,gemini}` handles protocol wrappers, while `internal/adapter/openai` remains the execution core; upstream DeepSeek calls are retained only in the OpenAI core.
- **Tool-calling parity across runtimes**: Go (`internal/toolcall`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) share aligned parsing/anti-leak semantics, now centered on XML/Markup-family payloads (`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml variants).
- **Config/runtime separation**: static config (`config`) and runtime policy (`settings`) are managed independently via Admin APIs, enabling hot updates and password rotation with JWT invalidation.
- **Streaming behavior upgrade**: `/v1/responses` and `/v1/chat/completions` now share a more consistent incremental tool-call emission strategy across SDK ecosystems.
- **Improved operability**: `/healthz`, `/readyz`, `/admin/version`, and `/admin/dev/captures` form a tighter post-deploy diagnostics loop.
## Key Capabilities
| Capability | Details |
@@ -102,7 +92,7 @@ For the full module-by-module architecture and directory responsibilities, see [
| DeepSeek PoW | Pure Go high-performance solver (DeepSeekHashV1), ms-level response |
| Tool Calling | Anti-leak handling: non-code-block feature match, early `delta.tool_calls`, structured incremental output |
| Admin API | Config management, runtime settings hot-reload, proxy management, account testing/batch test, session cleanup, import/export, Vercel sync, version check |
| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode) |
| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode, with server-side conversation history) |
| Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) |
## Platform Compatibility Matrix
@@ -183,6 +173,8 @@ Recommended per deployment mode:
- Local run: read `config.json` directly
- Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON`, or paste raw JSON directly
The WebUI admin panels “Full configuration template” is loaded from the same `config.example.json`, so updating that file keeps the frontend template in sync.
### Option 1: Download Release Binaries
GitHub Actions automatically builds multi-platform archives on each Release:
@@ -352,6 +344,7 @@ The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usua
| `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) | — |
| `DS2API_CHAT_HISTORY_PATH` | Server-side conversation history file path | `data/chat_history.json` |
| `DS2API_ENV_WRITEBACK` | Auto-write env-backed config to file and transition to file mode (`1/true/yes/on`) | Disabled |
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel |
@@ -480,7 +473,7 @@ Workflow: `.github/workflows/release-artifacts.yml`
- **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds)
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt`
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), config template, README, LICENSE
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), `config.example.json`-based config template, README, LICENSE
## Disclaimer

View File

@@ -1 +1 @@
3.5.0
3.6.0

View File

@@ -5,14 +5,29 @@
"your-api-key-1",
"your-api-key-2"
],
"api_keys": [
{
"key": "your-api-key-1",
"name": "主 API Key",
"remark": "给 OpenAI 客户端使用"
},
{
"key": "your-api-key-2",
"name": "备用 API Key",
"remark": "压测或临时调试"
}
],
"accounts": [
{
"_comment": "邮箱登录方式",
"name": "主账号",
"remark": "优先用于生产流量",
"email": "example1@example.com",
"password": "your-password-1"
},
{
"_comment": "邮箱登录方式 - 账号2",
"name": "备用账号",
"email": "example2@example.com",
"password": "your-password-2"
},
@@ -34,6 +49,10 @@
"responses": {
"store_ttl_seconds": 900
},
"history_split": {
"enabled": true,
"trigger_after_turns": 1
},
"embeddings": {
"provider": "deterministic"
},

View File

@@ -258,12 +258,22 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` |
| `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` |
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | 默认与 `responses.store_ttl_seconds` 同步,若未设置则为 `900` |
| `VERCEL_TOKEN` | Vercel 同步 token | — |
| `VERCEL_PROJECT_ID` | Vercel 项目 ID | — |
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
| `DS2API_VERCEL_PROTECTION_BYPASS` | 部署保护绕过密钥(内部 Node→Go 调用) | — |
### 3.3 运行时行为配置(通过 Admin API 设置)
部分运行时行为无法通过环境变量直接配置,需要在部署后通过 Admin API 设置,例如:
- **自动删除会话模式** (`auto_delete.mode`):支持 `none` / `single` / `all`,默认为 `none`。可通过 `PUT /admin/settings` 更新。
- **每账号并发上限** (`account_max_inflight`):环境变量已支持,但也可通过 Admin API 热更新。
- **全局并发上限** (`global_max_inflight`):同上。
详细说明参见 [API.md](../API.md#admin-接口) 中 `/admin/settings` 部分。
### 3.3 Vercel 架构说明
```text

View File

@@ -0,0 +1,250 @@
package openai
import (
"errors"
"net/http"
"strings"
"time"
"ds2api/internal/auth"
"ds2api/internal/chathistory"
"ds2api/internal/config"
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/prompt"
"ds2api/internal/util"
)
const adminWebUISourceHeader = "X-Ds2-Source"
const adminWebUISourceValue = "admin-webui-api-tester"
type chatHistorySession struct {
store *chathistory.Store
entryID string
startedAt time.Time
lastPersist time.Time
finalPrompt string
startParams chathistory.StartParams
disabled bool
}
func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.RequestAuth, stdReq util.StandardRequest) *chatHistorySession {
if store == nil || r == nil || a == nil {
return nil
}
if !store.Enabled() {
return nil
}
if !shouldCaptureChatHistory(r) {
return nil
}
entry, err := store.Start(chathistory.StartParams{
CallerID: strings.TrimSpace(a.CallerID),
AccountID: strings.TrimSpace(a.AccountID),
Model: strings.TrimSpace(stdReq.ResponseModel),
Stream: stdReq.Stream,
UserInput: extractSingleUserInput(stdReq.Messages),
Messages: extractAllMessages(stdReq.Messages),
FinalPrompt: stdReq.FinalPrompt,
})
startParams := chathistory.StartParams{
CallerID: strings.TrimSpace(a.CallerID),
AccountID: strings.TrimSpace(a.AccountID),
Model: strings.TrimSpace(stdReq.ResponseModel),
Stream: stdReq.Stream,
UserInput: extractSingleUserInput(stdReq.Messages),
Messages: extractAllMessages(stdReq.Messages),
FinalPrompt: stdReq.FinalPrompt,
}
session := &chatHistorySession{
store: store,
entryID: entry.ID,
startedAt: time.Now(),
lastPersist: time.Now(),
finalPrompt: stdReq.FinalPrompt,
startParams: startParams,
}
if err != nil {
if entry.ID == "" {
config.Logger.Warn("[chat_history] start failed", "error", err)
return nil
}
config.Logger.Warn("[chat_history] start persisted in memory after write failure", "error", err)
}
return session
}
func shouldCaptureChatHistory(r *http.Request) bool {
if r == nil {
return false
}
if isVercelStreamPrepareRequest(r) || isVercelStreamReleaseRequest(r) {
return false
}
return strings.TrimSpace(r.Header.Get(adminWebUISourceHeader)) != adminWebUISourceValue
}
func extractSingleUserInput(messages []any) string {
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
if role != "user" {
continue
}
if normalized := strings.TrimSpace(prompt.NormalizeContent(msg["content"])); normalized != "" {
return normalized
}
}
return ""
}
func extractAllMessages(messages []any) []chathistory.Message {
out := make([]chathistory.Message, 0, len(messages))
for _, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
content := strings.TrimSpace(prompt.NormalizeContent(msg["content"]))
if role == "" || content == "" {
continue
}
out = append(out, chathistory.Message{
Role: role,
Content: content,
})
}
return out
}
func (s *chatHistorySession) progress(thinking, content string) {
if s == nil || s.store == nil || s.disabled {
return
}
now := time.Now()
if now.Sub(s.lastPersist) < 250*time.Millisecond {
return
}
s.lastPersist = now
s.persistUpdate(chathistory.UpdateParams{
Status: "streaming",
ReasoningContent: thinking,
Content: content,
StatusCode: http.StatusOK,
ElapsedMs: time.Since(s.startedAt).Milliseconds(),
})
}
func (s *chatHistorySession) success(statusCode int, thinking, content, finishReason string, usage map[string]any) {
if s == nil || s.store == nil || s.disabled {
return
}
s.persistUpdate(chathistory.UpdateParams{
Status: "success",
ReasoningContent: thinking,
Content: content,
StatusCode: statusCode,
ElapsedMs: time.Since(s.startedAt).Milliseconds(),
FinishReason: finishReason,
Usage: usage,
Completed: true,
})
}
func (s *chatHistorySession) error(statusCode int, message, finishReason, thinking, content string) {
if s == nil || s.store == nil || s.disabled {
return
}
s.persistUpdate(chathistory.UpdateParams{
Status: "error",
ReasoningContent: thinking,
Content: content,
Error: message,
StatusCode: statusCode,
ElapsedMs: time.Since(s.startedAt).Milliseconds(),
FinishReason: finishReason,
Completed: true,
})
}
func (s *chatHistorySession) stopped(thinking, content, finishReason string) {
if s == nil || s.store == nil || s.disabled {
return
}
s.persistUpdate(chathistory.UpdateParams{
Status: "stopped",
ReasoningContent: thinking,
Content: content,
StatusCode: http.StatusOK,
ElapsedMs: time.Since(s.startedAt).Milliseconds(),
FinishReason: finishReason,
Usage: openaifmt.BuildChatUsage(s.finalPrompt, thinking, content),
Completed: true,
})
}
func (s *chatHistorySession) retryMissingEntry() bool {
if s == nil || s.store == nil || s.disabled {
return false
}
entry, err := s.store.Start(s.startParams)
if errors.Is(err, chathistory.ErrDisabled) {
s.disabled = true
return false
}
if entry.ID == "" {
if err != nil {
config.Logger.Warn("[chat_history] recreate missing entry failed", "error", err)
}
return false
}
s.entryID = entry.ID
if err != nil {
config.Logger.Warn("[chat_history] recreate missing entry persisted in memory after write failure", "error", err)
}
return true
}
func (s *chatHistorySession) persistUpdate(params chathistory.UpdateParams) {
if s == nil || s.store == nil || s.disabled {
return
}
if _, err := s.store.Update(s.entryID, params); err != nil {
s.handlePersistError(params, err)
}
}
func (s *chatHistorySession) handlePersistError(params chathistory.UpdateParams, err error) {
if err == nil || s == nil {
return
}
if errors.Is(err, chathistory.ErrDisabled) {
s.disabled = true
return
}
if isChatHistoryMissingError(err) {
if s.retryMissingEntry() {
if _, retryErr := s.store.Update(s.entryID, params); retryErr != nil {
if errors.Is(retryErr, chathistory.ErrDisabled) || isChatHistoryMissingError(retryErr) {
s.disabled = true
return
}
config.Logger.Warn("[chat_history] retry after missing entry failed", "error", retryErr)
}
return
}
s.disabled = true
return
}
config.Logger.Warn("[chat_history] update failed", "error", err)
}
func isChatHistoryMissingError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "not found")
}

View File

@@ -0,0 +1,273 @@
package openai
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"ds2api/internal/auth"
"ds2api/internal/chathistory"
"ds2api/internal/util"
)
func newTestChatHistoryStore(t *testing.T) *chathistory.Store {
t.Helper()
store := chathistory.New(filepath.Join(t.TempDir(), "chat_history.json"))
if err := store.Err(); err != nil {
t.Fatalf("chat history store unavailable: %v", err)
}
return store
}
func blockChatHistoryDetailDir(t *testing.T, detailDir string) func() {
t.Helper()
blockedDir := detailDir + ".blocked"
if err := os.RemoveAll(blockedDir); err != nil {
t.Fatalf("remove blocked detail dir failed: %v", err)
}
if err := os.Rename(detailDir, blockedDir); err != nil {
t.Fatalf("move detail dir aside failed: %v", err)
}
if err := os.RemoveAll(detailDir); err != nil {
t.Fatalf("remove blocked detail path failed: %v", err)
}
if err := os.WriteFile(detailDir, []byte("blocked"), 0o644); err != nil {
t.Fatalf("write blocked detail path failed: %v", err)
}
var once sync.Once
return func() {
t.Helper()
once.Do(func() {
if err := os.RemoveAll(detailDir); err != nil {
t.Fatalf("remove blocking detail path failed: %v", err)
}
if err := os.Rename(blockedDir, detailDir); err != nil {
t.Fatalf("restore detail dir failed: %v", err)
}
})
}
}
func TestChatCompletionsNonStreamPersistsHistory(t *testing.T) {
historyStore := newTestChatHistoryStore(t)
h := &Handler{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)},
ChatHistory: historyStore,
}
reqBody := `{"model":"deepseek-chat","messages":[{"role":"system","content":"be precise"},{"role":"user","content":"hi there"},{"role":"assistant","content":"previous answer"}],"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("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one history item, got %d", len(snapshot.Items))
}
item := snapshot.Items[0]
if item.Status != "success" || item.UserInput != "hi there" {
t.Fatalf("unexpected persisted history summary: %#v", item)
}
full, err := historyStore.Get(item.ID)
if err != nil {
t.Fatalf("expected detail item, got %v", err)
}
if full.Content != "hello world" {
t.Fatalf("expected detail content persisted, got %#v", full)
}
if len(full.Messages) != 3 {
t.Fatalf("expected all request messages persisted, got %#v", full.Messages)
}
if full.FinalPrompt == "" {
t.Fatalf("expected final prompt to be persisted")
}
if item.CallerID != "caller:test" {
t.Fatalf("expected caller hash persisted in summary, got %#v", item.CallerID)
}
}
func TestStartChatHistoryRecoversFromTransientWriteFailure(t *testing.T) {
historyStore := newTestChatHistoryStore(t)
restore := blockChatHistoryDetailDir(t, historyStore.DetailDir())
t.Cleanup(restore)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
a := &auth.RequestAuth{
CallerID: "caller:test",
AccountID: "acct:test",
}
stdReq := util.StandardRequest{
ResponseModel: "deepseek-chat",
Stream: true,
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
},
FinalPrompt: "hello",
}
session := startChatHistory(historyStore, req, a, stdReq)
if session == nil {
t.Fatalf("expected session even when initial persistence fails")
}
if session.disabled {
t.Fatalf("expected session to remain active after transient start failure")
}
if session.entryID == "" {
t.Fatalf("expected session entry id to be retained")
}
if err := historyStore.Err(); err != nil {
t.Fatalf("transient start failure should not latch store error: %v", err)
}
session.lastPersist = time.Now().Add(-time.Second)
session.progress("thinking", "partial")
if session.disabled {
t.Fatalf("expected session to remain active after transient update failure")
}
if session.entryID == "" {
t.Fatalf("expected session entry id to remain set after update failure")
}
if err := historyStore.Err(); err != nil {
t.Fatalf("transient update failure should not latch store error: %v", err)
}
restore()
session.success(http.StatusOK, "thinking", "final answer", "stop", map[string]any{"total_tokens": 7})
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed after restore: %v", err)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one persisted item after restore, got %#v", snapshot.Items)
}
full, err := historyStore.Get(session.entryID)
if err != nil {
t.Fatalf("get restored entry failed: %v", err)
}
if full.Status != "success" || full.Content != "final answer" {
t.Fatalf("expected restored entry to persist final success, got %#v", full)
}
}
func TestHandleStreamContextCancelledMarksHistoryStopped(t *testing.T) {
historyStore := newTestChatHistoryStore(t)
entry, err := historyStore.Start(chathistory.StartParams{
CallerID: "caller:test",
Model: "deepseek-chat",
Stream: true,
UserInput: "hello",
})
if err != nil {
t.Fatalf("start history failed: %v", err)
}
session := &chatHistorySession{
store: historyStore,
entryID: entry.ID,
startedAt: time.Now(),
lastPersist: time.Now(),
finalPrompt: "hello",
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
rec := httptest.NewRecorder()
resp := makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello"}`, `data: [DONE]`)
h.handleStream(rec, req, resp, "cid-stop", "deepseek-chat", "prompt", false, false, nil, session)
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one history item, got %d", len(snapshot.Items))
}
full, err := historyStore.Get(snapshot.Items[0].ID)
if err != nil {
t.Fatalf("get detail failed: %v", err)
}
if full.Status != "stopped" {
t.Fatalf("expected stopped status, got %#v", full)
}
}
func TestChatCompletionsSkipsAdminWebUISource(t *testing.T) {
historyStore := newTestChatHistoryStore(t)
h := &Handler{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)},
ChatHistory: historyStore,
}
reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi there"}],"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")
req.Header.Set(adminWebUISourceHeader, adminWebUISourceValue)
rec := httptest.NewRecorder()
h.ChatCompletions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 0 {
t.Fatalf("expected admin webui source to be skipped, got %#v", snapshot.Items)
}
}
func TestChatCompletionsSkipsHistoryWhenDisabled(t *testing.T) {
historyStore := newTestChatHistoryStore(t)
if _, err := historyStore.SetLimit(chathistory.DisabledLimit); err != nil {
t.Fatalf("disable history store failed: %v", err)
}
h := &Handler{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)},
ChatHistory: historyStore,
}
reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi there"}],"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("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 0 {
t.Fatalf("expected disabled history to stay empty, got %#v", snapshot.Items)
}
}

View File

@@ -37,6 +37,14 @@ type chatStreamRuntime struct {
streamToolNames map[int]string
thinking strings.Builder
text strings.Builder
finalThinking string
finalText string
finalFinishReason string
finalUsage map[string]any
finalErrorStatus int
finalErrorMessage string
finalErrorCode string
}
func newChatStreamRuntime(
@@ -99,6 +107,9 @@ func (s *chatStreamRuntime) sendDone() {
}
func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) {
s.finalErrorStatus = status
s.finalErrorMessage = message
s.finalErrorCode = code
s.sendChunk(map[string]any{
"status_code": status,
"error": map[string]any{
@@ -111,9 +122,16 @@ func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) {
s.sendDone()
}
func (s *chatStreamRuntime) resetStreamToolCallState() {
s.streamToolCallIDs = map[int]string{}
s.streamToolNames = map[int]string{}
}
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
s.finalThinking = finalThinking
s.finalText = finalText
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
@@ -153,6 +171,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
[]map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, tcDelta)},
nil,
))
s.resetStreamToolCallState()
}
if evt.Content == "" {
continue
@@ -197,6 +216,8 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
return
}
usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText)
s.finalFinishReason = finishReason
s.finalUsage = usage
s.sendChunk(openaifmt.BuildChatStreamChunk(
s.completionID,
s.created,
@@ -294,6 +315,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
s.firstChunkSent = true
}
newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, tcDelta))
s.resetStreamToolCallState()
continue
}
if evt.Content != "" {

View File

@@ -0,0 +1,31 @@
package openai
import (
"fmt"
"regexp"
"strconv"
"strings"
)
var citationMarkerPattern = regexp.MustCompile(`(?i)\[citation:\s*(\d+)\]`)
func replaceCitationMarkersWithLinks(text string, links map[int]string) string {
if strings.TrimSpace(text) == "" || len(links) == 0 {
return text
}
return citationMarkerPattern.ReplaceAllStringFunc(text, func(match string) string {
sub := citationMarkerPattern.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
idx, err := strconv.Atoi(strings.TrimSpace(sub[1]))
if err != nil || idx <= 0 {
return match
}
url := strings.TrimSpace(links[idx])
if url == "" {
return match
}
return fmt.Sprintf("[%d](%s)", idx, url)
})
}

View File

@@ -0,0 +1,28 @@
package openai
import "testing"
func TestReplaceCitationMarkersWithLinks(t *testing.T) {
raw := "这是一条更新[citation:1],更多信息见[citation:2]。"
links := map[int]string{
1: "https://example.com/news-1",
2: "https://example.com/news-2",
}
got := replaceCitationMarkersWithLinks(raw, links)
want := "这是一条更新[1](https://example.com/news-1),更多信息见[2](https://example.com/news-2)。"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestReplaceCitationMarkersWithLinksKeepsUnknownIndex(t *testing.T) {
raw := "只有一个来源[citation:1],未知来源[citation:3]。"
links := map[int]string{1: "https://example.com/a"}
got := replaceCitationMarkersWithLinks(raw, links)
want := "只有一个来源[1](https://example.com/a),未知来源[citation:3]。"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}

View File

@@ -34,6 +34,8 @@ type ConfigReader interface {
EmbeddingsProvider() string
AutoDeleteMode() string
AutoDeleteSessions() bool
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
}
var _ AuthResolver = (*auth.Resolver)(nil)

View File

@@ -3,13 +3,15 @@ package openai
import "testing"
type mockOpenAIConfig struct {
aliases map[string]string
wideInput bool
autoDeleteMode string
toolMode string
earlyEmit string
responsesTTL int
embedProv string
aliases map[string]string
wideInput bool
autoDeleteMode string
toolMode string
earlyEmit string
responsesTTL int
embedProv string
historySplitEnabled bool
historySplitTurns int
}
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
@@ -27,7 +29,14 @@ func (m mockOpenAIConfig) AutoDeleteMode() string {
}
return m.autoDeleteMode
}
func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
func (m mockOpenAIConfig) HistorySplitEnabled() bool { return m.historySplitEnabled }
func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int {
if m.historySplitTurns <= 0 {
return 1
}
return m.historySplitTurns
}
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{

View File

@@ -63,32 +63,50 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error())
return
}
historySession := startChatHistory(h.ChatHistory, r, a, stdReq)
sessionID, err = h.DS.CreateSession(r.Context(), a, 3)
if err != nil {
if a.UseConfigToken {
if historySession != nil {
historySession.error(http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.", "error", "", "")
}
writeOpenAIError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.")
} else {
if historySession != nil {
historySession.error(http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first.", "error", "", "")
}
writeOpenAIError(w, http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first.")
}
return
}
pow, err := h.DS.GetPow(r.Context(), a, 3)
if err != nil {
if historySession != nil {
historySession.error(http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).", "error", "", "")
}
writeOpenAIError(w, http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).")
return
}
payload := stdReq.CompletionPayload(sessionID)
resp, err := h.DS.CallCompletion(r.Context(), a, payload, pow, 3)
if err != nil {
if historySession != nil {
historySession.error(http.StatusInternalServerError, "Failed to get completion.", "error", "", "")
}
writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.")
return
}
if stdReq.Stream {
h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames)
h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession)
return
}
h.handleNonStream(w, r.Context(), resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames)
h.handleNonStream(w, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession)
}
func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) {
@@ -124,30 +142,52 @@ func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAu
}
}
func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, historySession *chatHistorySession) {
if resp.StatusCode != http.StatusOK {
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if historySession != nil {
historySession.error(resp.StatusCode, string(body), "error", "", "")
}
writeOpenAIError(w, resp.StatusCode, string(body))
return
}
_ = ctx
result := sse.CollectStream(resp, thinkingEnabled, true)
stripReferenceMarkers := h.compatStripReferenceMarkers()
finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
if writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter) {
if searchEnabled {
finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks)
}
if shouldWriteUpstreamEmptyOutputError(finalText) {
status, message, code := upstreamEmptyOutputDetail(result.ContentFilter, finalText, finalThinking)
if historySession != nil {
historySession.error(status, message, code, finalThinking, finalText)
}
writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter)
return
}
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)
finishReason := "stop"
if choices, ok := respBody["choices"].([]map[string]any); ok && len(choices) > 0 {
if fr, _ := choices[0]["finish_reason"].(string); strings.TrimSpace(fr) != "" {
finishReason = fr
}
}
if historySession != nil {
historySession.success(http.StatusOK, finalThinking, finalText, finishReason, openaifmt.BuildChatUsage(finalPrompt, finalThinking, finalText))
}
writeJSON(w, http.StatusOK, respBody)
}
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, historySession *chatHistorySession) {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
if historySession != nil {
historySession.error(resp.StatusCode, string(body), "error", "", "")
}
writeOpenAIError(w, resp.StatusCode, string(body))
return
}
@@ -198,13 +238,32 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
OnKeepAlive: func() {
streamRuntime.sendKeepAlive()
},
OnParsed: streamRuntime.onParsed,
OnParsed: func(parsed sse.LineResult) streamengine.ParsedDecision {
decision := streamRuntime.onParsed(parsed)
if historySession != nil {
historySession.progress(streamRuntime.thinking.String(), streamRuntime.text.String())
}
return decision
},
OnFinalize: func(reason streamengine.StopReason, _ error) {
if string(reason) == "content_filter" {
streamRuntime.finalize("content_filter")
} else {
streamRuntime.finalize("stop")
}
if historySession == nil {
return
}
streamRuntime.finalize("stop")
if streamRuntime.finalErrorMessage != "" {
historySession.error(streamRuntime.finalErrorStatus, streamRuntime.finalErrorMessage, streamRuntime.finalErrorCode, streamRuntime.thinking.String(), streamRuntime.text.String())
return
}
historySession.success(http.StatusOK, streamRuntime.finalThinking, streamRuntime.finalText, streamRuntime.finalFinishReason, streamRuntime.finalUsage)
},
OnContextDone: func() {
if historySession != nil {
historySession.stopped(streamRuntime.thinking.String(), streamRuntime.text.String(), string(streamengine.StopReasonContextCancelled))
}
},
})
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
"ds2api/internal/chathistory"
"ds2api/internal/config"
"ds2api/internal/util"
)
@@ -25,9 +26,10 @@ const (
var writeJSON = util.WriteJSON
type Handler struct {
Store ConfigReader
Auth AuthResolver
DS DeepSeekCaller
Store ConfigReader
Auth AuthResolver
DS DeepSeekCaller
ChatHistory *chathistory.Store
leaseMu sync.Mutex
streamLeases map[string]streamLease

View File

@@ -1,7 +1,6 @@
package openai
import (
"context"
"encoding/json"
"io"
"net/http"
@@ -94,7 +93,7 @@ func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) {
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid-empty", "deepseek-chat", "prompt", false, nil)
h.handleNonStream(rec, resp, "cid-empty", "deepseek-chat", "prompt", false, false, nil, nil)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -113,7 +112,7 @@ func TestHandleNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWithoutOutp
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid-empty-filtered", "deepseek-chat", "prompt", false, nil)
h.handleNonStream(rec, resp, "cid-empty-filtered", "deepseek-chat", "prompt", false, false, nil, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400 for filtered upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -132,7 +131,7 @@ func TestHandleNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) {
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, nil)
h.handleNonStream(rec, resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, false, nil, nil)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -153,7 +152,7 @@ func TestHandleStreamToolsPlainTextStreamsBeforeFinish(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid6", "deepseek-chat", "prompt", false, false, []string{"search"})
h.handleStream(rec, req, resp, "cid6", "deepseek-chat", "prompt", false, false, []string{"search"}, nil)
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
@@ -190,7 +189,7 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid10", "deepseek-chat", "prompt", false, false, []string{"search"})
h.handleStream(rec, req, resp, "cid10", "deepseek-chat", "prompt", false, false, []string{"search"}, nil)
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
@@ -214,3 +213,51 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin
t.Fatalf("expected incomplete capture to flush as plain text instead of stalling, got=%q", content.String())
}
}
func TestHandleStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"前置文本\n<tool_calls>\n <tool_call>\n <tool_name>read_file</tool_name>\n <parameters>{\"path\":\"README.MD\"}</parameters>\n </tool_call>\n</tool_calls>"}`,
`data: {"p":"response/content","v":"中间文本\n<tool_calls>\n <tool_call>\n <tool_name>search</tool_name>\n <parameters>{\"q\":\"golang\"}</parameters>\n </tool_call>\n</tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid-multi", "deepseek-chat", "prompt", false, false, []string{"read_file", "search"}, nil)
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
ids := make([]string, 0, 2)
seen := make(map[string]struct{})
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
toolCalls, _ := delta["tool_calls"].([]any)
for _, rawCall := range toolCalls {
call, _ := rawCall.(map[string]any)
id := asString(call["id"])
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
}
}
if len(ids) != 2 {
t.Fatalf("expected two distinct tool call ids, got %#v body=%s", ids, rec.Body.String())
}
if ids[0] == ids[1] {
t.Fatalf("expected distinct tool call ids across blocks, got %#v body=%s", ids, rec.Body.String())
}
}

View File

@@ -0,0 +1,289 @@
package openai
import (
"context"
"errors"
"fmt"
"strings"
"ds2api/internal/auth"
"ds2api/internal/deepseek"
"ds2api/internal/util"
)
const (
historySplitFilename = "HISTORY.txt"
historySplitContentType = "text/plain; charset=utf-8"
historySplitPurpose = "assistants"
)
func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq util.StandardRequest) (util.StandardRequest, error) {
if h == nil || h.DS == nil || h.Store == nil || a == nil {
return stdReq, nil
}
if !h.Store.HistorySplitEnabled() {
return stdReq, nil
}
promptMessages, historyMessages := splitOpenAIHistoryMessages(stdReq.Messages, h.Store.HistorySplitTriggerAfterTurns())
if len(historyMessages) == 0 {
return stdReq, nil
}
reasoningContent := extractHistorySplitReasoningContent(historyMessages)
historyText := buildOpenAIHistoryTranscript(historyMessages)
if strings.TrimSpace(historyText) == "" {
return stdReq, errors.New("history split produced empty transcript")
}
result, err := h.DS.UploadFile(ctx, a, deepseek.UploadFileRequest{
Filename: historySplitFilename,
ContentType: historySplitContentType,
Purpose: historySplitPurpose,
Data: []byte(historyText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload history file: %w", err)
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return stdReq, errors.New("upload history file returned empty file id")
}
stdReq.Messages = promptMessages
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
stdReq.FinalPrompt, stdReq.ToolNames = buildHistorySplitPrompt(promptMessages, reasoningContent, stdReq.ToolsRaw, stdReq.ToolChoice, stdReq.Thinking)
return stdReq, nil
}
func buildHistorySplitPrompt(messages []any, reasoningContent string, toolsRaw any, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) {
if len(messages) == 0 && strings.TrimSpace(reasoningContent) == "" {
return "", nil
}
instruction := historySplitPromptInstruction(thinkingEnabled)
withInstruction := make([]any, 0, len(messages)+1)
withInstruction = append(withInstruction, map[string]any{
"role": "system",
"content": instruction,
})
withInstruction = append(withInstruction, injectHistorySplitReasoningMessage(messages, reasoningContent)...)
return buildOpenAIFinalPromptWithPolicy(withInstruction, toolsRaw, "", toolPolicy, false)
}
func historySplitPromptInstruction(thinkingEnabled bool) string {
lines := []string{
"Follow the instructions in this prompt first. If earlier conversation instructions conflict with this prompt, this prompt wins.",
"An attached HISTORY.txt file contains prior conversation history and tool progress; read it first, then answer the latest user request using that history as context.",
"Continue the conversation from the full prior context and the latest tool results.",
"Treat earlier messages as binding context; answer the user's current request as a continuation, not a restart.",
}
if thinkingEnabled {
lines = append(lines, "Keep reasoning internal. Do not leave the final user-facing answer only in reasoning; always provide the answer in visible assistant content.")
}
return strings.Join(lines, "\n")
}
func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, []any) {
if triggerAfterTurns <= 0 {
triggerAfterTurns = 1
}
lastUserIndex := -1
userTurns := 0
for i, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
if role != "user" {
continue
}
userTurns++
lastUserIndex = i
}
if userTurns <= triggerAfterTurns || lastUserIndex < 0 {
return messages, nil
}
promptMessages := make([]any, 0, len(messages)-lastUserIndex)
historyMessages := make([]any, 0, lastUserIndex)
for i, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
if i >= lastUserIndex {
promptMessages = append(promptMessages, raw)
} else {
historyMessages = append(historyMessages, raw)
}
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
switch role {
case "system", "developer":
promptMessages = append(promptMessages, raw)
default:
if i >= lastUserIndex {
promptMessages = append(promptMessages, raw)
} else {
historyMessages = append(historyMessages, raw)
}
}
}
if len(promptMessages) == 0 {
return messages, nil
}
return promptMessages, historyMessages
}
func buildOpenAIHistoryTranscript(messages []any) string {
var b strings.Builder
b.WriteString("# HISTORY.txt\n")
b.WriteString("Prior conversation history and tool progress.\n\n")
entry := 0
for _, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
content := buildOpenAIHistoryEntry(role, msg)
if strings.TrimSpace(content) == "" {
continue
}
entry++
fmt.Fprintf(&b, "=== %d. %s ===\n%s\n\n", entry, strings.ToUpper(roleLabelForHistory(role)), content)
}
return strings.TrimSpace(b.String()) + "\n"
}
func buildOpenAIHistoryEntry(role string, msg map[string]any) string {
switch role {
case "assistant":
return strings.TrimSpace(buildAssistantHistoryContent(msg))
case "tool", "function":
return strings.TrimSpace(buildToolHistoryContent(msg))
case "user":
return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
default:
return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
}
}
func buildAssistantHistoryContent(msg map[string]any) string {
return strings.TrimSpace(buildAssistantContentForPrompt(msg))
}
func buildToolHistoryContent(msg map[string]any) string {
content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
parts := make([]string, 0, 2)
if name := strings.TrimSpace(asString(msg["name"])); name != "" {
parts = append(parts, "name="+name)
}
if callID := strings.TrimSpace(asString(msg["tool_call_id"])); callID != "" {
parts = append(parts, "tool_call_id="+callID)
}
header := ""
if len(parts) > 0 {
header = "[" + strings.Join(parts, " ") + "]"
}
switch {
case header != "" && content != "":
return header + "\n" + content
case header != "":
return header
default:
return content
}
}
func extractHistorySplitReasoningContent(messages []any) string {
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
if role != "assistant" {
continue
}
reasoning := strings.TrimSpace(normalizeOpenAIReasoningContentForPrompt(msg["reasoning_content"]))
if reasoning == "" {
reasoning = strings.TrimSpace(extractOpenAIReasoningContentFromMessage(msg["content"]))
}
if reasoning != "" {
return reasoning
}
}
return ""
}
func injectHistorySplitReasoningMessage(messages []any, reasoningContent string) []any {
reasoningContent = strings.TrimSpace(reasoningContent)
if reasoningContent == "" {
return messages
}
reasoningMsg := map[string]any{
"role": "assistant",
"content": "",
"reasoning_content": reasoningContent,
}
lastUserIndex := lastOpenAIUserMessageIndex(messages)
if lastUserIndex < 0 {
out := make([]any, 0, len(messages)+1)
out = append(out, reasoningMsg)
out = append(out, messages...)
return out
}
out := make([]any, 0, len(messages)+1)
for i, raw := range messages {
if i == lastUserIndex {
out = append(out, reasoningMsg)
}
out = append(out, raw)
}
return out
}
func lastOpenAIUserMessageIndex(messages []any) int {
last := -1
for i, raw := range messages {
msg, ok := raw.(map[string]any)
if !ok {
continue
}
if strings.ToLower(strings.TrimSpace(asString(msg["role"]))) == "user" {
last = i
}
}
return last
}
func roleLabelForHistory(role string) string {
role = strings.ToLower(strings.TrimSpace(role))
switch role {
case "function":
return "tool"
case "":
return "unknown"
default:
return role
}
}
func prependUniqueRefFileID(existing []string, fileID string) []string {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return existing
}
out := make([]string, 0, len(existing)+1)
out = append(out, fileID)
for _, id := range existing {
trimmed := strings.TrimSpace(id)
if trimmed == "" || strings.EqualFold(trimmed, fileID) {
continue
}
out = append(out, trimmed)
}
return out
}

View File

@@ -0,0 +1,353 @@
package openai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
"ds2api/internal/util"
)
func historySplitTestMessages() []any {
toolCalls := []any{
map[string]any{
"name": "search",
"arguments": map[string]any{"query": "docs"},
},
}
return []any{
map[string]any{"role": "system", "content": "system instructions"},
map[string]any{"role": "user", "content": "first user turn"},
map[string]any{
"role": "assistant",
"content": "",
"reasoning_content": "hidden reasoning",
"tool_calls": toolCalls,
},
map[string]any{
"role": "tool",
"name": "search",
"tool_call_id": "call-1",
"content": "tool result",
},
map[string]any{"role": "user", "content": "latest user turn"},
}
}
func TestBuildOpenAIHistoryTranscriptPreservesOrderAndToolHistory(t *testing.T) {
promptMessages, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1)
if len(promptMessages) != 2 {
t.Fatalf("expected 2 prompt messages, got %d", len(promptMessages))
}
if len(historyMessages) != 3 {
t.Fatalf("expected 3 history messages, got %d", len(historyMessages))
}
transcript := buildOpenAIHistoryTranscript(historyMessages)
if !strings.Contains(transcript, "first user turn") {
t.Fatalf("expected user history in transcript, got %s", transcript)
}
if !strings.Contains(transcript, "<tool_calls>") {
t.Fatalf("expected assistant tool_calls in transcript, got %s", transcript)
}
if !strings.Contains(transcript, "tool_call_id=call-1") {
t.Fatalf("expected tool call id in transcript, got %s", transcript)
}
if !strings.Contains(transcript, "[reasoning_content]") {
t.Fatalf("expected reasoning block in HISTORY.txt, got %s", transcript)
}
if !strings.Contains(transcript, "hidden reasoning") {
t.Fatalf("expected reasoning text in HISTORY.txt, got %s", transcript)
}
userIdx := strings.Index(transcript, "=== 1. USER ===")
assistantIdx := strings.Index(transcript, "=== 2. ASSISTANT ===")
toolIdx := strings.Index(transcript, "=== 3. TOOL ===")
if userIdx < 0 || assistantIdx < 0 || toolIdx < 0 {
t.Fatalf("expected ordered role sections, got %s", transcript)
}
if userIdx >= assistantIdx || assistantIdx >= toolIdx {
t.Fatalf("expected USER -> ASSISTANT -> TOOL order, got %s", transcript)
}
if reasoningIdx := strings.Index(transcript, "[reasoning_content]"); reasoningIdx < 0 || reasoningIdx > strings.Index(transcript, "<tool_calls>") {
t.Fatalf("expected reasoning block before tool calls, got %s", transcript)
}
reasoning := extractHistorySplitReasoningContent(historyMessages)
if reasoning != "hidden reasoning" {
t.Fatalf("expected latest assistant reasoning to be extracted, got %q", reasoning)
}
finalPrompt, _ := buildHistorySplitPrompt(promptMessages, reasoning, nil, util.DefaultToolChoicePolicy(), false)
if !strings.Contains(finalPrompt, "latest user turn") {
t.Fatalf("expected latest user turn in final prompt, got %s", finalPrompt)
}
if strings.Contains(finalPrompt, "first user turn") {
t.Fatalf("expected earlier history to be removed from final prompt, got %s", finalPrompt)
}
if !strings.Contains(finalPrompt, "[reasoning_content]") || !strings.Contains(finalPrompt, "hidden reasoning") {
t.Fatalf("expected latest assistant reasoning to be attached to prompt, got %s", finalPrompt)
}
if !strings.Contains(finalPrompt, "HISTORY.txt") {
t.Fatalf("expected history instruction in final prompt, got %s", finalPrompt)
}
if !strings.Contains(finalPrompt, "Follow the instructions in this prompt first") {
t.Fatalf("expected stronger prompt override in final prompt, got %s", finalPrompt)
}
if strings.Index(finalPrompt, "Follow the instructions in this prompt first") > strings.Index(finalPrompt, "Continue the conversation") {
t.Fatalf("expected history split instruction before continuity instructions, got %s", finalPrompt)
}
}
func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
toolCalls := []any{
map[string]any{
"name": "search",
"arguments": map[string]any{"query": "docs"},
},
}
messages := []any{
map[string]any{"role": "system", "content": "system instructions"},
map[string]any{"role": "user", "content": "first user turn"},
map[string]any{
"role": "assistant",
"content": "",
"tool_calls": toolCalls,
},
map[string]any{
"role": "tool",
"name": "search",
"tool_call_id": "call-1",
"content": "tool result",
},
map[string]any{"role": "user", "content": "middle user turn"},
map[string]any{
"role": "assistant",
"content": "middle assistant turn",
},
map[string]any{"role": "user", "content": "latest user turn"},
}
promptMessages, historyMessages := splitOpenAIHistoryMessages(messages, 1)
if len(promptMessages) == 0 || len(historyMessages) == 0 {
t.Fatalf("expected both prompt and history messages, got prompt=%d history=%d", len(promptMessages), len(historyMessages))
}
reasoning := extractHistorySplitReasoningContent(historyMessages)
if reasoning != "" {
t.Fatalf("expected no reasoning in this fixture, got %q", reasoning)
}
promptText, _ := buildHistorySplitPrompt(promptMessages, reasoning, nil, util.DefaultToolChoicePolicy(), false)
if !strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected latest user turn in prompt, got %s", promptText)
}
if strings.Contains(promptText, "middle user turn") {
t.Fatalf("expected middle user turn to be split into history, got %s", promptText)
}
historyText := buildOpenAIHistoryTranscript(historyMessages)
if !strings.Contains(historyText, "middle user turn") {
t.Fatalf("expected middle user turn in HISTORY.txt, got %s", historyText)
}
if strings.Contains(historyText, "latest user turn") {
t.Fatalf("expected latest user turn to remain in prompt, got %s", historyText)
}
}
func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-chat",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
}
stdReq, err := normalizeOpenAIChatRequest(h.Store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply history split failed: %v", err)
}
if len(ds.uploadCalls) != 0 {
t.Fatalf("expected no upload on first turn, got %d", len(ds.uploadCalls))
}
if out.FinalPrompt != stdReq.FinalPrompt {
t.Fatalf("expected prompt unchanged on first turn")
}
if len(out.RefFileIDs) != len(stdReq.RefFileIDs) {
t.Fatalf("expected ref files unchanged on first turn")
}
}
func TestChatCompletionsHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
},
Auth: streamStatusAuthStub{},
DS: ds,
}
reqBody, _ := json.Marshal(map[string]any{
"model": "deepseek-chat",
"messages": historySplitTestMessages(),
"stream": false,
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(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("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
upload := ds.uploadCalls[0]
if upload.Filename != "HISTORY.txt" {
t.Fatalf("unexpected upload filename: %q", upload.Filename)
}
if upload.ContentType != "text/plain; charset=utf-8" {
t.Fatalf("unexpected content type: %q", upload.ContentType)
}
if upload.Purpose != "assistants" {
t.Fatalf("unexpected purpose: %q", upload.Purpose)
}
historyText := string(upload.Data)
if !strings.Contains(historyText, "first user turn") || !strings.Contains(historyText, "tool result") {
t.Fatalf("expected older turns in HISTORY.txt, got %s", historyText)
}
if strings.Contains(historyText, "latest user turn") {
t.Fatalf("expected latest turn to remain in prompt, got %s", historyText)
}
if ds.completionReq == nil {
t.Fatal("expected completion payload to be captured")
}
promptText, _ := ds.completionReq["prompt"].(string)
if !strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected latest turn in completion prompt, got %s", promptText)
}
if strings.Contains(promptText, "first user turn") {
t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText)
}
if !strings.Contains(promptText, "[reasoning_content]") || !strings.Contains(promptText, "hidden reasoning") {
t.Fatalf("expected latest assistant reasoning to be attached to completion prompt, got %s", promptText)
}
if !strings.Contains(promptText, "HISTORY.txt") {
t.Fatalf("expected history instruction in completion prompt, got %s", promptText)
}
if !strings.Contains(promptText, "Follow the instructions in this prompt first") {
t.Fatalf("expected stronger prompt override in completion prompt, got %s", promptText)
}
if strings.Index(promptText, "Follow the instructions in this prompt first") > strings.Index(promptText, "Continue the conversation") {
t.Fatalf("expected history split instruction before continuity instructions, got %s", promptText)
}
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
if len(refIDs) == 0 || refIDs[0] != "file-inline-1" {
t.Fatalf("expected uploaded history file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"])
}
}
func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
},
Auth: streamStatusAuthStub{},
DS: ds,
}
r := chi.NewRouter()
RegisterRoutes(r, h)
reqBody, _ := json.Marshal(map[string]any{
"model": "deepseek-chat",
"messages": historySplitTestMessages(),
"stream": false,
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(reqBody)))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
if ds.completionReq == nil {
t.Fatal("expected completion payload to be captured")
}
promptText, _ := ds.completionReq["prompt"].(string)
if !strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected latest turn in completion prompt, got %s", promptText)
}
if strings.Contains(promptText, "first user turn") {
t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText)
}
if !strings.Contains(promptText, "[reasoning_content]") || !strings.Contains(promptText, "hidden reasoning") {
t.Fatalf("expected latest assistant reasoning to be attached to completion prompt, got %s", promptText)
}
if !strings.Contains(promptText, "Follow the instructions in this prompt first") {
t.Fatalf("expected stronger prompt override in completion prompt, got %s", promptText)
}
if strings.Index(promptText, "Follow the instructions in this prompt first") > strings.Index(promptText, "Continue the conversation") {
t.Fatalf("expected history split instruction before continuity instructions, got %s", promptText)
}
}
func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) {
ds := &inlineUploadDSStub{uploadErr: context.DeadlineExceeded}
h := &Handler{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
},
Auth: streamStatusAuthStub{},
DS: ds,
}
reqBody, _ := json.Marshal(map[string]any{
"model": "deepseek-chat",
"messages": historySplitTestMessages(),
"stream": false,
})
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(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.StatusInternalServerError {
t.Fatalf("expected 500, got %d body=%s", rec.Code, rec.Body.String())
}
if ds.completionReq != nil {
t.Fatalf("did not expect completion payload on upload failure")
}
}

View File

@@ -6,6 +6,8 @@ import (
"ds2api/internal/prompt"
)
const assistantReasoningLabel = "reasoning_content"
func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any {
_ = traceID
out := make([]map[string]any, 0, len(raw))
@@ -55,17 +57,95 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
func buildAssistantContentForPrompt(msg map[string]any) string {
content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
toolHistory := prompt.FormatToolCallsForPrompt(msg["tool_calls"])
switch {
case content == "" && toolHistory == "":
return ""
case content == "":
return toolHistory
case toolHistory == "":
return content
default:
return content + "\n\n" + toolHistory
reasoning := strings.TrimSpace(normalizeOpenAIReasoningContentForPrompt(msg["reasoning_content"]))
if reasoning == "" {
reasoning = strings.TrimSpace(extractOpenAIReasoningContentFromMessage(msg["content"]))
}
toolHistory := prompt.FormatToolCallsForPrompt(msg["tool_calls"])
parts := make([]string, 0, 3)
if reasoning != "" {
parts = append(parts, formatPromptLabeledBlock(assistantReasoningLabel, reasoning))
}
if content != "" {
parts = append(parts, content)
}
if toolHistory != "" {
parts = append(parts, toolHistory)
}
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
default:
return strings.Join(parts, "\n\n")
}
}
func normalizeOpenAIReasoningContentForPrompt(v any) string {
switch x := v.(type) {
case string:
return x
case []any:
return strings.Join(extractOpenAIReasoningPartsFromItems(x), "\n")
case map[string]any:
return extractOpenAIReasoningTextFromItem(x)
default:
return ""
}
}
func extractOpenAIReasoningContentFromMessage(v any) string {
switch x := v.(type) {
case []any:
return strings.Join(extractOpenAIReasoningPartsFromItems(x), "\n")
case map[string]any:
return extractOpenAIReasoningTextFromItem(x)
default:
return ""
}
}
func extractOpenAIReasoningPartsFromItems(items []any) []string {
parts := make([]string, 0, len(items))
for _, item := range items {
if text := extractOpenAIReasoningTextFromItemMap(item); text != "" {
parts = append(parts, text)
}
}
return parts
}
func extractOpenAIReasoningTextFromItemMap(item any) string {
m, ok := item.(map[string]any)
if !ok {
return ""
}
return extractOpenAIReasoningTextFromItem(m)
}
func extractOpenAIReasoningTextFromItem(m map[string]any) string {
if m == nil {
return ""
}
switch strings.ToLower(strings.TrimSpace(asString(m["type"]))) {
case "reasoning", "thinking":
for _, key := range []string{"text", "thinking", "content"} {
if text := strings.TrimSpace(asString(m[key])); text != "" {
return text
}
}
}
return ""
}
func formatPromptLabeledBlock(label, text string) string {
label = strings.TrimSpace(label)
text = strings.TrimSpace(text)
if label == "" {
return text
}
return "[" + label + "]\n" + text + "\n[/" + label + "]"
}
func buildToolContentForPrompt(msg map[string]any) string {

View File

@@ -296,3 +296,31 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantArrayContentFallbackWhenTextE
t.Fatalf("expected content fallback text preserved, got %q", content)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantReasoningContentPreserved(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"content": "visible answer",
"reasoning_content": "internal reasoning",
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized assistant message, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, "[reasoning_content]") {
t.Fatalf("expected labeled reasoning block in assistant content, got %q", content)
}
if !strings.Contains(content, "internal reasoning") {
t.Fatalf("expected reasoning text in assistant content, got %q", content)
}
if !strings.Contains(content, "visible answer") {
t.Fatalf("expected visible answer in assistant content, got %q", content)
}
if reasoningIdx := strings.Index(content, "[reasoning_content]"); reasoningIdx < 0 || reasoningIdx > strings.Index(content, "visible answer") {
t.Fatalf("expected reasoning block before visible answer, got %q", content)
}
}

View File

@@ -85,6 +85,11 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error())
return
}
sessionID, err := h.DS.CreateSession(r.Context(), a, 3)
if err != nil {
@@ -112,10 +117,10 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
h.handleResponsesStream(w, r, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
return
}
h.handleResponsesNonStream(w, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames, stdReq.ToolChoice, traceID)
h.handleResponsesNonStream(w, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
}
func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
@@ -126,6 +131,9 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
stripReferenceMarkers := h.compatStripReferenceMarkers()
sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
if searchEnabled {
sanitizedText = replaceCitationMarkersWithLinks(sanitizedText, result.CitationLinks)
}
if writeUpstreamEmptyOutputError(w, sanitizedText, result.ContentFilter) {
return
}

View File

@@ -128,7 +128,7 @@ func (s *responsesStreamRuntime) finalize() {
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
if s.bufferToolContent {
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true, true)
}
textParsed := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
@@ -224,7 +224,7 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
s.emitTextDelta(trimmed)
continue
}
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, trimmed, s.toolNames), true)
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, trimmed, s.toolNames), true, true)
}
return streamengine.ParsedDecision{ContentSeen: contentSeen}

View File

@@ -39,7 +39,7 @@ func (s *responsesStreamRuntime) sendDone() {
}
}
func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEvent, emitContent bool) {
func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEvent, emitContent bool, resetAfterToolCalls bool) {
for _, evt := range events {
if emitContent && evt.Content != "" {
s.emitTextDelta(evt.Content)
@@ -56,6 +56,9 @@ func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEven
}
if len(evt.ToolCalls) > 0 {
s.emitFunctionCallDoneEvents(evt.ToolCalls)
if resetAfterToolCalls {
s.resetStreamToolCallState()
}
}
}
}

View File

@@ -152,6 +152,16 @@ func (s *responsesStreamRuntime) ensureToolCallID(callIndex int) string {
return id
}
func (s *responsesStreamRuntime) resetStreamToolCallState() {
s.streamToolCallIDs = map[int]string{}
s.functionItemIDs = map[int]string{}
s.functionOutputIDs = map[int]int{}
s.functionArgs = map[int]string{}
s.functionDone = map[int]bool{}
s.functionAdded = map[int]bool{}
s.functionNames = map[int]string{}
}
func (s *responsesStreamRuntime) ensureFunctionOutputIndex(callIndex int) int {
if idx, ok := s.functionOutputIDs[callIndex]; ok {
return idx

View File

@@ -109,6 +109,57 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) {
}
}
func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
sseLine := func(v string) string {
b, _ := json.Marshal(map[string]any{
"p": "response/content",
"v": v,
})
return "data: " + string(b) + "\n"
}
streamBody := sseLine("前置文本\n<tool_calls>\n <tool_call>\n <tool_name>read_file</tool_name>\n <parameters>{\"path\":\"README.MD\"}</parameters>\n </tool_call>\n</tool_calls>") +
sseLine("中间文本\n<tool_calls>\n <tool_call>\n <tool_name>search</tool_name>\n <parameters>{\"q\":\"golang\"}</parameters>\n </tool_call>\n</tool_calls>") +
"data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(streamBody)),
}
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file", "search"}, util.DefaultToolChoicePolicy(), "")
body := rec.Body.String()
doneEvents := extractSSEEventPayloads(body, "response.function_call_arguments.done")
if len(doneEvents) < 2 {
t.Fatalf("expected at least two function call done events, got %d body=%s", len(doneEvents), body)
}
ids := make([]string, 0, 2)
seen := make(map[string]struct{})
for _, payload := range doneEvents {
callID := asString(payload["call_id"])
if callID == "" {
continue
}
if _, ok := seen[callID]; ok {
continue
}
seen[callID] = struct{}{}
ids = append(ids, callID)
}
if len(ids) != 2 {
t.Fatalf("expected two distinct call ids, got %#v body=%s", ids, body)
}
if ids[0] == ids[1] {
t.Fatalf("expected distinct call ids across blocks, got %#v body=%s", ids, body)
}
}
func TestHandleResponsesStreamRequiredToolChoiceFailure(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
@@ -196,7 +247,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) {
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, []string{"read_file"}, policy, "")
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}, policy, "")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -223,7 +274,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", true, []string{"read_file"}, policy, "")
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", true, false, []string{"read_file"}, policy, "")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -245,7 +296,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T)
)),
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, util.DefaultToolChoicePolicy(), "")
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -267,7 +318,7 @@ func TestHandleResponsesNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWi
)),
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, util.DefaultToolChoicePolicy(), "")
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for filtered empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -289,7 +340,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testin
)),
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, nil, util.DefaultToolChoicePolicy(), "")
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, false, nil, util.DefaultToolChoicePolicy(), "")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
@@ -325,3 +376,30 @@ func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {
}
return nil, false
}
func extractSSEEventPayloads(body, targetEvent string) []map[string]any {
scanner := bufio.NewScanner(strings.NewReader(body))
matched := false
out := make([]map[string]any, 0, 4)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "event: ") {
evt := strings.TrimSpace(strings.TrimPrefix(line, "event: "))
matched = evt == targetEvent
continue
}
if !matched || !strings.HasPrefix(line, "data: ") {
continue
}
raw := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
if raw == "" || raw == "[DONE]" {
continue
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
continue
}
out = append(out, payload)
}
return out
}

View File

@@ -35,6 +35,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
ResolvedModel: resolvedModel,
ResponseModel: responseModel,
Messages: messagesRaw,
ToolsRaw: req["tools"],
FinalPrompt: finalPrompt,
ToolNames: toolNames,
ToolChoice: toolPolicy,
@@ -90,6 +91,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
ResolvedModel: resolvedModel,
ResponseModel: model,
Messages: messagesRaw,
ToolsRaw: req["tools"],
FinalPrompt: finalPrompt,
ToolNames: toolNames,
ToolChoice: toolPolicy,

View File

@@ -12,7 +12,6 @@ type toolStreamSieveState struct {
codeFenceStack []int
codeFencePendingTicks int
codeFenceLineStart bool
recentTextTail string
pendingToolRaw string
pendingToolCalls []toolcall.ParsedToolCall
disableDeltas bool
@@ -36,9 +35,6 @@ type toolCallDelta struct {
Arguments string
}
// Keep in sync with JS TOOL_SIEVE_CONTEXT_TAIL_LIMIT.
const toolSieveContextTailLimit = 2048
func (s *toolStreamSieveState) resetIncrementalToolState() {
s.disableDeltas = false
s.toolNameSent = false
@@ -54,18 +50,6 @@ func (s *toolStreamSieveState) noteText(content string) {
return
}
updateCodeFenceState(s, content)
s.recentTextTail = appendTail(s.recentTextTail, content, toolSieveContextTailLimit)
}
func appendTail(prev, next string, max int) string {
if max <= 0 {
return ""
}
combined := prev + next
if len(combined) <= max {
return combined
}
return combined[len(combined)-max:]
}
func hasMeaningfulText(text string) bool {

View File

@@ -42,6 +42,49 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) {
}
}
func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) {
var state toolStreamSieveState
const toolName = "write_to_file"
payload := strings.Repeat("x", 4096)
splitAt := len(payload) / 2
chunks := []string{
"<tool_calls>\n <tool_call>\n <tool_name>" + toolName + "</tool_name>\n <parameters>\n <content><![CDATA[",
payload[:splitAt],
payload[splitAt:],
"]]></content>\n </parameters>\n </tool_call>\n</tool_calls>",
}
var events []toolStreamEvent
for _, c := range chunks {
events = append(events, processToolSieveChunk(&state, c, []string{toolName})...)
}
events = append(events, flushToolSieve(&state, []string{toolName})...)
var textContent strings.Builder
toolCalls := 0
var gotPayload any
for _, evt := range events {
if evt.Content != "" {
textContent.WriteString(evt.Content)
}
if len(evt.ToolCalls) > 0 && gotPayload == nil {
gotPayload = evt.ToolCalls[0].Input["content"]
}
toolCalls += len(evt.ToolCalls)
}
if toolCalls != 1 {
t.Fatalf("expected one long XML tool call, got %d events=%#v", toolCalls, events)
}
if textContent.Len() != 0 {
t.Fatalf("expected no leaked text for long XML tool call, got %q", textContent.String())
}
got, _ := gotPayload.(string)
if got != payload {
t.Fatalf("expected long XML payload to survive intact, got len=%d want=%d", len(got), len(payload))
}
}
func TestProcessToolSieveXMLWithLeadingText(t *testing.T) {
var state toolStreamSieveState
// Model outputs some prose then an XML tool call.

View File

@@ -2,14 +2,26 @@ package openai
import "net/http"
func shouldWriteUpstreamEmptyOutputError(text string) bool {
return text == ""
}
func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) {
_ = text
if contentFilter {
return http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter"
}
if thinking != "" {
return http.StatusTooManyRequests, "Upstream model returned reasoning without visible output.", "upstream_empty_output"
}
return http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output"
}
func writeUpstreamEmptyOutputError(w http.ResponseWriter, text string, contentFilter bool) bool {
if text != "" {
if !shouldWriteUpstreamEmptyOutputError(text) {
return false
}
if contentFilter {
writeOpenAIErrorWithCode(w, http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter")
return true
}
writeOpenAIErrorWithCode(w, http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output")
status, message, code := upstreamEmptyOutputDetail(contentFilter, text, "")
writeOpenAIErrorWithCode(w, status, message, code)
return true
}

View File

@@ -33,6 +33,8 @@ type ConfigStore interface {
RuntimeGlobalMaxInflight(defaultSize int) int
RuntimeTokenRefreshIntervalHours() int
AutoDeleteMode() string
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
CompatStripReferenceMarkers() bool
AutoDeleteSessions() bool
}

View File

@@ -2,13 +2,16 @@ package admin
import (
"github.com/go-chi/chi/v5"
"ds2api/internal/chathistory"
)
type Handler struct {
Store ConfigStore
Pool PoolController
DS DeepSeekCaller
OpenAI OpenAIChatCaller
Store ConfigStore
Pool PoolController
DS DeepSeekCaller
OpenAI OpenAIChatCaller
ChatHistory *chathistory.Store
}
func RegisterRoutes(r chi.Router, h *Handler) {
@@ -25,6 +28,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Post("/config/import", h.configImport)
pr.Get("/config/export", h.configExport)
pr.Post("/keys", h.addKey)
pr.Put("/keys/{key}", h.updateKey)
pr.Delete("/keys/{key}", h.deleteKey)
pr.Get("/proxies", h.listProxies)
pr.Post("/proxies", h.addProxy)
@@ -33,6 +37,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Post("/proxies/test", h.testProxy)
pr.Get("/accounts", h.listAccounts)
pr.Post("/accounts", h.addAccount)
pr.Put("/accounts/{identifier}", h.updateAccount)
pr.Delete("/accounts/{identifier}", h.deleteAccount)
pr.Put("/accounts/{identifier}/proxy", h.updateAccountProxy)
pr.Get("/queue/status", h.queueStatus)
@@ -50,6 +55,11 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Get("/export", h.exportConfig)
pr.Get("/dev/captures", h.getDevCaptures)
pr.Delete("/dev/captures", h.clearDevCaptures)
pr.Get("/chat-history", h.getChatHistory)
pr.Get("/chat-history/{id}", h.getChatHistoryItem)
pr.Delete("/chat-history", h.clearChatHistory)
pr.Delete("/chat-history/{id}", h.deleteChatHistoryItem)
pr.Put("/chat-history/settings", h.updateChatHistorySettings)
pr.Get("/version", h.getVersion)
})
}

View File

@@ -32,6 +32,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
for _, acc := range accounts {
id := strings.ToLower(acc.Identifier())
if strings.Contains(id, q) ||
strings.Contains(strings.ToLower(acc.Name), q) ||
strings.Contains(strings.ToLower(acc.Remark), q) ||
strings.Contains(strings.ToLower(acc.Email), q) ||
strings.Contains(strings.ToLower(acc.Mobile), q) {
filtered = append(filtered, acc)
@@ -66,6 +68,8 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
}
items = append(items, map[string]any{
"identifier": acc.Identifier(),
"name": acc.Name,
"remark": acc.Remark,
"email": acc.Email,
"mobile": acc.Mobile,
"proxy_id": acc.ProxyID,
@@ -112,6 +116,46 @@ func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {
identifier = decoded
}
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
}
name, nameOK := fieldStringOptional(req, "name")
remark, remarkOK := fieldStringOptional(req, "remark")
err := h.Store.Update(func(c *config.Config) error {
for i, acc := range c.Accounts {
if !accountMatchesIdentifier(acc, identifier) {
continue
}
if nameOK {
c.Accounts[i].Name = name
}
if remarkOK {
c.Accounts[i].Remark = remark
}
return nil
}
return newRequestError("账号不存在")
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {

View File

@@ -7,6 +7,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestListAccountsPageSizeCapIs5000(t *testing.T) {
@@ -51,3 +53,36 @@ func TestListAccountsPageSizeAbove5000ClampedTo5000(t *testing.T) {
t.Fatalf("expected page_size clamped to 5000, got %v", payload["page_size"])
}
}
func TestUpdateAccountMetadataPreservesCredentials(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","name":"old name","remark":"old remark","password":"secret"}]
}`)
r := chi.NewRouter()
r.Put("/admin/accounts/{identifier}", h.updateAccount)
body := []byte(`{"name":"new name","remark":"new remark"}`)
req := httptest.NewRequest(http.MethodPut, "/admin/accounts/u@example.com", strings.NewReader(string(body)))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Accounts) != 1 {
t.Fatalf("unexpected accounts after update: %#v", snap.Accounts)
}
acc := snap.Accounts[0]
if acc.Email != "u@example.com" {
t.Fatalf("identifier changed unexpectedly: %#v", acc)
}
if acc.Name != "new name" || acc.Remark != "new remark" {
t.Fatalf("metadata update did not persist: %#v", acc)
}
if acc.Password != "secret" {
t.Fatalf("password should be preserved, got %#v", acc)
}
}

View File

@@ -0,0 +1,134 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/chathistory"
)
func (h *Handler) getChatHistory(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
snapshot, err := store.Snapshot()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"detail": err.Error(),
"path": store.Path(),
})
return
}
etag := chathistory.ListETag(snapshot.Revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"version": snapshot.Version,
"limit": snapshot.Limit,
"revision": snapshot.Revision,
"items": snapshot.Items,
"path": store.Path(),
})
}
func (h *Handler) getChatHistoryItem(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "history id is required"})
return
}
item, err := store.Get(id)
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]any{"detail": err.Error()})
return
}
etag := chathistory.DetailETag(item.ID, item.Revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"item": item,
})
}
func (h *Handler) clearChatHistory(w http.ResponseWriter, _ *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
if err := store.Clear(); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": err.Error(), "path": store.Path()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *Handler) deleteChatHistoryItem(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "history id is required"})
return
}
if err := store.Delete(id); err != nil {
status := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *Handler) updateChatHistorySettings(w http.ResponseWriter, r *http.Request) {
store := h.ChatHistory
if store == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
var body struct {
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
return
}
snapshot, err := store.SetLimit(body.Limit)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"limit": snapshot.Limit,
"revision": snapshot.Revision,
"items": snapshot.Items,
})
}

View File

@@ -0,0 +1,176 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/chathistory"
"ds2api/internal/config"
)
func newChatHistoryAdminHarness(t *testing.T) (*Handler, *chathistory.Store) {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil {
t.Fatalf("write config failed: %v", err)
}
t.Setenv("DS2API_CONFIG_PATH", configPath)
t.Setenv("DS2API_ADMIN_KEY", "admin")
t.Setenv("DS2API_CONFIG_JSON", "")
store, err := config.LoadStoreWithError()
if err != nil {
t.Fatalf("load config store failed: %v", err)
}
historyStore := chathistory.New(filepath.Join(dir, "chat_history.json"))
return &Handler{Store: store, ChatHistory: historyStore}, historyStore
}
func TestGetChatHistoryAndUpdateSettings(t *testing.T) {
h, historyStore := newChatHistoryAdminHarness(t)
entry, err := historyStore.Start(chathistory.StartParams{
CallerID: "caller:test",
AccountID: "user@example.com",
Model: "deepseek-chat",
UserInput: "hello",
})
if err != nil {
t.Fatalf("start history failed: %v", err)
}
if _, err := historyStore.Update(entry.ID, chathistory.UpdateParams{
Status: "success",
Content: "world",
Completed: true,
}); err != nil {
t.Fatalf("update history failed: %v", err)
}
r := chi.NewRouter()
RegisterRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/chat-history", nil)
req.Header.Set("Authorization", "Bearer admin")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode payload failed: %v", err)
}
items, _ := payload["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected one history item, got %#v", payload)
}
if rec.Header().Get("ETag") == "" {
t.Fatalf("expected list etag header")
}
notModifiedReq := httptest.NewRequest(http.MethodGet, "/chat-history", nil)
notModifiedReq.Header.Set("Authorization", "Bearer admin")
notModifiedReq.Header.Set("If-None-Match", rec.Header().Get("ETag"))
notModifiedRec := httptest.NewRecorder()
r.ServeHTTP(notModifiedRec, notModifiedReq)
if notModifiedRec.Code != http.StatusNotModified {
t.Fatalf("expected 304, got %d body=%s", notModifiedRec.Code, notModifiedRec.Body.String())
}
itemReq := httptest.NewRequest(http.MethodGet, "/chat-history/"+entry.ID, nil)
itemReq.Header.Set("Authorization", "Bearer admin")
itemRec := httptest.NewRecorder()
r.ServeHTTP(itemRec, itemReq)
if itemRec.Code != http.StatusOK {
t.Fatalf("expected item 200, got %d body=%s", itemRec.Code, itemRec.Body.String())
}
if itemRec.Header().Get("ETag") == "" {
t.Fatalf("expected detail etag header")
}
updateReq := httptest.NewRequest(http.MethodPut, "/chat-history/settings", bytes.NewReader([]byte(`{"limit":10}`)))
updateReq.Header.Set("Authorization", "Bearer admin")
updateRec := httptest.NewRecorder()
r.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("expected 200 from settings update, got %d body=%s", updateRec.Code, updateRec.Body.String())
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if snapshot.Limit != 10 {
t.Fatalf("expected limit=10, got %d", snapshot.Limit)
}
disableReq := httptest.NewRequest(http.MethodPut, "/chat-history/settings", bytes.NewReader([]byte(`{"limit":0}`)))
disableReq.Header.Set("Authorization", "Bearer admin")
disableRec := httptest.NewRecorder()
r.ServeHTTP(disableRec, disableReq)
if disableRec.Code != http.StatusOK {
t.Fatalf("expected 200 from disable update, got %d body=%s", disableRec.Code, disableRec.Body.String())
}
snapshot, err = historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot after disable failed: %v", err)
}
if snapshot.Limit != chathistory.DisabledLimit {
t.Fatalf("expected limit=0, got %d", snapshot.Limit)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected history preserved when disabled, got %d", len(snapshot.Items))
}
}
func TestDeleteAndClearChatHistory(t *testing.T) {
h, historyStore := newChatHistoryAdminHarness(t)
entryA, err := historyStore.Start(chathistory.StartParams{UserInput: "a"})
if err != nil {
t.Fatalf("start A failed: %v", err)
}
if _, err := historyStore.Start(chathistory.StartParams{UserInput: "b"}); err != nil {
t.Fatalf("start B failed: %v", err)
}
r := chi.NewRouter()
RegisterRoutes(r, h)
deleteReq := httptest.NewRequest(http.MethodDelete, "/chat-history/"+entryA.ID, nil)
deleteReq.Header.Set("Authorization", "Bearer admin")
deleteRec := httptest.NewRecorder()
r.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected delete 200, got %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
snapshot, err := historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one item after delete, got %d", len(snapshot.Items))
}
clearReq := httptest.NewRequest(http.MethodDelete, "/chat-history", nil)
clearReq.Header.Set("Authorization", "Bearer admin")
clearRec := httptest.NewRecorder()
r.ServeHTTP(clearRec, clearReq)
if clearRec.Code != http.StatusOK {
t.Fatalf("expected clear 200, got %d body=%s", clearRec.Code, clearRec.Body.String())
}
snapshot, err = historyStore.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 0 {
t.Fatalf("expected empty items after clear, got %d", len(snapshot.Items))
}
}

View File

@@ -53,25 +53,12 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
next.Accounts = normalizeAndDedupeAccounts(next.Accounts)
next.VercelSyncHash = c.VercelSyncHash
next.VercelSyncTime = c.VercelSyncTime
importedKeys = len(next.Keys)
importedKeys = len(next.APIKeys)
importedAccounts = len(next.Accounts)
} else {
existingKeys := map[string]struct{}{}
for _, k := range next.Keys {
existingKeys[k] = struct{}{}
}
for _, k := range incoming.Keys {
key := strings.TrimSpace(k)
if key == "" {
continue
}
if _, ok := existingKeys[key]; ok {
continue
}
existingKeys[key] = struct{}{}
next.Keys = append(next.Keys, key)
importedKeys++
}
var changed int
next.APIKeys, changed = mergeAPIKeysPreferStructured(next.APIKeys, incoming.APIKeys)
importedKeys += changed
existingAccounts := map[string]struct{}{}
for _, acc := range next.Accounts {

View File

@@ -11,6 +11,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
safe := map[string]any{
"keys": snap.Keys,
"api_keys": snap.APIKeys,
"accounts": []map[string]any{},
"proxies": []map[string]any{},
"env_backed": h.Store.IsEnvBacked(),
@@ -37,6 +38,8 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
}
accounts = append(accounts, map[string]any{
"identifier": acc.Identifier(),
"name": acc.Name,
"remark": acc.Remark,
"email": acc.Email,
"mobile": acc.Mobile,
"proxy_id": acc.ProxyID,

View File

@@ -19,7 +19,9 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
}
old := h.Store.Snapshot()
err := h.Store.Update(func(c *config.Config) error {
if keys, ok := toStringSlice(req["keys"]); ok {
if apiKeys, ok := toAPIKeys(req["api_keys"]); ok {
c.APIKeys = apiKeys
} else if keys, ok := toStringSlice(req["keys"]); ok {
c.Keys = keys
}
if accountsRaw, ok := req["accounts"].([]any); ok {
@@ -78,17 +80,19 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&req)
key, _ := req["key"].(string)
key = strings.TrimSpace(key)
name := fieldString(req, "name")
remark := fieldString(req, "remark")
if key == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Key 不能为空"})
return
}
err := h.Store.Update(func(c *config.Config) error {
for _, k := range c.Keys {
if k == key {
for _, item := range c.APIKeys {
if item.Key == key {
return fmt.Errorf("key 已存在")
}
}
c.Keys = append(c.Keys, key)
c.APIKeys = append(c.APIKeys, config.APIKey{Key: key, Name: name, Remark: remark})
return nil
})
if err != nil {
@@ -98,12 +102,25 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)})
}
func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
key := chi.URLParam(r, "key")
func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request) {
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "key 不能为空"})
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
}
name, nameOK := fieldStringOptional(req, "name")
remark, remarkOK := fieldStringOptional(req, "remark")
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, k := range c.Keys {
if k == key {
for i, item := range c.APIKeys {
if item.Key == key {
idx = i
break
}
@@ -111,7 +128,35 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
if idx < 0 {
return fmt.Errorf("key 不存在")
}
c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...)
if nameOK {
c.APIKeys[idx].Name = name
}
if remarkOK {
c.APIKeys[idx].Remark = remark
}
return nil
})
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)})
}
func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
key := chi.URLParam(r, "key")
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, item := range c.APIKeys {
if item.Key == key {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("key 不存在")
}
c.APIKeys = append(c.APIKeys[:idx], c.APIKeys[idx+1:]...)
return nil
})
if err != nil {
@@ -129,20 +174,23 @@ func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) {
}
importedKeys, importedAccounts := 0, 0
err := h.Store.Update(func(c *config.Config) error {
if apiKeys, ok := toAPIKeys(req["api_keys"]); ok {
var changed int
c.APIKeys, changed = mergeAPIKeysPreferStructured(c.APIKeys, apiKeys)
importedKeys += changed
}
if keys, ok := req["keys"].([]any); ok {
existing := map[string]bool{}
for _, k := range c.Keys {
existing[k] = true
}
legacy := make([]config.APIKey, 0, len(keys))
for _, k := range keys {
key := strings.TrimSpace(fmt.Sprintf("%v", k))
if key == "" || existing[key] {
if key == "" {
continue
}
c.Keys = append(c.Keys, key)
existing[key] = true
importedKeys++
legacy = append(legacy, config.APIKey{Key: key})
}
var changed int
c.APIKeys, changed = mergeAPIKeysPreferStructured(c.APIKeys, legacy)
importedKeys += changed
}
if accounts, ok := req["accounts"].([]any); ok {
existing := map[string]bool{}

View File

@@ -0,0 +1,76 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestKeyEndpointsPreserveStructuredMetadata(t *testing.T) {
h := newAdminTestHandler(t, `{
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}]
}`)
r := chi.NewRouter()
r.Post("/admin/keys", h.addKey)
r.Put("/admin/keys/{key}", h.updateKey)
r.Delete("/admin/keys/{key}", h.deleteKey)
addBody := []byte(`{"key":"k2","name":"secondary","remark":"staging"}`)
addReq := httptest.NewRequest(http.MethodPost, "/admin/keys", bytes.NewReader(addBody))
addRec := httptest.NewRecorder()
r.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusOK {
t.Fatalf("add status=%d body=%s", addRec.Code, addRec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after add: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("existing metadata was lost after add: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
t.Fatalf("new metadata was lost after add: %#v", snap.APIKeys[1])
}
updateBody := map[string]any{
"name": "primary-updated",
"remark": "prod-updated",
}
updateBytes, _ := json.Marshal(updateBody)
updateReq := httptest.NewRequest(http.MethodPut, "/admin/keys/k1", bytes.NewReader(updateBytes))
updateRec := httptest.NewRecorder()
r.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String())
}
snap = h.Store.Snapshot()
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after update: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Key != "k1" || snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" {
t.Fatalf("metadata update did not persist: %#v", snap.APIKeys[0])
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/keys/k1", nil)
deleteRec := httptest.NewRecorder()
r.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", deleteRec.Code, deleteRec.Body.String())
}
snap = h.Store.Snapshot()
if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" {
t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys)
}
if len(snap.Keys) != 1 || snap.Keys[0] != "k2" {
t.Fatalf("unexpected legacy keys after delete: %#v", snap.Keys)
}
}

View File

@@ -21,16 +21,17 @@ func boolFrom(v any) bool {
}
}
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) {
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, 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
claudeMap map[string]string
aliasMap map[string]string
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
compatCfg *config.CompatConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
autoDeleteCfg *config.AutoDeleteConfig
historySplitCfg *config.HistorySplitConfig
claudeMap map[string]string
aliasMap map[string]string
)
if raw, ok := req["admin"].(map[string]any); ok {
@@ -38,7 +39,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.JWTExpireHours = n
}
@@ -50,33 +51,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, 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 err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, 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, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
@@ -99,7 +100,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.StoreTTLSeconds = n
}
@@ -111,7 +112,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
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
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.Provider = p
}
@@ -147,7 +148,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
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
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
if mode == "" {
mode = "none"
@@ -160,5 +161,24 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
autoDeleteCfg = cfg
}
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
if raw, ok := req["history_split"].(map[string]any); ok {
cfg := &config.HistorySplitConfig{}
if v, exists := raw["enabled"]; exists {
b := boolFrom(v)
cfg.Enabled = &b
}
if v, exists := raw["trigger_after_turns"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.TriggerAfterTurns = &n
}
if err := config.ValidateHistorySplitConfig(*cfg); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
historySplitCfg = cfg
}
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, nil
}

View File

@@ -26,10 +26,14 @@ 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,
"compat": snap.Compat,
"responses": snap.Responses,
"embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,
"history_split": map[string]any{
"enabled": h.Store.HistorySplitEnabled(),
"trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(),
},
"claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),

View File

@@ -47,6 +47,25 @@ func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) {
}
}
func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
rec := httptest.NewRecorder()
h.getSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
historySplit, _ := body["history_split"].(map[string]any)
if got := boolFrom(historySplit["enabled"]); !got {
t.Fatalf("expected history_split.enabled=true, body=%v", body)
}
if got := intFrom(historySplit["trigger_after_turns"]); got != 1 {
t.Fatalf("expected history_split.trigger_after_turns=1, got %d body=%v", got, body)
}
}
func TestUpdateSettingsValidation(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
@@ -154,6 +173,30 @@ func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T)
}
}
func TestUpdateSettingsHistorySplit(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"history_split": map[string]any{
"enabled": false,
"trigger_after_turns": 3,
},
}
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())
}
snap := h.Store.Snapshot()
if snap.HistorySplit.Enabled == nil || *snap.HistorySplit.Enabled {
t.Fatalf("expected history_split.enabled=false, got %#v", snap.HistorySplit.Enabled)
}
if snap.HistorySplit.TriggerAfterTurns == nil || *snap.HistorySplit.TriggerAfterTurns != 3 {
t.Fatalf("expected history_split.trigger_after_turns=3, got %#v", snap.HistorySplit.TriggerAfterTurns)
}
}
func TestUpdateSettingsAutoDeleteMode(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`)
@@ -234,6 +277,75 @@ func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) {
}
}
func TestUpdateConfigPreservesStructuredAPIKeysWhenBothFieldsPresent(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["legacy"],
"api_keys":[{"key":"legacy","name":"primary","remark":"prod"}],
"accounts":[]
}`)
payload := map[string]any{
"keys": []any{"legacy", "new-key"},
"api_keys": []any{
map[string]any{"key": "legacy", "name": "primary-updated", "remark": "prod-updated"},
map[string]any{"key": "new-key", "name": "secondary", "remark": "staging"},
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" {
t.Fatalf("unexpected keys after config update: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after config update: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" {
t.Fatalf("structured metadata for existing key was not preserved: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
t.Fatalf("structured metadata for new key was not preserved: %#v", snap.APIKeys[1])
}
}
func TestUpdateConfigLegacyKeysPreserveStructuredMetadata(t *testing.T) {
h := newAdminTestHandler(t, `{
"api_keys":[{"key":"legacy","name":"primary","remark":"prod"}],
"accounts":[]
}`)
payload := map[string]any{
"keys": []any{"legacy", "new-key"},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" {
t.Fatalf("unexpected keys after legacy config update: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after legacy config update: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("existing structured metadata was lost: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Key != "new-key" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" {
t.Fatalf("new legacy key should remain metadata-free: %#v", snap.APIKeys[1])
}
}
func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) {
hash := authn.HashAdminPassword("old-password")
h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`)
@@ -315,6 +427,113 @@ func TestConfigImportMergeAndReplace(t *testing.T) {
}
}
func TestConfigImportMergePreservesStructuredAPIKeys(t *testing.T) {
h := newAdminTestHandler(t, `{
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}]
}`)
merge := map[string]any{
"mode": "merge",
"config": map[string]any{
"api_keys": []any{
map[string]any{"key": "k1", "name": "should-not-overwrite", "remark": "ignored"},
map[string]any{"key": "k2", "name": "secondary", "remark": "staging"},
},
},
}
mergeBytes, _ := json.Marshal(merge)
mergeReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(mergeBytes))
mergeRec := httptest.NewRecorder()
h.configImport(mergeRec, mergeReq)
if mergeRec.Code != http.StatusOK {
t.Fatalf("merge status=%d body=%s", mergeRec.Code, mergeRec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after structured merge: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("existing structured metadata was overwritten: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
t.Fatalf("new structured metadata was lost: %#v", snap.APIKeys[1])
}
}
func TestConfigImportMergeUpgradesLegacyAPIKeys(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["legacy"],
"accounts":[]
}`)
merge := map[string]any{
"mode": "merge",
"config": map[string]any{
"api_keys": []any{
map[string]any{"key": "legacy", "name": "primary", "remark": "prod"},
map[string]any{"key": "new-key", "name": "secondary", "remark": "staging"},
},
},
}
mergeBytes, _ := json.Marshal(merge)
mergeReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(mergeBytes))
mergeRec := httptest.NewRecorder()
h.configImport(mergeRec, mergeReq)
if mergeRec.Code != http.StatusOK {
t.Fatalf("merge status=%d body=%s", mergeRec.Code, mergeRec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" {
t.Fatalf("unexpected keys after legacy import merge: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after legacy import merge: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("legacy key metadata was not upgraded: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
t.Fatalf("new structured metadata was not preserved: %#v", snap.APIKeys[1])
}
}
func TestBatchImportUpgradesLegacyAPIKeys(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["legacy"],
"accounts":[]
}`)
payload := map[string]any{
"keys": []any{"legacy", "new-key"},
"api_keys": []any{
map[string]any{"key": "legacy", "name": "primary", "remark": "prod"},
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/admin/import", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.batchImport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" {
t.Fatalf("unexpected keys after batch import: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after batch import: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("legacy key metadata was not upgraded: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" {
t.Fatalf("new batch-imported key should stay metadata-free: %#v", snap.APIKeys[1])
}
}
func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
@@ -67,6 +67,14 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
c.AutoDelete.Mode = autoDeleteCfg.Mode
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
}
if historySplitCfg != nil {
if historySplitCfg.Enabled != nil {
c.HistorySplit.Enabled = historySplitCfg.Enabled
}
if historySplitCfg.TriggerAfterTurns != nil {
c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns
}
}
if claudeMap != nil {
c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil

View File

@@ -62,6 +62,8 @@ func toAccount(m map[string]any) config.Account {
email := fieldString(m, "email")
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
return config.Account{
Name: fieldString(m, "name"),
Remark: fieldString(m, "remark"),
Email: email,
Mobile: mobile,
Password: fieldString(m, "password"),
@@ -69,6 +71,116 @@ func toAccount(m map[string]any) config.Account {
}
}
func toAPIKeys(v any) ([]config.APIKey, bool) {
arr, ok := v.([]any)
if !ok {
return nil, false
}
out := make([]config.APIKey, 0, len(arr))
seen := map[string]struct{}{}
for _, item := range arr {
switch x := item.(type) {
case map[string]any:
key := fieldString(x, "key")
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, config.APIKey{
Key: key,
Name: fieldString(x, "name"),
Remark: fieldString(x, "remark"),
})
default:
key := strings.TrimSpace(fmt.Sprintf("%v", item))
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, config.APIKey{Key: key})
}
}
return out, true
}
func normalizeAPIKeyForStorage(item config.APIKey) config.APIKey {
return config.APIKey{
Key: strings.TrimSpace(item.Key),
Name: strings.TrimSpace(item.Name),
Remark: strings.TrimSpace(item.Remark),
}
}
func apiKeyHasMetadata(item config.APIKey) bool {
return strings.TrimSpace(item.Name) != "" || strings.TrimSpace(item.Remark) != ""
}
func mergeAPIKeysPreferStructured(existing, incoming []config.APIKey) ([]config.APIKey, int) {
if len(existing) == 0 && len(incoming) == 0 {
return nil, 0
}
merged := make([]config.APIKey, 0, len(existing)+len(incoming))
index := make(map[string]int, len(existing)+len(incoming))
for _, item := range existing {
item = normalizeAPIKeyForStorage(item)
if item.Key == "" {
continue
}
if _, ok := index[item.Key]; ok {
continue
}
index[item.Key] = len(merged)
merged = append(merged, item)
}
imported := 0
for _, item := range incoming {
item = normalizeAPIKeyForStorage(item)
if item.Key == "" {
continue
}
if idx, ok := index[item.Key]; ok {
keep := merged[idx]
next := mergeAPIKeyRecord(keep, item)
if next != keep {
merged[idx] = next
imported++
}
continue
}
index[item.Key] = len(merged)
merged = append(merged, item)
imported++
}
if len(merged) == 0 {
return nil, imported
}
return merged, imported
}
func mergeAPIKeyRecord(existing, incoming config.APIKey) config.APIKey {
existing = normalizeAPIKeyForStorage(existing)
incoming = normalizeAPIKeyForStorage(incoming)
if existing.Key == "" {
return incoming
}
if apiKeyHasMetadata(existing) {
return existing
}
if apiKeyHasMetadata(incoming) {
return incoming
}
return existing
}
func fieldString(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
@@ -77,6 +189,14 @@ func fieldString(m map[string]any, key string) string {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
func fieldStringOptional(m map[string]any, key string) (string, bool) {
v, ok := m[key]
if !ok || v == nil {
return "", false
}
return strings.TrimSpace(fmt.Sprintf("%v", v)), true
}
func statusOr(v int, d int) int {
if v == 0 {
return d
@@ -99,6 +219,8 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool {
}
func normalizeAccountForStorage(acc config.Account) config.Account {
acc.Name = strings.TrimSpace(acc.Name)
acc.Remark = strings.TrimSpace(acc.Remark)
acc.Email = strings.TrimSpace(acc.Email)
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
acc.ProxyID = strings.TrimSpace(acc.ProxyID)

View File

@@ -0,0 +1,766 @@
package chathistory
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"ds2api/internal/config"
)
const (
FileVersion = 2
DisabledLimit = 0
DefaultLimit = 20
MaxLimit = 50
defaultPreviewAt = 160
)
var allowedLimits = map[int]struct{}{
DisabledLimit: {},
10: {},
20: {},
50: {},
}
var ErrDisabled = errors.New("chat history disabled")
type Entry struct {
ID string `json:"id"`
Revision int64 `json:"revision"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CompletedAt int64 `json:"completed_at,omitempty"`
Status string `json:"status"`
CallerID string `json:"caller_id,omitempty"`
AccountID string `json:"account_id,omitempty"`
Model string `json:"model,omitempty"`
Stream bool `json:"stream"`
UserInput string `json:"user_input,omitempty"`
Messages []Message `json:"messages,omitempty"`
FinalPrompt string `json:"final_prompt,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
StatusCode int `json:"status_code,omitempty"`
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
Usage map[string]any `json:"usage,omitempty"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type SummaryEntry struct {
ID string `json:"id"`
Revision int64 `json:"revision"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CompletedAt int64 `json:"completed_at,omitempty"`
Status string `json:"status"`
CallerID string `json:"caller_id,omitempty"`
AccountID string `json:"account_id,omitempty"`
Model string `json:"model,omitempty"`
Stream bool `json:"stream"`
UserInput string `json:"user_input,omitempty"`
Preview string `json:"preview,omitempty"`
StatusCode int `json:"status_code,omitempty"`
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
DetailRevision int64 `json:"detail_revision"`
}
type File struct {
Version int `json:"version"`
Limit int `json:"limit"`
Revision int64 `json:"revision"`
Items []SummaryEntry `json:"items"`
}
type StartParams struct {
CallerID string
AccountID string
Model string
Stream bool
UserInput string
Messages []Message
FinalPrompt string
}
type UpdateParams struct {
Status string
ReasoningContent string
Content string
Error string
StatusCode int
ElapsedMs int64
FinishReason string
Usage map[string]any
Completed bool
}
type detailEnvelope struct {
Version int `json:"version"`
Item Entry `json:"item"`
}
type legacyFile struct {
Version int `json:"version"`
Limit int `json:"limit"`
Items []Entry `json:"items"`
}
type legacyProbe struct {
Items []map[string]json.RawMessage `json:"items"`
}
type Store struct {
mu sync.Mutex
path string
detailDir string
state File
details map[string]Entry
dirty map[string]struct{}
deleted map[string]struct{}
err error
}
func New(path string) *Store {
s := &Store{
path: strings.TrimSpace(path),
detailDir: strings.TrimSpace(path) + ".d",
state: File{
Version: FileVersion,
Limit: DefaultLimit,
Revision: 0,
Items: []SummaryEntry{},
},
details: map[string]Entry{},
dirty: map[string]struct{}{},
deleted: map[string]struct{}{},
}
s.mu.Lock()
defer s.mu.Unlock()
s.err = s.loadLocked()
return s
}
func (s *Store) Path() string {
if s == nil {
return ""
}
return s.path
}
func (s *Store) DetailDir() string {
if s == nil {
return ""
}
return s.detailDir
}
func (s *Store) Err() error {
if s == nil {
return errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
return s.err
}
func (s *Store) Snapshot() (File, error) {
if s == nil {
return File{}, errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return File{}, s.err
}
return cloneFile(s.state), nil
}
func (s *Store) Enabled() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return false
}
return s.state.Limit != DisabledLimit
}
func (s *Store) Get(id string) (Entry, error) {
if s == nil {
return Entry{}, errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return Entry{}, s.err
}
item, ok := s.details[strings.TrimSpace(id)]
if !ok {
return Entry{}, errors.New("chat history entry not found")
}
return cloneEntry(item), nil
}
func (s *Store) Start(params StartParams) (Entry, error) {
if s == nil {
return Entry{}, errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return Entry{}, s.err
}
if s.state.Limit == DisabledLimit {
return Entry{}, ErrDisabled
}
now := time.Now().UnixMilli()
revision := s.nextRevisionLocked()
entry := Entry{
ID: "chat_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
Revision: revision,
CreatedAt: now,
UpdatedAt: now,
Status: "streaming",
CallerID: strings.TrimSpace(params.CallerID),
AccountID: strings.TrimSpace(params.AccountID),
Model: strings.TrimSpace(params.Model),
Stream: params.Stream,
UserInput: strings.TrimSpace(params.UserInput),
Messages: cloneMessages(params.Messages),
FinalPrompt: strings.TrimSpace(params.FinalPrompt),
}
s.details[entry.ID] = entry
s.markDetailDirtyLocked(entry.ID)
s.rebuildIndexLocked()
if err := s.saveLocked(); err != nil {
return cloneEntry(entry), err
}
return cloneEntry(entry), nil
}
func (s *Store) Update(id string, params UpdateParams) (Entry, error) {
if s == nil {
return Entry{}, errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return Entry{}, s.err
}
target := strings.TrimSpace(id)
if target == "" {
return Entry{}, errors.New("history id is required")
}
item, ok := s.details[target]
if !ok {
return Entry{}, errors.New("chat history entry not found")
}
now := time.Now().UnixMilli()
item.Revision = s.nextRevisionLocked()
item.UpdatedAt = now
if params.Status != "" {
item.Status = params.Status
}
item.ReasoningContent = params.ReasoningContent
item.Content = params.Content
item.Error = strings.TrimSpace(params.Error)
item.StatusCode = params.StatusCode
item.ElapsedMs = params.ElapsedMs
item.FinishReason = strings.TrimSpace(params.FinishReason)
if params.Usage != nil {
item.Usage = cloneMap(params.Usage)
}
if params.Completed {
item.CompletedAt = now
}
s.details[target] = item
s.markDetailDirtyLocked(target)
s.rebuildIndexLocked()
if err := s.saveLocked(); err != nil {
return Entry{}, err
}
return cloneEntry(item), nil
}
func (s *Store) Delete(id string) error {
if s == nil {
return errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return s.err
}
target := strings.TrimSpace(id)
if target == "" {
return errors.New("history id is required")
}
if _, ok := s.details[target]; !ok {
return errors.New("chat history entry not found")
}
s.markDetailDeletedLocked(target)
delete(s.details, target)
s.nextRevisionLocked()
s.rebuildIndexLocked()
if err := s.saveLocked(); err != nil {
return err
}
return nil
}
func (s *Store) Clear() error {
if s == nil {
return errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return s.err
}
for id := range s.details {
s.markDetailDeletedLocked(id)
}
s.details = map[string]Entry{}
s.nextRevisionLocked()
s.rebuildIndexLocked()
if err := s.saveLocked(); err != nil {
return err
}
return nil
}
func (s *Store) SetLimit(limit int) (File, error) {
if s == nil {
return File{}, errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return File{}, s.err
}
if !isAllowedLimit(limit) {
return File{}, fmt.Errorf("unsupported chat history limit: %d", limit)
}
s.state.Limit = limit
s.nextRevisionLocked()
s.rebuildIndexLocked()
if err := s.saveLocked(); err != nil {
return File{}, err
}
return cloneFile(s.state), nil
}
func (s *Store) loadLocked() error {
if strings.TrimSpace(s.path) == "" {
return errors.New("chat history path is required")
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil && filepath.Dir(s.path) != "." {
return fmt.Errorf("create chat history dir: %w", err)
}
if err := os.MkdirAll(s.detailDir, 0o755); err != nil {
return fmt.Errorf("create chat history detail dir: %w", err)
}
raw, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if saveErr := s.saveLocked(); saveErr != nil {
config.Logger.Warn("[chat_history] bootstrap write failed", "path", s.path, "error", saveErr)
}
return nil
}
return fmt.Errorf("read chat history index: %w", err)
}
legacy, legacyOK, legacyErr := parseLegacy(raw)
if legacyErr != nil {
return legacyErr
}
if legacyOK {
s.loadLegacyLocked(legacy)
if err := s.saveLocked(); err != nil {
config.Logger.Warn("[chat_history] legacy migration writeback failed", "path", s.path, "error", err)
}
return nil
}
var state File
if err := json.Unmarshal(raw, &state); err != nil {
return fmt.Errorf("decode chat history index: %w", err)
}
if state.Version == 0 {
state.Version = FileVersion
}
if !isAllowedLimit(state.Limit) {
state.Limit = DefaultLimit
}
s.state = cloneFile(state)
s.details = map[string]Entry{}
for _, item := range state.Items {
detail, err := readDetailFile(filepath.Join(s.detailDir, item.ID+".json"))
if err != nil {
return err
}
s.details[item.ID] = detail
}
s.rebuildIndexLocked()
if saveErr := s.saveLocked(); saveErr != nil {
config.Logger.Warn("[chat_history] index rewrite failed", "path", s.path, "error", saveErr)
}
return nil
}
func (s *Store) loadLegacyLocked(legacy legacyFile) {
s.state.Version = FileVersion
s.state.Limit = legacy.Limit
if !isAllowedLimit(s.state.Limit) {
s.state.Limit = DefaultLimit
}
s.details = map[string]Entry{}
s.dirty = map[string]struct{}{}
s.deleted = map[string]struct{}{}
maxRevision := int64(0)
for _, item := range legacy.Items {
if strings.TrimSpace(item.ID) == "" {
continue
}
item.Messages = cloneMessages(item.Messages)
if item.Revision == 0 {
if item.UpdatedAt > 0 {
item.Revision = item.UpdatedAt
} else {
item.Revision = time.Now().UnixNano()
}
}
if item.Revision > maxRevision {
maxRevision = item.Revision
}
s.details[item.ID] = item
s.markDetailDirtyLocked(item.ID)
}
s.state.Revision = maxRevision
s.rebuildIndexLocked()
}
func (s *Store) saveLocked() error {
s.state.Version = FileVersion
if !isAllowedLimit(s.state.Limit) {
s.state.Limit = DefaultLimit
}
s.rebuildIndexLocked()
if err := os.MkdirAll(s.detailDir, 0o755); err != nil {
return fmt.Errorf("create chat history detail dir: %w", err)
}
for _, id := range sortedDetailIDs(s.deleted) {
path := filepath.Join(s.detailDir, id+".json")
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove stale chat history detail: %w", err)
}
}
for _, id := range sortedDetailIDs(s.dirty) {
item, ok := s.details[id]
if !ok {
continue
}
path := filepath.Join(s.detailDir, id+".json")
payload, err := json.MarshalIndent(detailEnvelope{
Version: FileVersion,
Item: item,
}, "", " ")
if err != nil {
return fmt.Errorf("encode chat history detail: %w", err)
}
if err := writeFileAtomic(path, append(payload, '\n')); err != nil {
return err
}
}
payload, err := json.MarshalIndent(s.state, "", " ")
if err != nil {
return fmt.Errorf("encode chat history index: %w", err)
}
if err := writeFileAtomic(s.path, append(payload, '\n')); err != nil {
return err
}
s.clearPendingDetailChangesLocked()
return nil
}
func (s *Store) rebuildIndexLocked() {
summaries := make([]SummaryEntry, 0, len(s.details))
for _, item := range s.details {
summaries = append(summaries, summaryFromEntry(item))
}
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].UpdatedAt == summaries[j].UpdatedAt {
return summaries[i].CreatedAt > summaries[j].CreatedAt
}
return summaries[i].UpdatedAt > summaries[j].UpdatedAt
})
if s.state.Limit < DisabledLimit || !isAllowedLimit(s.state.Limit) {
s.state.Limit = DefaultLimit
}
if s.state.Limit == DisabledLimit {
s.state.Items = summaries
return
}
if len(summaries) > s.state.Limit {
keep := make(map[string]struct{}, s.state.Limit)
for _, item := range summaries[:s.state.Limit] {
keep[item.ID] = struct{}{}
}
for id := range s.details {
if _, ok := keep[id]; !ok {
s.markDetailDeletedLocked(id)
delete(s.details, id)
}
}
summaries = summaries[:s.state.Limit]
}
s.state.Items = summaries
}
func (s *Store) nextRevisionLocked() int64 {
next := time.Now().UnixNano()
if next <= s.state.Revision {
next = s.state.Revision + 1
}
s.state.Revision = next
return next
}
func summaryFromEntry(item Entry) SummaryEntry {
return SummaryEntry{
ID: item.ID,
Revision: item.Revision,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
CompletedAt: item.CompletedAt,
Status: item.Status,
CallerID: item.CallerID,
AccountID: item.AccountID,
Model: item.Model,
Stream: item.Stream,
UserInput: item.UserInput,
Preview: buildPreview(item),
StatusCode: item.StatusCode,
ElapsedMs: item.ElapsedMs,
FinishReason: item.FinishReason,
DetailRevision: item.Revision,
}
}
func buildPreview(item Entry) string {
candidate := strings.TrimSpace(item.Content)
if candidate == "" {
candidate = strings.TrimSpace(item.ReasoningContent)
}
if candidate == "" {
candidate = strings.TrimSpace(item.Error)
}
if candidate == "" {
candidate = strings.TrimSpace(item.UserInput)
}
if len(candidate) > defaultPreviewAt {
return candidate[:defaultPreviewAt] + "..."
}
return candidate
}
func readDetailFile(path string) (Entry, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Entry{}, fmt.Errorf("read chat history detail: %w", err)
}
var env detailEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return Entry{}, fmt.Errorf("decode chat history detail: %w", err)
}
return cloneEntry(env.Item), nil
}
func parseLegacy(raw []byte) (legacyFile, bool, error) {
var legacy legacyFile
if err := json.Unmarshal(raw, &legacy); err != nil {
return legacyFile{}, false, nil
}
if len(legacy.Items) == 0 {
return legacy, false, nil
}
var probe legacyProbe
if err := json.Unmarshal(raw, &probe); err == nil {
for _, item := range probe.Items {
if _, ok := item["detail_revision"]; ok {
return legacy, false, nil
}
}
}
return legacy, true, nil
}
func writeFileAtomic(path string, body []byte) error {
dir := filepath.Dir(path)
if dir == "" {
dir = "."
}
if dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create chat history dir: %w", err)
}
}
tmpFile, err := os.CreateTemp(dir, ".chat-history-*.tmp")
if err != nil {
return fmt.Errorf("create temp chat history: %w", err)
}
tmpPath := tmpFile.Name()
cleanup := func() error {
if err := os.Remove(tmpPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove temp chat history: %w", err)
}
return nil
}
withCleanup := func(primary error, closeErr error) error {
errs := []error{primary}
if closeErr != nil {
errs = append(errs, fmt.Errorf("close temp chat history: %w", closeErr))
}
if cleanupErr := cleanup(); cleanupErr != nil {
errs = append(errs, cleanupErr)
}
return errors.Join(errs...)
}
if _, err := tmpFile.Write(body); err != nil {
return withCleanup(fmt.Errorf("write temp chat history: %w", err), tmpFile.Close())
}
if err := tmpFile.Sync(); err != nil {
return withCleanup(fmt.Errorf("sync temp chat history: %w", err), tmpFile.Close())
}
if err := tmpFile.Close(); err != nil {
if cleanupErr := cleanup(); cleanupErr != nil {
return errors.Join(fmt.Errorf("close temp chat history: %w", err), cleanupErr)
}
return fmt.Errorf("close temp chat history: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
if cleanupErr := cleanup(); cleanupErr != nil {
return errors.Join(fmt.Errorf("promote temp chat history: %w", err), cleanupErr)
}
return fmt.Errorf("promote temp chat history: %w", err)
}
return nil
}
func ListETag(revision int64) string {
return fmt.Sprintf(`W/"chat-history-list-%d"`, revision)
}
func DetailETag(id string, revision int64) string {
return fmt.Sprintf(`W/"chat-history-detail-%s-%d"`, strings.TrimSpace(id), revision)
}
func isAllowedLimit(limit int) bool {
_, ok := allowedLimits[limit]
return ok
}
func (s *Store) markDetailDirtyLocked(id string) {
id = strings.TrimSpace(id)
if id == "" {
return
}
if s.dirty == nil {
s.dirty = map[string]struct{}{}
}
if s.deleted == nil {
s.deleted = map[string]struct{}{}
}
s.dirty[id] = struct{}{}
delete(s.deleted, id)
}
func (s *Store) markDetailDeletedLocked(id string) {
id = strings.TrimSpace(id)
if id == "" {
return
}
if s.dirty == nil {
s.dirty = map[string]struct{}{}
}
if s.deleted == nil {
s.deleted = map[string]struct{}{}
}
s.deleted[id] = struct{}{}
delete(s.dirty, id)
}
func (s *Store) clearPendingDetailChangesLocked() {
s.dirty = map[string]struct{}{}
s.deleted = map[string]struct{}{}
}
func sortedDetailIDs(ids map[string]struct{}) []string {
if len(ids) == 0 {
return nil
}
out := make([]string, 0, len(ids))
for id := range ids {
out = append(out, id)
}
sort.Strings(out)
return out
}
func cloneFile(in File) File {
out := File{
Version: in.Version,
Limit: in.Limit,
Revision: in.Revision,
Items: make([]SummaryEntry, len(in.Items)),
}
copy(out.Items, in.Items)
return out
}
func cloneEntry(item Entry) Entry {
item.Usage = cloneMap(item.Usage)
item.Messages = cloneMessages(item.Messages)
return item
}
func cloneMap(in map[string]any) map[string]any {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneMessages(messages []Message) []Message {
if len(messages) == 0 {
return []Message{}
}
out := make([]Message, len(messages))
copy(out, messages)
return out
}

View File

@@ -0,0 +1,483 @@
package chathistory
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func blockDetailDir(t *testing.T, detailDir string) func() {
t.Helper()
blockedDir := detailDir + ".blocked"
if err := os.RemoveAll(blockedDir); err != nil {
t.Fatalf("remove blocked detail dir failed: %v", err)
}
if err := os.Rename(detailDir, blockedDir); err != nil {
t.Fatalf("move detail dir aside failed: %v", err)
}
if err := os.RemoveAll(detailDir); err != nil {
t.Fatalf("remove blocked detail path failed: %v", err)
}
if err := os.WriteFile(detailDir, []byte("blocked"), 0o644); err != nil {
t.Fatalf("write blocked detail path failed: %v", err)
}
var once sync.Once
return func() {
t.Helper()
once.Do(func() {
if err := os.RemoveAll(detailDir); err != nil {
t.Fatalf("remove blocking detail path failed: %v", err)
}
if err := os.Rename(blockedDir, detailDir); err != nil {
t.Fatalf("restore detail dir failed: %v", err)
}
})
}
}
func TestStoreCreatesAndPersistsEntries(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
store := New(path)
started, err := store.Start(StartParams{
CallerID: "caller:abc",
AccountID: "user@example.com",
Model: "deepseek-chat",
Stream: true,
UserInput: "hello",
})
if err != nil {
t.Fatalf("start entry failed: %v", err)
}
updated, err := store.Update(started.ID, UpdateParams{
Status: "success",
ReasoningContent: "thinking",
Content: "answer",
StatusCode: 200,
ElapsedMs: 321,
FinishReason: "stop",
Usage: map[string]any{"total_tokens": 9},
Completed: true,
})
if err != nil {
t.Fatalf("update entry failed: %v", err)
}
if updated.Status != "success" || updated.Content != "answer" {
t.Fatalf("unexpected updated entry: %#v", updated)
}
snapshot, err := store.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if snapshot.Limit != DefaultLimit {
t.Fatalf("unexpected default limit: %d", snapshot.Limit)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one item, got %d", len(snapshot.Items))
}
if snapshot.Items[0].CompletedAt == 0 {
t.Fatalf("expected completed_at to be populated")
}
if snapshot.Items[0].Preview != "answer" {
t.Fatalf("expected summary preview=answer, got %#v", snapshot.Items[0])
}
reloaded := New(path)
reloadedSnapshot, err := reloaded.Snapshot()
if err != nil {
t.Fatalf("reload snapshot failed: %v", err)
}
if len(reloadedSnapshot.Items) != 1 {
t.Fatalf("unexpected reloaded summaries: %#v", reloadedSnapshot.Items)
}
full, err := reloaded.Get(started.ID)
if err != nil {
t.Fatalf("get detail failed: %v", err)
}
if full.Content != "answer" {
t.Fatalf("expected detail content=answer, got %#v", full)
}
}
func TestStoreTrimsToConfiguredLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
store := New(path)
if _, err := store.SetLimit(10); err != nil {
t.Fatalf("set limit failed: %v", err)
}
for i := 0; i < 12; i++ {
entry, err := store.Start(StartParams{Model: "deepseek-chat", UserInput: "msg"})
if err != nil {
t.Fatalf("start %d failed: %v", i, err)
}
if _, err := store.Update(entry.ID, UpdateParams{Status: "success", Content: "ok", Completed: true}); err != nil {
t.Fatalf("update %d failed: %v", i, err)
}
}
snapshot, err := store.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 10 {
t.Fatalf("expected 10 items, got %d", len(snapshot.Items))
}
}
func TestStoreDeleteClearAndLimitValidation(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
store := New(path)
entry, err := store.Start(StartParams{UserInput: "hello"})
if err != nil {
t.Fatalf("start failed: %v", err)
}
if err := store.Delete(entry.ID); err != nil {
t.Fatalf("delete failed: %v", err)
}
snapshot, err := store.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 0 {
t.Fatalf("expected empty items after delete, got %d", len(snapshot.Items))
}
if _, err := store.SetLimit(999); err == nil {
t.Fatalf("expected invalid limit error")
}
if err := store.Clear(); err != nil {
t.Fatalf("clear failed: %v", err)
}
}
func TestStoreDisablePreservesHistoryAndBlocksNewEntries(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
store := New(path)
entry, err := store.Start(StartParams{UserInput: "hello"})
if err != nil {
t.Fatalf("start failed: %v", err)
}
if _, err := store.Update(entry.ID, UpdateParams{Status: "success", Content: "world", Completed: true}); err != nil {
t.Fatalf("update failed: %v", err)
}
snapshot, err := store.SetLimit(DisabledLimit)
if err != nil {
t.Fatalf("disable failed: %v", err)
}
if snapshot.Limit != DisabledLimit {
t.Fatalf("expected disabled limit, got %d", snapshot.Limit)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected disabled mode to preserve summaries, got %d", len(snapshot.Items))
}
if store.Enabled() {
t.Fatalf("expected store to report disabled")
}
if _, err := store.Start(StartParams{UserInput: "later"}); err != ErrDisabled {
t.Fatalf("expected ErrDisabled, got %v", err)
}
}
func TestStoreConcurrentUpdatesKeepSplitFilesValid(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
store := New(path)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
entry, err := store.Start(StartParams{
CallerID: "caller:test",
Model: "deepseek-chat",
UserInput: "hello",
})
if err != nil {
t.Errorf("start failed: %v", err)
return
}
_, err = store.Update(entry.ID, UpdateParams{
Status: "success",
Content: "answer",
ElapsedMs: int64(idx),
Completed: true,
})
if err != nil {
t.Errorf("update failed: %v", err)
}
}(i)
}
wg.Wait()
snapshot, err := store.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 8 {
t.Fatalf("expected 8 items, got %d", len(snapshot.Items))
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read index failed: %v", err)
}
var persisted File
if err := json.Unmarshal(raw, &persisted); err != nil {
t.Fatalf("persisted index is invalid json: %v", err)
}
if len(persisted.Items) != 8 {
t.Fatalf("expected persisted items=8, got %d", len(persisted.Items))
}
detailFiles, err := os.ReadDir(path + ".d")
if err != nil {
t.Fatalf("read detail dir failed: %v", err)
}
if len(detailFiles) != 8 {
t.Fatalf("expected 8 detail files, got %d", len(detailFiles))
}
}
func TestStoreAutoMigratesLegacyMonolith(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
legacy := legacyFile{
Version: 1,
Limit: 20,
Items: []Entry{{
ID: "chat_legacy",
CreatedAt: 1,
UpdatedAt: 2,
Status: "success",
UserInput: "hello",
Content: "world",
ReasoningContent: "thinking",
}},
}
body, _ := json.MarshalIndent(legacy, "", " ")
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write legacy file failed: %v", err)
}
store := New(path)
if err := store.Err(); err != nil {
t.Fatalf("expected legacy migration success, got %v", err)
}
snapshot, err := store.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one migrated summary, got %#v", snapshot.Items)
}
full, err := store.Get("chat_legacy")
if err != nil {
t.Fatalf("get migrated detail failed: %v", err)
}
if full.Content != "world" {
t.Fatalf("expected migrated detail content preserved, got %#v", full)
}
}
func TestStoreAutoMigratesMetadataOnlyLegacyMonolith(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
legacy := legacyFile{
Version: 1,
Limit: 20,
Items: []Entry{{
ID: "chat_metadata_only",
Revision: 0,
CreatedAt: 1,
UpdatedAt: 2,
Status: "error",
CallerID: "caller:test",
AccountID: "acct:test",
Model: "deepseek-chat",
Stream: true,
UserInput: "hello",
Error: "boom",
StatusCode: 500,
ElapsedMs: 12,
FinishReason: "error",
}},
}
body, _ := json.MarshalIndent(legacy, "", " ")
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write legacy file failed: %v", err)
}
store := New(path)
if err := store.Err(); err != nil {
t.Fatalf("expected legacy metadata-only migration success, got %v", err)
}
snapshot, err := store.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 1 {
t.Fatalf("expected one migrated summary, got %#v", snapshot.Items)
}
full, err := store.Get("chat_metadata_only")
if err != nil {
t.Fatalf("get migrated detail failed: %v", err)
}
if full.Error != "boom" || full.UserInput != "hello" {
t.Fatalf("expected metadata-only legacy fields preserved, got %#v", full)
}
if _, err := os.Stat(filepath.Join(store.DetailDir(), "chat_metadata_only.json")); err != nil {
t.Fatalf("expected migrated detail file to exist: %v", err)
}
}
func TestStoreLegacyMigrationBestEffortWhenRewriteFails(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
longID := "chat_" + strings.Repeat("x", 320)
legacy := legacyFile{
Version: 1,
Limit: 20,
Items: []Entry{{
ID: longID,
CreatedAt: 1,
UpdatedAt: 2,
Status: "success",
UserInput: "hello",
Content: "world",
}},
}
body, err := json.MarshalIndent(legacy, "", " ")
if err != nil {
t.Fatalf("marshal legacy file failed: %v", err)
}
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write legacy file failed: %v", err)
}
store := New(path)
if err := store.Err(); err != nil {
t.Fatalf("expected store to stay usable after migration writeback failure, got %v", err)
}
if !store.Enabled() {
t.Fatal("expected store to remain enabled after best-effort migration")
}
snapshot, err := store.Snapshot()
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
if len(snapshot.Items) != 1 || snapshot.Items[0].ID != longID {
t.Fatalf("unexpected snapshot after best-effort migration: %#v", snapshot.Items)
}
full, err := store.Get(longID)
if err != nil {
t.Fatalf("get migrated detail failed: %v", err)
}
if full.Content != "world" {
t.Fatalf("expected migrated content to stay in memory, got %#v", full)
}
if _, statErr := os.Stat(filepath.Join(store.DetailDir(), longID+".json")); statErr == nil {
t.Fatal("expected detail write to fail for overlong legacy id")
}
}
func TestStoreTransientPersistenceFailureDoesNotLatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
store := New(path)
first, err := store.Start(StartParams{UserInput: "first"})
if err != nil {
t.Fatalf("start first failed: %v", err)
}
restore := blockDetailDir(t, store.DetailDir())
t.Cleanup(restore)
blocked, err := store.Start(StartParams{UserInput: "blocked"})
if err == nil {
t.Fatalf("expected start failure while detail dir is blocked")
}
if blocked.ID == "" {
t.Fatalf("expected in-memory entry from failed start")
}
if err := store.Err(); err != nil {
t.Fatalf("transient start failure should not latch store error: %v", err)
}
if _, err := store.Update(first.ID, UpdateParams{Status: "success", Content: "one", Completed: true}); err == nil {
t.Fatalf("expected update failure while detail dir is blocked")
}
if err := store.Err(); err != nil {
t.Fatalf("transient update failure should not latch store error: %v", err)
}
restore()
if _, err := store.Update(blocked.ID, UpdateParams{Status: "success", Content: "two", Completed: true}); err != nil {
t.Fatalf("update after restore failed: %v", err)
}
if _, err := store.Start(StartParams{UserInput: "later"}); err != nil {
t.Fatalf("start after restore failed: %v", err)
}
full, err := store.Get(blocked.ID)
if err != nil {
t.Fatalf("get restored entry failed: %v", err)
}
if full.Content != "two" || full.Status != "success" {
t.Fatalf("expected restored entry persisted, got %#v", full)
}
}
func TestStoreWritesOnlyChangedDetailFiles(t *testing.T) {
path := filepath.Join(t.TempDir(), "chat_history.json")
store := New(path)
first, err := store.Start(StartParams{UserInput: "one"})
if err != nil {
t.Fatalf("start first failed: %v", err)
}
if _, err := store.Update(first.ID, UpdateParams{Status: "success", Content: "first", Completed: true}); err != nil {
t.Fatalf("update first failed: %v", err)
}
second, err := store.Start(StartParams{UserInput: "two"})
if err != nil {
t.Fatalf("start second failed: %v", err)
}
if _, err := store.Update(second.ID, UpdateParams{Status: "success", Content: "second", Completed: true}); err != nil {
t.Fatalf("update second failed: %v", err)
}
firstPath := filepath.Join(store.DetailDir(), first.ID+".json")
secondPath := filepath.Join(store.DetailDir(), second.ID+".json")
beforeFirst, err := os.ReadFile(firstPath)
if err != nil {
t.Fatalf("read first detail before update failed: %v", err)
}
beforeSecond, err := os.ReadFile(secondPath)
if err != nil {
t.Fatalf("read second detail before update failed: %v", err)
}
if _, err := store.Update(first.ID, UpdateParams{Status: "success", Content: "first-updated", Completed: true}); err != nil {
t.Fatalf("update first again failed: %v", err)
}
afterFirst, err := os.ReadFile(firstPath)
if err != nil {
t.Fatalf("read first detail after update failed: %v", err)
}
afterSecond, err := os.ReadFile(secondPath)
if err != nil {
t.Fatalf("read second detail after update failed: %v", err)
}
if bytes.Equal(beforeFirst, afterFirst) {
t.Fatalf("expected first detail file to change after update")
}
if !bytes.Equal(beforeSecond, afterSecond) {
t.Fatalf("expected untouched detail file to remain byte-identical")
}
}

View File

@@ -17,6 +17,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
if len(c.Keys) > 0 {
m["keys"] = c.Keys
}
if len(c.APIKeys) > 0 {
m["api_keys"] = c.APIKeys
}
if len(c.Accounts) > 0 {
m["accounts"] = c.Accounts
}
@@ -48,6 +51,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
m["embeddings"] = c.Embeddings
}
m["auto_delete"] = c.AutoDelete
if c.HistorySplit.Enabled != nil || c.HistorySplit.TriggerAfterTurns != nil {
m["history_split"] = c.HistorySplit
}
if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash
}
@@ -69,6 +75,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Keys); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "api_keys":
if err := json.Unmarshal(v, &c.APIKeys); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "accounts":
if err := json.Unmarshal(v, &c.Accounts); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -115,6 +125,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.AutoDelete); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "history_split":
if err := json.Unmarshal(v, &c.HistorySplit); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -130,12 +144,14 @@ func (c *Config) UnmarshalJSON(b []byte) error {
}
}
}
c.NormalizeCredentials()
return nil
}
func (c Config) Clone() Config {
clone := Config{
Keys: slices.Clone(c.Keys),
APIKeys: slices.Clone(c.APIKeys),
Accounts: slices.Clone(c.Accounts),
Proxies: slices.Clone(c.Proxies),
ClaudeMapping: cloneStringMap(c.ClaudeMapping),
@@ -147,9 +163,13 @@ func (c Config) Clone() Config {
WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput),
StripReferenceMarkers: cloneBoolPtr(c.Compat.StripReferenceMarkers),
},
Responses: c.Responses,
Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete,
Responses: c.Responses,
Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete,
HistorySplit: HistorySplitConfig{
Enabled: cloneBoolPtr(c.HistorySplit.Enabled),
TriggerAfterTurns: cloneIntPtr(c.HistorySplit.TriggerAfterTurns),
},
VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{},
@@ -179,6 +199,14 @@ func cloneBoolPtr(in *bool) *bool {
return &v
}
func cloneIntPtr(in *int) *int {
if in == nil {
return nil
}
v := *in
return &v
}
func parseConfigString(raw string) (Config, error) {
var cfg Config
candidates := []string{raw}

View File

@@ -8,24 +8,28 @@ import (
)
type Config struct {
Keys []string `json:"keys,omitempty"`
Accounts []Account `json:"accounts,omitempty"`
Proxies []Proxy `json:"proxies,omitempty"`
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
Admin AdminConfig `json:"admin,omitempty"`
Runtime RuntimeConfig `json:"runtime,omitempty"`
Compat CompatConfig `json:"compat,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
Keys []string `json:"keys,omitempty"`
APIKeys []APIKey `json:"api_keys,omitempty"`
Accounts []Account `json:"accounts,omitempty"`
Proxies []Proxy `json:"proxies,omitempty"`
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
Admin AdminConfig `json:"admin,omitempty"`
Runtime RuntimeConfig `json:"runtime,omitempty"`
Compat CompatConfig `json:"compat,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
HistorySplit HistorySplitConfig `json:"history_split"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
}
type Account struct {
Name string `json:"name,omitempty"`
Remark string `json:"remark,omitempty"`
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
@@ -33,6 +37,12 @@ type Account struct {
ProxyID string `json:"proxy_id,omitempty"`
}
type APIKey struct {
Key string `json:"key"`
Name string `json:"name,omitempty"`
Remark string `json:"remark,omitempty"`
}
type Proxy struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
@@ -73,6 +83,25 @@ func (c *Config) ClearAccountTokens() {
}
}
func (c *Config) NormalizeCredentials() {
if c == nil {
return
}
normalizedAPIKeys := normalizeAPIKeys(c.APIKeys)
if len(normalizedAPIKeys) > 0 {
c.APIKeys = normalizedAPIKeys
c.Keys = apiKeysToStrings(c.APIKeys)
} else {
c.Keys = normalizeKeys(c.Keys)
c.APIKeys = apiKeysFromStrings(c.Keys, nil)
}
for i := range c.Accounts {
c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name)
c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark)
}
}
// DropInvalidAccounts removes accounts that cannot be addressed by admin APIs
// (no email and no normalizable mobile). This prevents legacy token-only
// records from becoming orphaned empty entries after token stripping.
@@ -120,3 +149,8 @@ type AutoDeleteConfig struct {
Mode string `json:"mode,omitempty"`
Sessions bool `json:"sessions,omitempty"`
}
type HistorySplitConfig struct {
Enabled *bool `json:"enabled,omitempty"`
TriggerAfterTurns *int `json:"trigger_after_turns,omitempty"`
}

View File

@@ -154,6 +154,10 @@ func TestConfigJSONRoundtrip(t *testing.T) {
AutoDelete: AutoDeleteConfig{
Mode: "single",
},
HistorySplit: HistorySplitConfig{
Enabled: &trueVal,
TriggerAfterTurns: func() *int { v := 2; return &v }(),
},
Runtime: RuntimeConfig{
TokenRefreshIntervalHours: 12,
},
@@ -193,6 +197,12 @@ func TestConfigJSONRoundtrip(t *testing.T) {
if decoded.AutoDelete.Mode != "single" {
t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode)
}
if decoded.HistorySplit.Enabled == nil || !*decoded.HistorySplit.Enabled {
t.Fatalf("unexpected history split enabled: %#v", decoded.HistorySplit.Enabled)
}
if decoded.HistorySplit.TriggerAfterTurns == nil || *decoded.HistorySplit.TriggerAfterTurns != 2 {
t.Fatalf("unexpected history split trigger_after_turns: %#v", decoded.HistorySplit.TriggerAfterTurns)
}
if decoded.Compat.WideInputStrictOutput == nil || !*decoded.Compat.WideInputStrictOutput {
t.Fatalf("unexpected compat wide_input_strict_output: %#v", decoded.Compat.WideInputStrictOutput)
}
@@ -249,6 +259,8 @@ func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) {
func TestConfigCloneIsDeepCopy(t *testing.T) {
falseVal := false
trueVal := true
turns := 2
cfg := Config{
Keys: []string{"key1"},
Accounts: []Account{{Email: "user@test.com", Token: "token"}},
@@ -258,6 +270,10 @@ func TestConfigCloneIsDeepCopy(t *testing.T) {
Compat: CompatConfig{
StripReferenceMarkers: &falseVal,
},
HistorySplit: HistorySplitConfig{
Enabled: &trueVal,
TriggerAfterTurns: &turns,
},
AdditionalFields: map[string]any{"custom": "value"},
}
@@ -270,6 +286,12 @@ func TestConfigCloneIsDeepCopy(t *testing.T) {
if cfg.Compat.StripReferenceMarkers != nil {
*cfg.Compat.StripReferenceMarkers = true
}
if cfg.HistorySplit.Enabled != nil {
*cfg.HistorySplit.Enabled = false
}
if cfg.HistorySplit.TriggerAfterTurns != nil {
*cfg.HistorySplit.TriggerAfterTurns = 5
}
// Cloned should not be affected
if cloned.Keys[0] != "key1" {
@@ -284,6 +306,12 @@ func TestConfigCloneIsDeepCopy(t *testing.T) {
if cloned.Compat.StripReferenceMarkers == nil || *cloned.Compat.StripReferenceMarkers {
t.Fatalf("clone compat was affected: %#v", cloned.Compat.StripReferenceMarkers)
}
if cloned.HistorySplit.Enabled == nil || !*cloned.HistorySplit.Enabled {
t.Fatalf("clone history split enabled was affected: %#v", cloned.HistorySplit.Enabled)
}
if cloned.HistorySplit.TriggerAfterTurns == nil || *cloned.HistorySplit.TriggerAfterTurns != 2 {
t.Fatalf("clone history split trigger was affected: %#v", cloned.HistorySplit.TriggerAfterTurns)
}
}
func TestConfigCloneNilMaps(t *testing.T) {
@@ -529,6 +557,101 @@ func TestStoreUpdate(t *testing.T) {
}
}
func TestStoreUpdateReconcilesAPIKeyMutations(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}],
"accounts":[]
}`)
store := LoadStore()
if err := store.Update(func(cfg *Config) error {
cfg.APIKeys = append(cfg.APIKeys, APIKey{Key: "k2", Name: "secondary", Remark: "staging"})
return nil
}); err != nil {
t.Fatalf("add api key failed: %v", err)
}
snap := store.Snapshot()
if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" {
t.Fatalf("unexpected keys after api key add: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys length after add: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("metadata for existing key was lost: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
t.Fatalf("metadata for new key was lost: %#v", snap.APIKeys[1])
}
if err := store.Update(func(cfg *Config) error {
cfg.APIKeys = append([]APIKey(nil), cfg.APIKeys[1:]...)
return nil
}); err != nil {
t.Fatalf("delete api key failed: %v", err)
}
snap = store.Snapshot()
if len(snap.Keys) != 1 || snap.Keys[0] != "k2" {
t.Fatalf("unexpected keys after api key delete: %#v", snap.Keys)
}
if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" {
t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys)
}
}
func TestStoreUpdateReconcilesLegacyKeyMutations(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}],
"accounts":[]
}`)
store := LoadStore()
if err := store.Update(func(cfg *Config) error {
cfg.Keys = append(cfg.Keys, "k2")
return nil
}); err != nil {
t.Fatalf("legacy key update failed: %v", err)
}
snap := store.Snapshot()
if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" {
t.Fatalf("unexpected keys after legacy update: %#v", snap.Keys)
}
if len(snap.APIKeys) != 2 {
t.Fatalf("unexpected api keys after legacy update: %#v", snap.APIKeys)
}
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
t.Fatalf("metadata for preserved key was lost: %#v", snap.APIKeys[0])
}
if snap.APIKeys[1].Key != "k2" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" {
t.Fatalf("new legacy key should stay metadata-free: %#v", snap.APIKeys[1])
}
}
func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) {
cfg := Config{
Keys: []string{"legacy-key"},
APIKeys: []APIKey{
{Key: "structured-key", Name: "primary", Remark: "prod"},
},
}
cfg.NormalizeCredentials()
if len(cfg.Keys) != 1 || cfg.Keys[0] != "structured-key" {
t.Fatalf("unexpected normalized keys: %#v", cfg.Keys)
}
if len(cfg.APIKeys) != 1 {
t.Fatalf("unexpected normalized api keys: %#v", cfg.APIKeys)
}
if cfg.APIKeys[0].Key != "structured-key" || cfg.APIKeys[0].Name != "primary" || cfg.APIKeys[0].Remark != "prod" {
t.Fatalf("unexpected structured api key metadata: %#v", cfg.APIKeys[0])
}
}
func TestStoreClaudeMapping(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-chat","slow":"deepseek-reasoner"}}`)
store := LoadStore()

View File

@@ -0,0 +1,158 @@
package config
import (
"slices"
"strings"
)
func (c *Config) ReconcileCredentials(base Config) {
if c == nil {
return
}
currKeys := normalizeKeys(c.Keys)
currAPIKeys := normalizeAPIKeys(c.APIKeys)
baseKeys := normalizeKeys(base.Keys)
baseAPIKeys := normalizeAPIKeys(base.APIKeys)
keysChanged := !slices.Equal(currKeys, baseKeys)
apiKeysChanged := !equalAPIKeys(currAPIKeys, baseAPIKeys)
if keysChanged && !apiKeysChanged {
c.APIKeys = apiKeysFromStrings(currKeys, apiKeyMap(baseAPIKeys))
} else {
c.APIKeys = currAPIKeys
}
c.Keys = apiKeysToStrings(c.APIKeys)
}
func normalizeKeys(keys []string) []string {
if len(keys) == 0 {
return nil
}
out := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeAPIKeys(items []APIKey) []APIKey {
if len(items) == 0 {
return nil
}
out := make([]APIKey, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
key := strings.TrimSpace(item.Key)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, APIKey{
Key: key,
Name: strings.TrimSpace(item.Name),
Remark: strings.TrimSpace(item.Remark),
})
}
if len(out) == 0 {
return nil
}
return out
}
func apiKeysFromStrings(keys []string, meta map[string]APIKey) []APIKey {
if len(keys) == 0 {
return nil
}
out := make([]APIKey, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if item, ok := meta[key]; ok {
out = append(out, APIKey{
Key: key,
Name: strings.TrimSpace(item.Name),
Remark: strings.TrimSpace(item.Remark),
})
continue
}
out = append(out, APIKey{Key: key})
}
if len(out) == 0 {
return nil
}
return out
}
func apiKeysToStrings(items []APIKey) []string {
if len(items) == 0 {
return nil
}
keys := make([]string, 0, len(items))
for _, item := range items {
key := strings.TrimSpace(item.Key)
if key == "" {
continue
}
keys = append(keys, key)
}
if len(keys) == 0 {
return nil
}
return keys
}
func apiKeyMap(items []APIKey) map[string]APIKey {
if len(items) == 0 {
return nil
}
out := make(map[string]APIKey, len(items))
for _, item := range items {
key := strings.TrimSpace(item.Key)
if key == "" {
continue
}
if _, ok := out[key]; ok {
continue
}
out[key] = APIKey{
Key: key,
Name: strings.TrimSpace(item.Name),
Remark: strings.TrimSpace(item.Remark),
}
}
return out
}
func equalAPIKeys(a, b []APIKey) bool {
if len(a) != len(b) {
return false
}
return slices.EqualFunc(a, b, func(x, y APIKey) bool {
return strings.TrimSpace(x.Key) == strings.TrimSpace(y.Key) &&
strings.TrimSpace(x.Name) == strings.TrimSpace(y.Name) &&
strings.TrimSpace(x.Remark) == strings.TrimSpace(y.Remark)
})
}

View File

@@ -37,6 +37,10 @@ func RawStreamSampleRoot() string {
return ResolvePath("DS2API_RAW_STREAM_SAMPLE_ROOT", "tests/raw_stream_samples")
}
func ChatHistoryPath() string {
return ResolvePath("DS2API_CHAT_HISTORY_PATH", "data/chat_history.json")
}
func StaticAdminDir() string {
return ResolvePath("DS2API_STATIC_ADMIN_DIR", "static/admin")
}

View File

@@ -43,6 +43,7 @@ func LoadStoreWithError() (*Store, error) {
func loadStore() (*Store, error) {
cfg, fromEnv, err := loadConfig()
cfg.NormalizeCredentials()
if validateErr := ValidateConfig(cfg); validateErr != nil {
err = errors.Join(err, validateErr)
}
@@ -112,6 +113,7 @@ func loadConfigFromFile(path string) (Config, error) {
if err := json.Unmarshal(content, &cfg); err != nil {
return Config{}, err
}
cfg.NormalizeCredentials()
cfg.DropInvalidAccounts()
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
@@ -207,6 +209,7 @@ func (s *Store) UpdateAccountToken(identifier, token string) error {
func (s *Store) Replace(cfg Config) error {
s.mu.Lock()
defer s.mu.Unlock()
cfg.NormalizeCredentials()
s.cfg = cfg.Clone()
s.rebuildIndexes()
return s.saveLocked()
@@ -215,10 +218,13 @@ func (s *Store) Replace(cfg Config) error {
func (s *Store) Update(mutator func(*Config) error) error {
s.mu.Lock()
defer s.mu.Unlock()
cfg := s.cfg.Clone()
base := s.cfg.Clone()
cfg := base.Clone()
if err := mutator(&cfg); err != nil {
return err
}
cfg.ReconcileCredentials(base)
cfg.NormalizeCredentials()
s.cfg = cfg
s.rebuildIndexes()
return s.saveLocked()

View File

@@ -174,3 +174,21 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int {
func (s *Store) AutoDeleteSessions() bool {
return s.AutoDeleteMode() != "none"
}
func (s *Store) HistorySplitEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.HistorySplit.Enabled == nil {
return true
}
return *s.cfg.HistorySplit.Enabled
}
func (s *Store) HistorySplitTriggerAfterTurns() int {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.HistorySplit.TriggerAfterTurns == nil || *s.cfg.HistorySplit.TriggerAfterTurns <= 0 {
return 1
}
return *s.cfg.HistorySplit.TriggerAfterTurns
}

View File

@@ -0,0 +1,27 @@
package config
import "testing"
func TestStoreHistorySplitAccessors(t *testing.T) {
store := &Store{cfg: Config{}}
if !store.HistorySplitEnabled() {
t.Fatal("expected history split enabled by default")
}
if got := store.HistorySplitTriggerAfterTurns(); got != 1 {
t.Fatalf("default history split trigger_after_turns=%d want=1", got)
}
enabled := false
turns := 3
store.cfg.HistorySplit = HistorySplitConfig{
Enabled: &enabled,
TriggerAfterTurns: &turns,
}
if store.HistorySplitEnabled() {
t.Fatal("expected history split disabled after override")
}
if got := store.HistorySplitTriggerAfterTurns(); got != 3 {
t.Fatalf("history split trigger_after_turns=%d want=3", got)
}
}

View File

@@ -24,6 +24,9 @@ func ValidateConfig(c Config) error {
if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil {
return err
}
if err := ValidateHistorySplitConfig(c.HistorySplit); err != nil {
return err
}
if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
return err
}
@@ -111,6 +114,15 @@ func ValidateAutoDeleteConfig(autoDelete AutoDeleteConfig) error {
return ValidateAutoDeleteMode(autoDelete.Mode)
}
func ValidateHistorySplitConfig(historySplit HistorySplitConfig) error {
if historySplit.TriggerAfterTurns != nil {
if err := ValidateIntRange("history_split.trigger_after_turns", *historySplit.TriggerAfterTurns, 1, 1000, true); err != nil {
return err
}
}
return nil
}
func ValidateIntRange(name string, value, min, max int, required bool) error {
if value == 0 && !required {
return nil

View File

@@ -39,6 +39,13 @@ func TestValidateConfigRejectsInvalidValues(t *testing.T) {
cfg: Config{AutoDelete: AutoDeleteConfig{Mode: "maybe"}},
want: "auto_delete.mode",
},
{
name: "history split",
cfg: Config{HistorySplit: HistorySplitConfig{
TriggerAfterTurns: intPtr(0),
}},
want: "history_split.trigger_after_turns",
},
}
for _, tc := range tests {
@@ -59,3 +66,5 @@ func TestValidateConfigAcceptsLegacyAutoDeleteSessions(t *testing.T) {
t.Fatalf("expected legacy auto_delete.sessions config to remain valid, got %v", err)
}
}
func intPtr(v int) *int { return &v }

View File

@@ -18,6 +18,7 @@ const {
normalizePreparedToolNames,
boolDefaultTrue,
filterIncrementalToolCallDeltasByAllowed,
resetStreamToolCallState,
} = require('./toolcall_policy');
const {
estimateTokens,
@@ -115,6 +116,7 @@ module.exports.__test = {
normalizePreparedToolNames,
boolDefaultTrue,
filterIncrementalToolCallDeltasByAllowed,
resetStreamToolCallState,
estimateTokens,
buildUsage,
filterLeakedContentFilterParts,

View File

@@ -98,6 +98,15 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName
return out;
}
function resetStreamToolCallState(idStore, seenNames) {
if (idStore instanceof Map) {
idStore.clear();
}
if (seenNames instanceof Map) {
seenNames.clear();
}
}
function ensureStreamToolCallID(idStore, index) {
const key = Number.isInteger(index) ? index : 0;
const existing = idStore.get(key);
@@ -135,4 +144,5 @@ module.exports = {
boolDefaultTrue,
formatIncrementalToolCallDeltas,
filterIncrementalToolCallDeltasByAllowed,
resetStreamToolCallState,
};

View File

@@ -18,6 +18,7 @@ const {
formatIncrementalToolCallDeltas,
filterIncrementalToolCallDeltasByAllowed,
boolDefaultTrue,
resetStreamToolCallState,
} = require('./toolcall_policy');
const { createChatCompletionEmitter } = require('./stream_emitter');
const {
@@ -161,6 +162,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
continue;
}
if (evt.text) {
@@ -283,6 +285,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
if (evt.type === 'tool_calls') {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
continue;
}
if (evt.text) {

View File

@@ -1,14 +1,10 @@
'use strict';
// Keep in sync with Go toolSieveContextTailLimit.
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 2048;
function createToolSieveState() {
return {
pending: '',
capture: '',
capturing: false,
recentTextTail: '',
codeFenceStack: [],
codeFencePendingTicks: 0,
codeFenceLineStart: true,
@@ -39,20 +35,6 @@ function noteText(state, text) {
return;
}
updateCodeFenceState(state, text);
state.recentTextTail = appendTail(state.recentTextTail, text, TOOL_SIEVE_CONTEXT_TAIL_LIMIT);
}
function appendTail(prev, next, max) {
const left = typeof prev === 'string' ? prev : '';
const right = typeof next === 'string' ? next : '';
if (!Number.isFinite(max) || max <= 0) {
return '';
}
const combined = left + right;
if (combined.length <= max) {
return combined;
}
return combined.slice(combined.length - max);
}
function looksLikeToolExampleContext(text) {
@@ -171,11 +153,9 @@ function toStringSafe(v) {
}
module.exports = {
TOOL_SIEVE_CONTEXT_TAIL_LIMIT,
createToolSieveState,
resetIncrementalToolState,
noteText,
appendTail,
looksLikeToolExampleContext,
insideCodeFence,
insideCodeFenceWithState,

View File

@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"runtime"
"strings"
"time"
@@ -17,6 +20,7 @@ import (
"ds2api/internal/adapter/openai"
"ds2api/internal/admin"
"ds2api/internal/auth"
"ds2api/internal/chathistory"
"ds2api/internal/config"
"ds2api/internal/deepseek"
"ds2api/internal/webui"
@@ -46,17 +50,21 @@ func NewApp() (*App, error) {
} else {
config.Logger.Info("[PoW] pure Go solver ready")
}
chatHistoryStore := chathistory.New(config.ChatHistoryPath())
if err := chatHistoryStore.Err(); err != nil {
config.Logger.Warn("[chat_history] unavailable", "path", chatHistoryStore.Path(), "error", err)
}
openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient}
openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore}
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, OpenAI: openaiHandler}
adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: openaiHandler, ChatHistory: chatHistoryStore}
webuiHandler := webui.NewHandler()
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(filteredLogger())
r.Use(middleware.Recoverer)
r.Use(cors)
r.Use(timeout(0))
@@ -99,11 +107,44 @@ func timeout(d time.Duration) func(http.Handler) http.Handler {
return middleware.Timeout(d)
}
func filteredLogger() func(http.Handler) http.Handler {
color := !isWindowsRuntime()
base := &middleware.DefaultLogFormatter{
Logger: log.New(os.Stdout, "", log.LstdFlags),
NoColor: !color,
}
return middleware.RequestLogger(&filteredLogFormatter{base: base})
}
func isWindowsRuntime() bool {
return runtime.GOOS == "windows"
}
type filteredLogFormatter struct {
base *middleware.DefaultLogFormatter
}
func (f *filteredLogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
if r != nil && r.Method == http.MethodGet {
path := strings.TrimSpace(r.URL.Path)
if path == "/admin/chat-history" || strings.HasPrefix(path, "/admin/chat-history/") {
return noopLogEntry{}
}
}
return f.base.NewLogEntry(r)
}
type noopLogEntry struct{}
func (noopLogEntry) Write(_ int, _ int, _ http.Header, _ time.Duration, _ interface{}) {}
func (noopLogEntry) Panic(_ interface{}, _ []byte) {}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Vercel-Protection-Bypass")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Ds2-Source, X-Vercel-Protection-Bypass")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return

View File

@@ -0,0 +1,168 @@
package sse
import (
"strconv"
"strings"
)
type citationLinkCollector struct {
ordered []string
explicitRaw map[int]string
hasZeroIdx bool
}
func newCitationLinkCollector() *citationLinkCollector {
return &citationLinkCollector{
explicitRaw: map[int]string{},
}
}
func (c *citationLinkCollector) ingestChunk(chunk map[string]any) {
if c == nil || len(chunk) == 0 {
return
}
c.walkValue(chunk)
}
func (c *citationLinkCollector) build() map[int]string {
out := make(map[int]string, len(c.explicitRaw)+len(c.ordered))
for idx, u := range c.buildNormalizedExplicit() {
out[idx] = u
}
for i, u := range c.ordered {
idx := i + 1
if _, exists := out[idx]; !exists {
out[idx] = u
}
}
return out
}
func (c *citationLinkCollector) buildNormalizedExplicit() map[int]string {
out := make(map[int]string, len(c.explicitRaw))
// Default behavior keeps positive indices as-is (one-based payloads).
for idx, u := range c.explicitRaw {
if idx <= 0 || strings.TrimSpace(u) == "" {
continue
}
out[idx] = u
}
if !c.hasZeroIdx {
return out
}
// If zero index appears, upstream may be using zero-based indices.
// Add shifted candidates and resolve conflicts using ordered appearance,
// which matches visible citation marker order in response text.
for rawIdx, u := range c.explicitRaw {
if rawIdx < 0 || strings.TrimSpace(u) == "" {
continue
}
normalized := rawIdx + 1
existing, exists := out[normalized]
if !exists {
out[normalized] = u
continue
}
if c.preferURLForIndex(normalized, existing, u) == u {
out[normalized] = u
}
}
return out
}
func (c *citationLinkCollector) preferURLForIndex(idx int, current, candidate string) string {
if idx <= 0 || idx > len(c.ordered) {
return current
}
expected := c.ordered[idx-1]
switch {
case strings.TrimSpace(expected) == "":
return current
case candidate == expected && current != expected:
return candidate
default:
return current
}
}
func (c *citationLinkCollector) walkValue(v any) {
switch x := v.(type) {
case []any:
for _, item := range x {
c.walkValue(item)
}
case map[string]any:
c.captureURLAndIndex(x)
for _, vv := range x {
c.walkValue(vv)
}
}
}
func (c *citationLinkCollector) captureURLAndIndex(m map[string]any) {
url := strings.TrimSpace(asString(m["url"]))
if !isWebURL(url) {
return
}
c.addOrdered(url)
idx, hasIdx := citationIndexFromAny(m["cite_index"])
if !hasIdx {
return
}
if idx < 0 {
return
}
if idx == 0 {
c.hasZeroIdx = true
}
if existing, ok := c.explicitRaw[idx]; ok && strings.TrimSpace(existing) != "" {
return
}
c.explicitRaw[idx] = url
}
func (c *citationLinkCollector) addOrdered(url string) {
c.ordered = append(c.ordered, url)
}
func citationIndexFromAny(v any) (int, bool) {
switch x := v.(type) {
case int:
return x, true
case int32:
return int(x), true
case int64:
return int(x), true
case float32:
return int(x), true
case float64:
return int(x), true
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, false
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}
func isWebURL(v string) bool {
v = strings.ToLower(strings.TrimSpace(v))
return strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://")
}
func asString(v any) string {
s, _ := v.(string)
return s
}

View File

@@ -13,6 +13,7 @@ type CollectResult struct {
Text string
Thinking string
ContentFilter bool
CitationLinks map[int]string
}
// CollectStream fully consumes a DeepSeek SSE response and separates
@@ -28,11 +29,23 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
text := strings.Builder{}
thinking := strings.Builder{}
contentFilter := false
stopped := false
collector := newCitationLinkCollector()
currentType := "text"
if thinkingEnabled {
currentType = "thinking"
}
_ = deepseek.ScanSSELines(resp, func(line []byte) bool {
chunk, done, parsed := ParseDeepSeekSSELine(line)
if parsed && !done {
collector.ingestChunk(chunk)
}
if done {
return false
}
if stopped {
return true
}
result := ParseDeepSeekContentLine(line, thinkingEnabled, currentType)
currentType = result.NextType
if !result.Parsed {
@@ -42,7 +55,11 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
if result.ContentFilter {
contentFilter = true
}
return false
// Keep scanning to collect late-arriving citation metadata lines
// that can appear after response/status=FINISHED, but stop as soon
// as [DONE] arrives.
stopped = true
return true
}
for _, p := range result.Parts {
if p.Type == "thinking" {
@@ -59,5 +76,6 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
Text: text.String(),
Thinking: thinking.String(),
ContentFilter: contentFilter,
CitationLinks: collector.build(),
}
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"testing"
"time"
)
// ─── CollectStream edge cases ────────────────────────────────────────
@@ -115,6 +116,94 @@ func TestCollectStreamWithCitation(t *testing.T) {
}
}
func TestCollectStreamExtractsCitationLinks(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":0},{\"url\":\"https://example.com/b\",\"cite_index\":1}]}\n" +
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2]\"}\n" +
"data: [DONE]\n",
)
result := CollectStream(resp, false, false)
if got := result.CitationLinks[1]; got != "https://example.com/a" {
t.Fatalf("expected citation 1 link, got %q", got)
}
if got := result.CitationLinks[2]; got != "https://example.com/b" {
t.Fatalf("expected citation 2 link, got %q", got)
}
}
func TestCollectStreamExtractsCitationLinksForSequentialZeroBasedIndices(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":0},{\"url\":\"https://example.com/b\",\"cite_index\":1},{\"url\":\"https://example.com/c\",\"cite_index\":2}]}\n" +
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2][citation:3]\"}\n" +
"data: [DONE]\n",
)
result := CollectStream(resp, false, false)
if got := result.CitationLinks[1]; got != "https://example.com/a" {
t.Fatalf("expected citation 1 link, got %q", got)
}
if got := result.CitationLinks[2]; got != "https://example.com/b" {
t.Fatalf("expected citation 2 link, got %q", got)
}
if got := result.CitationLinks[3]; got != "https://example.com/c" {
t.Fatalf("expected citation 3 link, got %q", got)
}
}
func TestCollectStreamExtractsCitationLinksForOneBasedIndices(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":1},{\"url\":\"https://example.com/b\",\"cite_index\":2}]}\n" +
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2]\"}\n" +
"data: [DONE]\n",
)
result := CollectStream(resp, false, false)
if got := result.CitationLinks[1]; got != "https://example.com/a" {
t.Fatalf("expected citation 1 link, got %q", got)
}
if got := result.CitationLinks[2]; got != "https://example.com/b" {
t.Fatalf("expected citation 2 link, got %q", got)
}
}
func TestCollectStreamExtractsCitationLinksWithRepeatedURLsAndNilIndices(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":null},{\"url\":\"https://example.com/a\",\"cite_index\":null},{\"url\":\"https://example.com/b\",\"cite_index\":null}]}\n" +
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2][citation:3]\"}\n" +
"data: [DONE]\n",
)
result := CollectStream(resp, false, false)
if got := result.CitationLinks[1]; got != "https://example.com/a" {
t.Fatalf("expected citation 1 link, got %q", got)
}
if got := result.CitationLinks[2]; got != "https://example.com/a" {
t.Fatalf("expected citation 2 link, got %q", got)
}
if got := result.CitationLinks[3]; got != "https://example.com/b" {
t.Fatalf("expected citation 3 link, got %q", got)
}
}
func TestCollectStreamCollectsCitationLinksAfterFinished(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1]\"}\n" +
"data: {\"p\":\"response/status\",\"v\":\"FINISHED\"}\n" +
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":1}]}\n" +
"data: {\"p\":\"response/content\",\"v\":\"should-not-append\"}\n" +
"data: [DONE]\n",
)
result := CollectStream(resp, false, false)
if result.Text != "结论[citation:1]" {
t.Fatalf("expected text to freeze after finished, got %q", result.Text)
}
if got := result.CitationLinks[1]; got != "https://example.com/a" {
t.Fatalf("expected citation 1 link, got %q", got)
}
}
func TestCollectStreamMultipleThinkingChunks(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/thinking_content\",\"v\":\"part1\"}\n" +
@@ -139,6 +228,39 @@ func TestCollectStreamStatusFinished(t *testing.T) {
}
}
func TestCollectStreamStopsOnDoneAfterFinished(t *testing.T) {
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: pr,
}
resultCh := make(chan CollectResult, 1)
go func() {
resultCh <- CollectStream(resp, false, false)
}()
_, _ = io.WriteString(pw, "data: {\"p\":\"response/content\",\"v\":\"Hello\"}\n")
_, _ = io.WriteString(pw, "data: {\"p\":\"response/status\",\"v\":\"FINISHED\"}\n")
_, _ = io.WriteString(pw, "data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":1}]}\n")
_, _ = io.WriteString(pw, "data: [DONE]\n")
select {
case result := <-resultCh:
if result.Text != "Hello" {
t.Fatalf("expected text to freeze at FINISHED, got %q", result.Text)
}
if got := result.CitationLinks[1]; got != "https://example.com/a" {
t.Fatalf("expected citation metadata after FINISHED, got %q", got)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("CollectStream did not stop on [DONE] after FINISHED")
}
}
func TestCollectStreamStopsOnContentFilterStatus(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"safe\"}\n" +

View File

@@ -8,6 +8,7 @@ type StandardRequest struct {
ResolvedModel string
ResponseModel string
Messages []any
ToolsRaw any
FinalPrompt string
ToolNames []string
ToolChoice ToolChoicePolicy

View File

@@ -17,6 +17,7 @@ const {
normalizePreparedToolNames,
boolDefaultTrue,
filterIncrementalToolCallDeltasByAllowed,
resetStreamToolCallState,
buildUsage,
estimateTokens,
shouldSkipPath,
@@ -107,6 +108,16 @@ test('incremental and final tool formatting share stable id via idStore', () =>
assert.equal(incremental[0].id, finalCalls[0].id);
});
test('resetStreamToolCallState gives each completed block a fresh id', () => {
const idStore = new Map();
const first = formatIncrementalToolCallDeltas([{ index: 0, name: 'read_file' }], idStore);
resetStreamToolCallState(idStore);
const second = formatIncrementalToolCallDeltas([{ index: 0, name: 'search' }], idStore);
assert.equal(first.length, 1);
assert.equal(second.length, 1);
assert.notEqual(first[0].id, second[0].id);
});
test('formatIncrementalToolCallDeltas drops empty deltas (Go parity)', () => {
const idStore = new Map();
const formatted = formatIncrementalToolCallDeltas([{ index: 0 }], idStore);

View File

@@ -98,6 +98,26 @@ test('sieve emits tool_calls when XML tag spans multiple chunks', () => {
assert.equal(finalCalls[0].name, 'read_file');
});
test('sieve keeps long XML tool calls buffered until the closing tag arrives', () => {
const longContent = 'x'.repeat(4096);
const splitAt = longContent.length / 2;
const events = runSieve(
[
'<tool_calls>\n <tool_call>\n <tool_name>write_to_file</tool_name>\n <parameters>\n <content><![CDATA[',
longContent.slice(0, splitAt),
longContent.slice(splitAt),
']]></content>\n </parameters>\n </tool_call>\n</tool_calls>',
],
['write_to_file'],
);
const leakedText = collectText(events);
const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
assert.equal(leakedText, '');
assert.equal(finalCalls.length, 1);
assert.equal(finalCalls[0].name, 'write_to_file');
assert.equal(finalCalls[0].input.content, longContent);
});
test('sieve passes JSON tool_calls payload through as text (XML-only)', () => {
const events = runSieve(
['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'],

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { FileCode, Download, Upload, Copy, Check, AlertTriangle } from 'lucide-react'
import clsx from 'clsx'
import { useI18n } from '../i18n'
import { getBatchImportTemplates } from '../utils/batchImportTemplates'
export default function BatchImport({ onRefresh, onMessage, authFetch }) {
const { t } = useI18n()
@@ -11,55 +12,7 @@ export default function BatchImport({ onRefresh, onMessage, authFetch }) {
const [copied, setCopied] = useState(false)
const apiFetch = authFetch || fetch
const templates = {
full: {
name: t('batchImport.templates.full.name'),
desc: t('batchImport.templates.full.desc'),
config: {
keys: ["your-api-key-1", "your-api-key-2"],
accounts: [
{ email: "user1@example.com", password: "password1", token: "" },
{ email: "user2@example.com", password: "password2", token: "" },
{ mobile: "+8613800138001", password: "password3", token: "" }
],
claude_model_mapping: {
fast: "deepseek-chat",
slow: "deepseek-reasoner"
}
}
},
email_only: {
name: t('batchImport.templates.emailOnly.name'),
desc: t('batchImport.templates.emailOnly.desc'),
config: {
keys: ["your-api-key"],
accounts: [
{ email: "account1@example.com", password: "pass1", token: "" },
{ email: "account2@example.com", password: "pass2", token: "" },
{ email: "account3@example.com", password: "pass3", token: "" }
]
}
},
mobile_only: {
name: t('batchImport.templates.mobileOnly.name'),
desc: t('batchImport.templates.mobileOnly.desc'),
config: {
keys: ["your-api-key"],
accounts: [
{ mobile: "+8613800000001", password: "pass1", token: "" },
{ mobile: "+8613800000002", password: "pass2", token: "" },
{ mobile: "+8613800000003", password: "pass3", token: "" }
]
}
},
keys_only: {
name: t('batchImport.templates.keysOnly.name'),
desc: t('batchImport.templates.keysOnly.desc'),
config: {
keys: ["key-1", "key-2", "key-3"]
}
}
}
const templates = getBatchImportTemplates(t)
const handleImport = async () => {
if (!jsonInput.trim()) {

View File

@@ -6,6 +6,7 @@ import ApiKeysPanel from './ApiKeysPanel'
import AccountsTable from './AccountsTable'
import AddKeyModal from './AddKeyModal'
import AddAccountModal from './AddAccountModal'
import EditAccountModal from './EditAccountModal'
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
const { t } = useI18n()
@@ -30,9 +31,19 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
const {
showAddKey,
setShowAddKey,
openAddKey,
openEditKey,
closeKeyModal,
editingKey,
showAddAccount,
setShowAddAccount,
openAddAccount,
closeAddAccount,
showEditAccount,
editingAccount,
editAccount,
setEditAccount,
openEditAccount,
closeEditAccount,
newKey,
setNewKey,
copiedKey,
@@ -49,6 +60,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
addKey,
deleteKey,
addAccount,
updateAccount,
deleteAccount,
testAccount,
testAllAccounts,
@@ -94,7 +106,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
config={config}
keysExpanded={keysExpanded}
setKeysExpanded={setKeysExpanded}
setShowAddKey={setShowAddKey}
onAddKey={openAddKey}
onEditKey={openEditKey}
copiedKey={copiedKey}
setCopiedKey={setCopiedKey}
onDeleteKey={deleteKey}
@@ -117,7 +130,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
resolveAccountIdentifier={resolveAccountIdentifier}
proxies={config?.proxies || []}
onTestAll={testAllAccounts}
onShowAddAccount={() => setShowAddAccount(true)}
onShowAddAccount={openAddAccount}
onEditAccount={openEditAccount}
onTestAccount={testAccount}
onDeleteAccount={deleteAccount}
onDeleteAllSessions={deleteAllSessions}
@@ -133,10 +147,11 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
<AddKeyModal
show={showAddKey}
t={t}
editingKey={editingKey}
newKey={newKey}
setNewKey={setNewKey}
loading={loading}
onClose={() => setShowAddKey(false)}
onClose={closeKeyModal}
onAdd={addKey}
/>
@@ -146,9 +161,20 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
newAccount={newAccount}
setNewAccount={setNewAccount}
loading={loading}
onClose={() => setShowAddAccount(false)}
onClose={closeAddAccount}
onAdd={addAccount}
/>
<EditAccountModal
show={showEditAccount}
t={t}
editingAccount={editingAccount}
editAccount={editAccount}
setEditAccount={setEditAccount}
loading={loading}
onClose={closeEditAccount}
onSave={updateAccount}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react'
import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX } from 'lucide-react'
import clsx from 'clsx'
export default function AccountsTable({
@@ -20,6 +20,7 @@ export default function AccountsTable({
proxies,
onTestAll,
onShowAddAccount,
onEditAccount,
onTestAccount,
onDeleteAccount,
onDeleteAllSessions,
@@ -118,6 +119,7 @@ export default function AccountsTable({
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
)} />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{acc.name || '-'}</div>
<div
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
onClick={() => copyId(id)}
@@ -128,6 +130,9 @@ export default function AccountsTable({
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
}
</div>
{acc.remark && (
<div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
{acc.token_preview && (
@@ -176,6 +181,14 @@ export default function AccountsTable({
</option>
))}
</select>
<button
onClick={() => onEditAccount(acc)}
disabled={!id}
className="p-1 lg:p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={id ? t('accountManager.editAccountTitle') : t('accountManager.invalidIdentifier')}
>
<Pencil className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
</button>
<button
onClick={() => onTestAccount(id)}
disabled={testing[id]}

View File

@@ -23,6 +23,26 @@ export default function AddAccountModal({
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.namePlaceholder')}
value={newAccount.name}
onChange={e => setNewAccount({ ...newAccount, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.remarkOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.remarkPlaceholder')}
value={newAccount.remark}
onChange={e => setNewAccount({ ...newAccount, remark: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.emailOptional')}</label>
<input

View File

@@ -1,45 +1,75 @@
import { X } from 'lucide-react'
export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClose, onAdd }) {
export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, loading, onClose, onAdd }) {
if (!show) {
return null
}
const isEditing = Boolean(editingKey?.key)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
<div className="p-4 border-b border-border flex justify-between items-center">
<h3 className="font-semibold">{t('accountManager.modalAddKeyTitle')}</h3>
<h3 className="font-semibold">{isEditing ? t('accountManager.modalEditKeyTitle') : t('accountManager.modalAddKeyTitle')}</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.newKeyLabel')}</label>
<label className="block text-sm font-medium mb-1.5">{isEditing ? t('accountManager.keyLabel') : t('accountManager.newKeyLabel')}</label>
<div className="flex gap-2">
<input
type="text"
className="input-field bg-[#09090b] flex-1"
placeholder={t('accountManager.newKeyPlaceholder')}
value={newKey}
onChange={e => setNewKey(e.target.value)}
autoFocus
className={isEditing ? "input-field bg-muted/30 flex-1 cursor-not-allowed" : "input-field bg-[#09090b] flex-1"}
placeholder={isEditing ? t('accountManager.keyReadonlyPlaceholder') : t('accountManager.newKeyPlaceholder')}
value={newKey.key}
onChange={e => setNewKey({ ...newKey, key: e.target.value })}
autoFocus={!isEditing}
readOnly={isEditing}
/>
<button
type="button"
onClick={() => setNewKey('sk-' + crypto.randomUUID().replace(/-/g, ''))}
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
>
{t('accountManager.generate')}
</button>
{!isEditing && (
<button
type="button"
onClick={() => setNewKey({ ...newKey, key: 'sk-' + crypto.randomUUID().replace(/-/g, '') })}
className="px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm font-medium border border-border whitespace-nowrap"
>
{t('accountManager.generate')}
</button>
)}
</div>
<p className="text-xs text-muted-foreground mt-1.5">{t('accountManager.generateHint')}</p>
<p className="text-xs text-muted-foreground mt-1.5">
{isEditing ? t('accountManager.keyReadonlyHint') : t('accountManager.generateHint')}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.namePlaceholder')}
value={newKey.name}
onChange={e => setNewKey({ ...newKey, name: e.target.value })}
autoFocus={isEditing}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.remarkOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.remarkPlaceholder')}
value={newKey.remark}
onChange={e => setNewKey({ ...newKey, remark: e.target.value })}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={onAdd} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.addKeyLoading') : t('accountManager.addKeyAction')}
{loading
? (isEditing ? t('accountManager.editKeyLoading') : t('accountManager.addKeyLoading'))
: (isEditing ? t('accountManager.editKeyAction') : t('accountManager.addKeyAction'))}
</button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
import { Check, ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import clsx from 'clsx'
function fallbackCopyText(text) {
@@ -31,12 +31,16 @@ export default function ApiKeysPanel({
config,
keysExpanded,
setKeysExpanded,
setShowAddKey,
onAddKey,
onEditKey,
copiedKey,
setCopiedKey,
onDeleteKey,
}) {
const [failedKey, setFailedKey] = useState(null)
const apiKeys = Array.isArray(config?.api_keys) && config.api_keys.length > 0
? config.api_keys
: (config?.keys || []).map(key => ({ key, name: '', remark: '' }))
const handleCopyKey = async (key) => {
try {
@@ -74,11 +78,11 @@ export default function ApiKeysPanel({
)} />
<div>
<h2 className="text-lg font-semibold">{t('accountManager.apiKeysTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})</p>
<p className="text-sm text-muted-foreground">{t('accountManager.apiKeysDesc')} ({apiKeys.length || 0})</p>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); setShowAddKey(true) }}
onClick={(e) => { e.stopPropagation(); onAddKey() }}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
>
<Plus className="w-4 h-4" />
@@ -88,34 +92,43 @@ export default function ApiKeysPanel({
{keysExpanded && (
<div className="divide-y divide-border border-t border-border">
{config.keys?.length > 0 ? (
config.keys.map((key, i) => (
{apiKeys.length > 0 ? (
apiKeys.map((item, i) => (
<div key={i} className="p-4 flex items-center justify-between hover:bg-muted/50 transition-colors group">
<div className="flex items-center gap-2">
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 flex-1">
<div className="text-sm">{item.name || '-'}</div>
<button
onClick={() => handleCopyKey(key)}
onClick={() => handleCopyKey(item.key)}
className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block hover:bg-muted transition-colors"
title={t('accountManager.copyKeyTitle')}
>
{key.slice(0, 16)}****
{(item.key || '').slice(0, 16)}****
</button>
{copiedKey === key && (
<div className="text-sm text-muted-foreground truncate">{item.remark || '-'}</div>
{copiedKey === item.key && (
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
)}
{failedKey === key && (
{failedKey === item.key && (
<span className="text-xs text-destructive">{t('accountManager.copyFailed')}</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopyKey(key)}
onClick={() => onEditKey(item)}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"
title={t('accountManager.editKeyTitle')}
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleCopyKey(item.key)}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"
title={t('accountManager.copyKeyTitle')}
>
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
{copiedKey === item.key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={() => onDeleteKey(key)}
onClick={() => onDeleteKey(item.key)}
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title={t('accountManager.deleteKeyTitle')}
>

View File

@@ -0,0 +1,65 @@
import { X } from 'lucide-react'
export default function EditAccountModal({
show,
t,
editingAccount,
editAccount,
setEditAccount,
loading,
onClose,
onSave,
}) {
if (!show || !editingAccount) {
return null
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
<div className="bg-card w-full max-w-md rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
<div className="p-4 border-b border-border flex justify-between items-start gap-4">
<div className="min-w-0">
<h3 className="font-semibold">{t('accountManager.modalEditAccountTitle')}</h3>
<p className="mt-1 text-xs text-muted-foreground">{t('accountManager.editAccountHint')}</p>
</div>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="rounded-lg border border-border bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-muted-foreground mb-1">{t('accountManager.accountIdentifierLabel')}</div>
<code className="text-sm font-mono text-foreground break-all">{editingAccount.identifier}</code>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.nameOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.namePlaceholder')}
value={editAccount.name}
onChange={e => setEditAccount({ ...editAccount, name: e.target.value })}
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.remarkOptional')}</label>
<input
type="text"
className="input-field"
placeholder={t('accountManager.remarkPlaceholder')}
value={editAccount.remark}
onChange={e => setEditAccount({ ...editAccount, remark: e.target.value })}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium">{t('actions.cancel')}</button>
<button onClick={onSave} disabled={loading} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50">
{loading ? t('accountManager.editAccountLoading') : t('accountManager.editAccountAction')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -2,10 +2,14 @@ import { useState } from 'react'
export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, fetchAccounts, resolveAccountIdentifier }) {
const [showAddKey, setShowAddKey] = useState(false)
const [editingKey, setEditingKey] = useState(null)
const [showAddAccount, setShowAddAccount] = useState(false)
const [newKey, setNewKey] = useState('')
const [showEditAccount, setShowEditAccount] = useState(false)
const [editingAccount, setEditingAccount] = useState(null)
const [newKey, setNewKey] = useState({ key: '', name: '', remark: '' })
const [copiedKey, setCopiedKey] = useState(null)
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
const [newAccount, setNewAccount] = useState({ name: '', remark: '', email: '', mobile: '', password: '' })
const [editAccount, setEditAccount] = useState({ name: '', remark: '' })
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false)
@@ -14,23 +18,94 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const [deletingSessions, setDeletingSessions] = useState({})
const [updatingProxy, setUpdatingProxy] = useState({})
const openAddKey = () => {
setEditingKey(null)
setNewKey({ key: '', name: '', remark: '' })
setShowAddKey(true)
}
const openEditKey = (item) => {
if (!item?.key) return
setEditingKey(item)
setNewKey({
key: item.key || '',
name: item.name || '',
remark: item.remark || '',
})
setShowAddKey(true)
}
const closeKeyModal = () => {
setShowAddKey(false)
setEditingKey(null)
setNewKey({ key: '', name: '', remark: '' })
}
const openAddAccount = () => {
setShowEditAccount(false)
setEditingAccount(null)
setEditAccount({ name: '', remark: '' })
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
setShowAddAccount(true)
}
const closeAddAccount = () => {
setShowAddAccount(false)
setNewAccount({ name: '', remark: '', email: '', mobile: '', password: '' })
}
const openEditAccount = (account) => {
const identifier = resolveAccountIdentifier(account)
if (!identifier) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
setShowAddAccount(false)
setEditingAccount({
identifier,
})
setEditAccount({
name: account?.name || '',
remark: account?.remark || '',
})
setShowEditAccount(true)
}
const closeEditAccount = () => {
setShowEditAccount(false)
setEditingAccount(null)
setEditAccount({ name: '', remark: '' })
}
const addKey = async () => {
if (!newKey.trim()) return
const isEditing = Boolean(editingKey?.key)
if (!isEditing && !newKey.key.trim()) {
return
}
setLoading(true)
try {
const res = await apiFetch('/admin/keys', {
method: 'POST',
const endpoint = isEditing
? `/admin/keys/${encodeURIComponent(editingKey.key)}`
: '/admin/keys'
const method = isEditing ? 'PUT' : 'POST'
const payload = isEditing
? { name: newKey.name, remark: newKey.remark }
: { key: newKey.key.trim(), name: newKey.name, remark: newKey.remark }
if (!isEditing && !payload.key) {
return
}
const res = await apiFetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: newKey.trim() }),
body: JSON.stringify(payload),
})
if (res.ok) {
onMessage('success', t('accountManager.addKeySuccess'))
setNewKey('')
setShowAddKey(false)
onMessage('success', isEditing ? t('accountManager.updateKeySuccess') : t('accountManager.addKeySuccess'))
closeKeyModal()
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.failedToAdd'))
onMessage('error', data.detail || (isEditing ? t('messages.requestFailed') : t('messages.failedToAdd')))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
@@ -68,8 +143,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
})
if (res.ok) {
onMessage('success', t('accountManager.addAccountSuccess'))
setNewAccount({ email: '', mobile: '', password: '' })
setShowAddAccount(false)
closeAddAccount()
fetchAccounts(1)
onRefresh()
} else {
@@ -83,6 +157,35 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
}
}
const updateAccount = async () => {
const identifier = String(editingAccount?.identifier || '').trim()
if (!identifier) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
setLoading(true)
try {
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(identifier)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editAccount),
})
if (res.ok) {
onMessage('success', t('accountManager.updateAccountSuccess'))
closeEditAccount()
fetchAccounts()
onRefresh()
} else {
const data = await res.json()
onMessage('error', data.detail || t('messages.requestFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setLoading(false)
}
}
const deleteAccount = async (id) => {
const identifier = String(id || '').trim()
if (!identifier) {
@@ -244,9 +347,19 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
return {
showAddKey,
setShowAddKey,
openAddKey,
openEditKey,
closeKeyModal,
editingKey,
showAddAccount,
setShowAddAccount,
openAddAccount,
closeAddAccount,
showEditAccount,
editingAccount,
editAccount,
setEditAccount,
openEditAccount,
closeEditAccount,
newKey,
setNewKey,
copiedKey,
@@ -263,6 +376,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
addKey,
deleteKey,
addAccount,
updateAccount,
deleteAccount,
testAccount,
testAllAccounts,

View File

@@ -123,6 +123,7 @@ export function useChatStreamClient({
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${effectiveKey}`,
'X-Ds2-Source': 'admin-webui-api-tester',
}
if (requestAccount) {
headers['X-Ds2-Target-Account'] = requestAccount

View File

@@ -0,0 +1,892 @@
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { useI18n } from '../../i18n'
const LIMIT_OPTIONS = [0, 10, 20, 50]
const DISABLED_LIMIT = 0
const MESSAGE_COLLAPSE_AT = 700
const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode'
function formatDateTime(value, lang) {
if (!value) return '-'
try {
return new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value))
} catch {
return '-'
}
}
function formatElapsed(ms, t) {
if (!ms) return t('chatHistory.metaUnknown')
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`
}
function previewText(item) {
return item?.preview || item?.content || item?.reasoning_content || item?.error || item?.user_input || ''
}
function statusTone(status) {
switch (status) {
case 'success':
return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-600'
case 'error':
return 'border-destructive/20 bg-destructive/10 text-destructive'
case 'stopped':
return 'border-amber-500/20 bg-amber-500/10 text-amber-600'
default:
return 'border-border bg-secondary/60 text-muted-foreground'
}
}
function ExpandableText({ text = '', threshold = MESSAGE_COLLAPSE_AT, expandLabel, collapseLabel, buttonClassName = 'text-white hover:text-white/80' }) {
const shouldCollapse = text.length > threshold
const [expanded, setExpanded] = useState(false)
const contentRef = useRef(null)
const [maxHeight, setMaxHeight] = useState('none')
useEffect(() => {
setExpanded(false)
}, [text])
const visibleText = shouldCollapse && !expanded ? `${text.slice(0, threshold)}...` : text
useEffect(() => {
if (!contentRef.current) return
setMaxHeight(`${contentRef.current.scrollHeight}px`)
}, [expanded, visibleText])
return (
<div>
<div
className="overflow-hidden transition-[max-height] duration-300 ease-out"
style={{ maxHeight }}
>
<div ref={contentRef} className="whitespace-pre-wrap break-words">
{visibleText}
</div>
</div>
{shouldCollapse && (
<button
type="button"
onClick={() => setExpanded(prev => !prev)}
className={clsx('mt-3 inline-flex items-center gap-2 text-xs font-medium transition-colors', buttonClassName)}
>
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform duration-300', expanded && 'rotate-180')} />
{expanded ? collapseLabel : expandLabel}
</button>
)}
</div>
)
}
function ListModeIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 0h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm0 8h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1z" />
</svg>
)
}
function MergeModeIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 1h8a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1" />
</svg>
)
}
function RequestMessages({ item, t }) {
const messages = Array.isArray(item?.messages) && item.messages.length > 0
? item.messages
: [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }]
return (
<div className="space-y-5 max-w-4xl mx-auto">
{messages.map((message, index) => {
const role = message.role || 'user'
const isUser = role === 'user'
const isAssistant = role === 'assistant'
const isTool = role === 'tool'
const label = isUser
? t('chatHistory.role.user')
: (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system')))
return (
<div key={`${role}-${index}`} className="flex gap-4">
<div className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
isUser
? 'bg-secondary'
: (isAssistant ? 'bg-muted' : 'bg-background')
)}>
{isUser
? <UserRound className="w-4 h-4 text-muted-foreground" />
: <Bot className="w-4 h-4 text-foreground" />}
</div>
<div className="max-w-[88%] lg:max-w-[78%] text-left">
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-2 px-1">
{label}
</div>
<div className={clsx(
'rounded-2xl px-5 py-3 text-sm leading-relaxed shadow-sm border whitespace-pre-wrap break-words',
isUser
? 'bg-primary text-primary-foreground rounded-tr-sm border-primary/30'
: (isAssistant
? 'bg-secondary/60 text-foreground rounded-tl-sm border-border'
: 'bg-background text-foreground rounded-tl-sm border-border')
)}>
<div className="whitespace-pre-wrap break-words">
{message.content || t('chatHistory.emptyUserInput')}
</div>
</div>
</div>
</div>
)
})}
</div>
)
}
function MergedPromptView({ item, t }) {
const merged = item?.final_prompt || ''
return (
<div
className="max-w-4xl mx-auto rounded-2xl border px-5 py-4"
style={{
backgroundColor: 'rgb(231, 176, 8)',
borderColor: 'rgba(231, 176, 8, 0.45)',
}}
>
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300] mb-3">
{t('chatHistory.mergedInput')}
</div>
<div className="text-sm leading-7 text-[#2f2200] whitespace-pre-wrap break-words font-mono">
<ExpandableText
text={merged || t('chatHistory.emptyMergedPrompt')}
expandLabel={t('chatHistory.expand')}
collapseLabel={t('chatHistory.collapse')}
buttonClassName="text-[#2f2200] hover:text-black"
/>
</div>
</div>
)
}
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) {
if (!selectedItem) return null
return (
<>
{viewMode === 'list'
? <RequestMessages item={selectedItem} t={t} />
: <MergedPromptView item={selectedItem} t={t} />}
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
<div className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
selectedItem.status === 'error' ? 'bg-destructive/10 border-destructive/20' : 'bg-muted'
)}>
<Bot className={clsx('w-4 h-4', selectedItem.status === 'error' ? 'text-destructive' : 'text-foreground')} />
</div>
<div className="space-y-4 flex-1 min-w-0">
{(selectedItem.reasoning_content || '').trim() && (
<div className="text-xs bg-secondary/50 border border-border rounded-lg p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Sparkles className="w-3.5 h-3.5" />
<span className="font-medium">{t('chatHistory.reasoningTrace')}</span>
</div>
<div className="whitespace-pre-wrap leading-relaxed text-muted-foreground font-mono text-[12px] md:text-[13px] max-h-64 overflow-y-auto custom-scrollbar pl-5 border-l-2 border-border/50 break-words">
{selectedItem.reasoning_content}
</div>
</div>
)}
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words">
{selectedItem.status === 'error'
? <span className="text-destructive font-medium">{selectedItem.error || t('chatHistory.failedOutput')}</span>
: (selectedItem.content || t('chatHistory.emptyAssistantOutput'))}
</div>
</div>
</div>
<div className="max-w-4xl mx-auto rounded-xl border border-border bg-background/70 p-4 space-y-3">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">{t('chatHistory.metaTitle')}</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaAccount')}</div>
<div className="text-sm font-medium text-foreground">{selectedItem.account_id || t('chatHistory.metaUnknown')}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaElapsed')}</div>
<div className="text-sm font-medium text-foreground flex items-center gap-2">
<Clock3 className="w-3.5 h-3.5 text-muted-foreground" />
{formatElapsed(selectedItem.elapsed_ms, t)}
</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaModel')}</div>
<div className="text-sm font-medium text-foreground break-all">{selectedItem.model || t('chatHistory.metaUnknown')}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStatusCode')}</div>
<div className="text-sm font-medium text-foreground">{selectedItem.status_code || '-'}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaStream')}</div>
<div className="text-sm font-medium text-foreground">{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}</div>
</div>
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[11px] text-muted-foreground">{t('chatHistory.metaCaller')}</div>
<div className="text-sm font-medium text-foreground break-all">{selectedItem.caller_id || t('chatHistory.metaUnknown')}</div>
</div>
</div>
</div>
<button
type="button"
onClick={() => detailScrollRef.current?.scrollTo({ top: detailScrollRef.current?.scrollHeight || 0, behavior: 'smooth' })}
className={clsx('h-12 w-12 rounded-full border border-border bg-card/95 backdrop-blur shadow-lg text-muted-foreground hover:text-foreground hover:bg-secondary/90 flex items-center justify-center', bottomButtonClassName)}
title={t('chatHistory.backToBottom')}
>
<ArrowDown className="w-5 h-5" />
</button>
</>
)
}
export default function ChatHistoryContainer({ authFetch, onMessage }) {
const { t, lang } = useI18n()
const apiFetch = authFetch || fetch
const [items, setItems] = useState([])
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [selectedId, setSelectedId] = useState('')
const [selectedDetail, setSelectedDetail] = useState(null)
const [savingLimit, setSavingLimit] = useState(false)
const [clearing, setClearing] = useState(false)
const [deletingId, setDeletingId] = useState('')
const [detail, setDetail] = useState('')
const [confirmClearOpen, setConfirmClearOpen] = useState(false)
const [autoRefreshReady, setAutoRefreshReady] = useState(false)
const [viewMode, setViewMode] = useState(() => {
if (typeof localStorage === 'undefined') return 'list'
const stored = localStorage.getItem(VIEW_MODE_KEY)
return stored === 'merged' ? 'merged' : 'list'
})
const [isMobileView, setIsMobileView] = useState(() => typeof window !== 'undefined' ? window.innerWidth < 1024 : false)
const [mobileDetailOpen, setMobileDetailOpen] = useState(false)
const [mobileDetailVisible, setMobileDetailVisible] = useState(false)
const [mobileOrigin, setMobileOrigin] = useState({ x: 50, y: 50 })
const [pendingJumpToAssistant, setPendingJumpToAssistant] = useState(false)
const inFlightRef = useRef(false)
const detailInFlightRef = useRef(false)
const listETagRef = useRef('')
const detailETagRef = useRef('')
const assistantStartRef = useRef(null)
const detailScrollRef = useRef(null)
const mobileCloseTimerRef = useRef(null)
const selectedSummary = items.find(item => item.id === selectedId) || items[0] || null
const selectedItem = selectedDetail && selectedDetail.id === selectedId ? selectedDetail : null
const syncItems = (nextItems) => {
setItems(nextItems)
setSelectedId(prev => {
if (!nextItems.length) return ''
if (prev && nextItems.some(item => item.id === prev)) return prev
return nextItems[0].id
})
}
const loadList = async ({ mode = 'silent', announceError = false } = {}) => {
if (inFlightRef.current) return
inFlightRef.current = true
if (mode === 'manual') {
setRefreshing(true)
} else if (mode === 'initial') {
setLoading(true)
}
if (announceError) {
setDetail('')
}
try {
const headers = {}
if (listETagRef.current) {
headers['If-None-Match'] = listETagRef.current
}
const res = await apiFetch('/admin/chat-history', { headers })
if (res.status === 304) {
return
}
const data = await res.json()
if (!res.ok) {
throw new Error(data?.detail || t('chatHistory.loadFailed'))
}
listETagRef.current = res.headers.get('ETag') || ''
setLimit(typeof data.limit === 'number' ? data.limit : 20)
syncItems(Array.isArray(data.items) ? data.items : [])
} catch (error) {
setDetail(error.message || t('chatHistory.loadFailed'))
if (announceError) {
onMessage?.('error', error.message || t('chatHistory.loadFailed'))
}
} finally {
if (mode === 'initial') {
setLoading(false)
}
if (mode === 'manual') {
setRefreshing(false)
}
inFlightRef.current = false
}
}
const loadDetail = async (id, { announceError = false } = {}) => {
if (!id || detailInFlightRef.current) return
detailInFlightRef.current = true
try {
const headers = {}
if (detailETagRef.current) {
headers['If-None-Match'] = detailETagRef.current
}
const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { headers })
if (res.status === 304) {
return
}
const data = await res.json()
if (!res.ok) {
throw new Error(data?.detail || t('chatHistory.loadFailed'))
}
detailETagRef.current = res.headers.get('ETag') || ''
setSelectedDetail(data.item || null)
} catch (error) {
if (announceError) {
onMessage?.('error', error.message || t('chatHistory.loadFailed'))
}
} finally {
detailInFlightRef.current = false
}
}
useEffect(() => {
loadList({ mode: 'initial', announceError: true }).finally(() => {
setAutoRefreshReady(true)
})
}, [])
useEffect(() => {
if (!autoRefreshReady) return undefined
const timer = window.setInterval(() => {
loadList({ mode: 'silent', announceError: false })
}, 5000)
return () => window.clearInterval(timer)
}, [autoRefreshReady])
useEffect(() => {
if (!autoRefreshReady || !selectedId || selectedSummary?.status !== 'streaming') return undefined
const timer = window.setInterval(() => {
loadDetail(selectedId, { announceError: false })
}, 1000)
return () => window.clearInterval(timer)
}, [autoRefreshReady, selectedId, selectedSummary?.status])
useEffect(() => {
if (!selectedId) return undefined
detailETagRef.current = ''
setSelectedDetail(null)
loadDetail(selectedId, { announceError: false })
}, [selectedId, mobileDetailOpen])
useEffect(() => {
if (!pendingJumpToAssistant || !selectedItem || selectedItem.id !== selectedId) return undefined
const frame = window.requestAnimationFrame(() => {
assistantStartRef.current?.scrollIntoView({ behavior: 'auto', block: 'start' })
setPendingJumpToAssistant(false)
})
return () => window.cancelAnimationFrame(frame)
}, [pendingJumpToAssistant, selectedId, selectedItem?.id, selectedItem?.revision, mobileDetailOpen, viewMode])
useEffect(() => {
if (typeof localStorage === 'undefined') return
localStorage.setItem(VIEW_MODE_KEY, viewMode)
}, [viewMode])
useEffect(() => {
if (typeof window === 'undefined') return undefined
const handleResize = () => setIsMobileView(window.innerWidth < 1024)
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
useEffect(() => {
if (!isMobileView) {
setMobileDetailOpen(false)
setMobileDetailVisible(false)
}
}, [isMobileView])
useEffect(() => {
return () => {
if (mobileCloseTimerRef.current) {
window.clearTimeout(mobileCloseTimerRef.current)
}
}
}, [])
const handleRefresh = async ({ manual = true } = {}) => {
await loadList({ mode: manual ? 'manual' : 'silent', announceError: manual })
if (selectedId) {
detailETagRef.current = ''
await loadDetail(selectedId, { announceError: manual })
}
}
const handleLimitChange = async (nextLimit) => {
if (nextLimit === limit || savingLimit) return
setSavingLimit(true)
try {
const res = await apiFetch('/admin/chat-history/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: nextLimit }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data?.detail || t('chatHistory.updateLimitFailed'))
}
const resolvedLimit = typeof data.limit === 'number' ? data.limit : nextLimit
setLimit(resolvedLimit)
listETagRef.current = ''
syncItems(Array.isArray(data.items) ? data.items : [])
onMessage?.('success', t('chatHistory.limitUpdated', { limit: resolvedLimit === DISABLED_LIMIT ? t('chatHistory.off') : resolvedLimit }))
} catch (error) {
onMessage?.('error', error.message || t('chatHistory.updateLimitFailed'))
} finally {
setSavingLimit(false)
}
}
const handleDeleteItem = async (id) => {
if (!id || deletingId) return
setDeletingId(id)
try {
const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) {
throw new Error(data?.detail || t('chatHistory.deleteFailed'))
}
if (selectedId === id) {
detailETagRef.current = ''
setSelectedDetail(null)
}
syncItems(items.filter(item => item.id !== id))
onMessage?.('success', t('chatHistory.deleteSuccess'))
} catch (error) {
onMessage?.('error', error.message || t('chatHistory.deleteFailed'))
} finally {
setDeletingId('')
}
}
const handleClear = async () => {
if (clearing || !items.length) return
setClearing(true)
try {
const res = await apiFetch('/admin/chat-history', { method: 'DELETE' })
const data = await res.json()
if (!res.ok) {
throw new Error(data?.detail || t('chatHistory.clearFailed'))
}
listETagRef.current = ''
detailETagRef.current = ''
setSelectedDetail(null)
syncItems([])
onMessage?.('success', t('chatHistory.clearSuccess'))
} catch (error) {
onMessage?.('error', error.message || t('chatHistory.clearFailed'))
} finally {
setClearing(false)
}
}
const openMobileDetail = (itemId, event) => {
const x = typeof window !== 'undefined' && event?.clientX ? (event.clientX / window.innerWidth) * 100 : 50
const y = typeof window !== 'undefined' && event?.clientY ? (event.clientY / window.innerHeight) * 100 : 50
setMobileOrigin({ x, y })
setPendingJumpToAssistant(true)
setSelectedId(itemId)
setMobileDetailOpen(true)
setMobileDetailVisible(false)
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => setMobileDetailVisible(true))
})
}
const closeMobileDetail = () => {
setMobileDetailVisible(false)
if (mobileCloseTimerRef.current) {
window.clearTimeout(mobileCloseTimerRef.current)
}
mobileCloseTimerRef.current = window.setTimeout(() => {
setMobileDetailOpen(false)
}, 180)
}
const handleSelectItem = (itemId, event) => {
if (isMobileView) {
openMobileDetail(itemId, event)
return
}
setPendingJumpToAssistant(true)
setSelectedId(itemId)
}
if (loading) {
return (
<div className="h-[calc(100vh-140px)] rounded-2xl border border-border bg-card shadow-sm flex items-center justify-center">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t('chatHistory.loading')}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card shadow-sm p-4 lg:p-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="text-sm font-semibold text-foreground">{t('chatHistory.retentionTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">{t('chatHistory.retentionDesc')}</div>
</div>
<div className="flex flex-wrap gap-2 items-center">
{LIMIT_OPTIONS.map(option => (
<button
key={option}
type="button"
disabled={savingLimit}
onClick={() => handleLimitChange(option)}
className={clsx(
'h-9 px-3 rounded-lg border text-sm transition-colors',
option === limit
? (option === DISABLED_LIMIT
? 'border-destructive bg-destructive text-destructive-foreground'
: 'border-primary bg-primary text-primary-foreground')
: 'border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70'
)}
>
{option === DISABLED_LIMIT ? t('chatHistory.off') : option}
</button>
))}
<button
type="button"
onClick={() => handleRefresh({ manual: true })}
disabled={refreshing}
className={clsx(
'h-9 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center',
isMobileView ? 'w-9 justify-center px-0' : 'gap-2 px-3'
)}
>
{refreshing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCcw className="w-4 h-4" />}
{!isMobileView && t('chatHistory.refresh')}
</button>
<button
type="button"
onClick={() => setConfirmClearOpen(true)}
disabled={clearing || !items.length}
className="h-10 w-10 rounded-xl border border-border bg-[#111214] text-muted-foreground hover:text-destructive hover:bg-[#181a1d] disabled:opacity-50 flex items-center justify-center"
title={t('chatHistory.clearAll')}
>
{clearing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</div>
</div>
{detail && (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 text-destructive px-4 py-3 text-sm">
{detail}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-[340px,minmax(0,1fr)] gap-6 h-[calc(100vh-240px)] min-h-[520px]">
<div className="rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div className="text-sm font-semibold">{t('chatHistory.listTitle')}</div>
<div className="text-xs text-muted-foreground">{items.length}</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{!items.length && (
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex flex-col items-center justify-center gap-2 text-center px-6">
<MessageSquareText className="w-8 h-8 text-muted-foreground/50" />
<div className="text-sm font-medium text-foreground">{t('chatHistory.emptyTitle')}</div>
<div className="text-xs text-muted-foreground leading-6">{t('chatHistory.emptyDesc')}</div>
</div>
)}
{items.map(item => (
<button
key={item.id}
type="button"
onClick={(event) => handleSelectItem(item.id, event)}
className={clsx(
'w-full text-left rounded-xl border px-4 py-3 transition-colors',
selectedItem?.id === item.id
? 'border-primary/40 bg-primary/5'
: 'border-border hover:bg-secondary/40'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">
{item.user_input || t('chatHistory.untitled')}
</div>
<div className="text-[11px] text-muted-foreground mt-1 truncate">
{item.model || '-'}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={clsx('px-2 py-0.5 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(item.status))}>
{t(`chatHistory.status.${item.status || 'streaming'}`)}
</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
handleDeleteItem(item.id)
}}
disabled={deletingId === item.id}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{deletingId === item.id ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<div className="text-xs text-muted-foreground mt-3 line-clamp-2 whitespace-pre-wrap break-words">
{previewText(item) || t('chatHistory.noPreview')}
</div>
<div className="text-[11px] text-muted-foreground/80 mt-3">
{formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)}
</div>
</button>
))}
</div>
</div>
<div className="hidden lg:flex rounded-2xl border border-border bg-card shadow-sm min-h-0 overflow-hidden flex-col relative">
<div className="px-5 py-4 border-b border-border flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')}
</div>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
<button
type="button"
onClick={() => setViewMode('list')}
className={clsx(
'h-9 w-12 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'list'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeList')}
>
<ListModeIcon />
</button>
<button
type="button"
onClick={() => setViewMode('merged')}
className={clsx(
'h-9 w-12 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'merged'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeMerged')}
>
<MergeModeIcon />
</button>
</div>
<button
type="button"
onClick={() => detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.backToTop')}
>
<ArrowUp className="w-4 h-4" />
</button>
{selectedSummary && (
<span className={clsx('px-2.5 py-1 rounded-full border text-[10px] font-semibold uppercase tracking-wide', statusTone(selectedSummary.status))}>
{t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)}
</span>
)}
</div>
</div>
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 lg:p-6 space-y-6">
{!selectedItem && (
<div className="h-full rounded-xl border border-dashed border-border/80 bg-background/50 flex items-center justify-center text-sm text-muted-foreground">
{t('chatHistory.selectPrompt')}
</div>
)}
{selectedItem && (
<DetailConversation
selectedItem={selectedItem}
t={t}
viewMode={viewMode}
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
bottomButtonClassName="absolute right-5 bottom-5"
/>
)}
</div>
</div>
</div>
{isMobileView && mobileDetailOpen && selectedItem && (
<div
className={clsx(
'fixed inset-0 z-50 flex items-center justify-center px-3 py-4 bg-background/65 backdrop-blur-sm transition-opacity duration-200',
mobileDetailVisible ? 'opacity-100' : 'opacity-0'
)}
onClick={closeMobileDetail}
>
<div
onClick={(event) => event.stopPropagation()}
className={clsx(
'w-full h-full rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col transition-transform duration-200 ease-out',
mobileDetailVisible ? 'scale-100' : 'scale-90'
)}
style={{ transformOrigin: `${mobileOrigin.x}% ${mobileOrigin.y}%` }}
>
<div className="px-5 py-4 border-b border-border flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-foreground">{t('chatHistory.detailTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex items-center rounded-xl border border-border bg-background p-1">
<button
type="button"
onClick={() => setViewMode('list')}
className={clsx(
'h-9 w-10 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'list'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeList')}
>
<ListModeIcon />
</button>
<button
type="button"
onClick={() => setViewMode('merged')}
className={clsx(
'h-9 w-10 rounded-lg flex items-center justify-center transition-colors',
viewMode === 'merged'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
)}
title={t('chatHistory.viewModeMerged')}
>
<MergeModeIcon />
</button>
</div>
<button
type="button"
onClick={() => detailScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="h-9 w-9 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.backToTop')}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={closeMobileDetail}
className="h-9 w-9 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('actions.cancel')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div ref={detailScrollRef} className="flex-1 overflow-y-auto p-5 space-y-6">
<DetailConversation
selectedItem={selectedItem}
t={t}
viewMode={viewMode}
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
bottomButtonClassName="fixed right-5 bottom-5"
/>
</div>
</div>
</div>
)}
{confirmClearOpen && (
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center px-4">
<div className="w-full max-w-sm rounded-2xl border border-border bg-card shadow-2xl p-5 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="h-11 w-11 rounded-2xl bg-[#111214] text-muted-foreground flex items-center justify-center">
<Trash2 className="w-5 h-5" />
</div>
<div>
<div className="text-base font-semibold text-foreground">{t('chatHistory.confirmClearTitle')}</div>
<div className="text-sm text-muted-foreground mt-1">{t('chatHistory.confirmClearDesc')}</div>
</div>
</div>
<button
type="button"
onClick={() => setConfirmClearOpen(false)}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/70"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmClearOpen(false)}
className="h-10 px-4 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/60"
>
{t('actions.cancel')}
</button>
<button
type="button"
onClick={async () => {
setConfirmClearOpen(false)
await handleClear()
}}
className="h-10 px-4 rounded-lg border border-destructive/20 bg-destructive/10 text-destructive hover:bg-destructive/15 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{t('chatHistory.confirmClearAction')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,48 @@
export default function HistorySplitSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<div className="space-y-1">
<h3 className="font-semibold">{t('settings.historySplitTitle')}</h3>
<p className="text-sm text-muted-foreground">{t('settings.historySplitDesc')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-start gap-3 rounded-lg border border-border bg-background/60 p-4">
<input
type="checkbox"
checked={Boolean(form.history_split?.enabled ?? true)}
onChange={(e) => setForm((prev) => ({
...prev,
history_split: {
...prev.history_split,
enabled: e.target.checked,
},
}))}
className="mt-1 h-4 w-4 rounded border-border"
/>
<div className="space-y-1">
<span className="text-sm font-medium block">{t('settings.historySplitEnabled')}</span>
<span className="text-xs text-muted-foreground block">{t('settings.historySplitEnabledDesc')}</span>
</div>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.historySplitTriggerAfterTurns')}</span>
<input
type="number"
min={1}
max={1000}
value={form.history_split.trigger_after_turns}
onChange={(e) => setForm((prev) => ({
...prev,
history_split: {
...prev.history_split,
trigger_after_turns: Number(e.target.value || 1),
},
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
<p className="text-xs text-muted-foreground">{t('settings.historySplitTriggerHelp')}</p>
</label>
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm'
import SecuritySection from './SecuritySection'
import RuntimeSection from './RuntimeSection'
import BehaviorSection from './BehaviorSection'
import HistorySplitSection from './HistorySplitSection'
import CompatibilitySection from './CompatibilitySection'
import AutoDeleteSection from './AutoDeleteSection'
import ModelSection from './ModelSection'
@@ -95,6 +96,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
<BehaviorSection t={t} form={form} setForm={setForm} />
<HistorySplitSection t={t} form={form} setForm={setForm} />
<CompatibilitySection t={t} form={form} setForm={setForm} />
<AutoDeleteSection t={t} form={form} setForm={setForm} />

View File

@@ -17,6 +17,7 @@ const DEFAULT_FORM = {
responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' },
auto_delete: { mode: 'none' },
history_split: { enabled: true, trigger_after_turns: 1 },
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
model_aliases_text: '{}',
}
@@ -70,6 +71,10 @@ function fromServerForm(data) {
auto_delete: {
mode: normalizeAutoDeleteMode(data.auto_delete),
},
history_split: {
enabled: data.history_split?.enabled ?? true,
trigger_after_turns: Number(data.history_split?.trigger_after_turns || 1),
},
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
}
@@ -90,6 +95,10 @@ function toServerPayload(form) {
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() },
auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) },
history_split: {
enabled: Boolean(form.history_split?.enabled ?? true),
trigger_after_turns: Number(form.history_split?.trigger_after_turns || 1),
},
}
}

View File

@@ -10,12 +10,14 @@ import {
X,
Server,
Users,
Globe
Globe,
History
} from 'lucide-react'
import clsx from 'clsx'
import AccountManagerContainer from '../features/account/AccountManagerContainer'
import ApiTesterContainer from '../features/apiTester/ApiTesterContainer'
import ChatHistoryContainer from '../features/chatHistory/ChatHistoryContainer'
import BatchImport from '../components/BatchImport'
import VercelSyncContainer from '../features/vercel/VercelSyncContainer'
import SettingsContainer from '../features/settings/SettingsContainer'
@@ -33,6 +35,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
{ id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
{ id: 'proxies', label: t('nav.proxies.label'), icon: Globe, description: t('nav.proxies.desc') },
{ id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
{ id: 'history', label: t('nav.history.label'), icon: History, description: t('nav.history.desc') },
{ id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') },
{ id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') },
{ id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') },
@@ -98,6 +101,8 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
return <ProxyManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'test':
return <ApiTesterContainer config={config} onMessage={showMessage} authFetch={authFetch} />
case 'history':
return <ChatHistoryContainer onMessage={showMessage} authFetch={authFetch} />
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':

View File

@@ -17,6 +17,10 @@
"label": "API Test",
"desc": "Test API connectivity and responses"
},
"history": {
"label": "Conversations",
"desc": "Browse server-side external chat history"
},
"import": {
"label": "Batch Import",
"desc": "Bulk import account configuration"
@@ -92,7 +96,9 @@
},
"accountManager": {
"addKeySuccess": "API key added successfully.",
"updateKeySuccess": "API key updated successfully.",
"addAccountSuccess": "Account added successfully.",
"updateAccountSuccess": "Account metadata updated successfully.",
"requiredFields": "Password and email/mobile are required.",
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
"deleteAccountConfirm": "Are you sure you want to delete this account?",
@@ -106,15 +112,17 @@
"accountsUnit": "accounts",
"threadsUnit": "threads",
"apiKeysTitle": "API Keys",
"apiKeysDesc": "Manage the API access key pool",
"apiKeysDesc": "Manage the API access key pool. Click the pencil icon on each row to edit name and remark.",
"addKey": "Add key",
"editKeyTitle": "Edit key",
"editAccountTitle": "Edit account",
"copied": "Copied",
"copyFailed": "Copy failed",
"copyKeyTitle": "Copy key",
"deleteKeyTitle": "Delete key",
"noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool",
"accountsDesc": "Manage the DeepSeek account pool and edit name/remark.",
"testAll": "Refresh all tokens",
"addAccount": "Add account",
"testingAllAccounts": "Refreshing tokens for all accounts...",
@@ -124,13 +132,28 @@
"testStatusFailed": "Last test failed",
"noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key",
"modalEditKeyTitle": "Edit API key",
"modalEditAccountTitle": "Edit account details",
"newKeyLabel": "New key value",
"newKeyPlaceholder": "Enter a custom API key",
"keyLabel": "Key value",
"keyReadonlyPlaceholder": "Key value cannot be changed",
"keyReadonlyHint": "The key value is read-only. Update the name and remark instead.",
"generate": "Generate",
"generateHint": "Click Generate to create a random key.",
"addKeyLoading": "Adding...",
"addKeyAction": "Add key",
"editKeyLoading": "Saving...",
"editKeyAction": "Save changes",
"editAccountHint": "Only name and remark can be changed here. The account identifier stays the same.",
"accountIdentifierLabel": "Account identifier",
"editAccountLoading": "Saving...",
"editAccountAction": "Save changes",
"modalAddAccountTitle": "Add DeepSeek account",
"nameOptional": "Name (optional)",
"namePlaceholder": "e.g. Primary Account A",
"remarkOptional": "Remark (optional)",
"remarkPlaceholder": "e.g. Team shared / test only",
"emailOptional": "Email (optional)",
"mobileOptional": "Mobile (optional)",
"passwordLabel": "Password",
@@ -234,11 +257,71 @@
"enterMessage": "Enter a message...",
"adminConsoleLabel": "DeepSeek admin console"
},
"chatHistory": {
"loading": "Loading conversation history...",
"loadFailed": "Failed to load conversation history.",
"retentionTitle": "Retention",
"retentionDesc": "The server keeps only the latest N external /v1/chat/completions conversations.",
"off": "OFF",
"refresh": "Refresh",
"clearAll": "Clear all",
"clearSuccess": "Conversation history cleared.",
"clearFailed": "Failed to clear conversation history.",
"deleteSuccess": "Conversation deleted.",
"deleteFailed": "Failed to delete conversation.",
"updateLimitFailed": "Failed to update retention limit.",
"limitUpdated": "Retention limit updated to {limit}",
"listTitle": "History",
"detailTitle": "Details",
"viewModeList": "List mode",
"viewModeMerged": "Merged mode",
"emptyTitle": "No conversation history yet",
"emptyDesc": "When external clients call /v1/chat/completions, the server will save the results here automatically.",
"untitled": "Untitled conversation",
"noPreview": "No preview available.",
"selectPrompt": "Select a record on the left to view details.",
"mergedInput": "Final message sent to DeepSeek",
"emptyMergedPrompt": "No merged prompt is available.",
"expand": "Expand",
"collapse": "Collapse",
"reasoningTrace": "Reasoning Trace",
"failedOutput": "The request failed and no assistant output is available.",
"emptyAssistantOutput": "No assistant output is available.",
"emptyUserInput": "No user input is available.",
"confirmClearTitle": "Clear all records?",
"confirmClearDesc": "This deletes every server-side conversation record and cannot be undone.",
"confirmClearAction": "Clear all",
"metaTitle": "Metadata",
"metaAccount": "Account",
"metaElapsed": "Elapsed",
"metaModel": "Model",
"metaStatusCode": "Status code",
"metaStream": "Output mode",
"metaCaller": "Caller fingerprint",
"metaTime": "Completed at",
"metaUnknown": "Unknown",
"backToTop": "Back to top",
"backToBottom": "Jump to bottom",
"streamMode": "Streaming",
"nonStreamMode": "Non-streaming",
"status": {
"streaming": "Streaming",
"success": "Success",
"error": "Error",
"stopped": "Stopped"
},
"role": {
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System"
}
},
"batchImport": {
"templates": {
"full": {
"name": "Full configuration template",
"desc": "Includes keys, accounts, and model mapping"
"desc": "Loaded from config.example.json with keys, accounts, and defaults"
},
"emailOnly": {
"name": "Email-only accounts",
@@ -296,6 +379,12 @@
"behaviorTitle": "Behavior",
"responsesTTL": "Responses store TTL (seconds)",
"embeddingsProvider": "Embeddings provider",
"historySplitTitle": "History Split",
"historySplitDesc": "Pack earlier turns into an attached HISTORY.txt so the model reads the file first and then continues from the latest user request.",
"historySplitEnabled": "Enable history split",
"historySplitEnabledDesc": "Enabled by default. Turning this off falls back to normal full-context requests.",
"historySplitTriggerAfterTurns": "Trigger threshold (user turns)",
"historySplitTriggerHelp": "Default is 1, which means history split starts from the second turn.",
"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",

View File

@@ -17,6 +17,10 @@
"label": "API 测试",
"desc": "测试 API 连接与响应"
},
"history": {
"label": "对话记录",
"desc": "查看服务器保存的外部对话历史"
},
"import": {
"label": "批量导入",
"desc": "批量导入账号配置"
@@ -92,7 +96,9 @@
},
"accountManager": {
"addKeySuccess": "API 密钥添加成功",
"updateKeySuccess": "API 密钥更新成功",
"addAccountSuccess": "账号添加成功",
"updateAccountSuccess": "账号信息更新成功",
"requiredFields": "需要填写密码以及邮箱或手机号",
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?",
@@ -106,15 +112,17 @@
"accountsUnit": "个账号",
"threadsUnit": "线程",
"apiKeysTitle": "API 密钥",
"apiKeysDesc": "管理 API 访问密钥池",
"apiKeysDesc": "管理 API 访问密钥池,点每行右侧铅笔可修改名称和备注",
"addKey": "添加密钥",
"editKeyTitle": "编辑密钥",
"editAccountTitle": "编辑账号",
"copied": "已复制",
"copyFailed": "复制失败",
"copyKeyTitle": "复制密钥",
"deleteKeyTitle": "删除密钥",
"noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池",
"accountsDesc": "管理 DeepSeek 账号池,支持修改名称和备注",
"testAll": "刷新全部 Token",
"addAccount": "添加账号",
"testingAllAccounts": "正在刷新所有账号 Token...",
@@ -124,13 +132,28 @@
"testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
"modalEditKeyTitle": "编辑 API 密钥",
"modalEditAccountTitle": "编辑账号信息",
"newKeyLabel": "新密钥值",
"newKeyPlaceholder": "输入自定义 API 密钥",
"keyLabel": "密钥值",
"keyReadonlyPlaceholder": "密钥值不可修改",
"keyReadonlyHint": "密钥值不可编辑,仅可修改名称和备注。",
"generate": "生成",
"generateHint": "点击「生成」自动创建随机密钥",
"addKeyLoading": "添加中...",
"addKeyAction": "添加密钥",
"editKeyLoading": "保存中...",
"editKeyAction": "保存修改",
"editAccountHint": "这里只能修改名称和备注,账号标识保持不变。",
"accountIdentifierLabel": "账号标识",
"editAccountLoading": "保存中...",
"editAccountAction": "保存修改",
"modalAddAccountTitle": "添加 DeepSeek 账号",
"nameOptional": "名称(可选)",
"namePlaceholder": "例如:主账号 A",
"remarkOptional": "备注(可选)",
"remarkPlaceholder": "例如:团队共享 / 仅测试用",
"emailOptional": "邮箱 (可选)",
"mobileOptional": "手机号 (可选)",
"passwordLabel": "密码",
@@ -234,11 +257,71 @@
"enterMessage": "输入消息...",
"adminConsoleLabel": "DeepSeek 管理员界面"
},
"chatHistory": {
"loading": "正在加载对话记录...",
"loadFailed": "加载对话记录失败",
"retentionTitle": "保留条数",
"retentionDesc": "服务器端只保留最新 N 条外部 /v1/chat/completions 对话记录。",
"off": "OFF",
"refresh": "刷新",
"clearAll": "清空全部",
"clearSuccess": "对话记录已清空",
"clearFailed": "清空对话记录失败",
"deleteSuccess": "对话记录已删除",
"deleteFailed": "删除对话记录失败",
"updateLimitFailed": "更新保留条数失败",
"limitUpdated": "保留条数已更新为 {limit}",
"listTitle": "历史列表",
"detailTitle": "对话详情",
"viewModeList": "列表模式",
"viewModeMerged": "合并模式",
"emptyTitle": "还没有可用的对话记录",
"emptyDesc": "当外部客户端调用 /v1/chat/completions 时,服务端会自动把结果写入这里。",
"untitled": "未命名对话",
"noPreview": "暂无预览内容",
"selectPrompt": "从左侧选择一条记录查看详情。",
"mergedInput": "最终发送给 DeepSeek 的完整消息",
"emptyMergedPrompt": "没有可展示的完整消息。",
"expand": "展开全部",
"collapse": "收起",
"reasoningTrace": "思维链过程",
"failedOutput": "请求失败,未生成可展示的回答。",
"emptyAssistantOutput": "没有可展示的生成内容。",
"emptyUserInput": "没有可展示的用户输入。",
"confirmClearTitle": "确认清空全部记录?",
"confirmClearDesc": "此操作会删除服务器里的全部对话记录,无法恢复。",
"confirmClearAction": "确认清空",
"metaTitle": "元信息",
"metaAccount": "使用账号",
"metaElapsed": "耗时",
"metaModel": "模型",
"metaStatusCode": "状态码",
"metaStream": "输出模式",
"metaCaller": "调用方指纹",
"metaTime": "完成时间",
"metaUnknown": "未知",
"backToTop": "回到顶部",
"backToBottom": "跳到底部",
"streamMode": "流式",
"nonStreamMode": "非流式",
"status": {
"streaming": "进行中",
"success": "成功",
"error": "失败",
"stopped": "已停止"
},
"role": {
"user": "用户",
"assistant": "助手",
"tool": "工具",
"system": "系统"
}
},
"batchImport": {
"templates": {
"full": {
"name": "全量配置模板",
"desc": "包含密钥、账号及模型映射"
"desc": "直接复用 config.example.json包含密钥、账号和默认配置"
},
"emailOnly": {
"name": "仅邮箱账号",
@@ -296,6 +379,12 @@
"behaviorTitle": "行为设置",
"responsesTTL": "Responses 缓存 TTL",
"embeddingsProvider": "Embeddings Provider",
"historySplitTitle": "历史拆分",
"historySplitDesc": "将更早的对话整理成 HISTORY.txt 上传,让模型优先读取历史文件,再结合最新一轮继续回答。",
"historySplitEnabled": "启用历史拆分",
"historySplitEnabledDesc": "默认开启。关闭后会恢复为普通的完整上下文提交。",
"historySplitTriggerAfterTurns": "触发阈值(用户回合数)",
"historySplitTriggerHelp": "默认值为 1表示从第二轮开始拆分历史。",
"compatibilityTitle": "兼容性设置",
"compatibilityDesc": "用于控制输出格式兼容性,避免把模型原始流里的标记直接暴露到前端。",
"stripReferenceMarkers": "移除 [reference:N] 标记",

View File

@@ -0,0 +1,42 @@
import exampleConfig from '../../../config.example.json'
export function getBatchImportTemplates(t) {
return {
full: {
name: t('batchImport.templates.full.name'),
desc: t('batchImport.templates.full.desc'),
config: exampleConfig,
},
email_only: {
name: t('batchImport.templates.emailOnly.name'),
desc: t('batchImport.templates.emailOnly.desc'),
config: {
keys: ['your-api-key'],
accounts: [
{ email: 'account1@example.com', password: 'pass1', token: '' },
{ email: 'account2@example.com', password: 'pass2', token: '' },
{ email: 'account3@example.com', password: 'pass3', token: '' },
],
},
},
mobile_only: {
name: t('batchImport.templates.mobileOnly.name'),
desc: t('batchImport.templates.mobileOnly.desc'),
config: {
keys: ['your-api-key'],
accounts: [
{ mobile: '+8613800000001', password: 'pass1', token: '' },
{ mobile: '+8613800000002', password: 'pass2', token: '' },
{ mobile: '+8613800000003', password: 'pass3', token: '' },
],
},
},
keys_only: {
name: t('batchImport.templates.keysOnly.name'),
desc: t('batchImport.templates.keysOnly.desc'),
config: {
keys: ['key-1', 'key-2', 'key-3'],
},
},
}
}

View File

@@ -1,5 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const webuiDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(webuiDir, '..')
export default defineConfig(({ mode }) => ({
plugins: [
@@ -7,6 +12,9 @@ export default defineConfig(({ mode }) => ({
],
server: {
port: 5173,
fs: {
allow: [repoRoot],
},
proxy: {
// 代理 /admin 下的 API 请求到后端
'/admin': {