feat(commentary): 双 provider 评论 — Angel(Agnes) + 美团大模型(LongCat)
- 新增 articles.commentary_meituan{_status,_model,_error} 4 列 + commentary_engine
- LlmSetting 加 meituan_api_key/base_url/chat_model/interval_sec/enabled/commentary_prompt
- 新 app/services/llm/providers.py 工厂,支持多 provider 客户端
- enrichment 流程改为 commentary_angel + commentary_meituan 并行(asyncio.gather),
任一 provider 失败不影响另一个
- enrichment_loop 状态判定:任一 provider 状态不是 ok 都视为待 enrich
- alembic 0004_dual_commentary 迁移
- 前端 Feed 卡片 + ArticleDetail 详情页各加一条'美团评论'卡
- AdminLlmSettings 加美团 provider 配置卡(独立 api_key 编辑器,不回显明文)
- LlmSettingOut.meituan_api_key_set (bool) 替代直接回传 key
- 默认 URL https://api.longcat.chat/openai/v1 / 默认模型 LongCat-2.0-Preview
This commit is contained in:
128
backend/alembic/versions/0004_dual_commentary.py
Normal file
128
backend/alembic/versions/0004_dual_commentary.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""双 provider 评论:加美团评论列 + commentary_engine 标识
|
||||
|
||||
- commentary_engine 区分"实际写入的 provider 名称"(angel / meituan / "angel,meituan")
|
||||
- commentary_meituan{_status,_model,_error,_} 4 列
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-06-12
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: Union[str, None] = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === articles:加 5 列(4 个 status/model/error + 1 个 content)===
|
||||
op.add_column(
|
||||
"articles",
|
||||
sa.Column("commentary_engine", sa.String(32), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"articles",
|
||||
sa.Column(
|
||||
"commentary_meituan",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"articles",
|
||||
sa.Column(
|
||||
"commentary_meituan_status",
|
||||
sa.String(16),
|
||||
nullable=False,
|
||||
server_default="n/a",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"articles",
|
||||
sa.Column(
|
||||
"commentary_meituan_model",
|
||||
sa.String(64),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"articles",
|
||||
sa.Column(
|
||||
"commentary_meituan_error",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
# 旧存量:commentary_status=ok 的文章 → 标记 commentary_engine=angel
|
||||
op.execute(
|
||||
"UPDATE articles SET commentary_engine = 'angel' "
|
||||
"WHERE commentary_status = 'ok' AND commentary_engine IS NULL"
|
||||
)
|
||||
|
||||
# === llm_settings:加 6 列(美团 provider 配置)===
|
||||
op.add_column(
|
||||
"llm_settings",
|
||||
sa.Column("meituan_api_key", sa.Text, nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"llm_settings",
|
||||
sa.Column(
|
||||
"meituan_base_url",
|
||||
sa.String(255),
|
||||
nullable=False,
|
||||
server_default="https://api.longcat.chat/openai/v1",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"llm_settings",
|
||||
sa.Column(
|
||||
"meituan_chat_model",
|
||||
sa.String(64),
|
||||
nullable=False,
|
||||
server_default="LongCat-2.0-Preview",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"llm_settings",
|
||||
sa.Column(
|
||||
"meituan_interval_sec",
|
||||
sa.Float,
|
||||
nullable=False,
|
||||
server_default="2.0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"llm_settings",
|
||||
sa.Column(
|
||||
"meituan_enabled",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"llm_settings",
|
||||
sa.Column("meituan_commentary_prompt", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 反向顺序很重要(后加的先删)
|
||||
op.drop_column("llm_settings", "meituan_commentary_prompt")
|
||||
op.drop_column("llm_settings", "meituan_enabled")
|
||||
op.drop_column("llm_settings", "meituan_interval_sec")
|
||||
op.drop_column("llm_settings", "meituan_chat_model")
|
||||
op.drop_column("llm_settings", "meituan_base_url")
|
||||
op.drop_column("llm_settings", "meituan_api_key")
|
||||
|
||||
op.drop_column("articles", "commentary_meituan_error")
|
||||
op.drop_column("articles", "commentary_meituan_model")
|
||||
op.drop_column("articles", "commentary_meituan_status")
|
||||
op.drop_column("articles", "commentary_meituan")
|
||||
op.drop_column("articles", "commentary_engine")
|
||||
@@ -39,8 +39,9 @@ async def get_settings():
|
||||
commentary_prompt=defaults["commentary_prompt"],
|
||||
image_prompt_template=defaults["image_prompt_template"],
|
||||
blocklist_tags=[],
|
||||
meituan_api_key_set=False,
|
||||
)
|
||||
return LlmSettingOut.model_validate(row)
|
||||
return LlmSettingOut.from_row(row)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=LlmSettingOut)
|
||||
@@ -57,7 +58,7 @@ async def update_settings(body: LlmSettingUpdate):
|
||||
setattr(row, k, v)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return LlmSettingOut.model_validate(row)
|
||||
return LlmSettingOut.from_row(row)
|
||||
|
||||
|
||||
class ResetResponse(BaseModel):
|
||||
@@ -110,6 +111,30 @@ async def test_connection():
|
||||
return TestResponse(ok=False, configured=True, detail=f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
@router.post("/settings/test-meituan", response_model=TestResponse)
|
||||
async def test_meituan_connection():
|
||||
"""最小测试:发一个 'hi' chat 请求,确认美团大模型 LongCat 端点通。"""
|
||||
from app.services.llm.providers import get_meituan_client
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = (await session.execute(select(LlmSetting).where(LlmSetting.id == 1))).scalar_one_or_none()
|
||||
if row is None:
|
||||
return TestResponse(ok=False, configured=False, detail="美团 provider 未配置(api_key 空)")
|
||||
client = get_meituan_client(row)
|
||||
if client is None:
|
||||
return TestResponse(ok=False, configured=False, detail="美团 MEITUAN_API_KEY 未配置")
|
||||
try:
|
||||
reply = await client.chat(
|
||||
system="你是测试助手,只用 1 个词回答 OK 或 FAIL。",
|
||||
user="ping",
|
||||
temperature=0.0,
|
||||
max_tokens=10,
|
||||
)
|
||||
return TestResponse(ok=True, configured=True, detail=f"reply={reply[:50]!r}")
|
||||
except Exception as e:
|
||||
return TestResponse(ok=False, configured=True, detail=f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
class EnrichTriggerResponse(BaseModel):
|
||||
triggered: bool
|
||||
detail: str = ""
|
||||
|
||||
@@ -126,9 +126,12 @@ async def list_articles(
|
||||
published_at=art.published_at,
|
||||
fetched_at=art.fetched_at,
|
||||
image_url=art.image_url,
|
||||
# 列表预览钩子:分类 + LLM 点评 + AI 插图 缩略图
|
||||
# 列表预览钩子:分类 + LLM 点评(双 provider) + AI 插图 缩略图
|
||||
commentary=art.commentary,
|
||||
commentary_status=art.commentary_status,
|
||||
commentary_meituan=art.commentary_meituan,
|
||||
commentary_meituan_status=art.commentary_meituan_status,
|
||||
commentary_engine=art.commentary_engine,
|
||||
image_ai_url=art.image_ai_url,
|
||||
is_starred=art.id in starred_ids,
|
||||
)
|
||||
@@ -190,8 +193,14 @@ async def get_article(
|
||||
format_status=article.format_status,
|
||||
classify_status=article.classify_status,
|
||||
image_ai_status=article.image_ai_status,
|
||||
# 双 provider 评论
|
||||
commentary_status=article.commentary_status,
|
||||
commentary=article.commentary,
|
||||
commentary_engine=article.commentary_engine,
|
||||
commentary_meituan_status=article.commentary_meituan_status,
|
||||
commentary_meituan=article.commentary_meituan,
|
||||
commentary_meituan_model=article.commentary_meituan_model,
|
||||
commentary_meituan_error=article.commentary_meituan_error,
|
||||
entities=article.entities,
|
||||
sentiment=article.sentiment,
|
||||
duplicate_of=article.duplicate_of,
|
||||
|
||||
@@ -133,6 +133,14 @@ class Settings(BaseSettings):
|
||||
# 全局 LLM 调用间隔(秒),避免被限流
|
||||
llm_interval_sec: float = 2.0
|
||||
|
||||
# ===== 美团大模型 LongCat(双 provider 评论的第二个)=====
|
||||
# OpenAI 兼容端点;与 Agnes 并列,各自跑各自的 prompt,结果存到 articles 各自列
|
||||
# 留空 api_key = 不启用美团 provider(Angel 仍正常工作)
|
||||
meituan_api_key: str = ""
|
||||
meituan_base_url: str = "https://api.longcat.chat/openai/v1"
|
||||
meituan_chat_model: str = "LongCat-2.0-Preview"
|
||||
meituan_interval_sec: float = 2.0
|
||||
|
||||
# ===== 内部路径(部署后可调) =====
|
||||
project_root: Path = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@@ -71,16 +71,24 @@ class Article(Base):
|
||||
image_ai_status: Mapped[str] = mapped_column(
|
||||
String(16), default="n/a", nullable=False
|
||||
)
|
||||
# === 双 provider 评论(Angel = 原 commentary,美团 = LongCat)===
|
||||
commentary_status: Mapped[str] = mapped_column(
|
||||
String(16), default="n/a", nullable=False
|
||||
)
|
||||
commentary_engine: Mapped[str | None] = mapped_column(String(32)) # angel / meituan / 多 provider 拼接
|
||||
commentary_meituan_status: Mapped[str] = mapped_column(
|
||||
String(16), default="n/a", nullable=False
|
||||
)
|
||||
commentary_meituan_model: Mapped[str | None] = mapped_column(String(64))
|
||||
commentary_meituan_error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# === LLM 增强内容 ===
|
||||
image_ai_url: Mapped[str | None] = mapped_column(Text) # AI 生成的插图
|
||||
|
||||
# === ML 字段(预留,MVP 全 null)===
|
||||
category: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
commentary: Mapped[str | None] = mapped_column(Text)
|
||||
commentary: Mapped[str | None] = mapped_column(Text) # Angel 评论
|
||||
commentary_meituan: Mapped[str | None] = mapped_column(Text) # 美团评论
|
||||
entities: Mapped[dict | None] = mapped_column(JSONB)
|
||||
sentiment: Mapped[float | None] = mapped_column(Float)
|
||||
topic_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
@@ -49,6 +49,20 @@ class LlmSetting(Base):
|
||||
# === 总开关 ===
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# === 美团大模型(LongCat,OpenAI 兼容)===
|
||||
# 双 provider 评论架构:Angel + 美团并列,各跑各的 prompt,结果存到 articles 各自的列
|
||||
# api_key 留空 = 不启用该 provider
|
||||
meituan_api_key: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
meituan_base_url: Mapped[str] = mapped_column(
|
||||
String(255), default="https://api.longcat.chat/openai/v1", nullable=False
|
||||
)
|
||||
meituan_chat_model: Mapped[str] = mapped_column(
|
||||
String(64), default="LongCat-2.0-Preview", nullable=False
|
||||
)
|
||||
meituan_interval_sec: Mapped[float] = mapped_column(default=2.0, nullable=False)
|
||||
meituan_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
meituan_commentary_prompt: Mapped[str | None] = mapped_column(Text) # 留空用默认
|
||||
|
||||
# === 时间 ===
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
|
||||
@@ -34,8 +34,12 @@ class ArticleListItem(BaseModel):
|
||||
fetched_at: datetime
|
||||
image_url: str | None = None
|
||||
# === 列表预览钩子:点击进详情前的"诱导点" ===
|
||||
commentary: str | None = None # LLM 点评(列表里截断显示)
|
||||
# 双 provider 评论:Angel(原字段) + 美团(meituan 字段),前端两条都展示
|
||||
commentary: str | None = None # Angel 评论(列表里截断显示)
|
||||
commentary_status: str | None = None # ok/failed/pending/n/a
|
||||
commentary_meituan: str | None = None # 美团评论
|
||||
commentary_meituan_status: str | None = None
|
||||
commentary_engine: str | None = None # angel / meituan / "angel,meituan"
|
||||
image_ai_url: str | None = None # AI 插图(列表里缩略图)
|
||||
is_starred: bool = False
|
||||
|
||||
@@ -66,8 +70,14 @@ class ArticleDetail(BaseModel):
|
||||
format_status: str | None = None # pending/ok/failed/n/a
|
||||
classify_status: str | None = None
|
||||
image_ai_status: str | None = None
|
||||
# 双 provider 评论
|
||||
commentary_status: str | None = None
|
||||
commentary: str | None = None
|
||||
commentary: str | None = None # Angel
|
||||
commentary_engine: str | None = None
|
||||
commentary_meituan_status: str | None = None
|
||||
commentary_meituan: str | None = None
|
||||
commentary_meituan_model: str | None = None
|
||||
commentary_meituan_error: str | None = None
|
||||
entities: dict | None = None
|
||||
sentiment: float | None = None
|
||||
duplicate_of: int | None = None
|
||||
|
||||
@@ -20,8 +20,39 @@ class LlmSettingOut(BaseModel):
|
||||
enabled: bool = True
|
||||
# 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
|
||||
blocklist_tags: list[str] = []
|
||||
# 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
||||
# 安全:不回传 api_key 真值,只回传 meituan_api_key_set 表示"是否已配置"
|
||||
meituan_api_key_set: bool = False
|
||||
meituan_base_url: str = "https://api.longcat.chat/openai/v1"
|
||||
meituan_chat_model: str = "LongCat-2.0-Preview"
|
||||
meituan_interval_sec: float = 2.0
|
||||
meituan_enabled: bool = True
|
||||
meituan_commentary_prompt: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row) -> "LlmSettingOut":
|
||||
"""从 LlmSetting 构造,key 字段转 bool。"""
|
||||
return cls(
|
||||
format_prompt=row.format_prompt,
|
||||
classify_prompt=row.classify_prompt,
|
||||
commentary_prompt=row.commentary_prompt,
|
||||
image_prompt_template=row.image_prompt_template,
|
||||
image_size=row.image_size,
|
||||
chat_model=row.chat_model,
|
||||
image_model=row.image_model,
|
||||
interval_sec=row.interval_sec,
|
||||
enabled=row.enabled,
|
||||
blocklist_tags=row.blocklist_tags or [],
|
||||
meituan_api_key_set=bool(row.meituan_api_key),
|
||||
meituan_base_url=row.meituan_base_url,
|
||||
meituan_chat_model=row.meituan_chat_model,
|
||||
meituan_interval_sec=row.meituan_interval_sec,
|
||||
meituan_enabled=row.meituan_enabled,
|
||||
meituan_commentary_prompt=row.meituan_commentary_prompt,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class LlmSettingUpdate(BaseModel):
|
||||
"""PATCH — 全部字段 optional,只更新传入的。"""
|
||||
@@ -36,6 +67,13 @@ 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
|
||||
# 美团 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)
|
||||
meituan_chat_model: str | None = Field(default=None, max_length=64)
|
||||
meituan_interval_sec: float | None = Field(default=None, ge=0.0, le=60.0)
|
||||
meituan_enabled: bool | None = None
|
||||
meituan_commentary_prompt: str | None = None
|
||||
|
||||
|
||||
# === 默认提示词(模板,用户可改)===
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""LLM 智能增强服务(翻译后调)。
|
||||
|
||||
4 个独立任务(按顺序):
|
||||
1. classify — 分类 + 黑名单 gate(命中则删文章,后 3 步跳过)
|
||||
2. format — 排版译文(写入 body_zh_formatted,容器用 .article-body + 段落 .diary-para)
|
||||
3. image — 生成插图(写入 image_ai_url,prompt 用正文第一段)
|
||||
4. commentary — 写点评(写入 commentary)
|
||||
5 个独立任务(按顺序):
|
||||
1. classify — 分类 + 黑名单 gate(命中则删文章,后 4 步跳过)
|
||||
2. format — 排版译文(写入 body_zh_formatted,容器用 .article-body + 段落 .diary-para)
|
||||
3. image — 生成插图(写入 image_ai_url,prompt 用正文第一段)
|
||||
4. commentary_angel — 写 Angel 评论(写入 commentary)
|
||||
5. commentary_meituan — 写美团评论(写入 commentary_meituan)
|
||||
|
||||
双 provider 评论:Angel + 美团 大模型(LongCat) 并行,各自独立 try/except,
|
||||
任一失败不影响另一个。commentary_engine 字段记录实际写入的 provider。
|
||||
|
||||
排版容器 CSS(固定,不再让用户改):
|
||||
- 字体: system-ui 字体栈
|
||||
@@ -39,6 +43,12 @@ from app.models.llm_setting import LlmSetting
|
||||
from app.models.source import Source
|
||||
from app.schemas.llm import get_default_prompts
|
||||
from app.services.llm.client import LlmClient
|
||||
from app.services.llm.providers import (
|
||||
PROVIDER_ANGEL,
|
||||
PROVIDER_COMMENTARY_DEFAULTS,
|
||||
PROVIDER_MEITUAN,
|
||||
is_provider_enabled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("news.llm.enrichment")
|
||||
|
||||
@@ -144,6 +154,12 @@ async def get_setting() -> LlmSetting:
|
||||
return row
|
||||
|
||||
|
||||
# === 双 provider 评论 ===
|
||||
# Angel: commentary / commentary_status(沿用旧字段,完全不动)
|
||||
# 美团: commentary_meituan / commentary_meituan_status / commentary_meituan_model / commentary_meituan_error
|
||||
# commentary_engine 记录实际写入的 provider:angel / meituan / "angel,meituan"
|
||||
|
||||
|
||||
# === 单任务:format ===
|
||||
async def _enrich_format(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
||||
template = setting.format_prompt or get_default_prompts()["format_prompt"]
|
||||
@@ -270,9 +286,18 @@ def _first_paragraph(text: str, max_chars: int) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# === 单任务:commentary ===
|
||||
async def _enrich_commentary(article: Article, setting: LlmSetting, client: LlmClient) -> None:
|
||||
template = setting.commentary_prompt or get_default_prompts()["commentary_prompt"]
|
||||
# === 单任务:commentary(provider 通用版)===
|
||||
# provider=PROVIDER_ANGEL → 写入 commentary / commentary_status(老字段,完全不动)
|
||||
# provider=PROVIDER_MEITUAN → 写入 commentary_meituan / commentary_meituan_status / commentary_meituan_model / commentary_meituan_error
|
||||
def _default_commentary_prompt() -> str:
|
||||
return get_default_prompts()["commentary_prompt"]
|
||||
|
||||
|
||||
async def _enrich_commentary_angel(
|
||||
article: Article, setting: LlmSetting, client: LlmClient
|
||||
) -> None:
|
||||
"""Angel 评论 — 写入老字段(向后兼容)。"""
|
||||
template = setting.commentary_prompt or _default_commentary_prompt()
|
||||
prompt = _safe_format(
|
||||
template,
|
||||
{
|
||||
@@ -280,23 +305,65 @@ async def _enrich_commentary(article: Article, setting: LlmSetting, client: LlmC
|
||||
"body": (article.body_zh_text or "")[:3000],
|
||||
},
|
||||
)
|
||||
defaults = PROVIDER_COMMENTARY_DEFAULTS[PROVIDER_ANGEL]
|
||||
text = await client.chat(
|
||||
system="你是资深新闻评论员。",
|
||||
system=defaults["system"],
|
||||
user=prompt,
|
||||
temperature=0.6,
|
||||
max_tokens=600,
|
||||
temperature=defaults["temperature"],
|
||||
max_tokens=defaults["max_tokens"],
|
||||
)
|
||||
article.commentary = text or None
|
||||
article.commentary_status = "ok"
|
||||
# 记录 provider(已存在的 "angel" / 追加为 "angel,meituan")
|
||||
engines = set(filter(None, (article.commentary_engine or "").split(",")))
|
||||
engines.add(PROVIDER_ANGEL)
|
||||
article.commentary_engine = ",".join(sorted(engines))
|
||||
|
||||
|
||||
async def _enrich_commentary_meituan(
|
||||
article: Article, setting: LlmSetting, client: LlmClient
|
||||
) -> None:
|
||||
"""美团评论 — 写入 commentary_meituan 等新字段。"""
|
||||
# 优先用 setting.meituan_commentary_prompt,留空用默认
|
||||
template = setting.meituan_commentary_prompt or _default_commentary_prompt()
|
||||
prompt = _safe_format(
|
||||
template,
|
||||
{
|
||||
"title": (article.title_zh or article.title)[:200],
|
||||
"body": (article.body_zh_text or "")[:3000],
|
||||
},
|
||||
)
|
||||
defaults = PROVIDER_COMMENTARY_DEFAULTS[PROVIDER_MEITUAN]
|
||||
try:
|
||||
text = await client.chat(
|
||||
system=defaults["system"],
|
||||
user=prompt,
|
||||
temperature=defaults["temperature"],
|
||||
max_tokens=defaults["max_tokens"],
|
||||
)
|
||||
article.commentary_meituan = text or None
|
||||
article.commentary_meituan_status = "ok"
|
||||
article.commentary_meituan_error = None
|
||||
article.commentary_meituan_model = client.chat_model
|
||||
engines = set(filter(None, (article.commentary_engine or "").split(",")))
|
||||
engines.add(PROVIDER_MEITUAN)
|
||||
article.commentary_engine = ",".join(sorted(engines))
|
||||
except Exception as e:
|
||||
# 美团 provider 失败,标 failed 但不影响 Angel
|
||||
article.commentary_meituan_status = "failed"
|
||||
article.commentary_meituan_error = f"{type(e).__name__}: {e}"[:1000]
|
||||
article.commentary_meituan = None
|
||||
raise
|
||||
|
||||
|
||||
# === 总编排:enrich_article ===
|
||||
async def enrich_article(article_id: int) -> dict[str, str]:
|
||||
"""对单篇文章做 4 项 LLM 增强。
|
||||
"""对单篇文章做 5 项 LLM 增强。
|
||||
|
||||
顺序:classify(黑名单 gate) → format → image → commentary
|
||||
顺序:classify(黑名单 gate) → format → image → commentary(angel + meituan 并行)
|
||||
- classify 命中 blocklist → 整篇文章 DELETE,后续任务直接 return
|
||||
- 任一任务失败,只标 status 不影响其他任务
|
||||
- 双 provider 评论:Angel 和美团 用 asyncio.gather 并行,任一失败不影响另一个
|
||||
|
||||
返回 {task: status} 字典(用于日志)。
|
||||
"""
|
||||
@@ -315,7 +382,10 @@ async def enrich_article(article_id: int) -> dict[str, str]:
|
||||
setting = await get_setting()
|
||||
if not setting.enabled:
|
||||
logger.info("enrich_article: llm disabled, skip id=%s", article_id)
|
||||
return {"format": "skipped", "classify": "skipped", "image": "skipped", "commentary": "skipped"}
|
||||
return {
|
||||
"format": "skipped", "classify": "skipped", "image": "skipped",
|
||||
"commentary_angel": "skipped", "commentary_meituan": "skipped",
|
||||
}
|
||||
|
||||
# 用配置生成 client(允许热改设置)
|
||||
client = LlmClient(
|
||||
@@ -324,6 +394,12 @@ async def enrich_article(article_id: int) -> dict[str, str]:
|
||||
interval_sec=setting.interval_sec,
|
||||
)
|
||||
|
||||
# 美团 provider client(可能为 None = 未配置)
|
||||
meituan_client = None
|
||||
if is_provider_enabled(PROVIDER_MEITUAN, setting):
|
||||
from app.services.llm.providers import get_meituan_client
|
||||
meituan_client = get_meituan_client(setting)
|
||||
|
||||
results: dict[str, str] = {}
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -341,14 +417,17 @@ async def enrich_article(article_id: int) -> dict[str, str]:
|
||||
if cats:
|
||||
art.category = ",".join(cats)[:64] or None
|
||||
if drop:
|
||||
# 命中 blocklist → 删文章,后续 3 步全跳
|
||||
# 命中 blocklist → 删文章,后续 4 步全跳
|
||||
logger.info(
|
||||
"enrich_article id=%s dropped (blocklist hit, cats=%s, blocklist=%s)",
|
||||
article_id, cats, blocklist,
|
||||
)
|
||||
await session.delete(art)
|
||||
await session.commit()
|
||||
return {"classify": "dropped", "format": "skipped", "image": "skipped", "commentary": "skipped"}
|
||||
return {
|
||||
"classify": "dropped", "format": "skipped", "image": "skipped",
|
||||
"commentary_angel": "skipped", "commentary_meituan": "skipped",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("enrich classify failed for article %s: %s", article_id, e)
|
||||
art.classify_status = "failed"
|
||||
@@ -373,14 +452,32 @@ async def enrich_article(article_id: int) -> dict[str, str]:
|
||||
art.image_ai_status = "failed"
|
||||
results["image"] = f"failed:{type(e).__name__}"
|
||||
|
||||
# === 4) commentary ===
|
||||
try:
|
||||
await _enrich_commentary(art, setting, client)
|
||||
results["commentary"] = "ok"
|
||||
except Exception as e:
|
||||
logger.exception("enrich commentary failed for article %s: %s", article_id, e)
|
||||
art.commentary_status = "failed"
|
||||
results["commentary"] = f"failed:{type(e).__name__}"
|
||||
# === 4 + 5) commentary_angel + commentary_meituan 并行 ===
|
||||
# 关键:每个 provider 独立的 try/except,任一失败不影响另一个
|
||||
# 但 gather 需要返回 tuple,这里用嵌套函数封装
|
||||
async def _safe_angel() -> None:
|
||||
try:
|
||||
await _enrich_commentary_angel(art, setting, client)
|
||||
results["commentary_angel"] = "ok"
|
||||
except Exception as e:
|
||||
logger.exception("enrich commentary_angel failed for article %s: %s", article_id, e)
|
||||
art.commentary_status = "failed"
|
||||
results["commentary_angel"] = f"failed:{type(e).__name__}"
|
||||
|
||||
async def _safe_meituan() -> None:
|
||||
if meituan_client is None:
|
||||
art.commentary_meituan_status = "n/a"
|
||||
results["commentary_meituan"] = "n/a"
|
||||
return
|
||||
try:
|
||||
await _enrich_commentary_meituan(art, setting, meituan_client)
|
||||
results["commentary_meituan"] = "ok"
|
||||
except Exception as e:
|
||||
logger.exception("enrich commentary_meituan failed for article %s: %s", article_id, e)
|
||||
# status 已在内部置 failed
|
||||
results["commentary_meituan"] = f"failed:{type(e).__name__}"
|
||||
|
||||
await asyncio.gather(_safe_angel(), _safe_meituan())
|
||||
|
||||
await session.commit()
|
||||
logger.info("enrich_article id=%s: %s", article_id, results)
|
||||
@@ -423,6 +520,8 @@ async def enrichment_loop() -> None:
|
||||
| (Article.commentary_status != "ok")
|
||||
| (Article.image_ai_status.is_(None))
|
||||
| (Article.image_ai_status != "ok")
|
||||
| (Article.commentary_meituan_status.is_(None))
|
||||
| (Article.commentary_meituan_status.in_(("n/a", "pending", "failed")))
|
||||
),
|
||||
)
|
||||
.order_by(Article.id.asc())
|
||||
@@ -431,7 +530,7 @@ async def enrichment_loop() -> None:
|
||||
).scalars()
|
||||
candidates = list(rows)
|
||||
|
||||
# 过滤:任一 *_status 是 pending
|
||||
# 过滤:任一 *_status 是 pending(包括 NULL 和 n/a)
|
||||
todo_ids: list[int] = []
|
||||
for a in candidates:
|
||||
statuses = [
|
||||
@@ -439,6 +538,7 @@ async def enrichment_loop() -> None:
|
||||
a.classify_status or "pending",
|
||||
a.image_ai_status or "pending",
|
||||
a.commentary_status or "pending",
|
||||
a.commentary_meituan_status or "pending",
|
||||
]
|
||||
if any(s in ("pending", "failed", "n/a") for s in statuses):
|
||||
todo_ids.append(a.id)
|
||||
@@ -450,7 +550,7 @@ async def enrichment_loop() -> None:
|
||||
continue
|
||||
|
||||
# 并发 enrich 多篇(LlmClient 内部 interval_sec 已经做了限速,这里只并发不限并发上限)
|
||||
# 但为了不让 Agnes API 同时打太多,加一层并发上限
|
||||
# 但为了不让 LLM API 同时打太多,加一层并发上限
|
||||
sem = asyncio.Semaphore(3)
|
||||
async def _run_one(aid: int) -> None:
|
||||
async with sem:
|
||||
|
||||
102
backend/app/services/llm/providers.py
Normal file
102
backend/app/services/llm/providers.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""LLM provider 工厂。
|
||||
|
||||
历史:全站只用一个 LlmClient(单例)指 Agnes。
|
||||
现在:支持多个 provider,各自独立 base_url / api_key / model / 节流。
|
||||
|
||||
- `get_angel_client(setting)` — Agnes 客户端(原 LlmClient 等价)
|
||||
- `get_meituan_client(setting)` — 美团大模型客户端(OpenAI 兼容,LongCat)
|
||||
|
||||
设计:
|
||||
- 工厂每次返回新实例(无状态;节流靠 client 内部 Semaphore 自带)
|
||||
- Provider 不可用(api_key 空)= 返回 None
|
||||
- `get_provider_commentary_defaults()` 暴露 Angel / 美团 的 temperature / max_tokens / system 差异。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.models.llm_setting import LlmSetting
|
||||
from app.services.llm.client import LlmClient
|
||||
|
||||
logger = logging.getLogger("news.llm.providers")
|
||||
|
||||
# === Provider 名常量(供 enrichment/前端/日志统一引用)===
|
||||
PROVIDER_ANGEL = "angel" # Agnes(原 LlmClient 默认端点)
|
||||
PROVIDER_MEITUAN = "meituan" # 美团大模型(LongCat,OpenAI 兼容)
|
||||
|
||||
|
||||
def get_angel_client(setting: LlmSetting) -> LlmClient:
|
||||
"""Agnes 客户端 — 与 LlmClient 单例行为完全一致。"""
|
||||
return LlmClient(
|
||||
chat_model=setting.chat_model,
|
||||
image_model=setting.image_model,
|
||||
interval_sec=setting.interval_sec,
|
||||
)
|
||||
|
||||
|
||||
def get_meituan_client(setting: LlmSetting) -> LlmClient | None:
|
||||
"""美团大模型(LongCat)客户端。
|
||||
配置来源:llm_settings 表里 meituan_* 字段(API key / base_url / model / interval / enabled)。
|
||||
"""
|
||||
from app.config import settings as app_settings # 延迟导入,避免循环
|
||||
|
||||
api_key = getattr(setting, "meituan_api_key", "") or app_settings.meituan_api_key
|
||||
base_url = (
|
||||
getattr(setting, "meituan_base_url", "") or app_settings.meituan_base_url
|
||||
)
|
||||
model = (
|
||||
getattr(setting, "meituan_chat_model", "") or app_settings.meituan_chat_model
|
||||
)
|
||||
interval = (
|
||||
getattr(setting, "meituan_interval_sec", None) or app_settings.meituan_interval_sec
|
||||
)
|
||||
if not api_key:
|
||||
return None
|
||||
return LlmClient(
|
||||
base_url=base_url or "https://api.longcat.chat/openai/v1",
|
||||
api_key=api_key,
|
||||
chat_model=model or "LongCat-2.0-Preview",
|
||||
interval_sec=float(interval or 2.0),
|
||||
)
|
||||
|
||||
|
||||
def get_provider_client(provider: str, setting: LlmSetting) -> LlmClient | None:
|
||||
"""统一入口:按 provider 名取客户端。不可用时返回 None。"""
|
||||
if provider == PROVIDER_ANGEL:
|
||||
c = get_angel_client(setting)
|
||||
return c if c.is_configured() else None
|
||||
if provider == PROVIDER_MEITUAN:
|
||||
return get_meituan_client(setting)
|
||||
raise ValueError(f"unknown provider: {provider}")
|
||||
|
||||
|
||||
def is_provider_enabled(provider: str, setting: LlmSetting) -> bool:
|
||||
"""provider 是否启用 + 配置齐全。"""
|
||||
if not setting.enabled:
|
||||
return False
|
||||
if provider == PROVIDER_ANGEL:
|
||||
return get_provider_client(PROVIDER_ANGEL, setting) is not None
|
||||
if provider == PROVIDER_MEITUAN:
|
||||
if not bool(getattr(setting, "meituan_enabled", True)):
|
||||
return False
|
||||
return get_provider_client(PROVIDER_MEITUAN, setting) is not None
|
||||
return False
|
||||
|
||||
|
||||
# === Provider 评论差异(温度 / max_tokens / system)===
|
||||
# Angel: temperature=0.6, max_tokens=600, system="你是资深新闻评论员。"
|
||||
# 美团: temperature=0.7, max_tokens=1000, system=None(用户示例无 system 字段)
|
||||
|
||||
PROVIDER_COMMENTARY_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
PROVIDER_ANGEL: {
|
||||
"temperature": 0.6,
|
||||
"max_tokens": 600,
|
||||
"system": "你是资深新闻评论员。",
|
||||
},
|
||||
PROVIDER_MEITUAN: {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1000,
|
||||
"system": None,
|
||||
},
|
||||
}
|
||||
2057
frontend/package-lock.json
generated
Normal file
2057
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,12 @@ export interface ArticleListItem {
|
||||
fetched_at: string
|
||||
image_url?: string | null
|
||||
// 列表预览钩子(首页展示用,详情页看完整版)
|
||||
// 双 provider 评论:Angel(原字段)+ 美团(meituan 字段)
|
||||
commentary?: string | null
|
||||
commentary_status?: string | null
|
||||
commentary_meituan?: string | null
|
||||
commentary_meituan_status?: string | null
|
||||
commentary_engine?: string | null // angel / meituan / "angel,meituan"
|
||||
image_ai_url?: string | null
|
||||
is_starred: boolean
|
||||
}
|
||||
@@ -64,8 +68,13 @@ export interface ArticleDetail extends ArticleListItem {
|
||||
classify_status?: string | null
|
||||
image_ai_status?: string | null
|
||||
commentary_status?: string | null
|
||||
commentary_meituan_status?: string | null
|
||||
commentary_engine?: string | null
|
||||
// === LLM 内容 ===
|
||||
commentary?: string | null
|
||||
commentary?: string | null // Angel
|
||||
commentary_meituan?: string | null
|
||||
commentary_meituan_model?: string | null
|
||||
commentary_meituan_error?: string | null
|
||||
entities?: Record<string, any> | null
|
||||
sentiment?: number | null
|
||||
duplicate_of?: number | null
|
||||
@@ -83,6 +92,13 @@ export interface LlmSetting {
|
||||
enabled: boolean
|
||||
// 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
|
||||
blocklist_tags?: string[]
|
||||
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
||||
meituan_api_key_set?: boolean // 不回传 key 真值
|
||||
meituan_base_url?: string
|
||||
meituan_chat_model?: string
|
||||
meituan_interval_sec?: number
|
||||
meituan_enabled?: boolean
|
||||
meituan_commentary_prompt?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
@@ -156,6 +172,11 @@ export const adminApi = {
|
||||
'/admin/llm/settings/test'
|
||||
).then((r) => r.data)
|
||||
},
|
||||
testMeituanConnection() {
|
||||
return http.post<{ ok: boolean; detail: string; configured: boolean }>(
|
||||
'/admin/llm/settings/test-meituan'
|
||||
).then((r) => r.data)
|
||||
},
|
||||
triggerEnrich(articleId: number) {
|
||||
return http.post<{ triggered: boolean; detail: string; results: Record<string, string> | null }>(
|
||||
`/admin/llm/enrich/${articleId}`
|
||||
|
||||
@@ -20,8 +20,31 @@ const setting = ref<LlmSetting>({
|
||||
interval_sec: 2.0,
|
||||
enabled: true,
|
||||
blocklist_tags: [],
|
||||
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
|
||||
meituan_api_key_set: false,
|
||||
meituan_base_url: 'https://api.longcat.chat/openai/v1',
|
||||
meituan_chat_model: 'LongCat-2.0-Preview',
|
||||
meituan_interval_sec: 2.0,
|
||||
meituan_enabled: true,
|
||||
meituan_commentary_prompt: '',
|
||||
})
|
||||
|
||||
// === 美团 api_key 编辑(不回显真值)===
|
||||
const meituanKeyInput = ref('')
|
||||
const meituanKeyHasValue = ref(false) // 当前 DB 是否已设置
|
||||
const meituanKeyPlaceholder = computed(() => {
|
||||
if (meituanKeyHasValue.value) return '已配置(留空 = 不修改,输入新值 = 覆盖)'
|
||||
return '请输入 LongCat API Key'
|
||||
})
|
||||
|
||||
function loadMeituanKeyState() {
|
||||
// 从 setting 的 meituan_api_key_set 推断
|
||||
meituanKeyHasValue.value = !!setting.value.meituan_api_key_set
|
||||
meituanKeyInput.value = ''
|
||||
}
|
||||
|
||||
watch(() => setting.value.meituan_api_key_set, loadMeituanKeyState)
|
||||
|
||||
// === 屏蔽分类标签(文本框 ↔ 数组) ===
|
||||
// UI 用逗号分隔,存到 setting.blocklist_tags(数组)
|
||||
const blocklistText = computed({
|
||||
@@ -35,11 +58,13 @@ const blocklistText = computed({
|
||||
})
|
||||
|
||||
const testResult = ref<{ ok: boolean; detail: string; configured: boolean } | null>(null)
|
||||
const meituanTestResult = ref<{ ok: boolean; detail: string; configured: boolean } | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
setting.value = await adminApi.getLlmSettings()
|
||||
loadMeituanKeyState()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '加载失败')
|
||||
} finally {
|
||||
@@ -50,8 +75,20 @@ async function load() {
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await adminApi.updateLlmSettings(setting.value)
|
||||
// 美团 api_key:有输入才提交(否则不修改);清空用 "clear" 信号
|
||||
const body: any = { ...setting.value }
|
||||
delete body.meituan_api_key_set // 后端不需要这个字段
|
||||
if (meituanKeyInput.value === '__CLEAR__') {
|
||||
body.meituan_api_key = ''
|
||||
} else if (meituanKeyInput.value && meituanKeyInput.value.trim()) {
|
||||
body.meituan_api_key = meituanKeyInput.value.trim()
|
||||
} else {
|
||||
delete body.meituan_api_key
|
||||
}
|
||||
const updated = await adminApi.updateLlmSettings(body)
|
||||
setting.value = updated
|
||||
meituanKeyInput.value = '' // 重置输入
|
||||
loadMeituanKeyState()
|
||||
message.success('已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '保存失败')
|
||||
@@ -86,6 +123,21 @@ async function test() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testMeituan() {
|
||||
testing.value = true
|
||||
meituanTestResult.value = null
|
||||
try {
|
||||
meituanTestResult.value = await adminApi.testMeituanConnection()
|
||||
if (meituanTestResult.value.ok) message.success('美团连接 OK')
|
||||
else message.warning('美团连接失败')
|
||||
} catch (e: any) {
|
||||
meituanTestResult.value = { ok: false, detail: e?.message || '请求失败', configured: true }
|
||||
message.error('测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -139,6 +191,73 @@ onMounted(load)
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<NCard title="🐱 美团大模型(LongCat,双 provider 评论)" style="margin-top: 16px">
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
与 Angel(Agnes)并列,各跑各的 prompt,结果存到 articles.commentary_meituan 等字段。
|
||||
留空 api_key = 关闭该 provider,Angel 仍正常工作。
|
||||
</NText>
|
||||
<NSpace vertical style="margin-top: 12px">
|
||||
<NSpace align="center">
|
||||
<NText>启用美团 provider:</NText>
|
||||
<NSwitch v-model:value="setting.meituan_enabled" />
|
||||
<NText v-if="!setting.meituan_enabled" depth="3" style="font-size: 12px">(关闭后不再调美团评论)</NText>
|
||||
</NSpace>
|
||||
<NSpace align="center">
|
||||
<NText>API Key:</NText>
|
||||
<NInput
|
||||
v-model:value="meituanKeyInput"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="meituanKeyPlaceholder"
|
||||
style="width: 360px"
|
||||
/>
|
||||
<NButton
|
||||
v-if="meituanKeyHasValue"
|
||||
size="small"
|
||||
type="warning"
|
||||
ghost
|
||||
@click="meituanKeyInput = '__CLEAR__'"
|
||||
>
|
||||
清空
|
||||
</NButton>
|
||||
<NText v-if="meituanKeyHasValue" depth="3" style="font-size: 12px; color: #18a058">● 已配置</NText>
|
||||
<NText v-else depth="3" style="font-size: 12px; color: #d03050">● 未配置</NText>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText>Base URL:</NText>
|
||||
<NInput v-model:value="setting.meituan_base_url" placeholder="https://api.longcat.chat/openai/v1" style="width: 380px" />
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText>Chat 模型:</NText>
|
||||
<NInput v-model:value="setting.meituan_chat_model" placeholder="LongCat-2.0-Preview" style="width: 240px" />
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText>调用间隔(秒):</NText>
|
||||
<NInputNumber v-model:value="setting.meituan_interval_sec" :min="0" :max="60" :step="0.5" />
|
||||
<NText depth="3" style="font-size: 12px">(chat 串行,每次调用后等这么久)</NText>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NButton :loading="testing" @click="testMeituan">测美团连接</NButton>
|
||||
<NText v-if="meituanTestResult" :type="meituanTestResult.ok ? 'success' : 'warning'" style="font-size: 12px">
|
||||
{{ meituanTestResult.detail }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NDivider style="margin: 12px 0" />
|
||||
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
美团点评 prompt — 模板变量: <NCode>{title}</NCode> = 译后标题, <NCode>{body}</NCode> = 译文正文。留空用默认。
|
||||
</NText>
|
||||
<NInput
|
||||
v-model:value="setting.meituan_commentary_prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 12 }"
|
||||
placeholder="留空用默认"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</NCard>
|
||||
|
||||
<NCard title="排版提示词" style="margin-top: 16px">
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
模板变量: <NCode>{body}</NCode> = 译文正文
|
||||
|
||||
@@ -245,7 +245,10 @@ onMounted(load)
|
||||
插图:{{ article.image_ai_status || 'n/a' }}
|
||||
</NTag>
|
||||
<NTag size="tiny" :type="statusTagType(article.commentary_status)" :bordered="false" round>
|
||||
点评:{{ article.commentary_status || 'n/a' }}
|
||||
点评(Angel):{{ article.commentary_status || 'n/a' }}
|
||||
</NTag>
|
||||
<NTag size="tiny" :type="statusTagType(article.commentary_meituan_status)" :bordered="false" round>
|
||||
点评(美团):{{ article.commentary_meituan_status || 'n/a' }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
@@ -255,10 +258,10 @@ onMounted(load)
|
||||
本条翻译失败,可点 "重译" 重试,或查看后端日志。
|
||||
</NAlert>
|
||||
|
||||
<!-- 1) 评论(LLM 点评) -->
|
||||
<!-- 1) 评论(双 provider:Angel + 美团,各自一张卡) -->
|
||||
<NCard v-if="article.commentary" class="detail-card" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<span class="card-header-title">💬 评论</span>
|
||||
<span class="card-header-title">💬 Angel 评论</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NTag size="tiny" :type="statusTagType(article.commentary_status)" :bordered="false" round>
|
||||
@@ -268,6 +271,31 @@ onMounted(load)
|
||||
<p class="commentary-text-detail">{{ article.commentary }}</p>
|
||||
</NCard>
|
||||
|
||||
<NCard v-if="article.commentary_meituan" class="detail-card" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<span class="card-header-title commentary-header-meituan">🐱 美团评论</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NTag size="tiny" :type="statusTagType(article.commentary_meituan_status)" :bordered="false" round>
|
||||
{{ article.commentary_meituan_status || 'n/a' }}
|
||||
</NTag>
|
||||
</template>
|
||||
<p class="commentary-text-detail">{{ article.commentary_meituan }}</p>
|
||||
<NText v-if="article.commentary_meituan_model" :depth="3" style="font-size: 11px; display:block; margin-top:8px">
|
||||
模型: {{ article.commentary_meituan_model }}
|
||||
</NText>
|
||||
</NCard>
|
||||
|
||||
<NAlert
|
||||
v-else-if="article.commentary_meituan_status === 'failed' && article.commentary_meituan_error"
|
||||
type="warning"
|
||||
style="margin-top: 16px"
|
||||
:show-icon="false"
|
||||
>
|
||||
<div><strong>美团评论生成失败</strong></div>
|
||||
<div style="font-size: 12px; margin-top: 4px">{{ article.commentary_meituan_error }}</div>
|
||||
</NAlert>
|
||||
|
||||
<!-- 2) 译文(优先 LLM 排版版) -->
|
||||
<div v-if="showTranslation" style="margin-top: 16px">
|
||||
<NCard v-if="article.body_zh_formatted" class="detail-card">
|
||||
@@ -339,6 +367,10 @@ onMounted(load)
|
||||
color: var(--color-letter);
|
||||
}
|
||||
|
||||
.commentary-header-meituan {
|
||||
color: #c2410c; /* 橙色,与 Angel header 区分 */
|
||||
}
|
||||
|
||||
.commentary-text-detail {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.85;
|
||||
|
||||
@@ -224,20 +224,36 @@ onMounted(async () => {
|
||||
{{ bodyExcerpt(a.body_zh_text || a.summary_zh, 220) }}
|
||||
</div>
|
||||
|
||||
<!-- 评论钩子(淡木色背景 + 木色左边框,与 Android 对齐) -->
|
||||
<!-- 评论钩子(双 provider:Angel + 美团,淡木色背景 + 木色左边框,与 Android 对齐) -->
|
||||
<div
|
||||
v-if="a.commentary"
|
||||
v-if="a.commentary || a.commentary_meituan"
|
||||
class="commentary-box"
|
||||
>
|
||||
<NSpace align="center" :size="6" style="margin-bottom: 6px">
|
||||
<span class="commentary-label">💬 评论</span>
|
||||
<NTag size="tiny" :type="commentaryStatusType(a.commentary_status)" round :bordered="false">
|
||||
{{ a.commentary_status || 'n/a' }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<div class="commentary-text">
|
||||
{{ previewCommentary(a.commentary, 140) }}
|
||||
</div>
|
||||
<!-- Angel 评论 -->
|
||||
<template v-if="a.commentary">
|
||||
<NSpace align="center" :size="6" style="margin-bottom: 6px">
|
||||
<span class="commentary-label">💬 Angel 评论</span>
|
||||
<NTag size="tiny" :type="commentaryStatusType(a.commentary_status)" round :bordered="false">
|
||||
{{ a.commentary_status || 'n/a' }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<div class="commentary-text">
|
||||
{{ previewCommentary(a.commentary, 140) }}
|
||||
</div>
|
||||
</template>
|
||||
<!-- 美团评论 -->
|
||||
<template v-if="a.commentary_meituan">
|
||||
<div v-if="a.commentary" class="commentary-divider" />
|
||||
<NSpace align="center" :size="6" style="margin-bottom: 6px">
|
||||
<span class="commentary-label commentary-label-meituan">🐱 美团评论</span>
|
||||
<NTag size="tiny" :type="commentaryStatusType(a.commentary_meituan_status)" round :bordered="false">
|
||||
{{ a.commentary_meituan_status || 'n/a' }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<div class="commentary-text">
|
||||
{{ previewCommentary(a.commentary_meituan, 140) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
@@ -277,12 +293,23 @@ onMounted(async () => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.commentary-label-meituan {
|
||||
color: #c2410c; /* 橙色,与 Angel 区分 */
|
||||
}
|
||||
|
||||
.commentary-text {
|
||||
color: var(--color-letter);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.commentary-divider {
|
||||
height: 1px;
|
||||
background: var(--color-primary-soft);
|
||||
margin: 10px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ===== 桌面端默认宽度 ===== */
|
||||
.feed-source-select {
|
||||
min-width: 240px;
|
||||
|
||||
Reference in New Issue
Block a user