From d57f547bdb4336f322a5d3057356a24047c976b3 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Feb 2026 13:40:14 +0800 Subject: [PATCH 01/50] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 账号列表支持分页(每页10条,倒序显示) - API 密钥列表支持展开/关闭 --- routes/admin/config.py | 43 ++++++ webui/src/components/AccountManager.jsx | 178 +++++++++++++++++------- webui/src/locales/en.json | 3 +- webui/src/locales/zh.json | 3 +- 4 files changed, 173 insertions(+), 54 deletions(-) diff --git a/routes/admin/config.py b/routes/admin/config.py index d0739a5..5a8471a 100644 --- a/routes/admin/config.py +++ b/routes/admin/config.py @@ -122,6 +122,49 @@ async def delete_key(key: str, _: bool = Depends(verify_admin)): # ---------------------------------------------------------------------- # 账号管理 # ---------------------------------------------------------------------- +@router.get("/accounts") +async def list_accounts( + page: int = 1, + page_size: int = 10, + _: bool = Depends(verify_admin) +): + """获取账号列表(分页,倒序,密码脱敏)""" + accounts = CONFIG.get("accounts", []) + total = len(accounts) + + # 倒序排列 + accounts = list(reversed(accounts)) + + # 计算分页 + page = max(1, page) + page_size = max(1, min(100, page_size)) # 限制每页最多 100 条 + total_pages = (total + page_size - 1) // page_size if total > 0 else 1 + + start = (page - 1) * page_size + end = start + page_size + page_accounts = accounts[start:end] + + # 脱敏处理 + safe_accounts = [] + for acc in page_accounts: + safe_acc = { + "email": acc.get("email", ""), + "mobile": acc.get("mobile", ""), + "has_password": bool(acc.get("password")), + "has_token": bool(acc.get("token")), + "token_preview": acc.get("token", "")[:20] + "..." if acc.get("token") else "", + } + safe_accounts.append(safe_acc) + + return JSONResponse(content={ + "items": safe_accounts, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + }) + + @router.post("/accounts") async def add_account(request: Request, _: bool = Depends(verify_admin)): """添加账号""" diff --git a/webui/src/components/AccountManager.jsx b/webui/src/components/AccountManager.jsx index 25aa8c1..773b84e 100644 --- a/webui/src/components/AccountManager.jsx +++ b/webui/src/components/AccountManager.jsx @@ -8,7 +8,10 @@ import { Server, ShieldCheck, Copy, - Check + Check, + ChevronLeft, + ChevronRight, + ChevronDown } from 'lucide-react' import clsx from 'clsx' import { useI18n } from '../i18n' @@ -25,9 +28,36 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch const [testingAll, setTestingAll] = useState(false) const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] }) const [queueStatus, setQueueStatus] = useState(null) + const [keysExpanded, setKeysExpanded] = useState(false) + + // 分页状态 + const [accounts, setAccounts] = useState([]) + const [page, setPage] = useState(1) + const [pageSize] = useState(10) + const [totalPages, setTotalPages] = useState(1) + const [totalAccounts, setTotalAccounts] = useState(0) + const [loadingAccounts, setLoadingAccounts] = useState(false) const apiFetch = authFetch || fetch + const fetchAccounts = async (targetPage = page) => { + setLoadingAccounts(true) + try { + const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`) + if (res.ok) { + const data = await res.json() + setAccounts(data.items || []) + setTotalPages(data.total_pages || 1) + setTotalAccounts(data.total || 0) + setPage(data.page || 1) + } + } catch (e) { + console.error('Failed to fetch accounts:', e) + } finally { + setLoadingAccounts(false) + } + } + const fetchQueueStatus = async () => { try { const res = await apiFetch('/admin/queue/status') @@ -41,6 +71,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch } useEffect(() => { + fetchAccounts() fetchQueueStatus() const interval = setInterval(fetchQueueStatus, 5000) return () => clearInterval(interval) @@ -102,6 +133,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch onMessage('success', t('accountManager.addAccountSuccess')) setNewAccount({ email: '', mobile: '', password: '' }) setShowAddAccount(false) + fetchAccounts(1) // 添加后回到第一页 onRefresh() } else { const data = await res.json() @@ -120,6 +152,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' }) if (res.ok) { onMessage('success', t('messages.deleted')) + fetchAccounts() // 刷新当前页 onRefresh() } else { onMessage('error', t('messages.deleteFailed')) @@ -142,6 +175,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch ? t('apiTester.testSuccess', { account: identifier, time: data.response_time }) : `${identifier}: ${data.message}` onMessage(data.success ? 'success' : 'error', statusMessage) + fetchAccounts() // 刷新当前页 onRefresh() } catch (e) { onMessage('error', t('accountManager.testFailed', { error: e.message })) @@ -152,17 +186,17 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch const testAllAccounts = async () => { if (!confirm(t('accountManager.testAllConfirm'))) return - const accounts = config.accounts || [] - if (accounts.length === 0) return + const allAccounts = config.accounts || [] + if (allAccounts.length === 0) return setTestingAll(true) - setBatchProgress({ current: 0, total: accounts.length, results: [] }) + setBatchProgress({ current: 0, total: allAccounts.length, results: [] }) let successCount = 0 const results = [] - for (let i = 0; i < accounts.length; i++) { - const acc = accounts[i] + for (let i = 0; i < allAccounts.length; i++) { + const acc = allAccounts[i] const id = acc.email || acc.mobile try { @@ -178,10 +212,11 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch results.push({ id, success: false, message: e.message }) } - setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] }) + setBatchProgress({ current: i + 1, total: allAccounts.length, results: [...results] }) } - onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: accounts.length })) + onMessage('success', t('accountManager.testAllCompleted', { success: successCount, total: allAccounts.length })) + fetchAccounts() // 刷新当前页 onRefresh() setTestingAll(false) } @@ -228,13 +263,22 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch {/* API Keys Section */}
-
-
-

{t('accountManager.apiKeysTitle')}

-

{t('accountManager.apiKeysDesc')}

+
setKeysExpanded(!keysExpanded)} + > +
+ +
+

{t('accountManager.apiKeysTitle')}

+

{t('accountManager.apiKeysDesc')} ({config.keys?.length || 0})

+
-
- {config.keys?.length > 0 ? ( - config.keys.map((key, i) => ( -
-
-
- {key.slice(0, 16)}**** + {keysExpanded && ( +
+ {config.keys?.length > 0 ? ( + config.keys.map((key, i) => ( +
+
+
+ {key.slice(0, 16)}**** +
+ {copiedKey === key && ( + {t('accountManager.copied')} + )} +
+
+ +
- {copiedKey === key && ( - {t('accountManager.copied')} - )}
-
- - -
-
- )) - ) : ( -
{t('accountManager.noApiKeys')}
- )} -
+ )) + ) : ( +
{t('accountManager.noApiKeys')}
+ )} +
+ )}
{/* Accounts Section */} @@ -292,7 +338,7 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
+ {page} / {totalPages} + +
+
+ )}
{/* Modals */} diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 38d1795..2490dea 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -122,7 +122,8 @@ "passwordLabel": "Password", "passwordPlaceholder": "Account password", "addAccountLoading": "Adding...", - "addAccountAction": "Add account" + "addAccountAction": "Add account", + "pageInfo": "Page {current}/{total}, {count} accounts total" }, "apiTester": { "defaultMessage": "Hello, please introduce yourself in one sentence.", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index a8c0f71..7e2c5a2 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -122,7 +122,8 @@ "passwordLabel": "密码", "passwordPlaceholder": "账号密码", "addAccountLoading": "添加中...", - "addAccountAction": "添加账号" + "addAccountAction": "添加账号", + "pageInfo": "第 {current}/{total} 页,共 {count} 个账号" }, "apiTester": { "defaultMessage": "你好,请用一句话介绍你自己。", From ee0b7f08a07687a53004b636e858a7759a63de97 Mon Sep 17 00:00:00 2001 From: CJACK Date: Fri, 13 Feb 2026 22:30:55 +0800 Subject: [PATCH 02/50] refactor: centralize account cleanup in API routes and refine login checkbox styling. --- routes/claude.py | 2 +- routes/openai.py | 3 +-- webui/src/components/Login.jsx | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/routes/claude.py b/routes/claude.py index 706c022..e9c7b25 100644 --- a/routes/claude.py +++ b/routes/claude.py @@ -314,7 +314,7 @@ Remember: Output ONLY the JSON, no other text. The response must start with {{ a deepseek_resp.close() except Exception: pass - cleanup_account(request) + # 注意:不在此处调用 cleanup_account,由外层 finally 统一处理 return StreamingResponse( claude_sse_stream(), diff --git a/routes/openai.py b/routes/openai.py index 5ba60f2..f79283f 100644 --- a/routes/openai.py +++ b/routes/openai.py @@ -435,8 +435,7 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with except Exception as e: logger.error(f"[sse_stream] 异常: {e}") - finally: - cleanup_account(request) + # 注意:不在此处调用 cleanup_account,由外层 finally 统一处理 return StreamingResponse( sse_stream(), diff --git a/webui/src/components/Login.jsx b/webui/src/components/Login.jsx index 2ebe5d6..c06c12e 100644 --- a/webui/src/components/Login.jsx +++ b/webui/src/components/Login.jsx @@ -87,8 +87,8 @@ export default function Login({ onLogin, onMessage }) { checked={remember} onChange={e => setRemember(e.target.checked)} /> -
- +
+
{t('login.rememberSession')} From 648b80bb7b636b6a6639c2ef07f8895f7052531c Mon Sep 17 00:00:00 2001 From: CJACK Date: Fri, 13 Feb 2026 22:44:39 +0800 Subject: [PATCH 03/50] feat: Add support for parsing dictionary-based SSE fragments and prevent duplicate stream termination messages. --- core/config.py | 5 +++++ core/sse_parser.py | 20 ++++++++++++++++++++ routes/openai.py | 6 ++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/core/config.py b/core/config.py index 83e02ca..f215691 100644 --- a/core/config.py +++ b/core/config.py @@ -32,9 +32,14 @@ logger = logging.getLogger("ds2api") # -------------------------- 初始化 tokenizer -------------------------- chat_tokenizer_dir = resolve_path("DS2API_TOKENIZER_DIR", "") +# 抑制 Mistral tokenizer regex 警告(不影响 DeepSeek tokenization) +_tf_logger = logging.getLogger("transformers") +_tf_log_level = _tf_logger.level +_tf_logger.setLevel(logging.ERROR) tokenizer = transformers.AutoTokenizer.from_pretrained( chat_tokenizer_dir, trust_remote_code=True ) +_tf_logger.setLevel(_tf_log_level) # ---------------------------------------------------------------------- # 配置文件的读写函数 diff --git a/core/sse_parser.py b/core/sse_parser.py index 481c872..e1c234e 100644 --- a/core/sse_parser.py +++ b/core/sse_parser.py @@ -255,6 +255,26 @@ def parse_sse_chunk_for_content( return ([], True, new_fragment_type) contents.extend(result) + # 处理字典值(初始响应 chunk,包含 response.fragments) + elif isinstance(v_value, dict): + response_obj = v_value.get("response", v_value) + fragments = response_obj.get("fragments", []) + if isinstance(fragments, list): + for frag in fragments: + if isinstance(frag, dict): + frag_type = frag.get("type", "").upper() + frag_content = frag.get("content", "") + if frag_type == "THINK" or frag_type == "THINKING": + new_fragment_type = "thinking" + if frag_content: + contents.append((frag_content, "thinking")) + elif frag_type == "RESPONSE": + new_fragment_type = "text" + if frag_content: + contents.append((frag_content, "text")) + elif frag_content: + contents.append((frag_content, ptype)) + return (contents, False, new_fragment_type) diff --git a/routes/openai.py b/routes/openai.py index f79283f..2225581 100644 --- a/routes/openai.py +++ b/routes/openai.py @@ -194,6 +194,7 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with last_content_time = time.time() # 最后收到有效内容的时间 keepalive_count = 0 # 连续 keepalive 计数 has_content = False # 是否收到过内容 + stream_finished = False # 是否已发送过结束标记 def process_data(): """处理 DeepSeek SSE 数据流 - 使用 sse_parser 模块""" @@ -343,6 +344,7 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" last_send_time = current_time + stream_finished = True break new_choices = [] @@ -391,8 +393,8 @@ IMPORTANT: If calling tools, output ONLY the JSON. The response must start with except queue.Empty: continue - # 如果是超时退出,也发送结束标记 - if has_content: + # 如果是超时退出且尚未发送结束标记,补发结束标记 + if has_content and not stream_finished: prompt_tokens = len(final_prompt) // 4 thinking_tokens = len(final_thinking) // 4 completion_tokens = len(final_text) // 4 From 85ac0d95a4762902937062627ccbda995a70f802 Mon Sep 17 00:00:00 2001 From: CJACK Date: Fri, 13 Feb 2026 23:11:15 +0800 Subject: [PATCH 04/50] feat: Display Vercel configuration sync status and last sync time, and clear API tester streaming output before new requests. --- routes/admin/vercel.py | 42 ++++++++++++++++++++ static/admin/.gitkeep | 1 - webui/src/components/ApiTester.jsx | 20 ++++++---- webui/src/components/VercelSync.jsx | 61 +++++++++++++++++++++++++---- webui/src/locales/en.json | 6 ++- webui/src/locales/zh.json | 6 ++- 6 files changed, 118 insertions(+), 18 deletions(-) delete mode 100644 static/admin/.gitkeep diff --git a/routes/admin/vercel.py b/routes/admin/vercel.py index 91b03d3..cb365b5 100644 --- a/routes/admin/vercel.py +++ b/routes/admin/vercel.py @@ -2,8 +2,10 @@ """Admin Vercel 模块 - Vercel 同步和部署""" import asyncio import base64 +import hashlib import json import os +import time as _time import httpx from fastapi import APIRouter, HTTPException, Request, Depends @@ -23,6 +25,19 @@ VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "") VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "") +def _compute_config_hash() -> str: + """计算可同步配置的指纹哈希(仅包含 keys 和 accounts)""" + syncable = { + "keys": CONFIG.get("keys", []), + "accounts": [ + {k: v for k, v in acc.items() if k != "token"} + for acc in CONFIG.get("accounts", []) + ], + } + raw = json.dumps(syncable, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + return hashlib.md5(raw.encode("utf-8")).hexdigest() + + # ---------------------------------------------------------------------- # API 测试(通过本地 API) # ---------------------------------------------------------------------- @@ -228,6 +243,10 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)): if deploy_resp.status_code in [200, 201]: deploy_data = deploy_resp.json() + # 记录同步哈希和时间 + CONFIG["_vercel_sync_hash"] = _compute_config_hash() + CONFIG["_vercel_sync_time"] = int(_time.time()) + save_config(CONFIG) result = { "success": True, "message": "配置已同步,正在重新部署...", @@ -240,6 +259,10 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)): result["saved_credentials"] = saved_credentials return JSONResponse(content=result) + # 环境变量已更新,但无法自动触发重新部署 + CONFIG["_vercel_sync_hash"] = _compute_config_hash() + CONFIG["_vercel_sync_time"] = int(_time.time()) + save_config(CONFIG) result = { "success": True, "message": "配置已同步到 Vercel,请手动触发重新部署", @@ -259,6 +282,25 @@ async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)): raise HTTPException(status_code=500, detail=str(e)) +# ---------------------------------------------------------------------- +# 同步状态查询 +# ---------------------------------------------------------------------- +@router.get("/vercel/status") +async def get_vercel_sync_status(_: bool = Depends(verify_admin)): + """检查当前配置与上次同步到 Vercel 的配置是否一致""" + last_hash = CONFIG.get("_vercel_sync_hash", "") + last_time = CONFIG.get("_vercel_sync_time", 0) + current_hash = _compute_config_hash() + + synced = bool(last_hash and last_hash == current_hash) + + return JSONResponse(content={ + "synced": synced, + "last_sync_time": last_time if last_time else None, + "has_synced_before": bool(last_hash), + }) + + # ---------------------------------------------------------------------- # 导出配置 # ---------------------------------------------------------------------- diff --git a/static/admin/.gitkeep b/static/admin/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/static/admin/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/webui/src/components/ApiTester.jsx b/webui/src/components/ApiTester.jsx index 87fc7d9..017e7f6 100644 --- a/webui/src/components/ApiTester.jsx +++ b/webui/src/components/ApiTester.jsx @@ -154,6 +154,10 @@ export default function ApiTester({ config, onMessage, authFetch }) { } const sendTest = async () => { + // 清除上次的流式/思考内容,防止残留 + setStreamingContent('') + setStreamingThinking('') + if (selectedAccount) { setLoading(true) setResponse(null) @@ -209,12 +213,12 @@ export default function ApiTester({ config, onMessage, authFetch }) { onClick={() => setConfigExpanded(!configExpanded)} className="lg:hidden flex items-center justify-between p-4 w-full bg-muted/20 hover:bg-muted/30 transition-colors" > -
-
- -
- {t('apiTester.config')} +
+
+
+ {t('apiTester.config')} +
@@ -365,9 +369,9 @@ export default function ApiTester({ config, onMessage, authFetch }) { {/* Input Area */}
-