feat: 舆情分析智能体改为经 Gateway 转发外部 AG-UI 网关

前端不再直连上游 SSE,改为 POST /api/sentiment-agent/stream 由后台代理转发,支持关闭 TLS 校验并回传完整错误信息。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-25 18:26:48 +08:00
1 parent 5c0864bf0a
commit 62a3faa7c8
17 files changed
+398 -130

No files matched your search

+2 -1
View File
@@ -346,6 +346,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
| **Compact Menu Overrides** (`/api/compact-menu-overrides`) | Compact/sidebar-summary left-nav overlay (简介模式菜单), **global/shared**. Two independent sets keyed by `menu_type` (`A` default / `B`); saving one type does not wipe the other. Type B rows use a `B::` node_id prefix so they stay unique on databases that still use `node_id` as the sole PK. `GET ?menu_type=` lists one type (open); `PUT {menu_type, overrides}` admin-only replaces that type. Frontend: `MenuManagementPage` 简介模式 type switcher + `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. Tests: `tests/test_compact_menu_overrides.py`. |
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
| **Sentiment Agent Proxy** (`/api/sentiment-agent`) | BFF for the frontend 舆情分析 virtual agent. `POST /stream` accepts the AG-UI body plus `apiUrl`/`authUsername`/`authPassword` from `runtime-config.js`, forwards to that URL with TLS verify off, and byte-pipes upstream SSE. Connection/HTTP failures return the full error in `detail`; mid-stream failures emit AG-UI `RUN_ERROR`. Distinct from `skill-packages/sentiment-analysis.skill`. Tests: `tests/test_sentiment_agent_proxy.py`. |
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business/purpose/host), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
@@ -736,7 +737,7 @@ MySQL index script synchronized.
or leaving the prompt blank emits no block. Tests:
`tests/test_es_query_routing_prompt.py`.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the existing frontend virtual-agent path. Its self-contained `config/sentiment-agent.json` owns the external AG-UI address; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`.
- **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the frontend virtual-agent chat page. The chat page goes through Gateway `POST /api/sentiment-agent/stream` (runtime-config URL/Basic Auth in the body, TLS verify off, raw AG-UI SSE passthrough). The skill package's self-contained `config/sentiment-agent.json` owns the external AG-UI address for agent-skill callers; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`.
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
#### Skill Address Management (技能地址管理 — URL/IP 批量替换)
@@ -162,6 +162,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET /taskAnalyseSearch/cop-task-three-list` | TaskCOP compatibility API (authenticated): paginated task list for the situation-overview sentiment page (`content`, `taskStatus`, `taskDirection`, `startTime`, `endTime`) |
| `GET/POST/PUT /api/task-reports/by-task/{task_id}` | Shared task-scoped situation-report detail. Query, first save, and full update all use the legacy-compatible `{state, msg, data:[{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}]}` shape; `contentJson` is preserved as a JSON string. Saving a non-empty report automatically moves the matching TaskCOP task to status `25` (the legacy list's “已完成” state). |
| `POST /api/task-reports/import/by-task/{task_id}` | TaskCOP detail import (authenticated): accept a `sq-report-mock.json`-compatible `data`/`records` array and atomically replace the selected task's full report. The path task id overrides task ids inside the file. |
| `POST /api/sentiment-agent/stream` | Authenticated BFF for the frontend sentiment-analysis virtual agent. Forwards the AG-UI JSON body to the `apiUrl` supplied from `runtime-config.js` (Basic Auth from the same payload). TLS certificate verification is off. Upstream SSE is piped through unchanged; connection/HTTP failures return the complete error in `detail`. |
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges local Vite origins `http://localhost|127.0.0.1:{5173,5174,3000,8080}`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. This keeps a Vite server that falls back from 5173 to 5174 (or the reverse) working after a backend restart. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
@@ -36,11 +36,11 @@ from app.gateway.routers import (
browser_context,
business_mapping,
channels,
checkpoint_migration,
compact_menu_overrides,
external_llmwiki,
feedback,
fixed_questions,
checkpoint_migration,
compact_menu_overrides,
external_llmwiki,
feedback,
fixed_questions,
dashboard_sessions,
deep_research,
html_page_favorites,
@@ -48,9 +48,9 @@ from app.gateway.routers import (
knowledge,
knowledge_ingest,
light_apps,
llm_metrics,
llmwiki,
llmwiki_index,
llm_metrics,
llmwiki,
llmwiki_index,
mcp,
memory,
menu_overrides,
@@ -82,6 +82,7 @@ from app.gateway.routers import (
runs,
scheduled_tasks,
sensitive_words,
sentiment_agent,
skill_addresses,
skills,
suggestions,
@@ -989,23 +990,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
async with langgraph_runtime(app):
logger.info("LangGraph runtime initialised")
# Run Alembic migrations before anything else touches the schema.
await _run_db_migrations()
# Local Wiki mirror/index scans are opt-in and run outside request
# handling. Per-library database leases keep multi-worker startup safe.
app.state.llmwiki_index_scheduler_task = None
local_wiki_index = app.state.config.llmwiki.local_wiki_index
if (
local_wiki_index.enabled
and local_wiki_index.auto_sync
and getattr(app.state, "llmwiki_sync_service", None) is not None
):
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
local_wiki_index_scheduler_loop(app)
)
# Run Alembic migrations before anything else touches the schema.
await _run_db_migrations()
# Local Wiki mirror/index scans are opt-in and run outside request
# handling. Per-library database leases keep multi-worker startup safe.
app.state.llmwiki_index_scheduler_task = None
local_wiki_index = app.state.config.llmwiki.local_wiki_index
if (
local_wiki_index.enabled
and local_wiki_index.auto_sync
and getattr(app.state, "llmwiki_sync_service", None) is not None
):
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
local_wiki_index_scheduler_loop(app)
)
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
try:
@@ -1083,26 +1084,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
)
ensure_deep_research_collector_agent()
except Exception:
logger.exception(
"Deep-research collector agent seeding failed at startup (non-fatal)"
)
# 内置「强制检索输出助手」:必须在 _sync_legacy_agents 之前补建,
# 使首次启动就能在智能体管理/广场中使用。每轮检索门禁由 harness
# 的 ForcedResearchMiddleware 执行,而不是仅依赖 SOUL 提示词。
try:
from app.gateway.routers._forced_research_seed import (
ensure_forced_research_agent,
)
ensure_forced_research_agent()
except Exception:
logger.exception(
"Forced-research responder agent seeding failed at startup (non-fatal)"
)
# ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
except Exception:
logger.exception(
"Deep-research collector agent seeding failed at startup (non-fatal)"
)
# 内置「强制检索输出助手」:必须在 _sync_legacy_agents 之前补建,
# 使首次启动就能在智能体管理/广场中使用。每轮检索门禁由 harness
# 的 ForcedResearchMiddleware 执行,而不是仅依赖 SOUL 提示词。
try:
from app.gateway.routers._forced_research_seed import (
ensure_forced_research_agent,
)
ensure_forced_research_agent()
except Exception:
logger.exception(
"Forced-research responder agent seeding failed at startup (non-fatal)"
)
# ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
if business_mapping_store is not None:
@@ -1202,22 +1203,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Scheduled task service failed to start")
yield
llmwiki_index_scheduler_task = getattr(app.state, "llmwiki_index_scheduler_task", None)
if llmwiki_index_scheduler_task is not None:
llmwiki_index_scheduler_task.cancel()
try:
await llmwiki_index_scheduler_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Local Wiki index scheduler shutdown error")
llmwiki_index_jobs = list(getattr(app.state, "llmwiki_index_jobs", set()))
for job in llmwiki_index_jobs:
job.cancel()
if llmwiki_index_jobs:
await asyncio.gather(*llmwiki_index_jobs, return_exceptions=True)
yield
llmwiki_index_scheduler_task = getattr(app.state, "llmwiki_index_scheduler_task", None)
if llmwiki_index_scheduler_task is not None:
llmwiki_index_scheduler_task.cancel()
try:
await llmwiki_index_scheduler_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Local Wiki index scheduler shutdown error")
llmwiki_index_jobs = list(getattr(app.state, "llmwiki_index_jobs", set()))
for job in llmwiki_index_jobs:
job.cancel()
if llmwiki_index_jobs:
await asyncio.gather(*llmwiki_index_jobs, return_exceptions=True)
# ?? curator ???
curator_task = getattr(app.state, "curator_scheduler_task", None)
@@ -1502,10 +1503,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# MCP API is mounted at /api/mcp
app.include_router(mcp.router)
# DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
app.include_router(llmwiki.router)
app.include_router(llmwiki_index.router)
app.include_router(external_llmwiki.router)
# DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
app.include_router(llmwiki.router)
app.include_router(llmwiki_index.router)
app.include_router(external_llmwiki.router)
# Memory API is mounted at /api/memory
app.include_router(memory.router)
@@ -1580,17 +1581,20 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.router)
# Sentiment-analysis virtual agent AG-UI SSE proxy at /api/sentiment-agent
app.include_router(sentiment_agent.router)
# Skill URL/IP batch replace (技能地址管理) at /api/skill-addresses
app.include_router(skill_addresses.router)
# 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
app.include_router(dashboard_sessions.router)
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
# Admin exact-match question/answer short-circuit at /api/fixed-questions
app.include_router(fixed_questions.router)
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
# Admin exact-match question/answer short-circuit at /api/fixed-questions
app.include_router(fixed_questions.router)
# Deep-research report structures (工作流配置 / 报告结构) at /api/report-structures
app.include_router(report_structures.router)
@@ -0,0 +1,220 @@
"""舆情分析虚拟智能体的 AG-UI SSE 反向代理。
前端对话页不直连外部网关(避免浏览器 CORS / 混合内容),改为把
``runtime-config.js`` 里的 ``apiUrl`` / Basic Auth 随 AG-UI 请求体传给本路由。
Gateway 关闭 TLS 校验后按传入地址转发上游 SSE,字节级透传给前端。
连接失败或上游非 2xx 时,把完整错误信息放进 HTTP ``detail``(流已开始则发
AG-UI ``RUN_ERROR`` 帧),前端原样展示。
Routes (prefix ``/api/sentiment-agent``):
POST /stream 透传上游 ``text/event-stream``
"""
from __future__ import annotations
import base64
import json
import logging
import ssl
from collections.abc import AsyncIterator, Mapping
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sentiment-agent", tags=["sentiment-agent"])
_MAX_ERROR_BODY_CHARS = 32_768
class SentimentAgentStreamRequest(BaseModel):
"""前端传入的 AG-UI 体 + runtime-config 上游地址/凭据。"""
model_config = ConfigDict(extra="ignore")
apiUrl: str = Field(default="", min_length=0, max_length=2048)
authUsername: str = Field(default="", max_length=512)
authPassword: str = Field(default="", max_length=512)
threadId: str = Field(..., min_length=1, max_length=256)
runId: str = Field(..., min_length=1, max_length=256)
state: dict[str, Any] = Field(default_factory=dict)
tools: list[Any] = Field(default_factory=list)
context: list[Any] = Field(default_factory=list)
forwardedProps: dict[str, Any] = Field(default_factory=dict)
messages: list[Any] = Field(default_factory=list)
def _unverified_ssl_context() -> ssl.SSLContext:
"""Do not validate the upstream certificate (intranet / self-signed hosts)."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _build_http_client(timeout_seconds: float) -> httpx.AsyncClient:
"""Upstream client: skip TLS cert checks, ignore env proxy, no redirects."""
return httpx.AsyncClient(
verify=_unverified_ssl_context(),
trust_env=False,
follow_redirects=False,
timeout=httpx.Timeout(timeout_seconds, connect=30.0),
)
def _format_exception(exc: BaseException) -> str:
parts = [f"{type(exc).__name__}: {exc}"]
seen = {id(exc)}
cause: BaseException | None = exc.__cause__ or exc.__context__
while cause is not None and id(cause) not in seen:
seen.add(id(cause))
parts.append(f"Caused by {type(cause).__name__}: {cause}")
cause = cause.__cause__ or cause.__context__
return " | ".join(parts)
def _decode_body(content: bytes | None) -> str:
if not content:
return ""
text = content.decode("utf-8", errors="replace").strip()
if len(text) > _MAX_ERROR_BODY_CHARS:
return text[:_MAX_ERROR_BODY_CHARS] + f"\n…(truncated, total {len(text)} chars)"
return text
def format_upstream_error(
*,
kind: str,
url: str,
status: int | None = None,
headers: Mapping[str, str] | None = None,
body: str = "",
exc: BaseException | None = None,
) -> str:
"""Assemble a complete, user-visible upstream failure string."""
lines = [f"舆情分析上游调用失败({kind})", f"url={url}"]
if status is not None:
lines.append(f"http_status={status}")
if headers:
location = headers.get("location") or headers.get("Location")
content_type = headers.get("content-type") or headers.get("Content-Type")
if location:
lines.append(f"location={location}")
if content_type:
lines.append(f"content_type={content_type}")
if body:
lines.append(f"response={body}")
if exc is not None:
lines.append(_format_exception(exc))
request_obj = getattr(exc, "request", None)
method = getattr(request_obj, "method", None)
url_obj = getattr(request_obj, "url", None)
if method and url_obj is not None:
lines.append(f"request={method} {url_obj}")
return "\n".join(lines)
def _upstream_headers(username: str, password: str) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
if username and password:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
headers["Authorization"] = f"Basic {token}"
return headers
def _agui_payload(body: SentimentAgentStreamRequest) -> dict[str, Any]:
return {
"threadId": body.threadId,
"runId": body.runId,
"state": body.state,
"tools": body.tools,
"context": body.context,
"forwardedProps": body.forwardedProps,
"messages": body.messages,
}
def _run_error_frame(message: str) -> bytes:
payload = json.dumps({"type": "RUN_ERROR", "message": message}, ensure_ascii=False)
return f"data: {payload}\n\n".encode()
@router.post("/stream")
async def stream_sentiment_agent(
request: Request,
body: SentimentAgentStreamRequest,
) -> StreamingResponse:
api_url = (body.apiUrl or "").strip()
if not api_url:
raise HTTPException(status_code=400, detail="缺少上游地址 apiUrl(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。")
config = get_app_config().sentiment_agent
payload = _agui_payload(body)
headers = _upstream_headers(body.authUsername, body.authPassword)
client = _build_http_client(config.timeout_seconds)
upstream: httpx.Response | None = None
try:
upstream = await client.send(
client.build_request("POST", api_url, headers=headers, json=payload),
stream=True,
)
except httpx.RequestError as exc:
await client.aclose()
raise HTTPException(
status_code=502,
detail=format_upstream_error(kind="network", url=api_url, exc=exc),
) from exc
if upstream.status_code >= 400:
err_body = _decode_body(await upstream.aread())
err_headers = dict(upstream.headers)
status = upstream.status_code
await upstream.aclose()
await client.aclose()
raise HTTPException(
status_code=502,
detail=format_upstream_error(
kind="http",
url=api_url,
status=status,
headers=err_headers,
body=err_body,
),
)
async def pump() -> AsyncIterator[bytes]:
assert upstream is not None
try:
async for chunk in upstream.aiter_bytes():
if await request.is_disconnected():
break
if chunk:
yield chunk
except httpx.RequestError as exc:
yield _run_error_frame(format_upstream_error(kind="stream", url=api_url, exc=exc))
finally:
await upstream.aclose()
await client.aclose()
return StreamingResponse(
pump(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -573,6 +573,11 @@ log_system:
url: 'https://your-log-system.example.com'
label: '日志系统'
# 舆情分析虚拟智能体:前端改 dist/runtime-config.js 的地址/账号/密码,
# 随请求传给 Gateway 转发;这里只配上游超时。
sentiment_agent:
timeout_seconds: 300
task_deeplink:
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
@@ -11,22 +11,23 @@ from pydantic import BaseModel, ConfigDict, Field
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
from deerflow.config.browser_context_config import BrowserContextConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.concurrency_config import ConcurrencyConfig
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.concurrency_config import ConcurrencyConfig
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
from deerflow.config.llmwiki_config import LlmWikiConfig
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
from deerflow.config.llmwiki_config import LlmWikiConfig
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
from deerflow.config.run_events_config import RunEventsConfig
from deerflow.config.runtime_paths import existing_project_file
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.sentiment_agent_config import SentimentAgentConfig
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import get_overrides_mtime as _get_skill_evolution_overrides_mtime
from deerflow.config.skill_evolution_runtime import load_overrides as _load_skill_evolution_overrides
@@ -43,8 +44,8 @@ from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_searc
from deerflow.config.vision_config import VisionConfig
from deerflow.config.y_log_config import YLogConfig
load_dotenv()
load_dotenv(Path(__file__).resolve().parents[4] / ".env.local", override=True)
load_dotenv()
load_dotenv(Path(__file__).resolve().parents[4] / ".env.local", override=True)
logger = logging.getLogger(__name__)
@@ -117,8 +118,8 @@ class AppConfig(BaseModel):
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)")
log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu")
llmwiki: LlmWikiConfig = Field(default_factory=LlmWikiConfig, description="Conditional legacy/WeKnora LLMWiki integration")
log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu")
llmwiki: LlmWikiConfig = Field(default_factory=LlmWikiConfig, description="Conditional legacy/WeKnora LLMWiki integration")
sandbox: SandboxConfig = Field(description="Sandbox configuration")
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
@@ -141,13 +142,17 @@ class AppConfig(BaseModel):
gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)")
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig, description="Optional cross-worker concurrency coordination")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig, description="Optional cross-worker concurrency coordination")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)")
y_log: YLogConfig = Field(default_factory=YLogConfig, description="操作日志上报配置(前端经 /api/public/config/y-log 读取)")
sentiment_agent: SentimentAgentConfig = Field(
default_factory=SentimentAgentConfig,
description="舆情分析虚拟智能体上游代理超时(地址由前端 runtime-config.js 随请求传入)",
)
@classmethod
def resolve_config_path(cls, config_path: str | None = None) -> Path:
@@ -0,0 +1,19 @@
"""舆情分析虚拟智能体的上游代理配置。
前端从 ``runtime-config.js`` 读取 AG-UI 地址和 Basic Auth,随每次请求传给
Gateway;本段只保留上游超时,地址以请求体里的 ``apiUrl`` 为准。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class SentimentAgentConfig(BaseModel):
"""Gateway 转发舆情分析 AG-UI 流时的超时。"""
timeout_seconds: float = Field(
default=300,
gt=0,
description="上游连接与读取超时(秒);流式问答可能持续数分钟",
)
+5
View File
@@ -331,6 +331,11 @@ y_log:
enabled: true
write_url: https://ch1.b.uat.4.cn/api/y-log/write
# 舆情分析虚拟智能体:前端改 dist/runtime-config.js 的地址/账号/密码,
# 随请求传给 Gateway 转发;这里只配上游超时。
sentiment_agent:
timeout_seconds: 300
summarization:
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep