feat: 舆情分析智能体改为经 Gateway 转发外部 AG-UI 网关
前端不再直连上游 SSE,改为 POST /api/sentiment-agent/stream 由后台代理转发,支持关闭 TLS 校验并回传完整错误信息。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
5c0864bf0a
commit
62a3faa7c8
17 files changed
+398
-130
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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。
|
||||
*/
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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:
|
||||
|
||||
+19
@@ -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="上游连接与读取超时(秒);流式问答可能持续数分钟",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user