feat: add Chinese UI, Responses models, and mirrored Windows setup
Localize the web console and persist custom model and capability settings. Use OpenAI Responses exclusively and move runtime limits into environment configuration. Bootstrap uv and the virtual environment on Windows, support optional China mirrors, and package source distributions without environments, build output, or credentials. Update regression tests and repository quality checks for the new behavior.
This commit is contained in:
1 parent
371aa6df56
commit
2ddec53954
183 files changed
+3427
-11857
No files matched your search
@@ -0,0 +1,161 @@
|
||||
"""Persist the single Responses endpoint without returning credentials to the browser."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||
|
||||
from artemis.config.paths import get_env_file
|
||||
from artemis.config.settings import settings
|
||||
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
|
||||
class ModelSettingsUpdate(BaseModel):
|
||||
base_url: str = "https://api.openai.com/v1"
|
||||
model: str = Field(min_length=1, max_length=200)
|
||||
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
||||
timeout_seconds: float = Field(default=120, gt=0, le=3600)
|
||||
max_retries: int = Field(default=0, ge=0, le=10)
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def validate_base_url(cls, value: str) -> str:
|
||||
value = value.strip().rstrip("/")
|
||||
parsed = urlsplit(value)
|
||||
if (
|
||||
parsed.scheme not in ("https", "http")
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
|
||||
if value.endswith(("/responses", "/chat/completions")):
|
||||
raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1")
|
||||
return value
|
||||
|
||||
@field_validator("model")
|
||||
@classmethod
|
||||
def validate_model(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value or any(c in value for c in "\r\n\x00"):
|
||||
raise ValueError("模型名称不能为空或包含换行")
|
||||
return value
|
||||
|
||||
|
||||
def get_model_settings() -> dict:
|
||||
return {
|
||||
"provider": "openai",
|
||||
"api_mode": "responses",
|
||||
"base_url": settings.OPENAI_BASE_URL or "https://api.openai.com/v1",
|
||||
"model": settings.OPENAI_MODEL,
|
||||
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||
"env_path": str(get_env_file()),
|
||||
}
|
||||
|
||||
|
||||
def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
||||
if update.api_key and any(c in update.api_key.get_secret_value() for c in "\r\n\x00"):
|
||||
raise ValueError("API Key 不能包含换行")
|
||||
values = {
|
||||
"OPENAI_BASE_URL": update.base_url,
|
||||
"OPENAI_MODEL": update.model,
|
||||
"OPENAI_TIMEOUT_SECONDS": str(update.timeout_seconds),
|
||||
"OPENAI_MAX_RETRIES": str(update.max_retries),
|
||||
}
|
||||
if update.api_key and update.api_key.get_secret_value().strip():
|
||||
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
|
||||
persist_environment(values)
|
||||
settings.OPENAI_BASE_URL = update.base_url
|
||||
settings.OPENAI_MODEL = update.model
|
||||
settings.OPENAI_TIMEOUT_SECONDS = update.timeout_seconds
|
||||
settings.OPENAI_MAX_RETRIES = update.max_retries
|
||||
if "OPENAI_API_KEY" in values:
|
||||
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
||||
from artemis.llm.router import ModelFactory
|
||||
from apps.admin_console.services.model_service import ModelService
|
||||
|
||||
ModelFactory._cache.clear()
|
||||
ModelService._llm_info_cache = None
|
||||
return get_model_settings()
|
||||
|
||||
|
||||
def persist_environment(values: dict[str, str]) -> None:
|
||||
with _write_lock:
|
||||
env_path = get_env_file()
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
||||
# Preserve comments and unrelated variables; quote values to avoid dotenv interpolation.
|
||||
kept = [
|
||||
line
|
||||
for line in original.splitlines()
|
||||
if line.removeprefix("export ").split("=", 1)[0].strip() not in values
|
||||
]
|
||||
for key, value in values.items():
|
||||
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
|
||||
kept.append(f"{key}='{escaped}'")
|
||||
content = "\n".join(kept) + "\n"
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".env-", dir=env_path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp_name, env_path)
|
||||
finally:
|
||||
Path(temp_name).unlink(missing_ok=True)
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
class CapabilitySettings(BaseModel):
|
||||
video_enabled: bool = True
|
||||
outputter_enabled: bool = True
|
||||
step_summarizer_enabled: bool = True
|
||||
|
||||
|
||||
def get_capability_settings() -> CapabilitySettings:
|
||||
return CapabilitySettings(
|
||||
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
||||
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
||||
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
||||
values = {
|
||||
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||
}
|
||||
persist_environment({key: str(value).lower() for key, value in values.items()})
|
||||
for key, value in values.items():
|
||||
setattr(settings, key, value)
|
||||
return get_capability_settings()
|
||||
|
||||
|
||||
async def test_model_settings(update: ModelSettingsUpdate) -> dict:
|
||||
from langchain_core.messages import HumanMessage
|
||||
from artemis.llm.router import ModelEndpoint, ModelFactory
|
||||
|
||||
key = update.api_key.get_secret_value().strip() if update.api_key else None
|
||||
endpoint = ModelEndpoint(
|
||||
model_name=update.model,
|
||||
api_key=key or None,
|
||||
api_base=update.base_url,
|
||||
timeout_seconds=update.timeout_seconds,
|
||||
max_retries=0,
|
||||
max_tokens=64,
|
||||
)
|
||||
model = ModelFactory.create_model(endpoint)
|
||||
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
|
||||
if not result.content:
|
||||
raise ValueError("Responses 接口返回空内容,请检查模型配置")
|
||||
return {"success": True, "message": "Responses 连接测试成功", "model": update.model}
|
||||
Reference in new issue
Block a user