li / api /openui.py
xiaolongmr
init
85270f9
Raw History Blame Contribute Delete
9.16 kB
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel, Field
from api.support import require_identity
from services.config import config
from services.log_service import LoggedCall
from services.protocol import openai_v1_chat_complete
# OpenUI Lang 1.0 系统提示词由 Node CLI 预生成(依赖 @openuidev/lang-core),
# 见 web/src/lib/openui/library.ts + `npx @openuidev/cli@latest generate`。
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_SYSTEM_PROMPT_PATH = _PROJECT_ROOT / "openui" / "system-prompt.txt"
_system_prompt_cache: str | None = None
_system_prompt_mtime: float | None = None
def load_system_prompt() -> str:
"""读取预生成的 OpenUI 系统提示词(约 29KB)。按 mtime 缓存:文件更新后
下一次调用自动生效,无需重启后端。"""
global _system_prompt_cache, _system_prompt_mtime
try:
mtime = _SYSTEM_PROMPT_PATH.stat().st_mtime
except OSError as exc:
raise HTTPException(
status_code=500,
detail={"error": f"缺少 OpenUI 系统提示词文件({_SYSTEM_PROMPT_PATH}):{exc}"},
) from exc
if _system_prompt_cache is None or _system_prompt_mtime != mtime:
_system_prompt_cache = _SYSTEM_PROMPT_PATH.read_text(encoding="utf-8")
_system_prompt_mtime = mtime
return _system_prompt_cache
def _first_non_empty(*values: Any) -> str:
for value in values:
text = str(value or "").strip()
if text:
return text
return ""
def _external_target() -> tuple[str, str, str] | None:
"""
可选的外部通道(不配置就不存在)。
只有在明确配置了 OpenUI Cloud 或第三方 OpenAI 兼容接口时才启用;
默认情况下生成式 UI 走本站自己的账号池,不需要任何外部 Key。
"""
settings = config.get()
cloud_key = _first_non_empty(settings.get("openui_api_key"), os.environ.get("THESYS_API_KEY"))
if cloud_key:
base_url = _first_non_empty(
settings.get("openui_base_url"),
os.environ.get("THESYS_BASE_URL"),
"https://api.thesys.dev/v1/embed",
).rstrip("/")
model = _first_non_empty(settings.get("openui_model"), os.environ.get("THESYS_MODEL"), "openai/gpt-5")
return base_url, cloud_key, model
base_url = _first_non_empty(settings.get("openui_llm_base_url"), os.environ.get("OPENUI_LLM_BASE_URL")).rstrip("/")
api_key = _first_non_empty(settings.get("openui_llm_api_key"), os.environ.get("OPENUI_LLM_API_KEY"))
model = _first_non_empty(settings.get("openui_llm_model"), os.environ.get("OPENUI_LLM_MODEL"))
if base_url and api_key and model:
return base_url, api_key, model
return None
def _text_of(content: Any) -> str:
"""从 content(可能是字符串,也可能是多模态分片数组)里抽出纯文本,用于日志预览。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
return " ".join(parts)
return str(content or "")
def _build_openui_messages(messages: list[OpenUiMessage]) -> list[dict[str, Any]]:
"""把请求里的消息转成 OpenAI 兼容格式。
普通消息用纯文本;带图片附件(data:image/ 的 base64)的用户/助手消息,
转成多模态 content 数组(text + image_url),让模型能真正“看到”图片。
"""
out: list[dict[str, Any]] = []
for item in messages:
if item.role not in ("user", "assistant"):
continue
images = [url for url in (item.images or []) if isinstance(url, str) and url.startswith("data:image/")]
if images:
content: list[dict[str, Any]] = []
if item.content.strip():
content.append({"type": "text", "text": item.content})
for url in images:
content.append({"type": "image_url", "image_url": {"url": url}})
out.append({"role": item.role, "content": content})
elif item.content.strip():
out.append({"role": item.role, "content": item.content})
return out
def _completion_text(data: Any) -> str:
"""从 OpenAI 兼容响应里取出文本(content 可能是字符串,也可能是分片数组)。"""
if not isinstance(data, dict):
return ""
choices = data.get("choices") or []
if not choices:
return ""
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
return str(content or "")
class OpenUiMessage(BaseModel):
role: str
content: str = ""
images: list[str] = Field(default_factory=list)
class OpenUiChatRequest(BaseModel):
messages: list[OpenUiMessage] = Field(default_factory=list)
async def _call_local_pool(identity: dict[str, object], model: str, messages: list[dict[str, Any]]) -> str:
"""走本站账号池(逆向 ChatGPT 通道),与 /v1/chat/completions 同一条链路。"""
settings = config.get()
payload: dict[str, Any] = {
"model": model,
"stream": False,
"messages": [{"role": "system", "content": load_system_prompt()}, *messages],
}
max_tokens = _first_non_empty(settings.get("openui_max_tokens"), os.environ.get("OPENUI_MAX_TOKENS"))
payload["max_tokens"] = int(max_tokens) if str(max_tokens).isdigit() else 4096
last_user = next((_text_of(m["content"]) for m in reversed(messages) if m["role"] == "user"), "")
call = LoggedCall(identity, "/api/openui/chat", model, "生成式 UI", request_text=str(last_user)[:500])
result = await call.run(openai_v1_chat_complete.handle, payload, identity)
text = _completion_text(result)
if not text.strip():
raise HTTPException(status_code=502, detail={"error": "模型没有返回生成式 UI 内容"})
return text
async def _call_external(target: tuple[str, str, str], messages: list[dict[str, Any]]) -> str:
base_url, api_key, model = target
request_body: dict[str, Any] = {
"model": model,
"stream": False,
"messages": [{"role": "system", "content": load_system_prompt()}, *messages],
}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(180.0, connect=20.0)) as client:
response = await client.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=request_body,
)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail={"error": f"无法连接生成式 UI 模型服务:{exc}"}) from exc
if response.status_code >= 400:
raise HTTPException(
status_code=502,
detail={"error": f"生成式 UI 模型服务返回 {response.status_code}", "detail": response.text[:500]},
)
try:
data = response.json()
except ValueError as exc:
raise HTTPException(status_code=502, detail={"error": "生成式 UI 模型返回了非 JSON 响应"}) from exc
text = _completion_text(data)
if not text.strip():
raise HTTPException(status_code=502, detail={"error": "生成式 UI 模型未返回内容"})
return text
def create_router() -> APIRouter:
router = APIRouter()
@router.post("/api/openui/chat")
async def openui_chat(payload: OpenUiChatRequest, authorization: str | None = Header(default=None)):
identity = require_identity(authorization)
messages = _build_openui_messages(payload.messages)
if not messages:
raise HTTPException(status_code=400, detail={"error": "缺少可发送的消息"})
# 默认走本站账号池;只有显式配置了外部通道才改走外部。
external = _external_target()
if external:
source = await _call_external(external, messages)
return {"source": source, "channel": "external"}
settings = config.get()
# 号池侧的模型 id 与 OpenUI Cloud 的 `openui_model` 不是同一个命名空间,
# 因此本地通道单独用 openui_pool_model,避免配了云端模型名却拿去查号池。
model = _first_non_empty(
settings.get("openui_pool_model"),
os.environ.get("OPENUI_POOL_MODEL"),
"auto",
)
source = await _call_local_pool(identity, model, messages)
return {"source": source, "channel": "local"}
return router