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:
48
backend/alembic/versions/0005_agnes_key.py
Normal file
48
backend/alembic/versions/0005_agnes_key.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Angel(Agnes)凭据存入 settings 表
|
||||||
|
|
||||||
|
- llm_settings.agnes_api_key TEXT (留空 = 用 .env AGNES_API_KEY 兜底)
|
||||||
|
- llm_settings.agnes_base_url_override VARCHAR(255) (留空 = 用 .env AGNES_BASE_URL)
|
||||||
|
|
||||||
|
API 安全:LlmSettingOut 只回 agnes_api_key_set (bool),不回明文
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-06-12
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0005"
|
||||||
|
down_revision: Union[str, None] = "0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"llm_settings",
|
||||||
|
sa.Column(
|
||||||
|
"agnes_api_key",
|
||||||
|
sa.Text,
|
||||||
|
nullable=False,
|
||||||
|
server_default="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"llm_settings",
|
||||||
|
sa.Column(
|
||||||
|
"agnes_base_url_override",
|
||||||
|
sa.String(255),
|
||||||
|
nullable=False,
|
||||||
|
server_default="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("llm_settings", "agnes_base_url_override")
|
||||||
|
op.drop_column("llm_settings", "agnes_api_key")
|
||||||
@@ -92,13 +92,24 @@ class TestResponse(BaseModel):
|
|||||||
|
|
||||||
@router.post("/settings/test", response_model=TestResponse)
|
@router.post("/settings/test", response_model=TestResponse)
|
||||||
async def test_connection():
|
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:
|
async with AsyncSessionLocal() as session:
|
||||||
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
|
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"
|
if row is None:
|
||||||
client = LlmClient(chat_model=chat_model)
|
return TestResponse(ok=False, configured=False, detail="LLM 设置未初始化")
|
||||||
|
# 用工厂:DB key 优先,.env 兜底
|
||||||
|
client = get_angel_client(row)
|
||||||
if not client.is_configured():
|
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:
|
try:
|
||||||
reply = await client.chat(
|
reply = await client.chat(
|
||||||
system="你是测试助手,只用 1 个词回答 OK 或 FAIL。",
|
system="你是测试助手,只用 1 个词回答 OK 或 FAIL。",
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ class LlmSetting(Base):
|
|||||||
# === 总开关 ===
|
# === 总开关 ===
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
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 兼容)===
|
# === 美团大模型(LongCat,OpenAI 兼容)===
|
||||||
# 双 provider 评论架构:Angel + 美团并列,各跑各的 prompt,结果存到 articles 各自的列
|
# 双 provider 评论架构:Angel + 美团并列,各跑各的 prompt,结果存到 articles 各自的列
|
||||||
# api_key 留空 = 不启用该 provider
|
# api_key 留空 = 不启用该 provider
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class LlmSettingOut(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
# 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
|
# 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
|
||||||
blocklist_tags: list[str] = []
|
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 评论
|
# 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
||||||
# 安全:不回传 api_key 真值,只回传 meituan_api_key_set 表示"是否已配置"
|
# 安全:不回传 api_key 真值,只回传 meituan_api_key_set 表示"是否已配置"
|
||||||
meituan_api_key_set: bool = False
|
meituan_api_key_set: bool = False
|
||||||
@@ -44,6 +48,9 @@ class LlmSettingOut(BaseModel):
|
|||||||
interval_sec=row.interval_sec,
|
interval_sec=row.interval_sec,
|
||||||
enabled=row.enabled,
|
enabled=row.enabled,
|
||||||
blocklist_tags=row.blocklist_tags or [],
|
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_api_key_set=bool(row.meituan_api_key),
|
||||||
meituan_base_url=row.meituan_base_url,
|
meituan_base_url=row.meituan_base_url,
|
||||||
meituan_chat_model=row.meituan_chat_model,
|
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)
|
interval_sec: float | None = Field(default=None, ge=0.0, le=60.0)
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
blocklist_tags: list[str] | 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/空 = 不修改;显式传空字符串 = 清空)
|
# 美团 provider 字段(api_key 可更新;None/空 = 不修改;显式传空字符串 = 清空)
|
||||||
meituan_api_key: str | None = Field(default=None, max_length=512)
|
meituan_api_key: str | None = Field(default=None, max_length=512)
|
||||||
meituan_base_url: str | None = Field(default=None, max_length=255)
|
meituan_base_url: str | None = Field(default=None, max_length=255)
|
||||||
|
|||||||
@@ -387,12 +387,9 @@ async def enrich_article(article_id: int) -> dict[str, str]:
|
|||||||
"commentary_angel": "skipped", "commentary_meituan": "skipped",
|
"commentary_angel": "skipped", "commentary_meituan": "skipped",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 用配置生成 client(允许热改设置)
|
# 用工厂生成 Angel 客户端(凭据:DB 优先,.env 兜底)
|
||||||
client = LlmClient(
|
from app.services.llm.providers import get_angel_client
|
||||||
chat_model=setting.chat_model,
|
client = get_angel_client(setting)
|
||||||
image_model=setting.image_model,
|
|
||||||
interval_sec=setting.interval_sec,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 美团 provider client(可能为 None = 未配置)
|
# 美团 provider client(可能为 None = 未配置)
|
||||||
meituan_client = None
|
meituan_client = None
|
||||||
|
|||||||
@@ -27,11 +27,29 @@ PROVIDER_MEITUAN = "meituan" # 美团大模型(LongCat,OpenAI 兼容)
|
|||||||
|
|
||||||
|
|
||||||
def get_angel_client(setting: LlmSetting) -> LlmClient:
|
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(
|
return LlmClient(
|
||||||
chat_model=setting.chat_model,
|
base_url=base_url or "https://apihub.agnes-ai.com/v1",
|
||||||
image_model=setting.image_model,
|
api_key=api_key,
|
||||||
interval_sec=setting.interval_sec,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ export interface LlmSetting {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
// 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
|
// 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
|
||||||
blocklist_tags?: string[]
|
blocklist_tags?: string[]
|
||||||
|
// Angel(Agnes)凭据 — DB 存,优先于 .env
|
||||||
|
agnes_api_key_set?: boolean // 不回传 key 真值
|
||||||
|
agnes_base_url_override?: string // 留空 = 用 .env
|
||||||
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
||||||
meituan_api_key_set?: boolean // 不回传 key 真值
|
meituan_api_key_set?: boolean // 不回传 key 真值
|
||||||
meituan_base_url?: string
|
meituan_base_url?: string
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const setting = ref<LlmSetting>({
|
|||||||
interval_sec: 2.0,
|
interval_sec: 2.0,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
blocklist_tags: [],
|
blocklist_tags: [],
|
||||||
|
// Angel(Agnes)凭据
|
||||||
|
agnes_api_key_set: false,
|
||||||
|
agnes_base_url_override: '',
|
||||||
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
||||||
meituan_api_key_set: false,
|
meituan_api_key_set: false,
|
||||||
meituan_base_url: 'https://api.longcat.chat/openai/v1',
|
meituan_base_url: 'https://api.longcat.chat/openai/v1',
|
||||||
@@ -29,6 +32,21 @@ const setting = ref<LlmSetting>({
|
|||||||
meituan_commentary_prompt: '',
|
meituan_commentary_prompt: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// === Angel api_key 编辑(不回显真值) ===
|
||||||
|
const agnesKeyInput = ref('')
|
||||||
|
const agnesKeyHasValue = ref(false)
|
||||||
|
const agnesKeyPlaceholder = computed(() => {
|
||||||
|
if (agnesKeyHasValue.value) return '已配置(留空 = 不修改,输入新值 = 覆盖)'
|
||||||
|
return '请输入 Agnes API Key(DB 留空 = 用 .env AGNES_API_KEY)'
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadAgnesKeyState() {
|
||||||
|
agnesKeyHasValue.value = !!setting.value.agnes_api_key_set
|
||||||
|
agnesKeyInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => setting.value.agnes_api_key_set, loadAgnesKeyState)
|
||||||
|
|
||||||
// === 美团 api_key 编辑(不回显真值)===
|
// === 美团 api_key 编辑(不回显真值)===
|
||||||
const meituanKeyInput = ref('')
|
const meituanKeyInput = ref('')
|
||||||
const meituanKeyHasValue = ref(false) // 当前 DB 是否已设置
|
const meituanKeyHasValue = ref(false) // 当前 DB 是否已设置
|
||||||
@@ -65,6 +83,7 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
setting.value = await adminApi.getLlmSettings()
|
setting.value = await adminApi.getLlmSettings()
|
||||||
loadMeituanKeyState()
|
loadMeituanKeyState()
|
||||||
|
loadAgnesKeyState()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.response?.data?.title || '加载失败')
|
message.error(e?.response?.data?.title || '加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,6 +97,8 @@ async function save() {
|
|||||||
// 美团 api_key:有输入才提交(否则不修改);清空用 "clear" 信号
|
// 美团 api_key:有输入才提交(否则不修改);清空用 "clear" 信号
|
||||||
const body: any = { ...setting.value }
|
const body: any = { ...setting.value }
|
||||||
delete body.meituan_api_key_set // 后端不需要这个字段
|
delete body.meituan_api_key_set // 后端不需要这个字段
|
||||||
|
delete body.agnes_api_key_set // 后端不需要这个字段
|
||||||
|
// --- 美团 key ---
|
||||||
if (meituanKeyInput.value === '__CLEAR__') {
|
if (meituanKeyInput.value === '__CLEAR__') {
|
||||||
body.meituan_api_key = ''
|
body.meituan_api_key = ''
|
||||||
} else if (meituanKeyInput.value && meituanKeyInput.value.trim()) {
|
} else if (meituanKeyInput.value && meituanKeyInput.value.trim()) {
|
||||||
@@ -85,10 +106,20 @@ async function save() {
|
|||||||
} else {
|
} else {
|
||||||
delete body.meituan_api_key
|
delete body.meituan_api_key
|
||||||
}
|
}
|
||||||
|
// --- Angel key(同样的三态语义) ---
|
||||||
|
if (agnesKeyInput.value === '__CLEAR__') {
|
||||||
|
body.agnes_api_key = ''
|
||||||
|
} else if (agnesKeyInput.value && agnesKeyInput.value.trim()) {
|
||||||
|
body.agnes_api_key = agnesKeyInput.value.trim()
|
||||||
|
} else {
|
||||||
|
delete body.agnes_api_key
|
||||||
|
}
|
||||||
const updated = await adminApi.updateLlmSettings(body)
|
const updated = await adminApi.updateLlmSettings(body)
|
||||||
setting.value = updated
|
setting.value = updated
|
||||||
meituanKeyInput.value = '' // 重置输入
|
meituanKeyInput.value = '' // 重置输入
|
||||||
|
agnesKeyInput.value = ''
|
||||||
loadMeituanKeyState()
|
loadMeituanKeyState()
|
||||||
|
loadAgnesKeyState()
|
||||||
message.success('已保存')
|
message.success('已保存')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.response?.data?.title || '保存失败')
|
message.error(e?.response?.data?.title || '保存失败')
|
||||||
@@ -170,6 +201,43 @@ onMounted(load)
|
|||||||
<NSwitch v-model:value="setting.enabled" />
|
<NSwitch v-model:value="setting.enabled" />
|
||||||
<NText v-if="!setting.enabled" depth="3" style="font-size: 12px">(关闭后翻译后不再调 LLM)</NText>
|
<NText v-if="!setting.enabled" depth="3" style="font-size: 12px">(关闭后翻译后不再调 LLM)</NText>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
|
<NDivider style="margin: 4px 0" />
|
||||||
|
<NText strong>👼 Angel(Agnes)provider</NText>
|
||||||
|
<NText depth="3" style="font-size: 12px; margin-top: -8px">
|
||||||
|
DB key 优先,留空 = 用 .env 中的 <NCode>AGNES_API_KEY</NCode>。点击顶部"测连接"实测连通性。
|
||||||
|
</NText>
|
||||||
|
<NSpace align="center" :wrap="true" style="row-gap: 6px">
|
||||||
|
<NText style="min-width: 80px">API Key:</NText>
|
||||||
|
<NInput
|
||||||
|
v-model:value="agnesKeyInput"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:placeholder="agnesKeyPlaceholder"
|
||||||
|
style="width: 360px"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
v-if="agnesKeyHasValue"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
ghost
|
||||||
|
@click="agnesKeyInput = '__CLEAR__'"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</NButton>
|
||||||
|
<NText v-if="agnesKeyHasValue" depth="3" style="font-size: 12px; color: #18a058">● DB 已配置</NText>
|
||||||
|
<NText v-else depth="3" style="font-size: 12px; color: #d03050">● DB 未配置(将用 .env 兜底)</NText>
|
||||||
|
</NSpace>
|
||||||
|
<NSpace align="center" :wrap="true" style="row-gap: 6px">
|
||||||
|
<NText style="min-width: 80px">Base URL:</NText>
|
||||||
|
<NInput
|
||||||
|
v-model:value="setting.agnes_base_url_override"
|
||||||
|
placeholder="留空 = 用 .env AGNES_BASE_URL"
|
||||||
|
style="width: 380px"
|
||||||
|
/>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NDivider style="margin: 4px 0" />
|
||||||
<NSpace>
|
<NSpace>
|
||||||
<NText>文生文模型:</NText>
|
<NText>文生文模型:</NText>
|
||||||
<NInput v-model:value="setting.chat_model" placeholder="agnes-2.0-flash" style="width: 240px" />
|
<NInput v-model:value="setting.chat_model" placeholder="agnes-2.0-flash" style="width: 240px" />
|
||||||
@@ -194,7 +262,7 @@ onMounted(load)
|
|||||||
<NCard title="🐱 美团大模型(LongCat,双 provider 评论)" style="margin-top: 16px">
|
<NCard title="🐱 美团大模型(LongCat,双 provider 评论)" style="margin-top: 16px">
|
||||||
<NText depth="3" style="font-size: 12px">
|
<NText depth="3" style="font-size: 12px">
|
||||||
与 Angel(Agnes)并列,各跑各的 prompt,结果存到 articles.commentary_meituan 等字段。
|
与 Angel(Agnes)并列,各跑各的 prompt,结果存到 articles.commentary_meituan 等字段。
|
||||||
留空 api_key = 关闭该 provider,Angel 仍正常工作。
|
DB 留空 = 用 .env <NCode>MEITUAN_API_KEY</NCode>(.env 也没配 = 关闭该 provider,Angel 仍正常工作)。
|
||||||
</NText>
|
</NText>
|
||||||
<NSpace vertical style="margin-top: 12px">
|
<NSpace vertical style="margin-top: 12px">
|
||||||
<NSpace align="center">
|
<NSpace align="center">
|
||||||
|
|||||||
Reference in New Issue
Block a user