diff --git a/backend/alembic/versions/0005_agnes_key.py b/backend/alembic/versions/0005_agnes_key.py new file mode 100644 index 0000000..9494611 --- /dev/null +++ b/backend/alembic/versions/0005_agnes_key.py @@ -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") diff --git a/backend/app/api/admin_llm.py b/backend/app/api/admin_llm.py index e9c3470..cb994d1 100644 --- a/backend/app/api/admin_llm.py +++ b/backend/app/api/admin_llm.py @@ -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。", diff --git a/backend/app/models/llm_setting.py b/backend/app/models/llm_setting.py index 6061325..b9946cf 100644 --- a/backend/app/models/llm_setting.py +++ b/backend/app/models/llm_setting.py @@ -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 diff --git a/backend/app/schemas/llm.py b/backend/app/schemas/llm.py index 38fa177..172dc25 100644 --- a/backend/app/schemas/llm.py +++ b/backend/app/schemas/llm.py @@ -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) diff --git a/backend/app/services/llm/enrichment.py b/backend/app/services/llm/enrichment.py index 6d7dcdf..857ed78 100644 --- a/backend/app/services/llm/enrichment.py +++ b/backend/app/services/llm/enrichment.py @@ -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 diff --git a/backend/app/services/llm/providers.py b/backend/app/services/llm/providers.py index d0cf7e1..ffdee3e 100644 --- a/backend/app/services/llm/providers.py +++ b/backend/app/services/llm/providers.py @@ -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, ) diff --git a/frontend/src/api/articles.ts b/frontend/src/api/articles.ts index 05405a0..6df94d5 100644 --- a/frontend/src/api/articles.ts +++ b/frontend/src/api/articles.ts @@ -92,6 +92,9 @@ export interface LlmSetting { enabled: boolean // 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt blocklist_tags?: string[] + // Angel(Agnes)凭据 — DB 存,优先于 .env + agnes_api_key_set?: boolean // 不回传 key 真值 + agnes_base_url_override?: string // 留空 = 用 .env // 美团大模型(LongCat,OpenAI 兼容)双 provider 评论 meituan_api_key_set?: boolean // 不回传 key 真值 meituan_base_url?: string diff --git a/frontend/src/views/AdminLlmSettings.vue b/frontend/src/views/AdminLlmSettings.vue index 6a3b48b..3c642d6 100644 --- a/frontend/src/views/AdminLlmSettings.vue +++ b/frontend/src/views/AdminLlmSettings.vue @@ -20,6 +20,9 @@ const setting = ref({ interval_sec: 2.0, enabled: true, blocklist_tags: [], + // Angel(Agnes)凭据 + agnes_api_key_set: false, + agnes_base_url_override: '', // 美团大模型(LongCat,OpenAI 兼容)双 provider 评论 meituan_api_key_set: false, meituan_base_url: 'https://api.longcat.chat/openai/v1', @@ -29,6 +32,21 @@ const setting = ref({ 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 编辑(不回显真值)=== const meituanKeyInput = ref('') const meituanKeyHasValue = ref(false) // 当前 DB 是否已设置 @@ -65,6 +83,7 @@ async function load() { try { setting.value = await adminApi.getLlmSettings() loadMeituanKeyState() + loadAgnesKeyState() } catch (e: any) { message.error(e?.response?.data?.title || '加载失败') } finally { @@ -78,6 +97,8 @@ async function save() { // 美团 api_key:有输入才提交(否则不修改);清空用 "clear" 信号 const body: any = { ...setting.value } delete body.meituan_api_key_set // 后端不需要这个字段 + delete body.agnes_api_key_set // 后端不需要这个字段 + // --- 美团 key --- if (meituanKeyInput.value === '__CLEAR__') { body.meituan_api_key = '' } else if (meituanKeyInput.value && meituanKeyInput.value.trim()) { @@ -85,10 +106,20 @@ async function save() { } else { 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) setting.value = updated meituanKeyInput.value = '' // 重置输入 + agnesKeyInput.value = '' loadMeituanKeyState() + loadAgnesKeyState() message.success('已保存') } catch (e: any) { message.error(e?.response?.data?.title || '保存失败') @@ -170,6 +201,43 @@ onMounted(load) (关闭后翻译后不再调 LLM) + + + 👼 Angel(Agnes)provider + + DB key 优先,留空 = 用 .env 中的 AGNES_API_KEY。点击顶部"测连接"实测连通性。 + + + API Key: + + + 清空 + + ● DB 已配置 + ● DB 未配置(将用 .env 兜底) + + + Base URL: + + + + 文生文模型: @@ -194,7 +262,7 @@ onMounted(load) 与 Angel(Agnes)并列,各跑各的 prompt,结果存到 articles.commentary_meituan 等字段。 - 留空 api_key = 关闭该 provider,Angel 仍正常工作。 + DB 留空 = 用 .env MEITUAN_API_KEY(.env 也没配 = 关闭该 provider,Angel 仍正常工作)。