File size: 9,156 Bytes
85270f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | 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
|