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

+1 -1
View File
@@ -82,7 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`.
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`.
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。
- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`.
+2 -2
View File
@@ -156,8 +156,8 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_SENTIMENT_AGENT_VISIBLE: "true",
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
// 留空时该智能体对话页会提示未配置。
VITE_SENTIMENT_AGENT_API_URL: "https://gw.y.sit.4.cn/alg/stream/report-agent",
// 前端会把该地址随请求传给后台转发。留空时该智能体对话页会提示未配置。
VITE_SENTIMENT_AGENT_API_URL: "http://alg.gw.y.uat.cn/a6_report_agent/alg/stream/report-agent",
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
@@ -22,7 +22,7 @@ import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
/**
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
*/
export function SentimentAgentCard({
+40 -11
View File
@@ -1,13 +1,14 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
buildSentimentAgentHeaders,
readSentimentAgentEndpoint,
} from "./config";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { readSentimentAgentEndpoint } from "./config";
/**
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 舆情分析智能体走 Gateway 反向代理(POST,SSE 返回):
* 前端把 runtime-config.js 的 apiUrl / Basic Auth 随 AG-UI 体传给
* `/api/sentiment-agent/stream`,由后台转发上游(不校验证书、不拦地址)。
* 请求体业务字段仍为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
@@ -91,6 +92,28 @@ export interface RunSentimentAgentOptions {
handlers: SentimentAgentRunHandlers;
}
/** 把 Gateway / FastAPI 错误体展开成可展示的完整字符串。 */
async function readProxyError(response: Response): Promise<string> {
const text = await response.text();
if (!text.trim()) {
return `舆情分析服务请求失败(HTTP ${response.status})。`;
}
try {
const parsed = JSON.parse(text) as { detail?: unknown };
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
return parsed.detail;
}
if (parsed.detail != null) {
return typeof parsed.detail === "object"
? JSON.stringify(parsed.detail, null, 2)
: String(parsed.detail);
}
} catch {
// 非 JSON 时把原始响应原文抛给调用方。
}
return text;
}
/**
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
@@ -99,16 +122,22 @@ export async function runSentimentAgent(
options: RunSentimentAgentOptions,
): Promise<void> {
const { threadId, runId, messages, signal, handlers } = options;
const { apiUrl } = readSentimentAgentEndpoint();
if (!apiUrl) {
const endpoint = readSentimentAgentEndpoint();
if (!endpoint.apiUrl) {
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
}
const response = await fetch(apiUrl, {
const response = await apiFetch(`${getBackendBaseURL()}/api/sentiment-agent/stream`, {
method: "POST",
headers: buildSentimentAgentHeaders(),
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
signal,
body: JSON.stringify({
apiUrl: endpoint.apiUrl,
authUsername: endpoint.authUsername,
authPassword: endpoint.authPassword,
threadId,
runId,
state: {},
@@ -119,7 +148,7 @@ export async function runSentimentAgent(
}),
});
if (!response.ok) {
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
throw new Error(await readProxyError(response));
}
if (!response.body) {
throw new Error("舆情分析服务未返回流式响应。");
@@ -1,9 +1,9 @@
import { readRuntimeEnv } from "@/env";
/**
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
* 舆情分析智能体(经 Gateway 转发的虚拟智能体)的接入配置。
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
* dist/runtime-config.js 切换环境,无需重新打包。
* dist/runtime-config.js 切换环境,无需重新打包。前端把这三项随请求传给后台转发上游 SSE。
*/
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
@@ -35,25 +35,3 @@ export function isSentimentAgentVisible(): boolean {
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
}
function toBase64(value: string): string {
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
export function buildSentimentAgentHeaders(): Record<string, string> {
const { authUsername, authPassword } = readSentimentAgentEndpoint();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
if (authUsername && authPassword) {
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
}
return headers;
}
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = "workspace.sentiment-agent.";
/**
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
* 不创建后端 thread、不进 LangGraph —— 问答经 Gateway 转发外部 AG-UI 网关。
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
*/
export function useSentimentAgentChat(threadId: string) {
@@ -30,7 +30,8 @@ import { cn } from "@/lib/utils";
/**
* 舆情分析智能体对话页:一个前端虚拟智能体(不在后端 /api/agents 落库),
* 问答直连外部 AG-UI 网关(地址 + Basic Auth 在 runtime-config.js 配置)。
* 问答经 Gateway POST /api/sentiment-agent/stream 转发外部 AG-UI 网关
* (地址 + Basic Auth 在 runtime-config.js,随请求传给后台)。
* 页面复用普通问答的消息渲染(MessageList),输入框只保留发送按钮;
* 会话只存在当前标签页 sessionStorage,不创建后端 thread。
*/
+1 -1
View File
@@ -325,7 +325,7 @@ export default function StrategyRoutes() {
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
{/* 舆情分析虚拟智能体:前端直连外部 AG-UI 网关的专用对话页。 */}
{/* 舆情分析虚拟智能体:经 Gateway 转发外部 AG-UI 网关的专用对话页。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
+2 -2
View File
@@ -478,8 +478,8 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走前端直连外部
AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
+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