Spaces:
Running
Running
nacho commited on
Commit ·
b454740
1
Parent(s): 98d0688
refactor: clean dead code, extract shared stream logic, responsive UI rewrite
Browse files- .env.example +1 -1
- Dockerfile +1 -1
- deepseek_browser.py +12 -40
- main.py +146 -223
- static/index.html +360 -341
.env.example
CHANGED
|
@@ -25,7 +25,7 @@ DS2API_HUMANIZE=true
|
|
| 25 |
DS2API_MAX_CONCURRENT=3
|
| 26 |
|
| 27 |
# 最大同时活跃浏览器数(= 最大并发数)
|
| 28 |
-
DS2API_MAX_ACTIVE_BROWSERS=
|
| 29 |
|
| 30 |
# 请求超时(毫秒)
|
| 31 |
DS2API_TIMEOUT=60000
|
|
|
|
| 25 |
DS2API_MAX_CONCURRENT=3
|
| 26 |
|
| 27 |
# 最大同时活跃浏览器数(= 最大并发数)
|
| 28 |
+
DS2API_MAX_ACTIVE_BROWSERS=101
|
| 29 |
|
| 30 |
# 请求超时(毫秒)
|
| 31 |
DS2API_TIMEOUT=60000
|
Dockerfile
CHANGED
|
@@ -30,7 +30,7 @@ ENV DS2API_HOST=0.0.0.0
|
|
| 30 |
ENV DS2API_HEADLESS=true
|
| 31 |
ENV DS2API_HUMANIZE=true
|
| 32 |
ENV DS2API_MAX_CONCURRENT=50
|
| 33 |
-
ENV DS2API_MAX_ACTIVE_BROWSERS=
|
| 34 |
ENV DISPLAY=:99
|
| 35 |
|
| 36 |
# Start Xvfb + app
|
|
|
|
| 30 |
ENV DS2API_HEADLESS=true
|
| 31 |
ENV DS2API_HUMANIZE=true
|
| 32 |
ENV DS2API_MAX_CONCURRENT=50
|
| 33 |
+
ENV DS2API_MAX_ACTIVE_BROWSERS=101
|
| 34 |
ENV DISPLAY=:99
|
| 35 |
|
| 36 |
# Start Xvfb + app
|
deepseek_browser.py
CHANGED
|
@@ -33,19 +33,6 @@ class DeepSeekBrowser:
|
|
| 33 |
self._logged_in = False
|
| 34 |
self._ready = False
|
| 35 |
|
| 36 |
-
def _mask_email(self) -> str:
|
| 37 |
-
"""Generate a masked version of the email for skip_phrases filtering."""
|
| 38 |
-
parts = self.email.split("@")
|
| 39 |
-
if len(parts) == 2:
|
| 40 |
-
local = parts[0]
|
| 41 |
-
domain = parts[1]
|
| 42 |
-
if len(local) > 4:
|
| 43 |
-
masked = local[:4] + "*" * (len(local) - 4)
|
| 44 |
-
else:
|
| 45 |
-
masked = local[0] + "*" * (len(local) - 1)
|
| 46 |
-
return f"{masked}@{domain}"
|
| 47 |
-
return self.email
|
| 48 |
-
|
| 49 |
async def start(self):
|
| 50 |
self.profile_dir.mkdir(parents=True, exist_ok=True)
|
| 51 |
|
|
@@ -74,12 +61,12 @@ class DeepSeekBrowser:
|
|
| 74 |
)
|
| 75 |
|
| 76 |
self.page = await self.context.new_page()
|
| 77 |
-
await self.page.goto(self.DEEPSEEK_URL, timeout=
|
| 78 |
# Wait for page ready instead of fixed sleep
|
| 79 |
try:
|
| 80 |
-
await self.page.wait_for_selector('textarea', timeout=
|
| 81 |
except Exception:
|
| 82 |
-
await asyncio.sleep(
|
| 83 |
|
| 84 |
await self._check_login_state()
|
| 85 |
|
|
@@ -90,7 +77,7 @@ class DeepSeekBrowser:
|
|
| 90 |
await self._auto_login()
|
| 91 |
else:
|
| 92 |
try:
|
| 93 |
-
await self.page.wait_for_selector('textarea', timeout=
|
| 94 |
self._logged_in = True
|
| 95 |
self._ready = True
|
| 96 |
except Exception:
|
|
@@ -131,7 +118,7 @@ class DeepSeekBrowser:
|
|
| 131 |
# 1. 先等待页面加载完成(任意输入框出现),防止因为 Cloudflare 还在转圈导致直接尝试点击失败
|
| 132 |
try:
|
| 133 |
any_input = self.page.locator('input').first
|
| 134 |
-
await any_input.wait_for(state="visible", timeout=
|
| 135 |
except Exception:
|
| 136 |
pass
|
| 137 |
|
|
@@ -140,7 +127,7 @@ class DeepSeekBrowser:
|
|
| 140 |
pwd_tab = self.page.locator('text="密码登录"').first
|
| 141 |
if await pwd_tab.is_visible():
|
| 142 |
await pwd_tab.click()
|
| 143 |
-
await asyncio.sleep(0.
|
| 144 |
except Exception as e:
|
| 145 |
logger.debug("No password login tab found or error: %s", e)
|
| 146 |
|
|
@@ -148,9 +135,8 @@ class DeepSeekBrowser:
|
|
| 148 |
email_input = self.page.locator('input[placeholder*="邮箱"], input[placeholder*="手机"], input[placeholder*="Email"], input[placeholder*="email"], input[type="text"]').first
|
| 149 |
await email_input.wait_for(state="visible", timeout=10000)
|
| 150 |
await email_input.fill(self.email)
|
| 151 |
-
await asyncio.sleep(0.
|
| 152 |
except Exception as e:
|
| 153 |
-
# Take screenshot to debug
|
| 154 |
try:
|
| 155 |
await self.page.screenshot(path=f"/tmp/login_fail_{self.email.replace('@','_at_')}.png")
|
| 156 |
logger.error("Screenshot saved to /tmp/login_fail_%s.png", self.email.replace('@', '_at_'))
|
|
@@ -163,7 +149,7 @@ class DeepSeekBrowser:
|
|
| 163 |
password_input = self.page.locator('input[type="password"]').first
|
| 164 |
await password_input.wait_for(state="visible", timeout=5000)
|
| 165 |
await password_input.fill(self.password)
|
| 166 |
-
await asyncio.sleep(0.
|
| 167 |
except Exception as e:
|
| 168 |
logger.error("Password input error: %s", e)
|
| 169 |
raise
|
|
@@ -171,13 +157,13 @@ class DeepSeekBrowser:
|
|
| 171 |
try:
|
| 172 |
login_button = self.page.locator('button:has-text("登录")').first
|
| 173 |
await login_button.click()
|
| 174 |
-
await asyncio.sleep(
|
| 175 |
except Exception as e:
|
| 176 |
logger.error("Login button error: %s", e)
|
| 177 |
raise
|
| 178 |
|
| 179 |
try:
|
| 180 |
-
await self.page.wait_for_selector('textarea', timeout=
|
| 181 |
self._logged_in = True
|
| 182 |
self._ready = True
|
| 183 |
logger.info("Login successful!")
|
|
@@ -189,21 +175,11 @@ class DeepSeekBrowser:
|
|
| 189 |
pass
|
| 190 |
raise Exception("Login failed")
|
| 191 |
|
| 192 |
-
async def _human_delay(self, min_ms: int =
|
| 193 |
"""Minimal delay for speed — just enough to avoid race conditions."""
|
| 194 |
delay = random.uniform(min_ms, max_ms) / 1000
|
| 195 |
await asyncio.sleep(delay)
|
| 196 |
|
| 197 |
-
def _get_skip_phrases(self) -> list:
|
| 198 |
-
"""Build skip phrases list, dynamically including masked email."""
|
| 199 |
-
phrases = [
|
| 200 |
-
'深度思考', '智能搜索', '快速模式', '专家模式',
|
| 201 |
-
'内容由 AI 生成', '开启新对话', '暂无历史对话', '今天',
|
| 202 |
-
]
|
| 203 |
-
masked = self._mask_email()
|
| 204 |
-
phrases.append(masked)
|
| 205 |
-
return phrases
|
| 206 |
-
|
| 207 |
async def new_chat(self):
|
| 208 |
"""Start a new chat by clicking the new-chat button instead of full page reload."""
|
| 209 |
try:
|
|
@@ -341,10 +317,6 @@ class DeepSeekBrowser:
|
|
| 341 |
await self.page.evaluate(click_js, ['深度思考', 'DeepThink', 'R1'])
|
| 342 |
await asyncio.sleep(0.5)
|
| 343 |
|
| 344 |
-
# 保留智能搜索可选
|
| 345 |
-
if 'search' in model:
|
| 346 |
-
await self.page.evaluate(click_js, ['智能搜索'])
|
| 347 |
-
await asyncio.sleep(0.5)
|
| 348 |
except Exception as e:
|
| 349 |
logger.warning("[switch_model] click error: %s", e)
|
| 350 |
|
|
@@ -578,4 +550,4 @@ class DeepSeekBrowser:
|
|
| 578 |
|
| 579 |
async def close(self):
|
| 580 |
if self.context:
|
| 581 |
-
await self.context.close()
|
|
|
|
| 33 |
self._logged_in = False
|
| 34 |
self._ready = False
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
async def start(self):
|
| 37 |
self.profile_dir.mkdir(parents=True, exist_ok=True)
|
| 38 |
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
self.page = await self.context.new_page()
|
| 64 |
+
await self.page.goto(self.DEEPSEEK_URL, timeout=30000)
|
| 65 |
# Wait for page ready instead of fixed sleep
|
| 66 |
try:
|
| 67 |
+
await self.page.wait_for_selector('textarea', timeout=10000)
|
| 68 |
except Exception:
|
| 69 |
+
await asyncio.sleep(1)
|
| 70 |
|
| 71 |
await self._check_login_state()
|
| 72 |
|
|
|
|
| 77 |
await self._auto_login()
|
| 78 |
else:
|
| 79 |
try:
|
| 80 |
+
await self.page.wait_for_selector('textarea', timeout=8000)
|
| 81 |
self._logged_in = True
|
| 82 |
self._ready = True
|
| 83 |
except Exception:
|
|
|
|
| 118 |
# 1. 先等待页面加载完成(任意输入框出现),防止因为 Cloudflare 还在转圈导致直接尝试点击失败
|
| 119 |
try:
|
| 120 |
any_input = self.page.locator('input').first
|
| 121 |
+
await any_input.wait_for(state="visible", timeout=15000)
|
| 122 |
except Exception:
|
| 123 |
pass
|
| 124 |
|
|
|
|
| 127 |
pwd_tab = self.page.locator('text="密码登录"').first
|
| 128 |
if await pwd_tab.is_visible():
|
| 129 |
await pwd_tab.click()
|
| 130 |
+
await asyncio.sleep(0.1)
|
| 131 |
except Exception as e:
|
| 132 |
logger.debug("No password login tab found or error: %s", e)
|
| 133 |
|
|
|
|
| 135 |
email_input = self.page.locator('input[placeholder*="邮箱"], input[placeholder*="手机"], input[placeholder*="Email"], input[placeholder*="email"], input[type="text"]').first
|
| 136 |
await email_input.wait_for(state="visible", timeout=10000)
|
| 137 |
await email_input.fill(self.email)
|
| 138 |
+
await asyncio.sleep(0.1)
|
| 139 |
except Exception as e:
|
|
|
|
| 140 |
try:
|
| 141 |
await self.page.screenshot(path=f"/tmp/login_fail_{self.email.replace('@','_at_')}.png")
|
| 142 |
logger.error("Screenshot saved to /tmp/login_fail_%s.png", self.email.replace('@', '_at_'))
|
|
|
|
| 149 |
password_input = self.page.locator('input[type="password"]').first
|
| 150 |
await password_input.wait_for(state="visible", timeout=5000)
|
| 151 |
await password_input.fill(self.password)
|
| 152 |
+
await asyncio.sleep(0.1)
|
| 153 |
except Exception as e:
|
| 154 |
logger.error("Password input error: %s", e)
|
| 155 |
raise
|
|
|
|
| 157 |
try:
|
| 158 |
login_button = self.page.locator('button:has-text("登录")').first
|
| 159 |
await login_button.click()
|
| 160 |
+
await asyncio.sleep(1.5)
|
| 161 |
except Exception as e:
|
| 162 |
logger.error("Login button error: %s", e)
|
| 163 |
raise
|
| 164 |
|
| 165 |
try:
|
| 166 |
+
await self.page.wait_for_selector('textarea', timeout=20000)
|
| 167 |
self._logged_in = True
|
| 168 |
self._ready = True
|
| 169 |
logger.info("Login successful!")
|
|
|
|
| 175 |
pass
|
| 176 |
raise Exception("Login failed")
|
| 177 |
|
| 178 |
+
async def _human_delay(self, min_ms: int = 5, max_ms: int = 30):
|
| 179 |
"""Minimal delay for speed — just enough to avoid race conditions."""
|
| 180 |
delay = random.uniform(min_ms, max_ms) / 1000
|
| 181 |
await asyncio.sleep(delay)
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
async def new_chat(self):
|
| 184 |
"""Start a new chat by clicking the new-chat button instead of full page reload."""
|
| 185 |
try:
|
|
|
|
| 317 |
await self.page.evaluate(click_js, ['深度思考', 'DeepThink', 'R1'])
|
| 318 |
await asyncio.sleep(0.5)
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
except Exception as e:
|
| 321 |
logger.warning("[switch_model] click error: %s", e)
|
| 322 |
|
|
|
|
| 550 |
|
| 551 |
async def close(self):
|
| 552 |
if self.context:
|
| 553 |
+
await self.context.close()
|
main.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
| 4 |
import logging
|
| 5 |
import logging.handlers
|
| 6 |
import os
|
|
|
|
| 7 |
import time
|
| 8 |
import uuid
|
| 9 |
from pathlib import Path
|
|
@@ -70,7 +71,7 @@ app.add_middleware(
|
|
| 70 |
|
| 71 |
config: Config = load_config()
|
| 72 |
manager = AccountManager(
|
| 73 |
-
max_active_browsers=int(os.getenv("DS2API_MAX_ACTIVE_BROWSERS", "
|
| 74 |
)
|
| 75 |
|
| 76 |
|
|
@@ -99,6 +100,93 @@ def verify_api_key(authorization: Optional[str] = Header(None)) -> str:
|
|
| 99 |
return token
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
@app.get("/v1/models")
|
| 103 |
async def list_models(authorization: str = Header(...)):
|
| 104 |
verify_api_key(authorization)
|
|
@@ -151,112 +239,15 @@ async def chat_completions(
|
|
| 151 |
browser = await manager.get_or_create_browser_with_retry(account, headless=config.browser.headless)
|
| 152 |
|
| 153 |
if request.stream:
|
| 154 |
-
async def stream_with_cleanup():
|
| 155 |
-
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
|
| 156 |
-
try:
|
| 157 |
-
is_tool_call = False
|
| 158 |
-
not_tool_call = False
|
| 159 |
-
content_buffer = ""
|
| 160 |
-
|
| 161 |
-
async for chunk_data in browser.stream_message(prompt, timeout=120, model=model):
|
| 162 |
-
chunk_type = chunk_data.get("type", "content")
|
| 163 |
-
chunk_text = chunk_data.get("chunk", "")
|
| 164 |
-
|
| 165 |
-
if chunk_type == "thinking":
|
| 166 |
-
delta = {"reasoning_content": chunk_text}
|
| 167 |
-
else:
|
| 168 |
-
if request.tools and not is_tool_call and not not_tool_call:
|
| 169 |
-
content_buffer += chunk_text
|
| 170 |
-
# Wait until we have enough characters to decide
|
| 171 |
-
if len(content_buffer) < 12:
|
| 172 |
-
if not "<tool_call>".startswith(content_buffer):
|
| 173 |
-
not_tool_call = True
|
| 174 |
-
delta = {"content": content_buffer}
|
| 175 |
-
else:
|
| 176 |
-
continue # keep buffering
|
| 177 |
-
else:
|
| 178 |
-
if content_buffer.startswith("<tool_call>"):
|
| 179 |
-
is_tool_call = True
|
| 180 |
-
continue # buffer the whole tool call
|
| 181 |
-
else:
|
| 182 |
-
not_tool_call = True
|
| 183 |
-
delta = {"content": content_buffer}
|
| 184 |
-
elif request.tools and is_tool_call:
|
| 185 |
-
content_buffer += chunk_text
|
| 186 |
-
continue # buffer until stream ends
|
| 187 |
-
else:
|
| 188 |
-
delta = {"content": chunk_text}
|
| 189 |
-
|
| 190 |
-
data = {
|
| 191 |
-
"id": chunk_id,
|
| 192 |
-
"object": "chat.completion.chunk",
|
| 193 |
-
"created": int(time.time()),
|
| 194 |
-
"model": request.model,
|
| 195 |
-
"choices": [
|
| 196 |
-
{
|
| 197 |
-
"index": 0,
|
| 198 |
-
"delta": delta,
|
| 199 |
-
"finish_reason": None,
|
| 200 |
-
}
|
| 201 |
-
],
|
| 202 |
-
}
|
| 203 |
-
yield f"data: {json.dumps(data)}\n\n"
|
| 204 |
-
|
| 205 |
-
if is_tool_call:
|
| 206 |
-
# Process buffered tool call at the end
|
| 207 |
-
import re
|
| 208 |
-
m = re.search(r'<tool_call>(.*?)</tool_call>', content_buffer, re.DOTALL)
|
| 209 |
-
if m:
|
| 210 |
-
try:
|
| 211 |
-
tcall = json.loads(m.group(1))
|
| 212 |
-
t_name = tcall.get("name", "")
|
| 213 |
-
t_args = json.dumps(tcall.get("arguments", {}))
|
| 214 |
-
delta = {
|
| 215 |
-
"tool_calls": [
|
| 216 |
-
{
|
| 217 |
-
"index": 0,
|
| 218 |
-
"id": f"call_{uuid.uuid4().hex[:8]}",
|
| 219 |
-
"type": "function",
|
| 220 |
-
"function": {
|
| 221 |
-
"name": t_name,
|
| 222 |
-
"arguments": t_args
|
| 223 |
-
}
|
| 224 |
-
}
|
| 225 |
-
]
|
| 226 |
-
}
|
| 227 |
-
data = {
|
| 228 |
-
"id": chunk_id,
|
| 229 |
-
"object": "chat.completion.chunk",
|
| 230 |
-
"created": int(time.time()),
|
| 231 |
-
"model": request.model,
|
| 232 |
-
"choices": [{"index": 0, "delta": delta, "finish_reason": "tool_calls"}]
|
| 233 |
-
}
|
| 234 |
-
yield f"data: {json.dumps(data)}\n\n"
|
| 235 |
-
except Exception as e:
|
| 236 |
-
logger.error("Failed to parse tool call: %s", e)
|
| 237 |
-
|
| 238 |
-
final_data = {
|
| 239 |
-
"id": chunk_id,
|
| 240 |
-
"object": "chat.completion.chunk",
|
| 241 |
-
"created": int(time.time()),
|
| 242 |
-
"model": request.model,
|
| 243 |
-
"choices": [
|
| 244 |
-
{
|
| 245 |
-
"index": 0,
|
| 246 |
-
"delta": {},
|
| 247 |
-
"finish_reason": "stop",
|
| 248 |
-
}
|
| 249 |
-
],
|
| 250 |
-
}
|
| 251 |
-
yield f"data: {json.dumps(final_data)}\n\n"
|
| 252 |
-
yield "data: [DONE]\n\n"
|
| 253 |
-
except Exception as e:
|
| 254 |
-
yield f"data: {json.dumps({'error': {'message': str(e)}})}\n\n"
|
| 255 |
-
finally:
|
| 256 |
-
await manager.release(account)
|
| 257 |
-
|
| 258 |
return StreamingResponse(
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
media_type="text/event-stream",
|
| 261 |
)
|
| 262 |
|
|
@@ -278,7 +269,6 @@ async def chat_completions(
|
|
| 278 |
finish_reason = "stop"
|
| 279 |
|
| 280 |
if request.tools and "<tool_call>" in content:
|
| 281 |
-
import re
|
| 282 |
m = re.search(r'<tool_call>(.*?)</tool_call>', content, re.DOTALL)
|
| 283 |
if m:
|
| 284 |
try:
|
|
@@ -390,19 +380,22 @@ async def import_accounts(request: Request, admin_key: str = Header(...)):
|
|
| 390 |
saved["accounts"] = list(acc_map.values())
|
| 391 |
_save_settings(saved)
|
| 392 |
|
| 393 |
-
# 异步触发新导入账号的预登录
|
| 394 |
async def prelogin_new_accounts():
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
if new_accounts:
|
| 408 |
asyncio.create_task(prelogin_new_accounts())
|
|
@@ -447,9 +440,6 @@ async def login_account(request: Request, admin_key: str = Header(...)):
|
|
| 447 |
async def _do_login():
|
| 448 |
try:
|
| 449 |
logger.info("Manual login triggered for %s...", email)
|
| 450 |
-
# If it's already logged in, we might want to restart the browser.
|
| 451 |
-
# get_or_create_browser_with_retry will reuse if account.browser exists.
|
| 452 |
-
# To force a reconnect, we close the existing one first.
|
| 453 |
if account.browser:
|
| 454 |
try:
|
| 455 |
await account.browser.close()
|
|
@@ -504,90 +494,17 @@ async def admin_chat(request: Request, admin_key: str = Header(...)):
|
|
| 504 |
browser = await manager.get_or_create_browser_with_retry(account, headless=config.browser.headless)
|
| 505 |
|
| 506 |
if req.stream:
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
if chunk_type == "thinking":
|
| 519 |
-
delta = {"reasoning_content": chunk_text}
|
| 520 |
-
else:
|
| 521 |
-
if req.tools and not is_tool_call and not not_tool_call:
|
| 522 |
-
content_buffer += chunk_text
|
| 523 |
-
if len(content_buffer) < 12:
|
| 524 |
-
if not "<tool_call>".startswith(content_buffer):
|
| 525 |
-
not_tool_call = True
|
| 526 |
-
delta = {"content": content_buffer}
|
| 527 |
-
else:
|
| 528 |
-
continue
|
| 529 |
-
else:
|
| 530 |
-
if content_buffer.startswith("<tool_call>"):
|
| 531 |
-
is_tool_call = True
|
| 532 |
-
continue
|
| 533 |
-
else:
|
| 534 |
-
not_tool_call = True
|
| 535 |
-
delta = {"content": content_buffer}
|
| 536 |
-
elif req.tools and is_tool_call:
|
| 537 |
-
content_buffer += chunk_text
|
| 538 |
-
continue
|
| 539 |
-
else:
|
| 540 |
-
delta = {"content": chunk_text}
|
| 541 |
-
|
| 542 |
-
data = {
|
| 543 |
-
"id": chunk_id,
|
| 544 |
-
"object": "chat.completion.chunk",
|
| 545 |
-
"created": int(time.time()),
|
| 546 |
-
"model": req.model,
|
| 547 |
-
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
|
| 548 |
-
}
|
| 549 |
-
yield f"data: {json.dumps(data)}\n\n"
|
| 550 |
-
|
| 551 |
-
if is_tool_call:
|
| 552 |
-
import re
|
| 553 |
-
m = re.search(r'<tool_call>(.*?)</tool_call>', content_buffer, re.DOTALL)
|
| 554 |
-
if m:
|
| 555 |
-
try:
|
| 556 |
-
tcall = json.loads(m.group(1))
|
| 557 |
-
t_name = tcall.get("name", "")
|
| 558 |
-
t_args = json.dumps(tcall.get("arguments", {}))
|
| 559 |
-
delta = {
|
| 560 |
-
"tool_calls": [
|
| 561 |
-
{
|
| 562 |
-
"index": 0,
|
| 563 |
-
"id": f"call_{uuid.uuid4().hex[:8]}",
|
| 564 |
-
"type": "function",
|
| 565 |
-
"function": {
|
| 566 |
-
"name": t_name,
|
| 567 |
-
"arguments": t_args
|
| 568 |
-
}
|
| 569 |
-
}
|
| 570 |
-
]
|
| 571 |
-
}
|
| 572 |
-
data = {
|
| 573 |
-
"id": chunk_id,
|
| 574 |
-
"object": "chat.completion.chunk",
|
| 575 |
-
"created": int(time.time()),
|
| 576 |
-
"model": req.model,
|
| 577 |
-
"choices": [{"index": 0, "delta": delta, "finish_reason": "tool_calls"}]
|
| 578 |
-
}
|
| 579 |
-
yield f"data: {json.dumps(data)}\n\n"
|
| 580 |
-
except Exception as e:
|
| 581 |
-
logger.error("Failed to parse admin stream tool call: %s", e)
|
| 582 |
-
|
| 583 |
-
yield f"data: {json.dumps({'id': chunk_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': req.model, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
|
| 584 |
-
yield "data: [DONE]\n\n"
|
| 585 |
-
except Exception as e:
|
| 586 |
-
yield f"data: {json.dumps({'error': {'message': str(e)}})}\n\n"
|
| 587 |
-
finally:
|
| 588 |
-
await manager.release(account)
|
| 589 |
-
|
| 590 |
-
return StreamingResponse(stream_with_cleanup(), media_type="text/event-stream")
|
| 591 |
|
| 592 |
response_data = await browser.send_message(prompt, timeout=120, model=model)
|
| 593 |
await manager.release(account)
|
|
@@ -605,7 +522,6 @@ async def admin_chat(request: Request, admin_key: str = Header(...)):
|
|
| 605 |
finish_reason = "stop"
|
| 606 |
|
| 607 |
if req.tools and "<tool_call>" in content:
|
| 608 |
-
import re
|
| 609 |
m = re.search(r'<tool_call>(.*?)</tool_call>', content, re.DOTALL)
|
| 610 |
if m:
|
| 611 |
try:
|
|
@@ -779,20 +695,28 @@ async def startup():
|
|
| 779 |
|
| 780 |
|
| 781 |
async def _prelogin_all():
|
| 782 |
-
"""
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
|
| 797 |
|
| 798 |
def main():
|
|
@@ -806,5 +730,4 @@ def main():
|
|
| 806 |
|
| 807 |
|
| 808 |
if __name__ == "__main__":
|
| 809 |
-
main()
|
| 810 |
-
|
|
|
|
| 4 |
import logging
|
| 5 |
import logging.handlers
|
| 6 |
import os
|
| 7 |
+
import re
|
| 8 |
import time
|
| 9 |
import uuid
|
| 10 |
from pathlib import Path
|
|
|
|
| 71 |
|
| 72 |
config: Config = load_config()
|
| 73 |
manager = AccountManager(
|
| 74 |
+
max_active_browsers=int(os.getenv("DS2API_MAX_ACTIVE_BROWSERS", "101")),
|
| 75 |
)
|
| 76 |
|
| 77 |
|
|
|
|
| 100 |
return token
|
| 101 |
|
| 102 |
|
| 103 |
+
async def _stream_chat_response(
|
| 104 |
+
browser,
|
| 105 |
+
prompt: str,
|
| 106 |
+
model: str,
|
| 107 |
+
has_tools: bool,
|
| 108 |
+
manager,
|
| 109 |
+
account,
|
| 110 |
+
):
|
| 111 |
+
"""Shared async generator for streaming chat completions with optional tool call detection."""
|
| 112 |
+
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
|
| 113 |
+
try:
|
| 114 |
+
is_tool_call = False
|
| 115 |
+
not_tool_call = False
|
| 116 |
+
content_buffer = ""
|
| 117 |
+
|
| 118 |
+
async for chunk_data in browser.stream_message(prompt, timeout=120, model=model):
|
| 119 |
+
chunk_type = chunk_data.get("type", "content")
|
| 120 |
+
chunk_text = chunk_data.get("chunk", "")
|
| 121 |
+
|
| 122 |
+
if chunk_type == "thinking":
|
| 123 |
+
delta = {"reasoning_content": chunk_text}
|
| 124 |
+
else:
|
| 125 |
+
if has_tools and not is_tool_call and not not_tool_call:
|
| 126 |
+
content_buffer += chunk_text
|
| 127 |
+
if len(content_buffer) < 12:
|
| 128 |
+
if not "<tool_call>".startswith(content_buffer):
|
| 129 |
+
not_tool_call = True
|
| 130 |
+
delta = {"content": content_buffer}
|
| 131 |
+
else:
|
| 132 |
+
continue
|
| 133 |
+
else:
|
| 134 |
+
if content_buffer.startswith("<tool_call>"):
|
| 135 |
+
is_tool_call = True
|
| 136 |
+
continue
|
| 137 |
+
else:
|
| 138 |
+
not_tool_call = True
|
| 139 |
+
delta = {"content": content_buffer}
|
| 140 |
+
elif has_tools and is_tool_call:
|
| 141 |
+
content_buffer += chunk_text
|
| 142 |
+
continue
|
| 143 |
+
else:
|
| 144 |
+
delta = {"content": chunk_text}
|
| 145 |
+
|
| 146 |
+
data = {
|
| 147 |
+
"id": chunk_id,
|
| 148 |
+
"object": "chat.completion.chunk",
|
| 149 |
+
"created": int(time.time()),
|
| 150 |
+
"model": model,
|
| 151 |
+
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
|
| 152 |
+
}
|
| 153 |
+
yield f"data: {json.dumps(data)}\n\n"
|
| 154 |
+
|
| 155 |
+
if is_tool_call:
|
| 156 |
+
m = re.search(r'<tool_call>(.*?)</tool_call>', content_buffer, re.DOTALL)
|
| 157 |
+
if m:
|
| 158 |
+
try:
|
| 159 |
+
tcall = json.loads(m.group(1))
|
| 160 |
+
delta = {
|
| 161 |
+
"tool_calls": [{
|
| 162 |
+
"index": 0,
|
| 163 |
+
"id": f"call_{uuid.uuid4().hex[:8]}",
|
| 164 |
+
"type": "function",
|
| 165 |
+
"function": {
|
| 166 |
+
"name": tcall.get("name", ""),
|
| 167 |
+
"arguments": json.dumps(tcall.get("arguments", {}))
|
| 168 |
+
}
|
| 169 |
+
}]
|
| 170 |
+
}
|
| 171 |
+
data = {
|
| 172 |
+
"id": chunk_id,
|
| 173 |
+
"object": "chat.completion.chunk",
|
| 174 |
+
"created": int(time.time()),
|
| 175 |
+
"model": model,
|
| 176 |
+
"choices": [{"index": 0, "delta": delta, "finish_reason": "tool_calls"}]
|
| 177 |
+
}
|
| 178 |
+
yield f"data: {json.dumps(data)}\n\n"
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.error("Failed to parse tool call: %s", e)
|
| 181 |
+
|
| 182 |
+
yield f"data: {json.dumps({'id': chunk_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': model, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
|
| 183 |
+
yield "data: [DONE]\n\n"
|
| 184 |
+
except Exception as e:
|
| 185 |
+
yield f"data: {json.dumps({'error': {'message': str(e)}})}\n\n"
|
| 186 |
+
finally:
|
| 187 |
+
await manager.release(account)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
@app.get("/v1/models")
|
| 191 |
async def list_models(authorization: str = Header(...)):
|
| 192 |
verify_api_key(authorization)
|
|
|
|
| 239 |
browser = await manager.get_or_create_browser_with_retry(account, headless=config.browser.headless)
|
| 240 |
|
| 241 |
if request.stream:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
return StreamingResponse(
|
| 243 |
+
_stream_chat_response(
|
| 244 |
+
browser=browser,
|
| 245 |
+
prompt=prompt,
|
| 246 |
+
model=model,
|
| 247 |
+
has_tools=bool(request.tools),
|
| 248 |
+
manager=manager,
|
| 249 |
+
account=account,
|
| 250 |
+
),
|
| 251 |
media_type="text/event-stream",
|
| 252 |
)
|
| 253 |
|
|
|
|
| 269 |
finish_reason = "stop"
|
| 270 |
|
| 271 |
if request.tools and "<tool_call>" in content:
|
|
|
|
| 272 |
m = re.search(r'<tool_call>(.*?)</tool_call>', content, re.DOTALL)
|
| 273 |
if m:
|
| 274 |
try:
|
|
|
|
| 380 |
saved["accounts"] = list(acc_map.values())
|
| 381 |
_save_settings(saved)
|
| 382 |
|
| 383 |
+
# 异步触发新导入账号的并行预登录
|
| 384 |
async def prelogin_new_accounts():
|
| 385 |
+
sem = asyncio.Semaphore(20)
|
| 386 |
+
async def _login_one(account):
|
| 387 |
+
async with sem:
|
| 388 |
+
try:
|
| 389 |
+
logger.info("Pre-logging in newly imported account %s...", account.email)
|
| 390 |
+
await manager.get_or_create_browser_with_retry(
|
| 391 |
+
account, headless=config.browser.headless
|
| 392 |
+
)
|
| 393 |
+
logger.info("Pre-login OK: %s", account.email)
|
| 394 |
+
except Exception as e:
|
| 395 |
+
logger.error("Pre-login FAILED for %s: %s", account.email, e)
|
| 396 |
+
tasks = [_login_one(a) for a in new_accounts]
|
| 397 |
+
if tasks:
|
| 398 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 399 |
|
| 400 |
if new_accounts:
|
| 401 |
asyncio.create_task(prelogin_new_accounts())
|
|
|
|
| 440 |
async def _do_login():
|
| 441 |
try:
|
| 442 |
logger.info("Manual login triggered for %s...", email)
|
|
|
|
|
|
|
|
|
|
| 443 |
if account.browser:
|
| 444 |
try:
|
| 445 |
await account.browser.close()
|
|
|
|
| 494 |
browser = await manager.get_or_create_browser_with_retry(account, headless=config.browser.headless)
|
| 495 |
|
| 496 |
if req.stream:
|
| 497 |
+
return StreamingResponse(
|
| 498 |
+
_stream_chat_response(
|
| 499 |
+
browser=browser,
|
| 500 |
+
prompt=prompt,
|
| 501 |
+
model=model,
|
| 502 |
+
has_tools=bool(req.tools),
|
| 503 |
+
manager=manager,
|
| 504 |
+
account=account,
|
| 505 |
+
),
|
| 506 |
+
media_type="text/event-stream",
|
| 507 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
|
| 509 |
response_data = await browser.send_message(prompt, timeout=120, model=model)
|
| 510 |
await manager.release(account)
|
|
|
|
| 522 |
finish_reason = "stop"
|
| 523 |
|
| 524 |
if req.tools and "<tool_call>" in content:
|
|
|
|
| 525 |
m = re.search(r'<tool_call>(.*?)</tool_call>', content, re.DOTALL)
|
| 526 |
if m:
|
| 527 |
try:
|
|
|
|
| 695 |
|
| 696 |
|
| 697 |
async def _prelogin_all():
|
| 698 |
+
"""并行预登录全部账号,信号量控制并发避免打崩服务器。"""
|
| 699 |
+
sem = asyncio.Semaphore(20)
|
| 700 |
+
total = len(manager.accounts)
|
| 701 |
+
done = 0
|
| 702 |
+
|
| 703 |
+
async def _login_one(email: str, account):
|
| 704 |
+
nonlocal done
|
| 705 |
+
async with sem:
|
| 706 |
+
try:
|
| 707 |
+
logger.info("[prelogin %d/%d] %s ...", done + 1, total, email)
|
| 708 |
+
await manager.get_or_create_browser_with_retry(
|
| 709 |
+
account, headless=config.browser.headless
|
| 710 |
+
)
|
| 711 |
+
logger.info("[prelogin OK %d/%d] %s (muted=%s)", done + 1, total, email, account.is_muted)
|
| 712 |
+
except Exception as e:
|
| 713 |
+
logger.error("[prelogin FAIL %d/%d] %s: %s", done + 1, total, email, e)
|
| 714 |
+
done += 1
|
| 715 |
+
|
| 716 |
+
tasks = [_login_one(email, acc) for email, acc in manager.accounts.items()]
|
| 717 |
+
if tasks:
|
| 718 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 719 |
+
logger.info("Pre-login complete: %d/%d accounts ready", sum(1 for a in manager.accounts.values() if a.logged_in), total)
|
| 720 |
|
| 721 |
|
| 722 |
def main():
|
|
|
|
| 730 |
|
| 731 |
|
| 732 |
if __name__ == "__main__":
|
| 733 |
+
main()
|
|
|
static/index.html
CHANGED
|
@@ -2,172 +2,246 @@
|
|
| 2 |
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
<title>DS2API · 控制台</title>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
| 9 |
<style>
|
| 10 |
:root{
|
| 11 |
-
--bg:#
|
| 12 |
--border:rgba(255,255,255,.06);--border-focus:rgba(96,165,250,.5);
|
| 13 |
--text:#c9d1d9;--text-dim:#4a5568;--text-muted:#2d3748;
|
| 14 |
-
--accent:hsl(217,92%,60%);--accent-glow:hsla(217,92%,60%,.
|
| 15 |
--green:hsl(142,71%,45%);--red:hsl(0,84%,60%);--amber:hsl(38,92%,50%);
|
| 16 |
-
--radius:
|
| 17 |
--font-ui:'Inter',system-ui,sans-serif;--font-mono:'JetBrains Mono',monospace;
|
|
|
|
| 18 |
}
|
| 19 |
html.light-mode{
|
| 20 |
-
--bg:#
|
| 21 |
--border:rgba(0,0,0,.08);--border-focus:rgba(59,130,246,.5);
|
| 22 |
--text:#0f172a;--text-dim:#475569;--text-muted:#64748b;
|
| 23 |
-
--accent:hsl(217,92%,55%);--accent-glow:hsla(217,92%,55%,.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
.
|
| 48 |
-
.topbar .
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
.
|
| 61 |
-
.
|
| 62 |
-
.
|
| 63 |
-
.
|
| 64 |
-
.
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
.
|
| 77 |
-
.
|
| 78 |
-
.
|
| 79 |
-
.
|
| 80 |
-
.
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
.
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
.
|
| 100 |
-
.
|
| 101 |
-
.
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
.
|
| 116 |
-
.
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
.
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
.
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
.
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
.login-overlay.hidden{opacity:0;pointer-events:none}
|
| 144 |
-
.login-box{
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
.login-box .btn-primary{width:100%;padding:12px;font-size:14px}
|
| 149 |
-
.login-box .err-msg{color:var(--red);font-size:12px;margin-top:
|
| 150 |
-
.app-wrap{transition:filter .3s}
|
| 151 |
.app-wrap.blur{filter:blur(8px);pointer-events:none}
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
|
| 162 |
-
.thinking-block{
|
| 163 |
-
.thinking-block summary{cursor:pointer;user-select:none;outline:none;font-weight:600;margin-bottom:8px}
|
| 164 |
.thinking-block summary:hover{color:var(--accent)}
|
| 165 |
-
.thinking-content{white-space:pre-wrap;word-break:break-word;opacity:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
</style>
|
| 167 |
</head>
|
| 168 |
<body>
|
| 169 |
|
| 170 |
-
<!-- Login Overlay -->
|
| 171 |
<div class="login-overlay" id="loginOverlay">
|
| 172 |
<div class="login-box">
|
| 173 |
<div class="logo-big">▸ DS2API</div>
|
|
@@ -182,161 +256,136 @@ select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%
|
|
| 182 |
</div>
|
| 183 |
|
| 184 |
<div class="app-wrap" id="appWrap">
|
|
|
|
| 185 |
<div class="topbar">
|
| 186 |
<span class="logo">▸ DS2API</span>
|
| 187 |
-
<span class="badge
|
|
|
|
|
|
|
| 188 |
<div class="stats" id="topStats">
|
| 189 |
-
<span class="si"><span class="dot" style="background:var(--accent)"></span>
|
| 190 |
-
<span class="si"><span class="dot" style="background:var(--green)"></span>
|
| 191 |
-
<span class="si"><span class="dot" style="background:var(--amber)"></span>
|
| 192 |
-
<span class="si"><span class="dot" style="background:var(--text-dim)"></span> 排队 <b>—</b></span>
|
| 193 |
</div>
|
| 194 |
-
<button class="btn btn-sm" style="padding:4px 8px;border-radius:6px;font-size:12px;margin-left:auto" onclick="toggleTheme()" id="themeBtn">☀️ 日间</button>
|
| 195 |
-
<div class="poll-indicator" id="pollDot"></div>
|
| 196 |
</div>
|
| 197 |
|
| 198 |
-
<
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
<div class="
|
| 202 |
-
<
|
| 203 |
-
<div class="
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
| 208 |
</div>
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
<
|
| 213 |
-
<div class="
|
| 214 |
-
<
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
|
| 223 |
-
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
|
| 224 |
-
</select>
|
| 225 |
-
</div>
|
| 226 |
-
<label class="check-label" style="margin-top:auto;padding-bottom:2px">
|
| 227 |
-
<input type="checkbox" id="stream" checked> 流式
|
| 228 |
-
</label>
|
| 229 |
-
</div>
|
| 230 |
-
<div class="form-group">
|
| 231 |
-
<textarea id="prompt" placeholder="输入消息… (Ctrl+Enter 发送)">你好,用一句话介绍你自己</textarea>
|
| 232 |
-
</div>
|
| 233 |
-
<div class="row-between">
|
| 234 |
-
<div class="row">
|
| 235 |
-
<button class="btn btn-primary" onclick="sendMsg()" id="sendBtn">▸ 发送</button>
|
| 236 |
-
<span id="reqStatus" style="font-size:11px;color:var(--text-dim)"></span>
|
| 237 |
-
</div>
|
| 238 |
-
<button class="btn btn-sm" onclick="clearResp()">清空</button>
|
| 239 |
-
</div>
|
| 240 |
-
<div class="response-wrap">
|
| 241 |
-
<div class="response-bar">
|
| 242 |
-
<span id="respLabel">响应</span>
|
| 243 |
-
<span id="respTime"></span>
|
| 244 |
-
</div>
|
| 245 |
-
<div id="response"></div>
|
| 246 |
-
</div>
|
| 247 |
-
</div>
|
| 248 |
</div>
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
<tbody id="tbl"><tr><td colspan="7" class="empty">加载中…</td></tr></tbody>
|
| 261 |
-
</table>
|
| 262 |
-
</div>
|
| 263 |
-
</div>
|
| 264 |
-
<div class="card" style="animation-delay:.3s">
|
| 265 |
-
<div class="card-header">
|
| 266 |
-
<h2><span class="icon">📥</span> 导入账号</h2>
|
| 267 |
-
</div>
|
| 268 |
-
<div class="card-body">
|
| 269 |
-
<div style="font-size:11px;color:var(--text-dim);margin-bottom:8px">支持 JSON 或 邮箱:密码[:备注] 格式,每行一个</div>
|
| 270 |
-
<textarea id="inp" placeholder="[ {"email":"user@gmail.com","password":"xxx"}, {"email":"user2@gmail.com","password":"xxx","name":"备注"} ]" style="min-height:110px"></textarea>
|
| 271 |
-
<div class="row" style="margin-top:12px">
|
| 272 |
-
<button class="btn btn-primary" onclick="doImport()">▸ 导入</button>
|
| 273 |
-
<span id="msg" style="font-size:11px;color:var(--text-dim)"></span>
|
| 274 |
-
</div>
|
| 275 |
-
</div>
|
| 276 |
-
</div>
|
| 277 |
-
</div>
|
| 278 |
-
<div class="card" style="animation-delay:.4s">
|
| 279 |
-
<div class="card-header">
|
| 280 |
-
<h2><span class="icon">⚙️</span> 设置</h2>
|
| 281 |
-
<button class="btn btn-sm" onclick="loadSettings()">刷新</button>
|
| 282 |
-
</div>
|
| 283 |
-
<div class="card-body">
|
| 284 |
-
<div class="form-group">
|
| 285 |
-
<label>API Keys(每行一个)</label>
|
| 286 |
-
<textarea id="setApiKeys" placeholder="sk-key1 sk-key2" style="min-height:60px"></textarea>
|
| 287 |
-
</div>
|
| 288 |
-
<div class="row" style="gap:10px">
|
| 289 |
-
<div class="form-group" style="flex:1">
|
| 290 |
-
<label>Admin Key</label>
|
| 291 |
-
<input type="password" id="setAdminKey" placeholder="管理密钥">
|
| 292 |
-
</div>
|
| 293 |
-
<div class="form-group" style="flex:1">
|
| 294 |
-
<label>并发浏览器数量</label>
|
| 295 |
-
<input type="number" id="setMaxBrowsers" placeholder="3" title="建议1核机器设为2-3">
|
| 296 |
-
</div>
|
| 297 |
-
</div>
|
| 298 |
-
<div style="margin-top:0px;padding-top:14px;border-top:1px solid var(--border)">
|
| 299 |
-
<div class="form-group">
|
| 300 |
-
<label>日志文件</label>
|
| 301 |
-
<div class="row" style="gap:10px">
|
| 302 |
-
<label class="check-label"><input type="checkbox" id="setLogFile"> 启用写入文件</label>
|
| 303 |
-
<input type="text" id="setLogMaxMb" placeholder="10" style="width:60px;text-align:center"> MB
|
| 304 |
-
</div>
|
| 305 |
-
</div>
|
| 306 |
-
</div>
|
| 307 |
-
<div class="row">
|
| 308 |
-
<button class="btn btn-primary" onclick="saveSettings()">▸ 保存设置</button>
|
| 309 |
-
<span id="setMsg" style="font-size:11px;color:var(--text-dim)"></span>
|
| 310 |
-
</div>
|
| 311 |
-
</div>
|
| 312 |
-
</div>
|
| 313 |
</div>
|
|
|
|
|
|
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
</div>
|
|
|
|
| 316 |
|
| 317 |
-
|
| 318 |
-
<div class="card"
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
<
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
</div>
|
| 332 |
-
<div class="
|
| 333 |
-
<
|
|
|
|
| 334 |
</div>
|
| 335 |
</div>
|
|
|
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
</div>
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
-
</div><!-- /app-wrap -->
|
| 340 |
|
| 341 |
<script>
|
| 342 |
const H=location.origin;
|
|
@@ -345,25 +394,23 @@ const LS={
|
|
| 345 |
set(k,v){try{localStorage.setItem('ds2_'+k,v)}catch(e){}}
|
| 346 |
};
|
| 347 |
|
| 348 |
-
// ── Theme ──
|
| 349 |
function initTheme(){
|
| 350 |
if(LS.get('theme','dark')==='light') document.documentElement.classList.add('light-mode');
|
| 351 |
updateThemeBtn();
|
| 352 |
}
|
| 353 |
function toggleTheme(){
|
| 354 |
-
const isLight
|
| 355 |
-
LS.set('theme',
|
| 356 |
updateThemeBtn();
|
| 357 |
}
|
| 358 |
function updateThemeBtn(){
|
| 359 |
-
const btn
|
| 360 |
-
if(btn) btn.textContent
|
| 361 |
}
|
| 362 |
initTheme();
|
| 363 |
|
| 364 |
let _adminKey='';
|
| 365 |
|
| 366 |
-
// ── Auth Gate ──
|
| 367 |
async function doLogin(){
|
| 368 |
const key=document.getElementById('loginKey').value.trim();
|
| 369 |
const err=document.getElementById('loginErr');
|
|
@@ -373,8 +420,7 @@ async function doLogin(){
|
|
| 373 |
try{
|
| 374 |
const r=await fetch(H+'/admin/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})});
|
| 375 |
if(!r.ok)throw new Error('密钥错误');
|
| 376 |
-
_adminKey=key;
|
| 377 |
-
LS.set('adminKey',key);
|
| 378 |
document.getElementById('loginOverlay').classList.add('hidden');
|
| 379 |
document.getElementById('appWrap').classList.remove('blur');
|
| 380 |
initApp();
|
|
@@ -384,7 +430,6 @@ async function doLogin(){
|
|
| 384 |
|
| 385 |
document.getElementById('loginKey').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
|
| 386 |
|
| 387 |
-
// Auto-login if key saved
|
| 388 |
(async()=>{
|
| 389 |
const saved=LS.get('adminKey','');
|
| 390 |
if(saved){
|
|
@@ -410,7 +455,7 @@ function toast(m,ok){
|
|
| 410 |
function clearResp(){
|
| 411 |
document.getElementById('response').textContent='';
|
| 412 |
document.getElementById('respTime').textContent='';
|
| 413 |
-
document.getElementById('respLabel').textContent='
|
| 414 |
document.getElementById('reqStatus').textContent='';
|
| 415 |
}
|
| 416 |
|
|
@@ -423,7 +468,6 @@ async function api(p,o={}){
|
|
| 423 |
return r.json();
|
| 424 |
}
|
| 425 |
|
| 426 |
-
/* ── Send Message ── */
|
| 427 |
async function sendMsg(){
|
| 428 |
const model=document.getElementById('model').value;
|
| 429 |
const prompt=document.getElementById('prompt').value.trim();
|
|
@@ -431,11 +475,12 @@ async function sendMsg(){
|
|
| 431 |
const resp=document.getElementById('response');
|
| 432 |
const status=document.getElementById('reqStatus');
|
| 433 |
const timeEl=document.getElementById('respTime');
|
|
|
|
| 434 |
const btn=document.getElementById('sendBtn');
|
| 435 |
|
| 436 |
if(!prompt)return toast('请输入消息',0);
|
| 437 |
btn.disabled=true;btn.textContent='发送中…';
|
| 438 |
-
resp.textContent='';timeEl.textContent='';status.textContent='';
|
| 439 |
|
| 440 |
const t0=Date.now();
|
| 441 |
try{
|
|
@@ -448,8 +493,8 @@ async function sendMsg(){
|
|
| 448 |
|
| 449 |
if(stream){
|
| 450 |
const reader=r.body.getReader(),dec=new TextDecoder();
|
| 451 |
-
let fullContent='',
|
| 452 |
-
resp.innerHTML
|
| 453 |
const thinkBlock=document.getElementById('thinkBlock');
|
| 454 |
const thinkContent=document.getElementById('thinkContent');
|
| 455 |
const ansContent=document.getElementById('ansContent');
|
|
@@ -486,9 +531,7 @@ async function sendMsg(){
|
|
| 486 |
if(msg?.reasoning_content){
|
| 487 |
html+=`<details class="thinking-block"><summary>深度思考</summary><div class="thinking-content">${msg.reasoning_content.replace(/</g,'<')}</div></details>`;
|
| 488 |
}
|
| 489 |
-
if(msg?.content){
|
| 490 |
-
html+=`<div>${msg.content.replace(/</g,'<')}</div>`;
|
| 491 |
-
}
|
| 492 |
resp.innerHTML=html||`<pre>${JSON.stringify(d,null,2)}</pre>`;
|
| 493 |
timeEl.textContent=((Date.now()-t0)/1000).toFixed(1)+'s';
|
| 494 |
status.textContent=r.status+' OK';status.style.color='var(--green)';
|
|
@@ -500,9 +543,9 @@ async function sendMsg(){
|
|
| 500 |
btn.disabled=false;btn.textContent='▸ 发送';
|
| 501 |
}
|
| 502 |
|
| 503 |
-
/* ── Stats & Accounts ── */
|
| 504 |
function flashPoll(){
|
| 505 |
const d=document.getElementById('pollDot');
|
|
|
|
| 506 |
d.classList.remove('active');void d.offsetWidth;d.classList.add('active');
|
| 507 |
}
|
| 508 |
|
|
@@ -518,10 +561,9 @@ async function loadStats(){
|
|
| 518 |
document.getElementById('d-muted').textContent=a.muted||0;
|
| 519 |
document.getElementById('d-queue').textContent=a.queue_size;
|
| 520 |
document.getElementById('topStats').innerHTML=
|
| 521 |
-
`<span class="si"><span class="dot" style="background:var(--accent)"></span>
|
| 522 |
-
<span class="si"><span class="dot" style="background:var(--green)"></span>
|
| 523 |
-
<span class="si"><span class="dot" style="background:var(--amber)"></span>
|
| 524 |
-
<span class="si"><span class="dot" style="background:var(--text-dim)"></span> 排队 <b>${a.queue_size}</b></span>`;
|
| 525 |
}catch(e){}
|
| 526 |
}
|
| 527 |
|
|
@@ -532,49 +574,40 @@ async function loadAccounts(){
|
|
| 532 |
for(const a of d.accounts){
|
| 533 |
r+=`<tr>
|
| 534 |
<td><span class="email">${a.email}</span></td>
|
| 535 |
-
<td class="hide-
|
| 536 |
<td><span class="badge ${a.logged_in?'badge-on':'badge-off'}">${a.logged_in?'在线':'离线'}</span></td>
|
| 537 |
<td><span class="badge ${a.in_use?'badge-on':'badge-idle'}">${a.in_use?'使用中':'空闲'}</span></td>
|
| 538 |
-
<td>${a.is_muted?`<span class="badge badge-warn" title="${a.muted_until||''}">禁言</span>`:'<span class="badge badge-idle">正常</span>'}</td>
|
| 539 |
-
<td class="hide-
|
| 540 |
<td><button class="btn btn-sm" onclick="doLoginAccount('${a.email}')">${a.logged_in?'重连':'登录'}</button></td>
|
| 541 |
</tr>`;
|
| 542 |
}
|
| 543 |
-
document.getElementById('tbl').innerHTML=r||'<tr><td colspan="7"
|
| 544 |
}catch(e){
|
| 545 |
-
document.getElementById('tbl').innerHTML=
|
| 546 |
}
|
| 547 |
}
|
| 548 |
|
| 549 |
async function doLoginAccount(email){
|
| 550 |
try{
|
| 551 |
-
toast('正在唤醒 '+email+'...',
|
| 552 |
-
await api('/admin/accounts/login',{
|
| 553 |
-
|
| 554 |
-
body:JSON.stringify({email}),
|
| 555 |
-
headers:{'admin-key':getAdminKey()}
|
| 556 |
-
});
|
| 557 |
-
toast('已触发登录任务', 1);
|
| 558 |
loadAll();
|
| 559 |
-
}catch(e){toast('触发失败: '+e.message,
|
| 560 |
}
|
| 561 |
|
| 562 |
async function doImport(){
|
| 563 |
const v=document.getElementById('inp').value.trim();
|
| 564 |
if(!v)return toast('请输入账号',0);
|
| 565 |
const accts=[];
|
| 566 |
-
// 自动检测:JSON 格式 vs 文本格式
|
| 567 |
if(/^\s*[\[{]/.test(v)){
|
| 568 |
try{
|
| 569 |
let parsed=JSON.parse(v);
|
| 570 |
if(Array.isArray(parsed)){
|
| 571 |
-
for(const a of parsed){
|
| 572 |
-
if(a.email&&a.password)accts.push({email:a.email.trim(),password:a.password,name:a.name||'',proxy:a.proxy||''});
|
| 573 |
-
}
|
| 574 |
}else if(parsed.accounts&&Array.isArray(parsed.accounts)){
|
| 575 |
-
for(const a of parsed.accounts){
|
| 576 |
-
if(a.email&&a.password)accts.push({email:a.email.trim(),password:a.password,name:a.name||'',proxy:a.proxy||''});
|
| 577 |
-
}
|
| 578 |
}else if(parsed.email&&parsed.password){
|
| 579 |
accts.push({email:parsed.email.trim(),password:parsed.password,name:parsed.name||'',proxy:parsed.proxy||''});
|
| 580 |
}
|
|
@@ -583,16 +616,12 @@ async function doImport(){
|
|
| 583 |
for(const l of v.split('\n')){
|
| 584 |
const t=l.trim();if(!t)continue;
|
| 585 |
const p=t.split(':',4);
|
| 586 |
-
if(p.length>=2)accts.push({email:p[0].trim(),password:p[1],name:p[2]||'',proxy:p[3]||''});
|
| 587 |
}
|
| 588 |
}
|
| 589 |
if(!accts.length)return toast('未识别到有效账号',0);
|
| 590 |
try{
|
| 591 |
-
const d=await api('/admin/accounts/import',{
|
| 592 |
-
method:'POST',json:true,
|
| 593 |
-
body:JSON.stringify({accounts:accts}),
|
| 594 |
-
headers:{'admin-key':getAdminKey()}
|
| 595 |
-
});
|
| 596 |
document.getElementById('inp').value='';
|
| 597 |
document.getElementById('msg').textContent='已导入 '+d.imported+' 个';
|
| 598 |
toast('成功导入 '+d.imported+' 个',1);
|
|
@@ -602,15 +631,11 @@ async function doImport(){
|
|
| 602 |
|
| 603 |
async function loadAll(){await loadStats();await loadAccounts()}
|
| 604 |
|
| 605 |
-
// Ctrl+Enter to send
|
| 606 |
document.getElementById('prompt').addEventListener('keydown',e=>{if(e.ctrlKey&&e.key==='Enter')sendMsg()});
|
| 607 |
|
| 608 |
-
let _pollTimer=null;
|
| 609 |
-
let _logTimer=null;
|
| 610 |
function initApp(){
|
| 611 |
-
loadAll();
|
| 612 |
-
loadSettings();
|
| 613 |
-
loadLogs();
|
| 614 |
if(!_pollTimer)_pollTimer=setInterval(loadAll,12000);
|
| 615 |
if(!_logTimer)_logTimer=setInterval(loadLogs,3000);
|
| 616 |
}
|
|
@@ -623,7 +648,6 @@ async function loadSettings(){
|
|
| 623 |
document.getElementById('setMaxBrowsers').value=d.max_active_browsers||3;
|
| 624 |
document.getElementById('setLogFile').checked=d.log_file_enabled||false;
|
| 625 |
document.getElementById('setLogMaxMb').value=d.log_file_max_mb||10;
|
| 626 |
-
// Update level buttons
|
| 627 |
const lvl={10:'DEBUG',20:'INFO',30:'WARNING',40:'ERROR'}[d.log_level]||'INFO';
|
| 628 |
document.querySelectorAll('#levelBtns .btn').forEach(b=>b.classList.toggle('active',b.textContent===lvl||(b.textContent==='WARN'&&lvl==='WARNING')));
|
| 629 |
}catch(e){}
|
|
@@ -637,18 +661,13 @@ async function saveSettings(){
|
|
| 637 |
const maxBrowsers=parseInt(document.getElementById('setMaxBrowsers').value)||3;
|
| 638 |
if(!keys.length){toast('请至少填写一个 API Key',0);return}
|
| 639 |
try{
|
| 640 |
-
await api('/admin/settings',{
|
| 641 |
-
method:'POST',json:true,
|
| 642 |
-
body:JSON.stringify({api_keys:keys,admin_key:ak||undefined,max_active_browsers:maxBrowsers,log_file_enabled:logFile,log_file_max_mb:logMax}),
|
| 643 |
-
headers:{'admin-key':getAdminKey()}
|
| 644 |
-
});
|
| 645 |
if(ak&&ak!==_adminKey){_adminKey=ak;LS.set('adminKey',ak)}
|
| 646 |
document.getElementById('setMsg').textContent='已保存';
|
| 647 |
toast('设置已保存',1);
|
| 648 |
}catch(e){toast(e.message,0)}
|
| 649 |
}
|
| 650 |
|
| 651 |
-
/* ── Logs ── */
|
| 652 |
async function loadLogs(){
|
| 653 |
try{
|
| 654 |
const d=await api('/admin/logs?n=200',{headers:{'admin-key':getAdminKey()}});
|
|
@@ -680,4 +699,4 @@ async function setLevel(lvl){
|
|
| 680 |
}
|
| 681 |
</script>
|
| 682 |
</body>
|
| 683 |
-
</html>
|
|
|
|
| 2 |
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
| 6 |
<title>DS2API · 控制台</title>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
| 9 |
<style>
|
| 10 |
:root{
|
| 11 |
+
--bg:#080c14;--surface:rgba(14,18,30,.85);--surface-solid:#0e121e;
|
| 12 |
--border:rgba(255,255,255,.06);--border-focus:rgba(96,165,250,.5);
|
| 13 |
--text:#c9d1d9;--text-dim:#4a5568;--text-muted:#2d3748;
|
| 14 |
+
--accent:hsl(217,92%,60%);--accent-glow:hsla(217,92%,60%,.12);
|
| 15 |
--green:hsl(142,71%,45%);--red:hsl(0,84%,60%);--amber:hsl(38,92%,50%);
|
| 16 |
+
--radius:14px;--radius-sm:10px;--radius-xs:8px;
|
| 17 |
--font-ui:'Inter',system-ui,sans-serif;--font-mono:'JetBrains Mono',monospace;
|
| 18 |
+
--shadow:0 1px 3px rgba(0,0,0,.3),0 1px 2px rgba(0,0,0,.2);
|
| 19 |
}
|
| 20 |
html.light-mode{
|
| 21 |
+
--bg:#f1f5f9;--surface:rgba(255,255,255,.9);--surface-solid:#fff;
|
| 22 |
--border:rgba(0,0,0,.08);--border-focus:rgba(59,130,246,.5);
|
| 23 |
--text:#0f172a;--text-dim:#475569;--text-muted:#64748b;
|
| 24 |
+
--accent:hsl(217,92%,55%);--accent-glow:hsla(217,92%,55%,.1);
|
| 25 |
+
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
| 26 |
+
}
|
| 27 |
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
| 28 |
+
body{
|
| 29 |
+
font-family:var(--font-ui);background:var(--bg);color:var(--text);
|
| 30 |
+
font-size:clamp(13px,1.6vw,15px);line-height:1.6;min-height:100vh;
|
| 31 |
+
overflow-x:hidden;-webkit-font-smoothing:antialiased;
|
| 32 |
+
}
|
| 33 |
+
body::before{
|
| 34 |
+
content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
|
| 35 |
+
background:radial-gradient(ellipse 80% 50% at 50% -20%,var(--accent-glow),transparent 70%);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
::-webkit-scrollbar{width:5px;height:5px}
|
| 39 |
+
::-webkit-scrollbar-track{background:transparent}
|
| 40 |
+
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
| 41 |
+
|
| 42 |
+
.topbar{
|
| 43 |
+
position:sticky;top:0;z-index:50;
|
| 44 |
+
backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
|
| 45 |
+
background:rgba(8,12,20,.85);border-bottom:1px solid var(--border);
|
| 46 |
+
padding:0 clamp(12px,3vw,24px);height:54px;display:flex;align-items:center;gap:10px;
|
| 47 |
+
}
|
| 48 |
+
html.light-mode .topbar{background:rgba(241,245,249,.85)}
|
| 49 |
+
.topbar .logo{
|
| 50 |
+
font-weight:800;font-size:clamp(13px,2vw,15px);
|
| 51 |
+
background:linear-gradient(135deg,var(--accent),hsl(280,80%,65%));
|
| 52 |
+
-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:1px;
|
| 53 |
+
white-space:nowrap;flex-shrink:0;
|
| 54 |
+
}
|
| 55 |
+
.topbar .badge{
|
| 56 |
+
font-size:10px;color:var(--text-dim);border:1px solid var(--border);
|
| 57 |
+
padding:2px 8px;border-radius:20px;font-weight:500;white-space:nowrap;
|
| 58 |
+
display:none;
|
| 59 |
+
}
|
| 60 |
+
@media(min-width:480px){.topbar .badge{display:inline}}
|
| 61 |
+
.topbar .spacer{flex:1}
|
| 62 |
+
.topbar .stats{display:flex;gap:clamp(6px,2vw,18px);font-size:11px;font-weight:500;flex-shrink:0}
|
| 63 |
+
.topbar .stats .si{display:flex;align-items:center;gap:4px;color:var(--text-dim);white-space:nowrap}
|
| 64 |
+
.topbar .stats .si b{color:var(--text);font-weight:600}
|
| 65 |
+
.topbar .stats .hide-sm{display:none}
|
| 66 |
+
@media(min-width:640px){.topbar .stats .hide-sm{display:flex}}
|
| 67 |
+
.dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
| 68 |
+
.btn{
|
| 69 |
+
display:inline-flex;align-items:center;justify-content:center;gap:5px;
|
| 70 |
+
padding:clamp(6px,1vw,8px) clamp(10px,2vw,16px);
|
| 71 |
+
font-size:clamp(11px,1.4vw,13px);font-family:var(--font-ui);
|
| 72 |
+
background:var(--surface-solid);color:var(--text);
|
| 73 |
+
border:1px solid var(--border);border-radius:var(--radius-xs);
|
| 74 |
+
cursor:pointer;white-space:nowrap;transition:all .15s;
|
| 75 |
+
min-height:36px;min-width:36px;user-select:none;
|
| 76 |
+
}
|
| 77 |
+
.btn:hover{background:rgba(96,165,250,.1);border-color:var(--border-focus)}
|
| 78 |
+
.btn:active{transform:scale(.97)}
|
| 79 |
+
.btn:disabled{opacity:.4;pointer-events:none}
|
| 80 |
+
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600}
|
| 81 |
+
.btn-primary:hover{background:hsl(217,92%,50%);border-color:hsl(217,92%,50%)}
|
| 82 |
+
.btn-sm{padding:4px 10px;font-size:11px;min-height:28px}
|
| 83 |
+
.btn-icon{padding:6px;min-width:36px;min-height:36px}
|
| 84 |
+
|
| 85 |
+
main{
|
| 86 |
+
position:relative;z-index:1;padding:clamp(12px,2vw,20px);
|
| 87 |
+
display:grid;gap:clamp(10px,2vw,16px);
|
| 88 |
+
grid-template-columns:1fr;
|
| 89 |
+
}
|
| 90 |
+
@media(min-width:640px){main{grid-template-columns:1fr 1fr}}
|
| 91 |
+
@media(min-width:1024px){main{grid-template-columns:1fr 1fr 1fr}}
|
| 92 |
+
@media(min-width:1400px){main{grid-template-columns:1fr 1fr 1fr 1fr}}
|
| 93 |
+
|
| 94 |
+
.card{
|
| 95 |
+
background:var(--surface);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
|
| 96 |
+
border:1px solid var(--border);border-radius:var(--radius);
|
| 97 |
+
box-shadow:var(--shadow);display:flex;flex-direction:column;
|
| 98 |
+
overflow:hidden;animation:fadeUp .4s ease-out both;
|
| 99 |
+
}
|
| 100 |
+
.card.span-2{grid-column:span 1}
|
| 101 |
+
.card.span-full{grid-column:1/-1}
|
| 102 |
+
@media(min-width:640px){.card.span-2{grid-column:span 2}}
|
| 103 |
+
@media(min-width:1024px){.card.span-2{grid-column:span 2}}
|
| 104 |
+
.card-header{
|
| 105 |
+
display:flex;align-items:center;justify-content:space-between;gap:8px;
|
| 106 |
+
padding:clamp(10px,1.5vw,14px) clamp(12px,2vw,18px);
|
| 107 |
+
border-bottom:1px solid var(--border);flex-wrap:wrap;
|
| 108 |
+
}
|
| 109 |
+
.card-header h2{
|
| 110 |
+
font-size:clamp(12px,1.5vw,14px);font-weight:600;
|
| 111 |
+
display:flex;align-items:center;gap:6px;color:var(--text);
|
| 112 |
+
}
|
| 113 |
+
.card-header .icon{font-size:clamp(14px,1.8vw,16px)}
|
| 114 |
+
.card-body{padding:clamp(10px,1.5vw,16px);flex:1}
|
| 115 |
+
|
| 116 |
+
.form-group{margin-bottom:clamp(8px,1.2vw,12px)}
|
| 117 |
+
.form-group:last-child{margin-bottom:0}
|
| 118 |
+
label{display:block;font-size:11px;color:var(--text-dim);margin-bottom:4px;font-weight:500}
|
| 119 |
+
input,textarea,select{
|
| 120 |
+
width:100%;padding:clamp(8px,1vw,10px) clamp(10px,1.5vw,12px);
|
| 121 |
+
font-size:clamp(12px,1.4vw,14px);font-family:var(--font-ui);
|
| 122 |
+
background:rgba(0,0,0,.25);color:var(--text);
|
| 123 |
+
border:1px solid var(--border);border-radius:var(--radius-xs);
|
| 124 |
+
outline:none;transition:border-color .15s,box-shadow .15s;
|
| 125 |
+
}
|
| 126 |
+
html.light-mode input,html.light-mode textarea,html.light-mode select{background:rgba(0,0,0,.03)}
|
| 127 |
+
input:focus,textarea:focus,select:focus{
|
| 128 |
+
border-color:var(--border-focus);box-shadow:0 0 0 3px var(--accent-glow);
|
| 129 |
+
}
|
| 130 |
+
textarea{resize:vertical;min-height:80px;font-family:var(--font-mono);font-size:12px}
|
| 131 |
+
select{-webkit-appearance:none;appearance:none;cursor:pointer}
|
| 132 |
+
.check-label{
|
| 133 |
+
display:inline-flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;color:var(--text);
|
| 134 |
+
white-space:nowrap;
|
| 135 |
+
}
|
| 136 |
+
.check-label input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);cursor:pointer}
|
| 137 |
+
|
| 138 |
+
.row{display:flex;align-items:center;gap:clamp(6px,1vw,10px);flex-wrap:wrap}
|
| 139 |
+
|
| 140 |
+
.tbl-wrap{
|
| 141 |
+
overflow-x:auto;-webkit-overflow-scrolling:touch;
|
| 142 |
+
margin:-1px;padding:1px;
|
| 143 |
+
}
|
| 144 |
+
.tbl-wrap::-webkit-scrollbar{height:4px}
|
| 145 |
+
table{width:100%;border-collapse:collapse;font-size:clamp(11px,1.3vw,13px);min-width:600px}
|
| 146 |
+
th{
|
| 147 |
+
text-align:left;padding:8px 10px;font-size:10px;text-transform:uppercase;
|
| 148 |
+
letter-spacing:.5px;color:var(--text-dim);font-weight:600;
|
| 149 |
+
background:rgba(0,0,0,.15);white-space:nowrap;
|
| 150 |
+
}
|
| 151 |
+
td{padding:clamp(6px,1vw,8px) 10px;border-bottom:1px solid var(--border);vertical-align:middle}
|
| 152 |
+
tr:last-child td{border-bottom:none}
|
| 153 |
+
.email{font-family:var(--font-mono);font-size:11px;word-break:break-all}
|
| 154 |
+
|
| 155 |
+
.badge{
|
| 156 |
+
display:inline-block;padding:2px 8px;border-radius:12px;font-size:10px;font-weight:600;
|
| 157 |
+
white-space:nowrap;
|
| 158 |
+
}
|
| 159 |
+
.badge-on{background:rgba(52,211,153,.15);color:var(--green)}
|
| 160 |
+
.badge-off{background:rgba(248,113,113,.12);color:var(--red)}
|
| 161 |
+
.badge-idle{background:rgba(255,255,255,.06);color:var(--text-dim)}
|
| 162 |
+
.badge-warn{background:rgba(251,191,36,.15);color:var(--amber)}
|
| 163 |
+
.badge-blue{background:var(--accent-glow);color:var(--accent)}
|
| 164 |
+
|
| 165 |
+
.resp-box{
|
| 166 |
+
background:rgba(0,0,0,.35);border:1px solid var(--border);
|
| 167 |
+
border-radius:var(--radius-xs);padding:clamp(8px,1vw,12px);
|
| 168 |
+
min-height:60px;max-height:500px;overflow-y:auto;
|
| 169 |
+
font-family:var(--font-mono);font-size:12px;line-height:1.55;
|
| 170 |
+
white-space:pre-wrap;word-break:break-word;color:var(--text);
|
| 171 |
+
}
|
| 172 |
+
html.light-mode .resp-box{background:rgba(0,0,0,.03)}
|
| 173 |
+
.resp-meta{display:flex;align-items:center;gap:10px;margin-top:8px;font-size:11px;color:var(--text-dim);flex-wrap:wrap}
|
| 174 |
+
|
| 175 |
+
.log-viewer{
|
| 176 |
+
background:rgba(0,0,0,.4);border:1px solid var(--border);
|
| 177 |
+
border-radius:var(--radius-xs);padding:10px 14px;
|
| 178 |
+
max-height:clamp(200px,40vh,400px);overflow-y:auto;
|
| 179 |
+
font-family:var(--font-mono);font-size:11px;line-height:1.6;
|
| 180 |
+
white-space:pre-wrap;word-break:break-all;color:var(--text-dim);
|
| 181 |
+
}
|
| 182 |
+
html.light-mode .log-viewer{background:rgba(0,0,0,.04);color:#475569}
|
| 183 |
+
.log-viewer .log-warn{color:var(--amber)}.log-viewer .log-err{color:var(--red)}
|
| 184 |
+
.log-viewer .log-info{color:var(--text)}.log-viewer .log-debug{color:var(--text-muted)}
|
| 185 |
+
.level-btns{display:flex;gap:3px;flex-wrap:wrap}
|
| 186 |
+
.level-btns .btn{padding:4px 8px;font-size:10px;font-family:var(--font-mono);border-radius:5px;min-height:26px}
|
| 187 |
+
.level-btns .btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
| 188 |
+
|
| 189 |
+
.login-overlay{
|
| 190 |
+
position:fixed;inset:0;z-index:200;background:var(--bg);
|
| 191 |
+
display:flex;align-items:center;justify-content:center;transition:opacity .4s;
|
| 192 |
+
padding:20px;
|
| 193 |
+
}
|
| 194 |
.login-overlay.hidden{opacity:0;pointer-events:none}
|
| 195 |
+
.login-box{
|
| 196 |
+
background:var(--surface);backdrop-filter:blur(16px);border:1px solid var(--border);
|
| 197 |
+
border-radius:var(--radius);padding:clamp(28px,5vw,40px);
|
| 198 |
+
width:100%;max-width:380px;text-align:center;animation:fadeUp .5s ease-out;
|
| 199 |
+
}
|
| 200 |
+
.login-box .logo-big{
|
| 201 |
+
font-size:clamp(20px,4vw,24px);font-weight:800;
|
| 202 |
+
background:linear-gradient(135deg,var(--accent),hsl(280,80%,65%));
|
| 203 |
+
-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:6px;
|
| 204 |
+
}
|
| 205 |
+
.login-box .sub{color:var(--text-dim);font-size:12px;margin-bottom:24px}
|
| 206 |
+
.login-box .form-group{text-align:left;margin-bottom:14px}
|
| 207 |
.login-box .btn-primary{width:100%;padding:12px;font-size:14px}
|
| 208 |
+
.login-box .err-msg{color:var(--red);font-size:12px;margin-top:8px;min-height:18px}
|
|
|
|
| 209 |
.app-wrap.blur{filter:blur(8px);pointer-events:none}
|
| 210 |
|
| 211 |
+
.toast{
|
| 212 |
+
position:fixed;top:20px;right:clamp(10px,3vw,20px);z-index:300;
|
| 213 |
+
padding:10px 18px;border-radius:var(--radius-xs);
|
| 214 |
+
font-size:12px;font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,.4);
|
| 215 |
+
animation:slideIn .3s ease-out;
|
| 216 |
+
background:var(--surface-solid);color:var(--text);border:1px solid var(--border);
|
| 217 |
+
max-width:min(360px,90vw);
|
| 218 |
+
}
|
| 219 |
+
.toast-ok{border-left:3px solid var(--green)}
|
| 220 |
+
.toast-err{border-left:3px solid var(--red)}
|
| 221 |
|
| 222 |
+
.thinking-block{margin-bottom:10px;border-left:3px solid var(--accent);padding-left:12px;color:var(--text-dim);font-size:12px}
|
| 223 |
+
.thinking-block summary{cursor:pointer;user-select:none;font-weight:600;margin-bottom:6px;outline:none}
|
|
|
|
| 224 |
.thinking-block summary:hover{color:var(--accent)}
|
| 225 |
+
.thinking-content{white-space:pre-wrap;word-break:break-word;opacity:.8}
|
| 226 |
+
|
| 227 |
+
.inline-stats{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;margin-bottom:8px}
|
| 228 |
+
.inline-stats span{display:flex;align-items:center;gap:4px;color:var(--text-dim)}
|
| 229 |
+
.inline-stats span b{color:var(--text)}
|
| 230 |
+
|
| 231 |
+
@keyframes fadeUp{from{transform:translateY(8px);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 232 |
+
@keyframes slideIn{from{transform:translateX(60px);opacity:0}to{transform:translateX(0);opacity:1}}
|
| 233 |
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
| 234 |
+
.poll-dot{animation:pulse 1.2s ease-in-out;width:5px;height:5px;border-radius:50%;background:var(--accent);display:inline-block;margin-left:6px}
|
| 235 |
+
.poll-dot.active{animation:none}
|
| 236 |
+
|
| 237 |
+
.hint{font-size:10px;color:var(--text-dim);margin-top:4px}
|
| 238 |
+
|
| 239 |
+
@media(max-width:639px){.hide-xs{display:none!important}}
|
| 240 |
+
@media(max-width:1023px){.hide-md{display:none!important}}
|
| 241 |
</style>
|
| 242 |
</head>
|
| 243 |
<body>
|
| 244 |
|
|
|
|
| 245 |
<div class="login-overlay" id="loginOverlay">
|
| 246 |
<div class="login-box">
|
| 247 |
<div class="logo-big">▸ DS2API</div>
|
|
|
|
| 256 |
</div>
|
| 257 |
|
| 258 |
<div class="app-wrap" id="appWrap">
|
| 259 |
+
|
| 260 |
<div class="topbar">
|
| 261 |
<span class="logo">▸ DS2API</span>
|
| 262 |
+
<span class="badge">浏览器模式</span>
|
| 263 |
+
<span class="spacer"></span>
|
| 264 |
+
<button class="btn btn-sm hide-xs" onclick="toggleTheme()" id="themeBtn">🌙</button>
|
| 265 |
<div class="stats" id="topStats">
|
| 266 |
+
<span class="si"><span class="dot" style="background:var(--accent)"></span> <b>—</b></span>
|
| 267 |
+
<span class="si hide-sm"><span class="dot" style="background:var(--green)"></span> <b>—</b></span>
|
| 268 |
+
<span class="si hide-sm"><span class="dot" style="background:var(--amber)"></span> <b>—</b></span>
|
|
|
|
| 269 |
</div>
|
|
|
|
|
|
|
| 270 |
</div>
|
| 271 |
|
| 272 |
+
<main>
|
| 273 |
+
|
| 274 |
+
<div class="card span-2" style="animation-delay:0s">
|
| 275 |
+
<div class="card-header">
|
| 276 |
+
<h2><span class="icon">💬</span> 对话测试</h2>
|
| 277 |
+
<div class="row">
|
| 278 |
+
<select id="model" style="width:auto;min-width:130px">
|
| 279 |
+
<option value="deepseek-v4-flash">V4 Flash</option>
|
| 280 |
+
<option value="deepseek-v4-pro">V4 Pro</option>
|
| 281 |
+
</select>
|
| 282 |
+
<label class="check-label"><input type="checkbox" id="stream" checked> 流式</label>
|
| 283 |
+
</div>
|
| 284 |
</div>
|
| 285 |
+
<div class="card-body">
|
| 286 |
+
<div class="form-group">
|
| 287 |
+
<textarea id="prompt" placeholder="输入消息… (Ctrl+Enter 发送)" style="min-height:90px"></textarea>
|
| 288 |
+
</div>
|
| 289 |
+
<div class="row" style="justify-content:space-between">
|
| 290 |
+
<button class="btn btn-primary" id="sendBtn" onclick="sendMsg()">▸ 发送</button>
|
| 291 |
+
<button class="btn btn-sm" onclick="clearResp()">清空</button>
|
| 292 |
+
</div>
|
| 293 |
+
<div class="resp-box" id="response" style="margin-top:12px"></div>
|
| 294 |
+
<div class="resp-meta">
|
| 295 |
+
<span id="reqStatus"></span>
|
| 296 |
+
<span id="respTime"></span>
|
| 297 |
+
<span id="respLabel" style="margin-left:auto;font-weight:600"></span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
|
| 302 |
+
<div class="card" style="animation-delay:.1s">
|
| 303 |
+
<div class="card-header"><h2><span class="icon">📥</span> 导入账号</h2></div>
|
| 304 |
+
<div class="card-body">
|
| 305 |
+
<div class="hint" style="margin-bottom:6px">JSON 或 邮箱:密码[:备注] 格式,每行一个</div>
|
| 306 |
+
<div class="form-group">
|
| 307 |
+
<textarea id="inp" placeholder='[{"email":"user@gmail.com","password":"xxx"}]' style="min-height:100px"></textarea>
|
| 308 |
+
</div>
|
| 309 |
+
<div class="row" style="justify-content:space-between">
|
| 310 |
+
<button class="btn btn-primary" onclick="doImport()">▸ 导入</button>
|
| 311 |
+
<span id="msg" style="font-size:11px;color:var(--text-dim)"></span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
</div>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
|
| 316 |
+
<div class="card span-full" style="animation-delay:.2s">
|
| 317 |
+
<div class="card-header">
|
| 318 |
+
<h2><span class="icon">👥</span> 账号列表</h2>
|
| 319 |
+
<div class="inline-stats hide-xs" id="cardStats">
|
| 320 |
+
<span><span class="dot" style="background:var(--accent)"></span> 总计 <b id="d-total">—</b></span>
|
| 321 |
+
<span><span class="dot" style="background:var(--green)"></span> 在线 <b id="d-online">—</b></span>
|
| 322 |
+
<span><span class="dot" style="background:var(--amber)"></span> 使用中 <b id="d-inuse">—</b></span>
|
| 323 |
+
<span>可用 <b id="d-avail">—</b></span>
|
| 324 |
+
<span>禁言 <b id="d-muted">—</b></span>
|
| 325 |
+
<span>排队 <b id="d-queue">—</b></span>
|
| 326 |
+
<span class="poll-dot" id="pollDot"></span>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
<div class="card-body" style="padding:0">
|
| 330 |
+
<div class="tbl-wrap">
|
| 331 |
+
<table>
|
| 332 |
+
<thead><tr>
|
| 333 |
+
<th>邮箱</th><th class="hide-xs">备注</th><th>状态</th><th>使用</th>
|
| 334 |
+
<th class="hide-xs">禁言</th><th class="hide-xs">错误</th><th>操作</th>
|
| 335 |
+
</tr></thead>
|
| 336 |
+
<tbody id="tbl"><tr><td colspan="7" style="text-align:center;padding:20px;color:var(--text-muted)">加载中…</td></tr></tbody>
|
| 337 |
+
</table>
|
| 338 |
+
</div>
|
| 339 |
</div>
|
| 340 |
+
</div>
|
| 341 |
|
| 342 |
+
<div class="card" style="animation-delay:.3s">
|
| 343 |
+
<div class="card-header"><h2><span class="icon">⚙️</span> 系统设置</h2></div>
|
| 344 |
+
<div class="card-body">
|
| 345 |
+
<div class="form-group">
|
| 346 |
+
<label>API Keys(每行一个)</label>
|
| 347 |
+
<textarea id="setApiKeys" style="min-height:60px;font-family:var(--font-mono);font-size:11px" placeholder="sk-xxx"></textarea>
|
| 348 |
+
</div>
|
| 349 |
+
<div class="form-group">
|
| 350 |
+
<label>Admin Key</label>
|
| 351 |
+
<input type="text" id="setAdminKey">
|
| 352 |
+
</div>
|
| 353 |
+
<div class="form-group">
|
| 354 |
+
<label>最大活跃浏览器数</label>
|
| 355 |
+
<input type="number" id="setMaxBrowsers" min="1" max="200" style="width:100px">
|
| 356 |
+
</div>
|
| 357 |
+
<div class="row" style="gap:8px;margin-bottom:10px">
|
| 358 |
+
<label class="check-label"><input type="checkbox" id="setLogFile"> 日志写入文件</label>
|
| 359 |
+
<input type="text" id="setLogMaxMb" placeholder="10" style="width:50px;text-align:center"> <span style="font-size:11px;color:var(--text-dim)">MB</span>
|
| 360 |
</div>
|
| 361 |
+
<div class="row">
|
| 362 |
+
<button class="btn btn-primary" onclick="saveSettings()">▸ 保存</button>
|
| 363 |
+
<span id="setMsg" style="font-size:11px;color:var(--text-dim)"></span>
|
| 364 |
</div>
|
| 365 |
</div>
|
| 366 |
+
</div>
|
| 367 |
|
| 368 |
+
<div class="card span-full" style="animation-delay:.4s">
|
| 369 |
+
<div class="card-header">
|
| 370 |
+
<h2><span class="icon">📋</span> 实时日志</h2>
|
| 371 |
+
<div class="row" style="gap:6px">
|
| 372 |
+
<div class="level-btns" id="levelBtns">
|
| 373 |
+
<button class="btn" onclick="setLevel('DEBUG')">DEBUG</button>
|
| 374 |
+
<button class="btn active" onclick="setLevel('INFO')">INFO</button>
|
| 375 |
+
<button class="btn" onclick="setLevel('WARNING')">WARN</button>
|
| 376 |
+
<button class="btn" onclick="setLevel('ERROR')">ERROR</button>
|
| 377 |
+
</div>
|
| 378 |
+
<button class="btn btn-sm hide-xs" onclick="clearLogs()">清空</button>
|
| 379 |
+
<label class="check-label hide-xs"><input type="checkbox" id="logAutoScroll" checked> 自动滚动</label>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
<div class="card-body" style="padding:10px">
|
| 383 |
+
<div class="log-viewer" id="logViewer">加载中…</div>
|
| 384 |
+
</div>
|
| 385 |
</div>
|
| 386 |
+
|
| 387 |
+
</main>
|
| 388 |
</div>
|
|
|
|
| 389 |
|
| 390 |
<script>
|
| 391 |
const H=location.origin;
|
|
|
|
| 394 |
set(k,v){try{localStorage.setItem('ds2_'+k,v)}catch(e){}}
|
| 395 |
};
|
| 396 |
|
|
|
|
| 397 |
function initTheme(){
|
| 398 |
if(LS.get('theme','dark')==='light') document.documentElement.classList.add('light-mode');
|
| 399 |
updateThemeBtn();
|
| 400 |
}
|
| 401 |
function toggleTheme(){
|
| 402 |
+
const isLight=document.documentElement.classList.toggle('light-mode');
|
| 403 |
+
LS.set('theme',isLight?'light':'dark');
|
| 404 |
updateThemeBtn();
|
| 405 |
}
|
| 406 |
function updateThemeBtn(){
|
| 407 |
+
const btn=document.getElementById('themeBtn');
|
| 408 |
+
if(btn) btn.textContent=document.documentElement.classList.contains('light-mode')?'☀️':'🌙';
|
| 409 |
}
|
| 410 |
initTheme();
|
| 411 |
|
| 412 |
let _adminKey='';
|
| 413 |
|
|
|
|
| 414 |
async function doLogin(){
|
| 415 |
const key=document.getElementById('loginKey').value.trim();
|
| 416 |
const err=document.getElementById('loginErr');
|
|
|
|
| 420 |
try{
|
| 421 |
const r=await fetch(H+'/admin/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})});
|
| 422 |
if(!r.ok)throw new Error('密钥错误');
|
| 423 |
+
_adminKey=key;LS.set('adminKey',key);
|
|
|
|
| 424 |
document.getElementById('loginOverlay').classList.add('hidden');
|
| 425 |
document.getElementById('appWrap').classList.remove('blur');
|
| 426 |
initApp();
|
|
|
|
| 430 |
|
| 431 |
document.getElementById('loginKey').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin()});
|
| 432 |
|
|
|
|
| 433 |
(async()=>{
|
| 434 |
const saved=LS.get('adminKey','');
|
| 435 |
if(saved){
|
|
|
|
| 455 |
function clearResp(){
|
| 456 |
document.getElementById('response').textContent='';
|
| 457 |
document.getElementById('respTime').textContent='';
|
| 458 |
+
document.getElementById('respLabel').textContent='';
|
| 459 |
document.getElementById('reqStatus').textContent='';
|
| 460 |
}
|
| 461 |
|
|
|
|
| 468 |
return r.json();
|
| 469 |
}
|
| 470 |
|
|
|
|
| 471 |
async function sendMsg(){
|
| 472 |
const model=document.getElementById('model').value;
|
| 473 |
const prompt=document.getElementById('prompt').value.trim();
|
|
|
|
| 475 |
const resp=document.getElementById('response');
|
| 476 |
const status=document.getElementById('reqStatus');
|
| 477 |
const timeEl=document.getElementById('respTime');
|
| 478 |
+
const label=document.getElementById('respLabel');
|
| 479 |
const btn=document.getElementById('sendBtn');
|
| 480 |
|
| 481 |
if(!prompt)return toast('请输入消息',0);
|
| 482 |
btn.disabled=true;btn.textContent='发送中…';
|
| 483 |
+
resp.textContent='';timeEl.textContent='';status.textContent='';label.textContent='响应';
|
| 484 |
|
| 485 |
const t0=Date.now();
|
| 486 |
try{
|
|
|
|
| 493 |
|
| 494 |
if(stream){
|
| 495 |
const reader=r.body.getReader(),dec=new TextDecoder();
|
| 496 |
+
let fullContent='',fullThinking='';
|
| 497 |
+
resp.innerHTML='<details class="thinking-block" id="thinkBlock" style="display:none"><summary>深度思考</summary><div class="thinking-content" id="thinkContent"></div></details><div id="ansContent"></div>';
|
| 498 |
const thinkBlock=document.getElementById('thinkBlock');
|
| 499 |
const thinkContent=document.getElementById('thinkContent');
|
| 500 |
const ansContent=document.getElementById('ansContent');
|
|
|
|
| 531 |
if(msg?.reasoning_content){
|
| 532 |
html+=`<details class="thinking-block"><summary>深度思考</summary><div class="thinking-content">${msg.reasoning_content.replace(/</g,'<')}</div></details>`;
|
| 533 |
}
|
| 534 |
+
if(msg?.content) html+=`<div>${msg.content.replace(/</g,'<')}</div>`;
|
|
|
|
|
|
|
| 535 |
resp.innerHTML=html||`<pre>${JSON.stringify(d,null,2)}</pre>`;
|
| 536 |
timeEl.textContent=((Date.now()-t0)/1000).toFixed(1)+'s';
|
| 537 |
status.textContent=r.status+' OK';status.style.color='var(--green)';
|
|
|
|
| 543 |
btn.disabled=false;btn.textContent='▸ 发送';
|
| 544 |
}
|
| 545 |
|
|
|
|
| 546 |
function flashPoll(){
|
| 547 |
const d=document.getElementById('pollDot');
|
| 548 |
+
if(!d)return;
|
| 549 |
d.classList.remove('active');void d.offsetWidth;d.classList.add('active');
|
| 550 |
}
|
| 551 |
|
|
|
|
| 561 |
document.getElementById('d-muted').textContent=a.muted||0;
|
| 562 |
document.getElementById('d-queue').textContent=a.queue_size;
|
| 563 |
document.getElementById('topStats').innerHTML=
|
| 564 |
+
`<span class="si"><span class="dot" style="background:var(--accent)"></span> <b>${a.total}</b></span>
|
| 565 |
+
<span class="si hide-sm"><span class="dot" style="background:var(--green)"></span> <b>${a.logged_in}</b></span>
|
| 566 |
+
<span class="si hide-sm"><span class="dot" style="background:var(--amber)"></span> <b>${a.in_use}</b></span>`;
|
|
|
|
| 567 |
}catch(e){}
|
| 568 |
}
|
| 569 |
|
|
|
|
| 574 |
for(const a of d.accounts){
|
| 575 |
r+=`<tr>
|
| 576 |
<td><span class="email">${a.email}</span></td>
|
| 577 |
+
<td class="hide-xs">${a.name||'—'}</td>
|
| 578 |
<td><span class="badge ${a.logged_in?'badge-on':'badge-off'}">${a.logged_in?'在线':'离线'}</span></td>
|
| 579 |
<td><span class="badge ${a.in_use?'badge-on':'badge-idle'}">${a.in_use?'使用中':'空闲'}</span></td>
|
| 580 |
+
<td class="hide-xs">${a.is_muted?`<span class="badge badge-warn" title="${a.muted_until||''}">禁言</span>`:'<span class="badge badge-idle">正常</span>'}</td>
|
| 581 |
+
<td class="hide-xs">${a.error_count>0?`<span class="badge badge-off">${a.error_count}</span>`:'—'}</td>
|
| 582 |
<td><button class="btn btn-sm" onclick="doLoginAccount('${a.email}')">${a.logged_in?'重连':'登录'}</button></td>
|
| 583 |
</tr>`;
|
| 584 |
}
|
| 585 |
+
document.getElementById('tbl').innerHTML=r||'<tr><td colspan="7" style="text-align:center;padding:20px;color:var(--text-muted)">暂无账号</td></tr>';
|
| 586 |
}catch(e){
|
| 587 |
+
document.getElementById('tbl').innerHTML=`<tr><td colspan="7" style="color:var(--red);text-align:center;padding:16px">${e.message}</td></tr>`;
|
| 588 |
}
|
| 589 |
}
|
| 590 |
|
| 591 |
async function doLoginAccount(email){
|
| 592 |
try{
|
| 593 |
+
toast('正在唤醒 '+email+'...',1);
|
| 594 |
+
await api('/admin/accounts/login',{method:'POST',json:true,body:JSON.stringify({email}),headers:{'admin-key':getAdminKey()}});
|
| 595 |
+
toast('已触发登录任务',1);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
loadAll();
|
| 597 |
+
}catch(e){toast('触发失败: '+e.message,0)}
|
| 598 |
}
|
| 599 |
|
| 600 |
async function doImport(){
|
| 601 |
const v=document.getElementById('inp').value.trim();
|
| 602 |
if(!v)return toast('请输入账号',0);
|
| 603 |
const accts=[];
|
|
|
|
| 604 |
if(/^\s*[\[{]/.test(v)){
|
| 605 |
try{
|
| 606 |
let parsed=JSON.parse(v);
|
| 607 |
if(Array.isArray(parsed)){
|
| 608 |
+
for(const a of parsed) if(a.email&&a.password) accts.push({email:a.email.trim(),password:a.password,name:a.name||'',proxy:a.proxy||''});
|
|
|
|
|
|
|
| 609 |
}else if(parsed.accounts&&Array.isArray(parsed.accounts)){
|
| 610 |
+
for(const a of parsed.accounts) if(a.email&&a.password) accts.push({email:a.email.trim(),password:a.password,name:a.name||'',proxy:a.proxy||''});
|
|
|
|
|
|
|
| 611 |
}else if(parsed.email&&parsed.password){
|
| 612 |
accts.push({email:parsed.email.trim(),password:parsed.password,name:parsed.name||'',proxy:parsed.proxy||''});
|
| 613 |
}
|
|
|
|
| 616 |
for(const l of v.split('\n')){
|
| 617 |
const t=l.trim();if(!t)continue;
|
| 618 |
const p=t.split(':',4);
|
| 619 |
+
if(p.length>=2) accts.push({email:p[0].trim(),password:p[1],name:p[2]||'',proxy:p[3]||''});
|
| 620 |
}
|
| 621 |
}
|
| 622 |
if(!accts.length)return toast('未识别到有效账号',0);
|
| 623 |
try{
|
| 624 |
+
const d=await api('/admin/accounts/import',{method:'POST',json:true,body:JSON.stringify({accounts:accts}),headers:{'admin-key':getAdminKey()}});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
document.getElementById('inp').value='';
|
| 626 |
document.getElementById('msg').textContent='已导入 '+d.imported+' 个';
|
| 627 |
toast('成功导入 '+d.imported+' 个',1);
|
|
|
|
| 631 |
|
| 632 |
async function loadAll(){await loadStats();await loadAccounts()}
|
| 633 |
|
|
|
|
| 634 |
document.getElementById('prompt').addEventListener('keydown',e=>{if(e.ctrlKey&&e.key==='Enter')sendMsg()});
|
| 635 |
|
| 636 |
+
let _pollTimer=null,_logTimer=null;
|
|
|
|
| 637 |
function initApp(){
|
| 638 |
+
loadAll();loadSettings();loadLogs();
|
|
|
|
|
|
|
| 639 |
if(!_pollTimer)_pollTimer=setInterval(loadAll,12000);
|
| 640 |
if(!_logTimer)_logTimer=setInterval(loadLogs,3000);
|
| 641 |
}
|
|
|
|
| 648 |
document.getElementById('setMaxBrowsers').value=d.max_active_browsers||3;
|
| 649 |
document.getElementById('setLogFile').checked=d.log_file_enabled||false;
|
| 650 |
document.getElementById('setLogMaxMb').value=d.log_file_max_mb||10;
|
|
|
|
| 651 |
const lvl={10:'DEBUG',20:'INFO',30:'WARNING',40:'ERROR'}[d.log_level]||'INFO';
|
| 652 |
document.querySelectorAll('#levelBtns .btn').forEach(b=>b.classList.toggle('active',b.textContent===lvl||(b.textContent==='WARN'&&lvl==='WARNING')));
|
| 653 |
}catch(e){}
|
|
|
|
| 661 |
const maxBrowsers=parseInt(document.getElementById('setMaxBrowsers').value)||3;
|
| 662 |
if(!keys.length){toast('请至少填写一个 API Key',0);return}
|
| 663 |
try{
|
| 664 |
+
await api('/admin/settings',{method:'POST',json:true,body:JSON.stringify({api_keys:keys,admin_key:ak||undefined,max_active_browsers:maxBrowsers,log_file_enabled:logFile,log_file_max_mb:logMax}),headers:{'admin-key':getAdminKey()}});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
if(ak&&ak!==_adminKey){_adminKey=ak;LS.set('adminKey',ak)}
|
| 666 |
document.getElementById('setMsg').textContent='已保存';
|
| 667 |
toast('设置已保存',1);
|
| 668 |
}catch(e){toast(e.message,0)}
|
| 669 |
}
|
| 670 |
|
|
|
|
| 671 |
async function loadLogs(){
|
| 672 |
try{
|
| 673 |
const d=await api('/admin/logs?n=200',{headers:{'admin-key':getAdminKey()}});
|
|
|
|
| 699 |
}
|
| 700 |
</script>
|
| 701 |
</body>
|
| 702 |
+
</html>
|