豆包网页端服务转Api服务
This commit is contained in:
1 parent
e7e5c0f83d
commit
b37295d2d5
17 files changed
+1829
-1
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,193 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from playwright.async_api import (
|
||||
Browser,
|
||||
BrowserContext,
|
||||
Page,
|
||||
async_playwright,
|
||||
)
|
||||
|
||||
from .config import settings
|
||||
|
||||
STEALTH_JS = """
|
||||
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [1, 2, 3, 4, 5],
|
||||
});
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['zh-CN', 'zh', 'en'],
|
||||
});
|
||||
Object.defineProperty(navigator, 'platform', {
|
||||
get: () => 'Win32',
|
||||
});
|
||||
window.chrome = { runtime: {} };
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) =>
|
||||
parameters.name === 'notifications'
|
||||
? Promise.resolve({ state: Notification.permission })
|
||||
: originalQuery(parameters);
|
||||
"""
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
_instance: Optional["BrowserManager"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def __new__(cls) -> "BrowserManager":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not hasattr(self, "_initialized"):
|
||||
self._initialized = True
|
||||
self.playwright = None
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
self.page: Optional[Page] = None
|
||||
self._ready = False
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self._ready and self.page is not None and not self.page.is_closed()
|
||||
|
||||
async def start(self) -> Page:
|
||||
async with self._lock:
|
||||
if self.ready:
|
||||
return self.page
|
||||
|
||||
logger.info("Starting Playwright browser...")
|
||||
self.playwright = await async_playwright().start()
|
||||
|
||||
self.browser = await self.playwright.chromium.launch(
|
||||
headless=settings.browser_headless,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-features=IsolateOrigins,site-per-process",
|
||||
"--window-size=1280,720",
|
||||
],
|
||||
)
|
||||
|
||||
storage_state = (
|
||||
str(settings.state_file) if settings.state_file.exists() else None
|
||||
)
|
||||
|
||||
self.context = await self.browser.new_context(
|
||||
viewport={
|
||||
"width": settings.viewport_width,
|
||||
"height": settings.viewport_height,
|
||||
},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
storage_state=storage_state,
|
||||
)
|
||||
|
||||
await self.context.add_init_script(STEALTH_JS)
|
||||
self.context.set_default_timeout(settings.page_load_timeout)
|
||||
self.context.set_default_navigation_timeout(60000)
|
||||
|
||||
self.page = await self.context.new_page()
|
||||
|
||||
await self._navigate_to_doubao()
|
||||
self._ready = True
|
||||
logger.info("Browser started and navigated to Doubao")
|
||||
return self.page
|
||||
|
||||
async def _navigate_to_doubao(self):
|
||||
if not self.page:
|
||||
return
|
||||
try:
|
||||
await self.page.goto(settings.doubao_url, wait_until="domcontentloaded")
|
||||
await self.page.wait_for_timeout(3000)
|
||||
except Exception as e:
|
||||
logger.warning(f"Navigation warning: {e}")
|
||||
|
||||
async def save_state(self):
|
||||
if self.context:
|
||||
await self.context.storage_state(path=str(settings.state_file))
|
||||
cookies = await self.context.cookies()
|
||||
with open(settings.cookies_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cookies, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Saved browser state ({len(cookies)} cookies)")
|
||||
|
||||
async def check_login(self) -> bool:
|
||||
"""Check if the user is logged in by looking for login indicators."""
|
||||
if not self.ready:
|
||||
return False
|
||||
try:
|
||||
login_indicators = await self.page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const body = document.body ? document.body.innerText : '';
|
||||
const url = window.location.href;
|
||||
if (url.includes('login') || url.includes('passport')) return false;
|
||||
|
||||
const loginBtn = document.querySelector(
|
||||
'[class*="login"], [class*="sign-in"], [data-testid*="login"]'
|
||||
);
|
||||
if (loginBtn && loginBtn.offsetParent !== null) return false;
|
||||
|
||||
const inputArea = document.querySelector(
|
||||
'textarea, [contenteditable="true"], [role="textbox"]'
|
||||
);
|
||||
return !!inputArea;
|
||||
}
|
||||
"""
|
||||
)
|
||||
return login_indicators
|
||||
except Exception as e:
|
||||
logger.warning(f"Login check failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_page(self) -> Page:
|
||||
if not self.ready:
|
||||
await self.start()
|
||||
return self.page
|
||||
|
||||
async def reload(self):
|
||||
if self.page:
|
||||
await self.page.reload(wait_until="domcontentloaded")
|
||||
await self.page.wait_for_timeout(2000)
|
||||
|
||||
async def restart(self):
|
||||
logger.info("Restarting browser session...")
|
||||
await self.stop()
|
||||
await asyncio.sleep(1)
|
||||
await self.start()
|
||||
|
||||
async def stop(self):
|
||||
self._ready = False
|
||||
try:
|
||||
if self.context:
|
||||
await self.save_state()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save state during stop: {e}")
|
||||
try:
|
||||
if self.browser:
|
||||
await self.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self.playwright:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.page = None
|
||||
self.context = None
|
||||
self.browser = None
|
||||
self.playwright = None
|
||||
logger.info("Browser stopped")
|
||||
|
||||
|
||||
browser_manager = BrowserManager()
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
api_key: str = "sk-your-api-key-here"
|
||||
api_port: int = 8000
|
||||
|
||||
browser_headless: bool = False
|
||||
doubao_url: str = "https://www.doubao.com/chat/"
|
||||
doubao_model: str = "doubao-pro"
|
||||
viewport_width: int = 1280
|
||||
viewport_height: int = 720
|
||||
|
||||
data_dir: Path = Path("/app/data")
|
||||
cookies_file: Path = Path("/app/data/cookies.json")
|
||||
state_file: Path = Path("/app/data/browser_state.json")
|
||||
|
||||
response_timeout: int = 120
|
||||
page_load_timeout: int = 30000
|
||||
typing_delay_min: int = 30
|
||||
typing_delay_max: int = 80
|
||||
inter_message_delay: float = 0.5
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
vnc_port: int = 5900
|
||||
novnc_port: int = 6080
|
||||
vnc_view_only: bool = False
|
||||
screen_width: int = 1280
|
||||
screen_height: int = 720
|
||||
screen_depth: int = 24
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from typing import AsyncGenerator, List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
from playwright.async_api import Locator, Page, TimeoutError as PWTimeout
|
||||
|
||||
from .config import settings
|
||||
from .models import ChatMessage
|
||||
|
||||
FIND_INPUT_JS = """
|
||||
() => {
|
||||
const selectors = [
|
||||
'.tiptap.ProseMirror[contenteditable="true"]',
|
||||
'div[contenteditable="true"]',
|
||||
'textarea[placeholder*="发"]',
|
||||
'textarea[placeholder*="输入"]',
|
||||
'textarea[placeholder*="问"]',
|
||||
'textarea',
|
||||
'[role="textbox"]',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.offsetParent !== null) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) return sel;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
"""
|
||||
|
||||
FIND_SEND_BTN_JS = """
|
||||
() => {
|
||||
const selectors = [
|
||||
'button[aria-label*="发送"]',
|
||||
'button[aria-label*="Send"]',
|
||||
'button[type="submit"]',
|
||||
'button[class*="send"]',
|
||||
'button[class*="submit"]',
|
||||
'[data-testid*="send"]',
|
||||
'button[class*="Send"]',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.offsetParent !== null) return sel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
"""
|
||||
|
||||
GET_RESPONSE_TEXT_JS = """
|
||||
() => {
|
||||
const messageList = document.querySelector('[class*="message-list"]');
|
||||
if (messageList) {
|
||||
const msgs = Array.from(messageList.children).filter(el => {
|
||||
const cls = el.className || '';
|
||||
return el.offsetParent !== null
|
||||
&& !cls.includes('action-bar')
|
||||
&& !cls.includes('suggest-')
|
||||
&& el.innerText.trim();
|
||||
});
|
||||
if (msgs.length > 0) {
|
||||
return msgs[msgs.length - 1].innerText.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
"""
|
||||
|
||||
IS_GENERATING_JS = """
|
||||
() => {
|
||||
const stopSelectors = [
|
||||
'[class*="stop-generate"]',
|
||||
'[class*="stop_generate"]',
|
||||
'[aria-label*="停止"]',
|
||||
'[aria-label*="Stop"]',
|
||||
'[data-testid*="stop"]',
|
||||
'button[disabled][class*="send"]',
|
||||
];
|
||||
for (const sel of stopSelectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.offsetParent !== null) return true;
|
||||
}
|
||||
const spinnerSelectors = [
|
||||
'[class*="typing-indicator"]',
|
||||
'[class*="thinking"]',
|
||||
'[class*="loading-dot"]',
|
||||
'svg[class*="loading"]',
|
||||
'[class*="generating"]',
|
||||
];
|
||||
for (const sel of spinnerSelectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.offsetParent !== null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
"""
|
||||
|
||||
NEW_CHAT_BTN_JS = """
|
||||
() => {
|
||||
const selectors = [
|
||||
'button[aria-label*="新对话"]',
|
||||
'button[aria-label*="New"]',
|
||||
'a[aria-label*="新对话"]',
|
||||
'a[aria-label*="New"]',
|
||||
'[class*="new-chat"]',
|
||||
'[class*="new_chat"]',
|
||||
'[data-testid*="new-chat"]',
|
||||
'[data-testid*="new_chat"]',
|
||||
'a[href*="/chat"][class*="new"]',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.offsetParent !== null) return sel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
"""
|
||||
|
||||
INSPECT_DOM_JS = """
|
||||
() => {
|
||||
const result = {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
textareas: [],
|
||||
contentEditables: [],
|
||||
buttons: [],
|
||||
messageContainers: [],
|
||||
};
|
||||
document.querySelectorAll('textarea').forEach(el => {
|
||||
result.textareas.push({
|
||||
placeholder: el.placeholder,
|
||||
className: el.className.slice(0, 80),
|
||||
visible: el.offsetParent !== null,
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[contenteditable="true"]').forEach(el => {
|
||||
result.contentEditables.push({
|
||||
className: el.className.slice(0, 80),
|
||||
visible: el.offsetParent !== null,
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('button').forEach(el => {
|
||||
if (el.offsetParent !== null) {
|
||||
result.buttons.push({
|
||||
text: el.innerText.slice(0, 40),
|
||||
ariaLabel: el.getAttribute('aria-label'),
|
||||
className: el.className.slice(0, 80),
|
||||
});
|
||||
}
|
||||
});
|
||||
const msgPatterns = ['message', 'chat-item', 'conversation', 'receive', 'bot', 'ai-msg'];
|
||||
msgPatterns.forEach(pattern => {
|
||||
document.querySelectorAll(`[class*="${pattern}"]`).forEach(el => {
|
||||
if (el.offsetParent !== null && result.messageContainers.length < 20) {
|
||||
result.messageContainers.push({
|
||||
pattern: pattern,
|
||||
className: el.className.slice(0, 80),
|
||||
text: el.innerText.slice(0, 100),
|
||||
childCount: el.children.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class DoubaoChat:
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
|
||||
async def start_new_chat(self) -> bool:
|
||||
"""Try to start a new chat conversation."""
|
||||
try:
|
||||
selector = await self.page.evaluate(NEW_CHAT_BTN_JS)
|
||||
if selector:
|
||||
btn = self.page.locator(selector).first
|
||||
await btn.click()
|
||||
await self.page.wait_for_timeout(2000)
|
||||
logger.info("Started new chat via button")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"New chat button not found: {e}")
|
||||
|
||||
try:
|
||||
await self.page.goto(settings.doubao_url, wait_until="domcontentloaded")
|
||||
await self.page.wait_for_timeout(3000)
|
||||
logger.info("Navigated to fresh chat URL")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to navigate to chat URL: {e}")
|
||||
return False
|
||||
|
||||
async def _find_input(self) -> Optional[Tuple[Locator, str]]:
|
||||
"""Find the input element, returns (locator, selector)."""
|
||||
selector = await self.page.evaluate(FIND_INPUT_JS)
|
||||
if not selector:
|
||||
return None
|
||||
loc = self.page.locator(selector).first
|
||||
try:
|
||||
await loc.wait_for(state="visible", timeout=5000)
|
||||
return loc, selector
|
||||
except PWTimeout:
|
||||
return None
|
||||
|
||||
async def _find_send_button(self) -> Optional[Locator]:
|
||||
"""Find the send button."""
|
||||
selector = await self.page.evaluate(FIND_SEND_BTN_JS)
|
||||
if not selector:
|
||||
return None
|
||||
loc = self.page.locator(selector).first
|
||||
try:
|
||||
await loc.wait_for(state="visible", timeout=3000)
|
||||
return loc
|
||||
except PWTimeout:
|
||||
return None
|
||||
|
||||
async def _type_message(self, input_loc: Locator, text: str):
|
||||
"""Type a message with human-like delays."""
|
||||
await input_loc.click()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
if len(text) > 1000:
|
||||
await self.page.keyboard.insert_text(text)
|
||||
await asyncio.sleep(0.3)
|
||||
else:
|
||||
delay = random.randint(settings.typing_delay_min, settings.typing_delay_max)
|
||||
await self.page.keyboard.type(text, delay=delay)
|
||||
|
||||
await asyncio.sleep(settings.inter_message_delay)
|
||||
|
||||
async def _send_via_enter(self, input_loc: Locator) -> bool:
|
||||
"""Try sending by pressing Enter."""
|
||||
await input_loc.press("Enter")
|
||||
await asyncio.sleep(0.5)
|
||||
return True
|
||||
|
||||
async def _send_via_button(self) -> bool:
|
||||
"""Try sending by clicking the send button."""
|
||||
btn = await self._find_send_button()
|
||||
if btn:
|
||||
await btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _is_input_empty(self, input_loc: Locator, selector: str) -> bool:
|
||||
"""Check if the input element is empty (message was sent)."""
|
||||
try:
|
||||
if "textarea" in selector:
|
||||
val = await input_loc.input_value()
|
||||
else:
|
||||
val = await input_loc.inner_text()
|
||||
return not val.strip()
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
async def send_message(self, text: str) -> str:
|
||||
"""Send a message to the Doubao chat.
|
||||
Returns the previous response text (for change detection).
|
||||
"""
|
||||
result = await self._find_input()
|
||||
if not result:
|
||||
logger.error("Input element not found on page")
|
||||
raise RuntimeError("无法找到输入框,请通过VNC检查页面状态")
|
||||
|
||||
input_loc, selector = result
|
||||
|
||||
old_response = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
|
||||
|
||||
await self._type_message(input_loc, text)
|
||||
|
||||
sent = await self._send_via_enter(input_loc)
|
||||
if sent:
|
||||
await asyncio.sleep(0.8)
|
||||
if not await self._is_input_empty(input_loc, selector):
|
||||
logger.info("Enter didn't send, trying send button")
|
||||
sent = await self._send_via_button()
|
||||
|
||||
if not sent:
|
||||
logger.error("Failed to send message")
|
||||
raise RuntimeError("消息发送失败,请通过VNC检查页面状态")
|
||||
|
||||
return old_response
|
||||
|
||||
async def _wait_for_response_start(
|
||||
self, old_text: str, timeout: int = 30
|
||||
) -> Optional[str]:
|
||||
"""Wait for the response to start appearing.
|
||||
|
||||
Uses two-phase detection:
|
||||
Phase 1: wait for user message to appear (text changes from old_text)
|
||||
Phase 2: wait for AI response to start (text changes from baseline)
|
||||
|
||||
Returns baseline text (after user message, before AI response),
|
||||
or None on timeout.
|
||||
"""
|
||||
start = time.time()
|
||||
phase = 1
|
||||
baseline = None
|
||||
|
||||
while time.time() - start < timeout:
|
||||
current = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
|
||||
|
||||
if phase == 1:
|
||||
if current and current != old_text:
|
||||
baseline = current
|
||||
phase = 2
|
||||
await asyncio.sleep(0.5)
|
||||
elif phase == 2:
|
||||
if current != baseline:
|
||||
logger.debug("Response started")
|
||||
return baseline
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
if baseline is not None:
|
||||
logger.warning("Response may have started during baseline capture")
|
||||
return baseline
|
||||
|
||||
logger.warning("Response did not start within timeout")
|
||||
return None
|
||||
|
||||
async def stream_response(self, old_text: str = "") -> AsyncGenerator[str, None]:
|
||||
"""Yield response text chunks as they appear.
|
||||
|
||||
Args:
|
||||
old_text: Previous response text from before the message was sent.
|
||||
Used to detect when the new response starts.
|
||||
"""
|
||||
baseline = await self._wait_for_response_start(old_text)
|
||||
if baseline is None:
|
||||
return
|
||||
|
||||
last_text = baseline
|
||||
stable_count = 0
|
||||
STABLE_THRESHOLD = 6
|
||||
POLL_INTERVAL = 0.15
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
current_text = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
|
||||
|
||||
if current_text and len(current_text) > len(last_text):
|
||||
new_chunk = current_text[len(last_text):]
|
||||
last_text = current_text
|
||||
stable_count = 0
|
||||
if new_chunk.strip():
|
||||
yield new_chunk
|
||||
|
||||
elif current_text and current_text == last_text:
|
||||
stable_count += 1
|
||||
if stable_count >= STABLE_THRESHOLD:
|
||||
is_gen = await self.page.evaluate(IS_GENERATING_JS)
|
||||
if not is_gen:
|
||||
logger.debug("Response complete (stable + not generating)")
|
||||
break
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > settings.response_timeout:
|
||||
logger.warning(f"Response timeout after {elapsed:.1f}s")
|
||||
break
|
||||
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
|
||||
async def get_response_full(self, old_text: str = "") -> str:
|
||||
"""Get the complete response text (non-streaming)."""
|
||||
chunks = []
|
||||
async for chunk in self.stream_response(old_text):
|
||||
chunks.append(chunk)
|
||||
return "".join(chunks)
|
||||
|
||||
async def inspect(self) -> dict:
|
||||
"""Return DOM structure info for debugging."""
|
||||
return await self.page.evaluate(INSPECT_DOM_JS)
|
||||
|
||||
@staticmethod
|
||||
def messages_to_prompt(messages: List[ChatMessage]) -> str:
|
||||
"""Convert OpenAI messages array to a single text prompt."""
|
||||
if len(messages) == 1 and messages[0].role == "user":
|
||||
return messages[0].content
|
||||
|
||||
parts: List[str] = []
|
||||
for msg in messages:
|
||||
if msg.role == "system":
|
||||
parts.append(f"【系统设定】\n{msg.content}")
|
||||
elif msg.role == "user":
|
||||
parts.append(f"【用户】\n{msg.content}")
|
||||
elif msg.role == "assistant":
|
||||
parts.append(f"【AI回复】\n{msg.content}")
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
from .browser_manager import browser_manager
|
||||
from .config import settings
|
||||
from .doubao import DoubaoChat
|
||||
from .models import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
Choice,
|
||||
ChoiceMessage,
|
||||
ChunkChoice,
|
||||
ChunkDelta,
|
||||
ModelInfo,
|
||||
ModelListResponse,
|
||||
SUPPORTED_MODELS,
|
||||
)
|
||||
|
||||
DEFAULT_API_KEY = "sk-your-api-key-here"
|
||||
|
||||
VNC_MODE_FILE = Path("/app/data/.vnc_mode")
|
||||
|
||||
|
||||
def _read_vnc_mode() -> str:
|
||||
if VNC_MODE_FILE.exists():
|
||||
mode = VNC_MODE_FILE.read_text().strip()
|
||||
return "viewonly" if mode in ("true", "viewonly") else "interactive"
|
||||
return "viewonly" if settings.vnc_view_only else "interactive"
|
||||
|
||||
|
||||
def _write_vnc_mode(mode: str):
|
||||
VNC_MODE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
VNC_MODE_FILE.write_text(mode)
|
||||
|
||||
|
||||
def _restart_x11vnc():
|
||||
try:
|
||||
subprocess.run(
|
||||
["supervisorctl", "restart", "x11vnc"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"supervisorctl restart failed: {e}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("DouBao2Api starting up...")
|
||||
try:
|
||||
await browser_manager.start()
|
||||
logged_in = await browser_manager.check_login()
|
||||
if not logged_in:
|
||||
logger.warning(
|
||||
"Not logged in! Open noVNC at "
|
||||
f"http://localhost:{settings.novnc_port}/vnc.html to login."
|
||||
)
|
||||
else:
|
||||
logger.info("Login detected, ready to serve requests.")
|
||||
except Exception as e:
|
||||
logger.error(f"Browser startup failed: {e}")
|
||||
|
||||
yield
|
||||
|
||||
logger.info("DouBao2Api shutting down...")
|
||||
await browser_manager.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="DouBao2Api", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
_request_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def verify_api_key(authorization: Optional[str] = Header(None)):
|
||||
if not settings.api_key or settings.api_key == DEFAULT_API_KEY:
|
||||
return
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing Authorization header. Expected: Bearer <key>")
|
||||
key = authorization[7:]
|
||||
if key != settings.api_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": "DouBao2Api",
|
||||
"version": "1.0.0",
|
||||
"browser_ready": browser_manager.ready,
|
||||
"endpoints": {
|
||||
"chat_completions": "POST /v1/chat/completions",
|
||||
"models": "GET /v1/models",
|
||||
"new_chat": "POST /chat/new",
|
||||
"login_status": "GET /login/status",
|
||||
"save_state": "POST /login/save",
|
||||
"inspect": "GET /inspect",
|
||||
"vnc_mode": "GET/POST /vnc/mode",
|
||||
"restart_browser": "POST /browser/restart",
|
||||
},
|
||||
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
|
||||
"vnc_mode": _read_vnc_mode(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "browser_ready": browser_manager.ready}
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def list_models():
|
||||
return ModelListResponse(data=[ModelInfo(**m) for m in SUPPORTED_MODELS])
|
||||
|
||||
|
||||
@app.get("/login/status")
|
||||
async def login_status():
|
||||
logged_in = await browser_manager.check_login()
|
||||
return {
|
||||
"logged_in": logged_in,
|
||||
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
|
||||
"instructions": (
|
||||
"Open the VNC URL in your browser, login to Doubao manually, "
|
||||
"then call POST /login/save to persist the session."
|
||||
)
|
||||
if not logged_in
|
||||
else "Session is active.",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/login/save")
|
||||
async def save_state():
|
||||
await browser_manager.save_state()
|
||||
return {"status": "saved", "message": "Browser state persisted to disk."}
|
||||
|
||||
|
||||
@app.get("/inspect")
|
||||
async def inspect_dom():
|
||||
page = await browser_manager.get_page()
|
||||
doubao = DoubaoChat(page)
|
||||
result = await doubao.inspect()
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/chat/new")
|
||||
async def new_chat(_: bool = Depends(verify_api_key)):
|
||||
page = await browser_manager.get_page()
|
||||
doubao = DoubaoChat(page)
|
||||
ok = await doubao.start_new_chat()
|
||||
return {"status": "new_chat_started" if ok else "failed", "ok": ok}
|
||||
|
||||
|
||||
@app.get("/vnc/mode")
|
||||
async def get_vnc_mode():
|
||||
mode = _read_vnc_mode()
|
||||
return {
|
||||
"mode": mode,
|
||||
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
|
||||
"hint": "POST /vnc/mode with {'mode':'interactive'} to enable mouse/keyboard" if mode == "viewonly" else "VNC is interactive",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/vnc/mode")
|
||||
async def set_vnc_mode(request: Request, _: bool = Depends(verify_api_key)):
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "").lower()
|
||||
if mode not in ("viewonly", "interactive"):
|
||||
raise HTTPException(status_code=400, detail="mode must be 'viewonly' or 'interactive'")
|
||||
|
||||
_write_vnc_mode(mode)
|
||||
_restart_x11vnc()
|
||||
logger.info(f"VNC mode set to {mode}, x11vnc restarted")
|
||||
return {"status": "ok", "mode": mode}
|
||||
|
||||
|
||||
@app.post("/browser/restart")
|
||||
async def restart_browser(_: bool = Depends(verify_api_key)):
|
||||
await browser_manager.restart()
|
||||
return {"status": "restarted", "browser_ready": browser_manager.ready}
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(
|
||||
request: ChatCompletionRequest,
|
||||
_=Depends(verify_api_key),
|
||||
):
|
||||
async with _request_lock:
|
||||
if not browser_manager.ready:
|
||||
try:
|
||||
await browser_manager.start()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Browser not ready: {e}. Please retry shortly.",
|
||||
)
|
||||
|
||||
if not await browser_manager.check_login():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=(
|
||||
"Not logged in to Doubao. Open "
|
||||
f"http://localhost:{settings.novnc_port}/vnc.html "
|
||||
"to login manually, then call POST /login/save."
|
||||
),
|
||||
)
|
||||
|
||||
page = await browser_manager.get_page()
|
||||
doubao = DoubaoChat(page)
|
||||
|
||||
if request.new_chat:
|
||||
await doubao.start_new_chat()
|
||||
logger.info("Started new chat session")
|
||||
|
||||
prompt = DoubaoChat.messages_to_prompt(request.messages)
|
||||
logger.info(f"Sending prompt ({len(prompt)} chars), stream={request.stream}")
|
||||
|
||||
try:
|
||||
old_text = await doubao.send_message(prompt)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected send error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {e}")
|
||||
|
||||
if request.stream:
|
||||
return StreamingResponse(
|
||||
_stream_sse(doubao, request.model, old_text),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
full_response = await doubao.get_response_full(old_text)
|
||||
except Exception as e:
|
||||
logger.error(f"Response capture failed: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"Failed to get response: {e}")
|
||||
|
||||
await browser_manager.save_state()
|
||||
|
||||
return ChatCompletionResponse(
|
||||
model=request.model,
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChoiceMessage(role="assistant", content=full_response),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _stream_sse(
|
||||
doubao: DoubaoChat,
|
||||
model: str,
|
||||
old_text: str,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
chat_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
||||
created = int(time.time())
|
||||
|
||||
first_chunk = ChatCompletionChunk(
|
||||
id=chat_id,
|
||||
created=created,
|
||||
model=model,
|
||||
choices=[ChunkChoice(index=0, delta=ChunkDelta(role="assistant"))],
|
||||
)
|
||||
yield f"data: {first_chunk.model_dump_json()}\n\n"
|
||||
|
||||
try:
|
||||
async for chunk_text in doubao.stream_response(old_text):
|
||||
if not chunk_text:
|
||||
continue
|
||||
chunk = ChatCompletionChunk(
|
||||
id=chat_id,
|
||||
created=created,
|
||||
model=model,
|
||||
choices=[
|
||||
ChunkChoice(index=0, delta=ChunkDelta(content=chunk_text))
|
||||
],
|
||||
)
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Streaming error: {e}")
|
||||
err_chunk = ChatCompletionChunk(
|
||||
id=chat_id,
|
||||
created=created,
|
||||
model=model,
|
||||
choices=[
|
||||
ChunkChoice(
|
||||
index=0,
|
||||
delta=ChunkDelta(content=f"\n[Error: {e}]"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
yield f"data: {err_chunk.model_dump_json()}\n\n"
|
||||
else:
|
||||
final_chunk = ChatCompletionChunk(
|
||||
id=chat_id,
|
||||
created=created,
|
||||
model=model,
|
||||
choices=[
|
||||
ChunkChoice(index=0, delta=ChunkDelta(), finish_reason="stop")
|
||||
],
|
||||
)
|
||||
yield f"data: {final_chunk.model_dump_json()}\n\n"
|
||||
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
try:
|
||||
await browser_manager.save_state()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=settings.api_port,
|
||||
log_level=settings.log_level.lower(),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: Literal["system", "user", "assistant", "tool"]
|
||||
content: str
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
model: str = "doubao-pro"
|
||||
messages: List[ChatMessage]
|
||||
temperature: Optional[float] = 0.7
|
||||
max_tokens: Optional[int] = None
|
||||
stream: Optional[bool] = False
|
||||
top_p: Optional[float] = 1.0
|
||||
frequency_penalty: Optional[float] = 0.0
|
||||
presence_penalty: Optional[float] = 0.0
|
||||
stop: Optional[Any] = None
|
||||
user: Optional[str] = None
|
||||
new_chat: Optional[bool] = False
|
||||
|
||||
|
||||
class CompletionUsage(BaseModel):
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class ChoiceMessage(ChatMessage):
|
||||
pass
|
||||
|
||||
|
||||
class Choice(BaseModel):
|
||||
index: int = 0
|
||||
message: ChoiceMessage
|
||||
finish_reason: str = "stop"
|
||||
|
||||
|
||||
class ChunkDelta(BaseModel):
|
||||
role: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class ChunkChoice(BaseModel):
|
||||
index: int = 0
|
||||
delta: ChunkDelta = ChunkDelta()
|
||||
finish_reason: Optional[str] = None
|
||||
|
||||
|
||||
class ChatCompletionResponse(BaseModel):
|
||||
id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:24]}")
|
||||
object: str = "chat.completion"
|
||||
created: int = Field(default_factory=lambda: int(time.time()))
|
||||
model: str = "doubao-pro"
|
||||
choices: List[Choice]
|
||||
usage: CompletionUsage = CompletionUsage()
|
||||
|
||||
|
||||
class ChatCompletionChunk(BaseModel):
|
||||
id: str
|
||||
object: str = "chat.completion.chunk"
|
||||
created: int = Field(default_factory=lambda: int(time.time()))
|
||||
model: str = "doubao-pro"
|
||||
choices: List[ChunkChoice]
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
id: str
|
||||
object: str = "model"
|
||||
created: int = Field(default_factory=lambda: int(time.time()))
|
||||
owned_by: str = "doubao"
|
||||
|
||||
|
||||
class ModelListResponse(BaseModel):
|
||||
object: str = "list"
|
||||
data: List[ModelInfo]
|
||||
|
||||
|
||||
SUPPORTED_MODELS = [
|
||||
{"id": "doubao-pro", "owned_by": "doubao"},
|
||||
{"id": "doubao-lite", "owned_by": "doubao"},
|
||||
{"id": "doubao-pro-32k", "owned_by": "doubao"},
|
||||
]
|
||||
Reference in new issue
Block a user