feat(admin): Angel(Agnes) provider 凭据 DB 化 + 安全 key_set 字段

- llm_settings.agnes_api_key           TEXT   (DB key 优先,.env 兜底)
- llm_settings.agnes_base_url_override VARCHAR (留空 = 用 .env)
- alembic 0005_agnes_key 迁移
- LlmSettingOut.agnes_api_key_set (bool) 替代直接回传 key
- LlmSettingUpdate 加 agnes_api_key / agnes_base_url_override(可空可清空)
- providers.get_angel_client 改用 DB key 优先
- enrichment.py 改为 get_angel_client() 工厂调用(热改 key 不需重启)
- /admin/llm/settings/test 走 get_angel_client(测的是 DB 里的 key)
- 前端 AdminLlmSettings 在'总开关 + 模型'卡里加 Angel api_key 输入框 +
  base_url 覆盖 + 已配置/未配置指示灯 + 清空按钮
- 顶部'测连接'按钮复用(测的就是 Angel)
This commit is contained in:
xiaji
2026-06-12 20:43:54 +08:00
parent 785b63cfed
commit aaf728f3f4
8 changed files with 179 additions and 15 deletions

View File

@@ -92,13 +92,24 @@ class TestResponse(BaseModel):
@router.post("/settings/test", response_model=TestResponse)
async def test_connection():
"""最小测试:发一个 'hi' chat 请求,确认 key + 端点通。"""
"""最小测试:发一个 'hi' chat 请求,确认 key + 端点通。
优先用 llm_settings 表里的 agnes_api_key / agnes_base_url_override,
都没有再 fallback 到 .env 里的 agnes_api_key / agnes_base_url。
"""
from app.services.llm.providers import get_angel_client
async with AsyncSessionLocal() as session:
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
chat_model = row.chat_model if row else "agnes-2.0-flash"
client = LlmClient(chat_model=chat_model)
if row is None:
return TestResponse(ok=False, configured=False, detail="LLM 设置未初始化")
# 用工厂:DB key 优先,.env 兜底
client = get_angel_client(row)
if not client.is_configured():
return TestResponse(ok=False, configured=False, detail="AGNES_API_KEY 未配置")
return TestResponse(
ok=False, configured=False,
detail="Angel api_key 未配置(请在设置页填 key,或在 .env 配 AGNES_API_KEY)",
)
try:
reply = await client.chat(
system="你是测试助手,只用 1 个词回答 OK 或 FAIL。",

View File

@@ -49,6 +49,15 @@ class LlmSetting(Base):
# === 总开关 ===
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# === Angel(Agnes)provider 凭据 — 在 settings 表里存,优先于 .env ===
# 留空 = 用 .env 里的 agnes_api_key(向后兼容,生产部署常用 .env 注入)
# 设值 = 走数据库(更便于在 UI 改 key,不用重启)
# 安全:API 返回 agnes_api_key_set bool,不回传明文
agnes_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False)
agnes_base_url_override: Mapped[str] = mapped_column(
String(255), default="", nullable=False
) # 留空 = 用 .env
# === 美团大模型(LongCat,OpenAI 兼容)===
# 双 provider 评论架构:Angel + 美团并列,各跑各的 prompt,结果存到 articles 各自的列
# api_key 留空 = 不启用该 provider

View File

@@ -20,6 +20,10 @@ class LlmSettingOut(BaseModel):
enabled: bool = True
# 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
blocklist_tags: list[str] = []
# === Angel(Agnes)凭据 — DB 存,优先于 .env ===
# 安全:不回传明文,只回 agnes_api_key_set (True = DB 已有 key)
agnes_api_key_set: bool = False
agnes_base_url_override: str = "" # 留空 = 用 .env
# 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
# 安全:不回传 api_key 真值,只回传 meituan_api_key_set 表示"是否已配置"
meituan_api_key_set: bool = False
@@ -44,6 +48,9 @@ class LlmSettingOut(BaseModel):
interval_sec=row.interval_sec,
enabled=row.enabled,
blocklist_tags=row.blocklist_tags or [],
# === key 安全转换 ===
agnes_api_key_set=bool(row.agnes_api_key),
agnes_base_url_override=row.agnes_base_url_override or "",
meituan_api_key_set=bool(row.meituan_api_key),
meituan_base_url=row.meituan_base_url,
meituan_chat_model=row.meituan_chat_model,
@@ -67,6 +74,9 @@ class LlmSettingUpdate(BaseModel):
interval_sec: float | None = Field(default=None, ge=0.0, le=60.0)
enabled: bool | None = None
blocklist_tags: list[str] | None = None
# === Angel provider 字段 ===
agnes_api_key: str | None = Field(default=None, max_length=512)
agnes_base_url_override: str | None = Field(default=None, max_length=255)
# 美团 provider 字段(api_key 可更新;None/空 = 不修改;显式传空字符串 = 清空)
meituan_api_key: str | None = Field(default=None, max_length=512)
meituan_base_url: str | None = Field(default=None, max_length=255)

View File

@@ -387,12 +387,9 @@ async def enrich_article(article_id: int) -> dict[str, str]:
"commentary_angel": "skipped", "commentary_meituan": "skipped",
}
# 用配置生成 client(允许热改设置)
client = LlmClient(
chat_model=setting.chat_model,
image_model=setting.image_model,
interval_sec=setting.interval_sec,
)
# 用工厂生成 Angel 客户端(凭据:DB 优先,.env 兜底)
from app.services.llm.providers import get_angel_client
client = get_angel_client(setting)
# 美团 provider client(可能为 None = 未配置)
meituan_client = None

View File

@@ -27,11 +27,29 @@ PROVIDER_MEITUAN = "meituan" # 美团大模型(LongCat,OpenAI 兼容)
def get_angel_client(setting: LlmSetting) -> LlmClient:
"""Agnes 客户端 — 与 LlmClient 单例行为完全一致。"""
"""Agnes(Angel)客户端。
凭据优先级(高 → 低):
1. llm_settings.agnes_api_key(DB 里存的 key,UI 可改)
2. .env AGNES_API_KEY
3. .env 任意一个都不配 = LlmClient.is_configured() = False
base_url 优先级:
1. llm_settings.agnes_base_url_override(DB 里存的)
2. .env AGNES_BASE_URL
"""
from app.config import settings as app_settings
api_key = (getattr(setting, "agnes_api_key", "") or app_settings.agnes_api_key) or ""
base_url = (
getattr(setting, "agnes_base_url_override", "") or app_settings.agnes_base_url
).rstrip("/")
return LlmClient(
chat_model=setting.chat_model,
image_model=setting.image_model,
interval_sec=setting.interval_sec,
base_url=base_url or "https://apihub.agnes-ai.com/v1",
api_key=api_key,
chat_model=setting.chat_model or "agnes-2.0-flash",
image_model=setting.image_model or "agnes-image-2.1-flash",
interval_sec=setting.interval_sec or 2.0,
)