岗位会商
This commit is contained in:
1 parent
1cd99a5a90
commit
1bc203b5cd
23 files changed
+2230
-401
No files matched your search
@@ -4,6 +4,29 @@
|
||||
>
|
||||
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
|
||||
|
||||
## 实施核对(2026-07-21)
|
||||
|
||||
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
|
||||
|
||||
```text
|
||||
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
|
||||
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
|
||||
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
|
||||
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
|
||||
```
|
||||
|
||||
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
|
||||
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
|
||||
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
|
||||
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
|
||||
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
|
||||
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
|
||||
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
|
||||
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
|
||||
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
|
||||
|
||||
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
|
||||
|
||||
## 0. 核心结论
|
||||
|
||||
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
|
||||
@@ -75,32 +98,32 @@
|
||||
|
||||
### 3.1 Session 扩展
|
||||
|
||||
`position_roundtable_sessions` 建议增加:
|
||||
`position_roundtable_sessions` 已使用下列持久化字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `workflow_status` | `intent_pending / active / summarizing / planning / completed / archived` |
|
||||
| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
|
||||
| `summary_thread_id` | 方案总结智能体线程 |
|
||||
| `summary_snapshot` | 最新有效总结报告快照 |
|
||||
| `action_plan_thread_id` | 行动规划智能体线程 |
|
||||
| `action_plan_snapshot` | 最新有效行动规划快照 |
|
||||
| `invalidated_at` | 最近一次级联失效时间,可选 |
|
||||
| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
|
||||
|
||||
### 3.2 Node 扩展
|
||||
|
||||
`position_roundtable_nodes` 建议增加:
|
||||
`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `display_status` | 面向前端的聚合状态,可后端计算也可前端映射 |
|
||||
| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
|
||||
| `rejection_count` | 当前节点累计驳回次数 |
|
||||
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
|
||||
| `valid_revision` | 当前有效交付版本 |
|
||||
| `revision` | 当前有效交付版本 |
|
||||
| `invalidated_by` | 导致当前节点失效的上游节点 key |
|
||||
|
||||
### 3.3 Deliverable 新表
|
||||
### 3.3 Deliverable 新表(后续可选)
|
||||
|
||||
建议新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
|
||||
当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
@@ -121,7 +144,7 @@
|
||||
| `created_at` | 创建时间 |
|
||||
| `updated_at` | 更新时间 |
|
||||
|
||||
这样右侧产物卡片、驳回记录、总结输入材料都能从同一张表聚合。
|
||||
当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
|
||||
|
||||
## 4. 驳回与级联失效规则
|
||||
|
||||
@@ -173,25 +196,27 @@
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/deliverables` | 获取右侧产物列表 |
|
||||
| `GET` | `/api/position-roundtable/deliverables/{deliverable_id}` | 获取产物详情 |
|
||||
| `POST` | `/api/position-roundtable/deliverables/{deliverable_id}/reject` | 驳回产物并触发级联失效 |
|
||||
| `POST` | `/api/position-roundtable/nodes/{node_id}/complete-turn` | 节点回答结束后同步回答与产物 |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
|
||||
|
||||
### 5.3 方案总结与行动规划
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/start` | 启动或重启方案总结 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 对方案总结继续提问或修改 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/start` | 基于总结和产物生成行动规划 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 对行动规划继续提问或修改 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
|
||||
|
||||
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
|
||||
|
||||
## 6. 前端实施步骤
|
||||
|
||||
### 阶段 1:状态与启动校验
|
||||
### 阶段 1:状态与启动校验(已完成)
|
||||
|
||||
- 扩展 `PositionNodeStatus` 和状态显示映射。
|
||||
- “确认意图并启动业务链”前补齐校验提示。
|
||||
@@ -201,7 +226,7 @@
|
||||
|
||||
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
|
||||
|
||||
### 阶段 2:流程图弹窗
|
||||
### 阶段 2:流程图弹窗(已完成)
|
||||
|
||||
- 左侧保留小型从上到下流程图。
|
||||
- 流程图右上角增加“查看”按钮。
|
||||
@@ -210,7 +235,7 @@
|
||||
|
||||
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
|
||||
|
||||
### 阶段 3:产物卡片与预览
|
||||
### 阶段 3:产物卡片与预览(已完成)
|
||||
|
||||
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
|
||||
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
|
||||
@@ -220,7 +245,7 @@
|
||||
|
||||
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
|
||||
|
||||
### 阶段 4:驳回弹窗与级联失效
|
||||
### 阶段 4:驳回弹窗与级联失效(已完成)
|
||||
|
||||
- 新增 `RejectDeliverableDialog`。
|
||||
- 驳回表单包含标题和理由,均必填。
|
||||
@@ -230,7 +255,7 @@
|
||||
|
||||
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
|
||||
|
||||
### 阶段 5:方案总结智能体
|
||||
### 阶段 5:方案总结智能体(已完成)
|
||||
|
||||
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
|
||||
- 输入材料改为岗位会商材料:
|
||||
@@ -245,7 +270,7 @@
|
||||
|
||||
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
|
||||
|
||||
### 阶段 6:行动规划智能体
|
||||
### 阶段 6:行动规划智能体(已完成)
|
||||
|
||||
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
|
||||
- 新增行动规划 prompt:
|
||||
@@ -261,7 +286,7 @@
|
||||
|
||||
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
|
||||
|
||||
### 阶段 7:返回修改与重新生成
|
||||
### 阶段 7:返回修改与重新生成(已完成)
|
||||
|
||||
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
|
||||
- 驳回后自动显示需要重做的节点。
|
||||
@@ -270,7 +295,7 @@
|
||||
|
||||
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
|
||||
|
||||
### 阶段 8:测试与回归
|
||||
### 阶段 8:测试与回归(已完成)
|
||||
|
||||
- 后端测试:
|
||||
- activate 校验。
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/roundtable/position">
|
||||
<BriefcaseBusiness />
|
||||
<span>岗位会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -54,6 +54,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
@@ -433,6 +434,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/position"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome={headerHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/chains"
|
||||
element={
|
||||
|
||||
@@ -45,7 +45,9 @@ export interface PositionRoundtableSession {
|
||||
chainSnapshot: Record<string, unknown> | null;
|
||||
intentThreadId: string | null;
|
||||
intentSnapshot: Record<string, unknown> | null;
|
||||
summaryThreadId: string | null;
|
||||
summarySnapshot: Record<string, unknown> | null;
|
||||
actionPlanThreadId: string | null;
|
||||
actionPlanSnapshot: Record<string, unknown> | null;
|
||||
status: "intent_pending" | "active" | "completed" | "archived";
|
||||
createdAt?: string;
|
||||
@@ -82,7 +84,9 @@ interface ServerSession {
|
||||
chain_snapshot?: Record<string, unknown> | null;
|
||||
intent_thread_id: string | null;
|
||||
intent_snapshot?: Record<string, unknown> | null;
|
||||
summary_thread_id?: string | null;
|
||||
summary_snapshot?: Record<string, unknown> | null;
|
||||
action_plan_thread_id?: string | null;
|
||||
action_plan_snapshot?: Record<string, unknown> | null;
|
||||
status: PositionRoundtableSession["status"];
|
||||
created_at?: string;
|
||||
@@ -132,7 +136,9 @@ function toSession(session: ServerSession): PositionRoundtableSession {
|
||||
chainSnapshot: session.chain_snapshot ?? null,
|
||||
intentThreadId: session.intent_thread_id,
|
||||
intentSnapshot: session.intent_snapshot ?? null,
|
||||
summaryThreadId: session.summary_thread_id ?? null,
|
||||
summarySnapshot: session.summary_snapshot ?? null,
|
||||
actionPlanThreadId: session.action_plan_thread_id ?? null,
|
||||
actionPlanSnapshot: session.action_plan_snapshot ?? null,
|
||||
status: session.status,
|
||||
createdAt: session.created_at,
|
||||
@@ -301,22 +307,104 @@ export async function rejectPositionNode(
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function generatePositionSummary(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, {
|
||||
export interface PositionBuiltinRunPreparation {
|
||||
agentId: string;
|
||||
displayName: string;
|
||||
threadId: string | null;
|
||||
prompt: string;
|
||||
sourceRevisions: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ServerBuiltinRunPreparation {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
thread_id?: string | null;
|
||||
prompt: string;
|
||||
source_revisions?: Record<string, number>;
|
||||
}
|
||||
|
||||
function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
|
||||
return {
|
||||
agentId: value.agent_id,
|
||||
displayName: value.display_name,
|
||||
threadId: value.thread_id ?? null,
|
||||
prompt: value.prompt,
|
||||
sourceRevisions: value.source_revisions ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
type BuiltinKind = "summary" | "action_plan";
|
||||
|
||||
async function prepareBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
||||
const result = await readJson<ServerBuiltinRunPreparation>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
}),
|
||||
"generate position summary",
|
||||
`prepare position ${kind}`,
|
||||
);
|
||||
return toBuiltinRunPreparation(result);
|
||||
}
|
||||
|
||||
async function completeBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
input: {
|
||||
threadId: string;
|
||||
content: string;
|
||||
artifactManifest: Array<Record<string, unknown>>;
|
||||
sourceRevisions: Record<string, number>;
|
||||
model?: string;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId,
|
||||
content: input.content,
|
||||
artifact_manifest: input.artifactManifest,
|
||||
source_revisions: input.sourceRevisions,
|
||||
model: input.model || null,
|
||||
}),
|
||||
}),
|
||||
`complete position ${kind}`,
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function generatePositionActionPlan(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"generate position action plan",
|
||||
);
|
||||
return toSession(result);
|
||||
export function preparePositionSummary(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "summary", message);
|
||||
}
|
||||
|
||||
export function completePositionSummary(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "summary", input);
|
||||
}
|
||||
|
||||
export function preparePositionActionPlan(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "action_plan", message);
|
||||
}
|
||||
|
||||
export function completePositionActionPlan(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "action_plan", input);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, FileText, LockKeyhole, Loader2 } from "lucide-react";
|
||||
import { Bot, FileText, LockKeyhole } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
@@ -17,15 +18,26 @@ import {
|
||||
type PositionRoundtableNode,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
interface PositionNodeChatProps {
|
||||
sessionId: string;
|
||||
/** Parent task id: all node threads are indexed under this task. */
|
||||
taskId: string;
|
||||
chainId: string | null;
|
||||
node: PositionRoundtableNode;
|
||||
currentPositionId: string;
|
||||
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
|
||||
agentName?: string | null;
|
||||
onNodeUpdate: (node: PositionRoundtableNode) => void;
|
||||
/** The workspace collapses the configuration panel on the first real turn. */
|
||||
onConversationStart?: () => void;
|
||||
onPreviewArtifact?: (artifact: {
|
||||
threadId: string;
|
||||
path: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface NodeArtifact {
|
||||
@@ -52,6 +64,26 @@ function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifa
|
||||
});
|
||||
}
|
||||
|
||||
function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean {
|
||||
return manifest.some((artifact) => {
|
||||
const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
|
||||
const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
|
||||
const mime =
|
||||
typeof artifact.mime_type === "string"
|
||||
? artifact.mime_type.toLowerCase()
|
||||
: typeof artifact.mimeType === "string"
|
||||
? artifact.mimeType.toLowerCase()
|
||||
: "";
|
||||
return (
|
||||
mime.includes("markdown") ||
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".markdown") ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct single-agent chat for one frozen business-chain node.
|
||||
*
|
||||
@@ -61,18 +93,24 @@ function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifa
|
||||
*/
|
||||
export function PositionNodeChat({
|
||||
sessionId,
|
||||
taskId,
|
||||
chainId,
|
||||
node,
|
||||
currentPositionId,
|
||||
sessionStatus,
|
||||
agentName,
|
||||
onNodeUpdate,
|
||||
onConversationStart,
|
||||
onPreviewArtifact,
|
||||
}: PositionNodeChatProps) {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { agent } = useAgent(node.agentId);
|
||||
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||
const [previewArtifact, setPreviewArtifact] = useState<NodeArtifact | null>(null);
|
||||
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
|
||||
const canWrite =
|
||||
sessionStatus !== "archived" &&
|
||||
@@ -81,6 +119,19 @@ export function PositionNodeChat({
|
||||
node.status !== "error" &&
|
||||
!node.invalidatedBy;
|
||||
|
||||
useEffect(() => {
|
||||
if (artifactsCtx?.open && artifactsCtx.selectedArtifact) {
|
||||
onPreviewArtifact?.({
|
||||
threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current,
|
||||
path: artifactsCtx.selectedArtifact,
|
||||
name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
|
||||
mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md")
|
||||
? "text/markdown"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoundThreadId(node.threadId);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
@@ -99,14 +150,21 @@ export function PositionNodeChat({
|
||||
[node.nodeKey, onNodeUpdate, sessionId],
|
||||
);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||
threadId: boundThreadId,
|
||||
assistantId: node.agentId,
|
||||
context: { ...settings.context, agent_id: node.agentId },
|
||||
threadMetadata: {
|
||||
thread_type: "position_roundtable",
|
||||
// ``taskId`` is the shared task-workspace convention. The two
|
||||
// position-specific identifiers retain the exact child node for audits
|
||||
// and restore, even when one position owns more than one chain seat.
|
||||
taskId,
|
||||
position_roundtable_task_id: taskId,
|
||||
position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
|
||||
position_session_id: sessionId,
|
||||
position_node_id: node.id,
|
||||
position_node_key: node.nodeKey,
|
||||
position_id: node.positionId,
|
||||
chain_id: chainId,
|
||||
agent_id: node.agentId,
|
||||
@@ -128,6 +186,7 @@ export function PositionNodeChat({
|
||||
path: file.path,
|
||||
mime_type: file.mimeType,
|
||||
modified_at: file.modifiedAt,
|
||||
size_bytes: file.sizeBytes,
|
||||
}));
|
||||
} catch {
|
||||
// Artifact listing is best-effort: a valid text answer must still be
|
||||
@@ -140,6 +199,9 @@ export function PositionNodeChat({
|
||||
upstreamRevision: upstreamRevisionRef.current,
|
||||
});
|
||||
onNodeUpdate(updated);
|
||||
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
|
||||
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -147,6 +209,14 @@ export function PositionNodeChat({
|
||||
},
|
||||
});
|
||||
|
||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||
// watching the actual stream id as well closes the last race where a page
|
||||
// refresh could happen between creation and the async node-index write.
|
||||
useEffect(() => {
|
||||
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
|
||||
bindCreatedThread(actualThreadId);
|
||||
}, [actualThreadId, bindCreatedThread]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (!canWrite) {
|
||||
@@ -162,6 +232,7 @@ export function PositionNodeChat({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onConversationStart?.();
|
||||
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
|
||||
const upstreamRevision = taskExternalContext.upstream_revision;
|
||||
upstreamRevisionRef.current =
|
||||
@@ -176,7 +247,16 @@ export function PositionNodeChat({
|
||||
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
},
|
||||
[canWrite, node.agentId, node.nodeKey, node.status, sendMessage, sessionId, sessionStatus],
|
||||
[
|
||||
canWrite,
|
||||
node.agentId,
|
||||
node.nodeKey,
|
||||
node.status,
|
||||
onConversationStart,
|
||||
sendMessage,
|
||||
sessionId,
|
||||
sessionStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
|
||||
@@ -201,13 +281,12 @@ export function PositionNodeChat({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||
{node.agentId}
|
||||
{agentDisplayName}
|
||||
</h2>
|
||||
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
|
||||
{readOnlyHint || `节点状态:${node.status}`}
|
||||
</p>
|
||||
</div>
|
||||
{thread.isLoading && <Loader2 className="size-4 animate-spin text-primary" />}
|
||||
</header>
|
||||
|
||||
{nodeArtifacts.length > 0 && (
|
||||
@@ -225,7 +304,16 @@ export function PositionNodeChat({
|
||||
key={artifact.path}
|
||||
type="button"
|
||||
title={`预览 ${artifact.name}`}
|
||||
onClick={() => setPreviewArtifact(artifact)}
|
||||
onClick={() => {
|
||||
if (boundThreadId && onPreviewArtifact) {
|
||||
onPreviewArtifact({
|
||||
threadId: boundThreadId,
|
||||
path: artifact.path,
|
||||
name: artifact.name,
|
||||
mimeType: artifact.mimeType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0" />
|
||||
@@ -242,6 +330,7 @@ export function PositionNodeChat({
|
||||
threadId={boundThreadId}
|
||||
thread={thread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-6"
|
||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
) : (
|
||||
@@ -275,15 +364,6 @@ export function PositionNodeChat({
|
||||
onStop={() => void thread.stop()}
|
||||
/>
|
||||
</div>
|
||||
{previewArtifact && boundThreadId && (
|
||||
<ArtifactPreviewModal
|
||||
threadId={boundThreadId}
|
||||
path={previewArtifact.path}
|
||||
name={previewArtifact.name}
|
||||
mimeType={previewArtifact.mimeType}
|
||||
onClose={() => setPreviewArtifact(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -97,11 +97,16 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
|
||||
* `detail.trace_tail` to help diagnose intranet deployment issues without
|
||||
* shell access to the server.
|
||||
*/
|
||||
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||
export async function initIntent(
|
||||
model?: string,
|
||||
taskContext?: Record<string, unknown> | null,
|
||||
): Promise<IntentInitResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
|
||||
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。
|
||||
body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => "");
|
||||
@@ -159,6 +164,8 @@ export interface StreamIntentRequest {
|
||||
threadId: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
/** Optional hidden business/task context injected into the model run. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Step 1 用户上传的参考文件(可选)。 */
|
||||
files?: IntentFileRef[];
|
||||
signal?: AbortSignal;
|
||||
@@ -333,6 +340,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
thread_id: req.threadId,
|
||||
message: req.message,
|
||||
model: req.model,
|
||||
task_context: req.taskContext ?? undefined,
|
||||
// 仅在有文件时带上,空数组后端也安全忽略。
|
||||
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||
}),
|
||||
|
||||
@@ -1054,6 +1054,36 @@ export async function initSummaryThread(
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-session thread for the position-roundtable action-planning
|
||||
* built-in agent. The agent id is fixed; callers persist/reuse the returned
|
||||
* thread id for subsequent plan refinements.
|
||||
*/
|
||||
export async function initPositionActionPlanThread(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<ReportThreadResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/position-action-plan/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "position_action_plan",
|
||||
event: "init_thread_http_error",
|
||||
message: `行动规划线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
agentId: "position-action-planner",
|
||||
agentName: "行动规划智能体",
|
||||
});
|
||||
throw new Error(`init position action-plan thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
|
||||
* a built-in singleton — the same agent that extracts the flowchart, but this run
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface Step1PanelProps {
|
||||
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
|
||||
*/
|
||||
headerLeftSlot?: React.ReactNode;
|
||||
headerRightSlot?: React.ReactNode;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
intentThreadId: string | null;
|
||||
intentReady: object | null;
|
||||
@@ -101,6 +102,7 @@ export interface Step1PanelProps {
|
||||
export function Step1Panel(props: Step1PanelProps) {
|
||||
const {
|
||||
headerLeftSlot,
|
||||
headerRightSlot,
|
||||
interactiveMessages,
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
@@ -175,9 +177,12 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
{headerRightSlot}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface UseStep1IntentOptions {
|
||||
currentStep: number;
|
||||
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
|
||||
selectedModel: string;
|
||||
/** Optional hidden task/business context for each intent-agent turn. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Optional task-aware welcome bubble text before the first user turn. */
|
||||
initialWelcomeText?: string;
|
||||
/** 主页面统一的 toast 入口。 */
|
||||
triggerToast: (msg: string) => void;
|
||||
}
|
||||
@@ -167,7 +171,7 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
|
||||
}
|
||||
|
||||
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
|
||||
const { currentStep, selectedModel, triggerToast } = options;
|
||||
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
|
||||
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
@@ -198,6 +202,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
useEffect(() => {
|
||||
taskContextRef.current = taskContext ?? null;
|
||||
}, [taskContext]);
|
||||
const initialWelcomeTextRef = useRef(initialWelcomeText);
|
||||
useEffect(() => {
|
||||
initialWelcomeTextRef.current = initialWelcomeText;
|
||||
}, [initialWelcomeText]);
|
||||
|
||||
const stopIntentStream = useCallback(() => {
|
||||
if (intentAborterRef.current) {
|
||||
@@ -225,7 +237,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: INTENT_WELCOME_TEXT,
|
||||
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]);
|
||||
@@ -233,7 +245,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
try {
|
||||
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
|
||||
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
|
||||
const res = await initIntent(selectedModelRef.current || undefined);
|
||||
const res = await initIntent(
|
||||
selectedModelRef.current || undefined,
|
||||
taskContextRef.current,
|
||||
);
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIntentThreadId(res.thread_id);
|
||||
setTimelineEntries([
|
||||
@@ -408,6 +423,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
taskContext: taskContextRef.current,
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
|
||||
Reference in new issue
Block a user