nacho commited on
Commit
b454740
·
1 Parent(s): 98d0688

refactor: clean dead code, extract shared stream logic, responsive UI rewrite

Browse files
Files changed (5) hide show
  1. .env.example +1 -1
  2. Dockerfile +1 -1
  3. deepseek_browser.py +12 -40
  4. main.py +146 -223
  5. 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=50
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=50
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=60000)
78
  # Wait for page ready instead of fixed sleep
79
  try:
80
- await self.page.wait_for_selector('textarea', timeout=15000)
81
  except Exception:
82
- await asyncio.sleep(2)
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=10000)
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=30000)
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.5)
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.5)
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.5)
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(3)
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=30000)
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 = 50, max_ms: int = 150):
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", "50")),
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
- stream_with_cleanup(),
 
 
 
 
 
 
 
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
- # 异步触发新导入账号的预登录 (只预热最多 max_active_browsers 个,防止卡死)
394
  async def prelogin_new_accounts():
395
- for i, account in enumerate(new_accounts):
396
- if i >= manager.max_active_browsers:
397
- break
398
- try:
399
- logger.info("Pre-logging in newly imported account %s...", account.email)
400
- await manager.get_or_create_browser_with_retry(
401
- account, headless=config.browser.headless
402
- )
403
- logger.info("Pre-login OK: %s", account.email)
404
- except Exception as e:
405
- logger.error("Pre-login FAILED for %s: %s", account.email, e)
 
 
 
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
- async def stream_with_cleanup():
508
- chunk_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
509
- try:
510
- is_tool_call = False
511
- not_tool_call = False
512
- content_buffer = ""
513
-
514
- async for chunk_data in browser.stream_message(prompt, timeout=120, model=model):
515
- chunk_type = chunk_data.get("type", "content")
516
- chunk_text = chunk_data.get("chunk", "")
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
- """Pre-login a limited number of accounts at startup for instant readiness."""
783
- count = 0
784
- for email, account in manager.accounts.items():
785
- if count >= manager.max_active_browsers:
786
- break
787
- try:
788
- logger.info("Pre-logging in %s...", email)
789
- await manager.get_or_create_browser_with_retry(
790
- account, headless=config.browser.headless
791
- )
792
- logger.info("Pre-login OK: %s (muted=%s)", email, account.is_muted)
793
- count += 1
794
- except Exception as e:
795
- logger.error("Pre-login FAILED for %s: %s", email, e)
 
 
 
 
 
 
 
 
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:#0a0e17;--surface:rgba(15,20,35,.72);--surface-solid:#0f1423;
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);
15
  --green:hsl(142,71%,45%);--red:hsl(0,84%,60%);--amber:hsl(38,92%,50%);
16
- --radius:16px;--radius-sm:10px;--radius-xs:8px;
17
  --font-ui:'Inter',system-ui,sans-serif;--font-mono:'JetBrains Mono',monospace;
 
18
  }
19
  html.light-mode{
20
- --bg:#f8fafc;--surface:rgba(255,255,255,.85);--surface-solid:#ffffff;
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%,.15);
24
- }
25
- html.light-mode body::before{background:radial-gradient(ellipse 80% 50% at 50% -20%,hsla(217,92%,55%,.08),transparent 70%)}
26
- html.light-mode .topbar{background:rgba(255,255,255,.75)}
27
- html.light-mode .log-viewer{background:rgba(0,0,0,.03);color:#334155}
28
- html.light-mode .btn{background:#ffffff;border-color:var(--border)}
29
- html.light-mode .btn:hover{background:#f1f5f9}
30
- html.light-mode .btn-primary, html.light-mode .btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
31
- html.light-mode input, html.light-mode textarea, html.light-mode select{background:#ffffff;box-shadow:inset 0 1px 2px rgba(0,0,0,.02)}
32
- html.light-mode input:focus, html.light-mode textarea:focus, html.light-mode select:focus{background:#ffffff}
33
- html.light-mode th{background:rgba(0,0,0,.04);color:var(--text-dim)}
34
- html.light-mode .toast{background:var(--surface-solid);color:var(--text);border:1px solid var(--border);box-shadow:0 10px 25px rgba(0,0,0,.1)}
35
- html.light-mode .login-box{background:rgba(255,255,255,.9)}
36
- *{box-sizing:border-box;margin:0;padding:0}
37
- body{font-family:var(--font-ui);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;min-height:100vh;overflow-x:hidden}
38
- body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 80% 50% at 50% -20%,hsla(217,92%,60%,.06),transparent 70%);pointer-events:none;z-index:0}
39
-
40
- /* ── scrollbar ── */
41
- ::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
42
-
43
- /* ── topbar ── */
44
- .topbar{position:sticky;top:0;z-index:50;backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);background:rgba(10,14,23,.8);border-bottom:1px solid var(--border);padding:0 24px;height:56px;display:flex;align-items:center;gap:12px}
45
- .topbar .logo{font-weight:800;font-size:15px;background:linear-gradient(135deg,var(--accent),hsl(280,80%,65%));-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:1px}
46
- .topbar .badge-mode{font-size:11px;color:var(--text-dim);border:1px solid var(--border);padding:3px 10px;border-radius:20px;font-weight:500}
47
- .topbar .stats{display:flex;gap:20px;margin-left:auto;font-size:12px;font-weight:500}
48
- .topbar .stats .si{display:flex;align-items:center;gap:5px;color:var(--text-dim)}
49
- .topbar .stats .si b{color:var(--text);font-weight:700}
50
- .topbar .stats .si .dot{width:6px;height:6px;border-radius:50%;display:inline-block}
51
- .topbar .poll-indicator{width:8px;height:8px;border-radius:50%;background:var(--accent);opacity:0;transition:opacity .3s;margin-left:8px}
52
- .topbar .poll-indicator.active{opacity:1;animation:pulse .6s ease-out}
53
- @keyframes pulse{0%{transform:scale(1);opacity:1}100%{transform:scale(2);opacity:0}}
54
- @media(max-width:768px){.topbar .stats{display:none}}
55
-
56
- /* ── layout ── */
57
- .container{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:24px 20px}
58
-
59
- /* ── dashboard cards ── */
60
- .dashboard{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:24px}
61
- .dash-card{background:var(--surface);backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:var(--radius-sm);padding:16px;text-align:center;animation:fadeUp .4s ease-out both}
62
- .dash-card:nth-child(2){animation-delay:.05s}.dash-card:nth-child(3){animation-delay:.1s}.dash-card:nth-child(4){animation-delay:.15s}.dash-card:nth-child(5){animation-delay:.2s}.dash-card:nth-child(6){animation-delay:.25s}
63
- .dash-card .label{font-size:11px;color:var(--text-dim);font-weight:600;text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
64
- .dash-card .value{font-size:28px;font-weight:800;font-family:var(--font-mono);color:var(--text)}
65
- .dash-card .value.green{color:var(--green)}.dash-card .value.red{color:var(--red)}.dash-card .value.amber{color:var(--amber)}.dash-card .value.accent{color:var(--accent)}
66
- @keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
67
- @media(max-width:768px){.dashboard{grid-template-columns:repeat(3,1fr);gap:8px}.dash-card{padding:12px}.dash-card .value{font-size:22px}}
68
- @media(max-width:480px){.dashboard{grid-template-columns:repeat(2,1fr)}}
69
-
70
- /* ── main grid ── */
71
- .grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;align-items:start}
72
- @media(max-width:900px){.grid{grid-template-columns:1fr;gap:16px}}
73
-
74
- /* ── card ── */
75
- .card{background:var(--surface);backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;animation:fadeUp .5s ease-out both}
76
- .card-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
77
- .card-header h2{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px}
78
- .card-header h2 .icon{font-size:16px}
79
- .card-header .hint{font-size:12px;color:var(--text-dim);font-family:var(--font-mono)}
80
- .card-body{padding:20px}
81
-
82
- /* ── forms ── */
83
- .form-group{margin-bottom:14px}
84
- .form-group label{display:block;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px}
85
- input[type=text],input[type=password],select,textarea{width:100%;background:rgba(0,0,0,.3);border:1px solid var(--border);border-radius:var(--radius-xs);padding:10px 14px;color:var(--text);font-family:var(--font-ui);font-size:13px;transition:border-color .2s,box-shadow .2s}
86
- input:focus,select:focus,textarea:focus{outline:none;border-color:var(--border-focus);box-shadow:0 0 0 3px var(--accent-glow)}
87
- textarea{font-family:var(--font-mono);min-height:80px;resize:vertical;font-size:12px}
88
- textarea::placeholder{color:var(--text-muted)}
89
- select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%234a5568'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}
90
- .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
91
- .row-between{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
92
- .check-label{font-size:12px;color:var(--text-dim);display:flex;align-items:center;gap:5px;cursor:pointer;user-select:none}
93
- .check-label input[type=checkbox]{accent-color:var(--accent)}
94
-
95
- /* ── buttons ── */
96
- .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:9px 18px;border:1px solid var(--border);border-radius:var(--radius-xs);background:transparent;color:var(--text);cursor:pointer;font-family:var(--font-ui);font-size:12px;font-weight:600;letter-spacing:.3px;white-space:nowrap;transition:all .15s ease}
97
- .btn:hover{border-color:var(--accent);color:var(--accent);transform:translateY(-1px)}
98
- .btn:active{transform:translateY(0)}
99
- .btn-primary{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:700}
100
- .btn-primary:hover{background:hsl(217,92%,55%);color:#fff;box-shadow:0 4px 16px hsla(217,92%,60%,.3)}
101
- .btn-sm{padding:6px 12px;font-size:11px;border-radius:6px}
102
- .btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important}
103
-
104
- /* ── response ── */
105
- .response-wrap{margin-top:14px;border:1px solid var(--border);border-radius:var(--radius-xs);overflow:hidden}
106
- .response-bar{display:flex;justify-content:space-between;align-items:center;padding:8px 14px;background:rgba(0,0,0,.2);border-bottom:1px solid var(--border);font-size:11px;color:var(--text-dim)}
107
- .response-bar .ok{color:var(--green)}.response-bar .err{color:var(--red)}
108
- #response{padding:14px;min-height:140px;max-height:420px;overflow-y:auto;font-family:var(--font-mono);font-size:12.5px;line-height:1.7;white-space:pre-wrap;color:var(--text)}
109
- #response:empty::after{content:'等待发送…';color:var(--text-muted)}
110
- #response .cursor{display:inline-block;width:2px;height:14px;background:var(--accent);animation:blink 1s step-end infinite;vertical-align:text-bottom;margin-left:1px}
111
- @keyframes blink{50%{opacity:0}}
112
-
113
- /* ── table ── */
114
- .tbl{width:100%;border-collapse:collapse;font-size:12px}
115
- .tbl thead{border-bottom:1px solid var(--border)}
116
- .tbl th{padding:10px 8px;text-align:left;color:var(--text-dim);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.6px;white-space:nowrap}
117
- .tbl td{padding:10px 8px;border-bottom:1px solid rgba(255,255,255,.03)}
118
- .tbl tr:hover td{background:rgba(96,165,250,.03)}
119
- .tbl .email{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:11px}
120
- @media(max-width:600px){.tbl th,.tbl td{font-size:11px;padding:8px 4px}.hide-m{display:none}}
121
-
122
- /* ── badge ── */
123
- .badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;font-size:10px;font-weight:600;border-radius:20px;letter-spacing:.3px;white-space:nowrap}
124
- .badge::before{content:'';width:5px;height:5px;border-radius:50%}
125
- .badge-on{color:var(--green);background:hsla(142,71%,45%,.1);border:1px solid hsla(142,71%,45%,.2)}.badge-on::before{background:var(--green)}
126
- .badge-off{color:var(--red);background:hsla(0,84%,60%,.08);border:1px solid hsla(0,84%,60%,.15)}.badge-off::before{background:var(--red)}
127
- .badge-idle{color:var(--text-dim);background:rgba(255,255,255,.03);border:1px solid var(--border)}.badge-idle::before{background:var(--text-dim)}
128
- .badge-warn{color:var(--amber);background:hsla(38,92%,50%,.08);border:1px solid hsla(38,92%,50%,.15)}.badge-warn::before{background:var(--amber)}
129
-
130
- /* ── toast ── */
131
- .toast{position:fixed;top:20px;right:20px;z-index:100;padding:12px 20px;font-size:12px;font-weight:600;border-radius:var(--radius-xs);animation:slideIn .3s ease-out;display:flex;align-items:center;gap:8px;backdrop-filter:blur(12px);border:1px solid}
132
- .toast-ok{background:hsla(142,71%,45%,.12);color:var(--green);border-color:hsla(142,71%,45%,.25)}
133
- .toast-err{background:hsla(0,84%,60%,.12);color:var(--red);border-color:hsla(0,84%,60%,.25)}
134
- @keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
135
-
136
- /* ── empty ── */
137
- .empty{color:var(--text-muted);padding:24px 0;text-align:center;font-size:12px}
138
- .key-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
139
- @media(max-width:600px){.key-row{grid-template-columns:1fr}}
140
-
141
- /* ── login overlay ── */
142
- .login-overlay{position:fixed;inset:0;z-index:200;background:var(--bg);display:flex;align-items:center;justify-content:center;transition:opacity .4s}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  .login-overlay.hidden{opacity:0;pointer-events:none}
144
- .login-box{background:var(--surface);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:var(--radius);padding:40px;width:100%;max-width:380px;text-align:center;animation:fadeUp .5s ease-out}
145
- .login-box .logo-big{font-size:24px;font-weight:800;background:linear-gradient(135deg,var(--accent),hsl(280,80%,65%));-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px}
146
- .login-box .sub{color:var(--text-dim);font-size:12px;margin-bottom:28px}
147
- .login-box .form-group{text-align:left;margin-bottom:16px}
 
 
 
 
 
 
 
 
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:10px;min-height:18px}
150
- .app-wrap{transition:filter .3s}
151
  .app-wrap.blur{filter:blur(8px);pointer-events:none}
152
 
153
- /* ── logs ── */
154
- .log-viewer{background:rgba(0,0,0,.4);border:1px solid var(--border);border-radius:var(--radius-xs);padding:10px 14px;max-height:350px;overflow-y:auto;font-family:var(--font-mono);font-size:11px;line-height:1.65;white-space:pre-wrap;word-break:break-all;color:var(--text-dim)}
155
- .log-viewer .log-warn{color:var(--amber)}.log-viewer .log-err{color:var(--red)}.log-viewer .log-info{color:var(--text)}.log-viewer .log-debug{color:var(--text-muted)}
156
- .level-btns{display:flex;gap:4px;flex-wrap:wrap}
157
- .level-btns .btn{padding:4px 10px;font-size:10px;font-family:var(--font-mono);border-radius:6px}
158
- .level-btns .btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
159
- .log-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
 
 
 
160
 
161
- /* ── thinking block ── */
162
- .thinking-block{margin-bottom:12px;border-left:3px solid var(--accent);padding-left:12px;color:var(--text-dim);font-size:13px}
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:0.8}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-mode">浏览器模式</span>
 
 
188
  <div class="stats" id="topStats">
189
- <span class="si"><span class="dot" style="background:var(--accent)"></span> 账号 <b>—</b></span>
190
- <span class="si"><span class="dot" style="background:var(--green)"></span> 在线 <b>—</b></span>
191
- <span class="si"><span class="dot" style="background:var(--amber)"></span> 使用中 <b>—</b></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
- <div class="container">
199
-
200
- <!-- Dashboard -->
201
- <div class="dashboard" id="dashboard">
202
- <div class="dash-card"><div class="label">总账号</div><div class="value accent" id="d-total">—</div></div>
203
- <div class="dash-card"><div class="label">在线</div><div class="value green" id="d-online">—</div></div>
204
- <div class="dash-card"><div class="label">可用</div><div class="value" id="d-avail">—</div></div>
205
- <div class="dash-card"><div class="label">使用中</div><div class="value amber" id="d-inuse">—</div></div>
206
- <div class="dash-card"><div class="label">禁言</div><div class="value red" id="d-muted">—</div></div>
207
- <div class="dash-card"><div class="label">排队</div><div class="value" id="d-queue">—</div></div>
 
 
208
  </div>
209
-
210
- <div class="grid">
211
-
212
- <!-- Left: API Test -->
213
- <div class="card" style="animation-delay:.1s">
214
- <div class="card-header">
215
- <h2><span class="icon">💬</span> 接口测试</h2>
216
- <span class="hint">/v1/chat/completions</span>
217
- </div>
218
- <div class="card-body">
219
- <div class="row" style="gap:10px;margin-bottom:14px">
220
- <div class="form-group" style="flex:1;margin-bottom:0">
221
- <select id="model">
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
- <!-- Right: Accounts -->
251
- <div style="display:flex;flex-direction:column;gap:20px">
252
- <div class="card" style="animation-delay:.2s">
253
- <div class="card-header">
254
- <h2><span class="icon">👥</span> 账号管理</h2>
255
- <button class="btn btn-sm" onclick="loadAccounts()">刷新</button>
256
- </div>
257
- <div class="card-body" style="padding:8px 12px">
258
- <table class="tbl">
259
- <thead><tr><th>邮箱</th><th class="hide-m">备注</th><th>登录</th><th>状态</th><th>禁言</th><th class="hide-m">错误</th><th>操作</th></tr></thead>
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="[&#10; {&quot;email&quot;:&quot;user@gmail.com&quot;,&quot;password&quot;:&quot;xxx&quot;},&#10; {&quot;email&quot;:&quot;user2@gmail.com&quot;,&quot;password&quot;:&quot;xxx&quot;,&quot;name&quot;:&quot;备注&quot;}&#10;]" 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&#10;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
- <!-- Logs Card (full width below grid) -->
318
- <div class="card" style="animation-delay:.5s;margin-top:20px">
319
- <div class="card-header">
320
- <h2><span class="icon">📋</span> 实时日志</h2>
321
- <div class="log-controls">
322
- <div class="level-btns" id="levelBtns">
323
- <button class="btn" onclick="setLevel('DEBUG')">DEBUG</button>
324
- <button class="btn active" onclick="setLevel('INFO')">INFO</button>
325
- <button class="btn" onclick="setLevel('WARNING')">WARN</button>
326
- <button class="btn" onclick="setLevel('ERROR')">ERROR</button>
327
- </div>
328
- <button class="btn btn-sm" onclick="clearLogs()">清空</button>
329
- <label class="check-label"><input type="checkbox" id="logAutoScroll" checked> 自动滚动</label>
330
- </div>
 
 
 
 
331
  </div>
332
- <div class="card-body" style="padding:12px">
333
- <div class="log-viewer" id="logViewer">加载中…</div>
 
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 = document.documentElement.classList.toggle('light-mode');
355
- LS.set('theme', isLight ? 'light' : 'dark');
356
  updateThemeBtn();
357
  }
358
  function updateThemeBtn(){
359
- const btn = document.getElementById('themeBtn');
360
- if(btn) btn.textContent = document.documentElement.classList.contains('light-mode') ? '🌙 夜间' : '☀️ 日间';
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='', fullThinking='';
452
- 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>';
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,'&lt;')}</div></details>`;
488
  }
489
- if(msg?.content){
490
- html+=`<div>${msg.content.replace(/</g,'&lt;')}</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> 账号 <b>${a.total}</b></span>
522
- <span class="si"><span class="dot" style="background:var(--green)"></span> 在线 <b>${a.logged_in}</b></span>
523
- <span class="si"><span class="dot" style="background:var(--amber)"></span> 使用中 <b>${a.in_use}</b></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-m">${a.name||'—'}</td>
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-m">${a.error_count>0?'<span class="badge badge-off">'+a.error_count+'</span>':'—'}</td>
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" class="empty">暂无账号</td></tr>';
544
  }catch(e){
545
- document.getElementById('tbl').innerHTML='<tr><td colspan="7" style="color:var(--red);text-align:center;padding:16px">'+e.message+'</td></tr>';
546
  }
547
  }
548
 
549
  async function doLoginAccount(email){
550
  try{
551
- toast('正在唤醒 '+email+'...', 1);
552
- await api('/admin/accounts/login',{
553
- method:'POST',json:true,
554
- body:JSON.stringify({email}),
555
- headers:{'admin-key':getAdminKey()}
556
- });
557
- toast('已触发登录任务', 1);
558
  loadAll();
559
- }catch(e){toast('触发失败: '+e.message, 0)}
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,'&lt;')}</div></details>`;
533
  }
534
+ if(msg?.content) html+=`<div>${msg.content.replace(/</g,'&lt;')}</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>