fix: regulatory 对话模式改用 event_stream_handler 修复工具调用截断,优化节点 prompt 和日志展示

- regulatory_node: stream_working 从 run_stream 改为 agent.run + event_stream_handler,
  解决工具调用后文本被截断的问题;添加 PartStartEvent 处理修复首字丢失
- consciousness_node: prompt 重写为三模式(生成/执行/报告),强调禁止编造 agent_id
- workflow API: _merge_runtime_status 暴露步骤输出内容(workflow_log 第三元素)
- 前端日志: 系统日志改为终端滚动样式,工作流步骤可展开查看输出

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 13:19:52 +00:00
parent ad5da2a118
commit d39c80743d
4 changed files with 153 additions and 107 deletions
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshCw, Search, Server, GitBranch } from 'lucide-react'; import { RefreshCw, Server, GitBranch, ChevronDown, ChevronRight } from 'lucide-react';
import apiClient from '../../api/client'; import apiClient from '../../api/client';
interface EventLog { interface EventLog {
@@ -28,12 +28,13 @@ interface WorkflowStep {
action: string; action: string;
status: string; status: string;
agent_id: string | null; agent_id: string | null;
output?: string;
} }
const LEVEL_STYLES: Record<string, { bg: string; text: string; label: string }> = { const LEVEL_COLORS: Record<string, string> = {
error: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]', label: 'ERROR' }, error: 'text-red-400',
warn: { bg: 'bg-[rgba(196,168,130,0.15)]', text: 'text-[#9a7d5e]', label: 'WARN' }, warn: 'text-yellow-400',
info: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]', label: 'INFO' }, info: 'text-green-400',
}; };
const STATUS_STYLES: Record<string, { bg: string; text: string }> = { const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
@@ -53,12 +54,14 @@ export function SystemLogsView() {
const [traceFilter, setTraceFilter] = useState(''); const [traceFilter, setTraceFilter] = useState('');
const [typeFilter, setTypeFilter] = useState(''); const [typeFilter, setTypeFilter] = useState('');
const [levelFilter, setLevelFilter] = useState(''); const [levelFilter, setLevelFilter] = useState('');
const terminalRef = useRef<HTMLDivElement>(null);
// Workflow logs state // Workflow logs state
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]); const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [selectedTrace, setSelectedTrace] = useState<string | null>(null); const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]); const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [wfLoading, setWfLoading] = useState(false); const [wfLoading, setWfLoading] = useState(false);
const [expandedStep, setExpandedStep] = useState<number | null>(null);
const fetchLogs = async () => { const fetchLogs = async () => {
setLoading(true); setLoading(true);
@@ -88,6 +91,7 @@ export function SystemLogsView() {
const fetchWorkflowDetail = async (traceId: string) => { const fetchWorkflowDetail = async (traceId: string) => {
setWfLoading(true); setWfLoading(true);
setExpandedStep(null);
try { try {
const resp = await apiClient.get(`/api/v1/workflow/${traceId}`); const resp = await apiClient.get(`/api/v1/workflow/${traceId}`);
setWorkflowSteps(resp.data.steps || []); setWorkflowSteps(resp.data.steps || []);
@@ -108,9 +112,16 @@ export function SystemLogsView() {
if (selectedTrace) fetchWorkflowDetail(selectedTrace); if (selectedTrace) fetchWorkflowDetail(selectedTrace);
}, [selectedTrace]); }, [selectedTrace]);
const handleSearch = (e: React.FormEvent) => { useEffect(() => {
e.preventDefault(); if (terminalRef.current) {
fetchLogs(); terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [logs]);
const formatTime = (ts: string | null) => {
if (!ts) return '--:--:--';
const d = new Date(ts);
return d.toLocaleTimeString('en-GB', { hour12: false });
}; };
return ( return (
@@ -143,18 +154,19 @@ export function SystemLogsView() {
{tab === 'system' ? ( {tab === 'system' ? (
<> <>
<form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5"> {/* Filter bar */}
<div className="flex gap-3 mb-4">
<input <input
type="text" type="text"
value={traceFilter} value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)} onChange={(e) => setTraceFilter(e.target.value)}
placeholder={t('agent.logFilterTraceId')} placeholder="Trace ID"
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15" className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15 w-40"
/> />
<select <select
value={typeFilter} value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)} onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15" className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
> >
<option value="">{t('agent.logFilterAllTypes')}</option> <option value="">{t('agent.logFilterAllTypes')}</option>
<option value="workflow_start">workflow_start</option> <option value="workflow_start">workflow_start</option>
@@ -168,54 +180,42 @@ export function SystemLogsView() {
<select <select
value={levelFilter} value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)} onChange={(e) => setLevelFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15" className="px-3 py-1.5 bg-bg-card border border-border-primary rounded-lg text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
> >
<option value="">{t('agent.logFilterAllLevels')}</option> <option value="">{t('agent.logFilterAllLevels')}</option>
<option value="info">INFO</option> <option value="info">INFO</option>
<option value="warn">WARN</option> <option value="warn">WARN</option>
<option value="error">ERROR</option> <option value="error">ERROR</option>
</select> </select>
<button type="submit" className="flex items-center justify-center gap-2 px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors"> <button
<Search size={14} /> {t('agent.logSearch')} onClick={fetchLogs}
className="px-3 py-1.5 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors"
>
{t('agent.logSearch')}
</button> </button>
</form> </div>
<div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden"> {/* Terminal-style log display */}
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto"> <div
<table className="w-full text-xs"> ref={terminalRef}
<thead className="bg-bg-secondary sticky top-0"> className="bg-[#1a1b1e] border border-border-primary rounded-xl p-4 h-[60vh] overflow-y-auto font-mono text-xs leading-relaxed"
<tr> >
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logLevel')}</th> {logs.length === 0 ? (
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logType')}</th> <div className="text-gray-500 text-center py-8">{t('agent.noLogs')}</div>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">Trace ID</th> ) : (
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th> logs.map((log) => {
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logMessage')}</th> const levelColor = LEVEL_COLORS[log.level] || 'text-gray-400';
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th> return (
</tr> <div key={log.id} className="py-0.5 hover:bg-white/5 px-1 rounded">
</thead> <span className="text-gray-500">[{formatTime(log.created_at)}]</span>{' '}
<tbody className="divide-y divide-border-primary"> <span className={`font-bold ${levelColor}`}>{log.level.toUpperCase().padEnd(5)}</span>{' '}
{logs.length === 0 ? ( <span className="text-blue-300">{log.node_name || 'system'}</span>{' '}
<tr><td colSpan={6} className="px-4 py-12 text-center text-text-muted">{t('agent.noLogs')}</td></tr> <span className="text-gray-200">{log.message}</span>
) : ( {log.trace_id && <span className="text-gray-600 ml-2">#{log.trace_id.slice(-6)}</span>}
logs.map((log) => { </div>
const style = LEVEL_STYLES[log.level] || LEVEL_STYLES.info; );
return ( })
<tr key={log.id} className="hover:bg-bg-secondary/50 transition-colors"> )}
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${style.bg} ${style.text}`}>{style.label}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary font-mono">{log.event_type}</td>
<td className="px-4 py-2.5 text-text-muted font-mono">{log.trace_id.slice(-8)}</td>
<td className="px-4 py-2.5 text-text-secondary">{log.node_name || '-'}</td>
<td className="px-4 py-2.5 text-text-primary max-w-xs truncate" title={log.message}>{log.message}</td>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">{log.created_at ? new Date(log.created_at).toLocaleString() : '-'}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div> </div>
</> </>
) : ( ) : (
@@ -259,38 +259,40 @@ export function SystemLogsView() {
{t('common.loading')} {t('common.loading')}
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto p-4 space-y-2">
<table className="w-full text-xs"> {workflowSteps.length === 0 ? (
<thead className="bg-bg-secondary sticky top-0"> <div className="text-center text-text-muted text-xs py-8">{t('workflow.noStepsYet')}</div>
<tr> ) : (
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">#</th> workflowSteps.map((step, idx) => {
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.name')}</th> const ss = STATUS_STYLES[step.status] || STATUS_STYLES.pending;
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th> const isExpanded = expandedStep === idx;
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('common.status')}</th> return (
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.workflowAction')}</th> <div key={idx} className="border border-border-primary rounded-lg overflow-hidden">
</tr> <button
</thead> onClick={() => setExpandedStep(isExpanded ? null : idx)}
<tbody className="divide-y divide-border-primary"> className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-secondary/50 transition-colors text-left"
{workflowSteps.length === 0 ? ( >
<tr><td colSpan={5} className="px-4 py-12 text-center text-text-muted">{t('workflow.noStepsYet')}</td></tr> {step.output ? (
) : ( isExpanded ? <ChevronDown size={14} className="text-text-muted shrink-0" /> : <ChevronRight size={14} className="text-text-muted shrink-0" />
workflowSteps.map((step, idx) => { ) : (
const ss = STATUS_STYLES[step.status] || STATUS_STYLES.pending; <div className="w-3.5 shrink-0" />
return ( )}
<tr key={idx} className="hover:bg-bg-secondary/50 transition-colors"> <span className="text-xs text-text-muted w-6">{step.step || idx + 1}</span>
<td className="px-4 py-2.5 text-text-muted">{step.step || idx + 1}</td> <span className="text-xs font-medium text-text-primary flex-1 truncate">{step.name}</span>
<td className="px-4 py-2.5 text-text-primary font-medium">{step.name}</td> <span className="text-xs text-text-secondary mr-2">{step.node}</span>
<td className="px-4 py-2.5 text-text-secondary">{step.node}</td> <span className={`px-2 py-0.5 rounded text-[10px] font-bold ${ss.bg} ${ss.text}`}>{step.status}</span>
<td className="px-4 py-2.5"> </button>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${ss.bg} ${ss.text}`}>{step.status}</span> {isExpanded && step.output && (
</td> <div className="px-4 pb-3 pt-1 border-t border-border-secondary">
<td className="px-4 py-2.5 text-text-secondary max-w-sm truncate" title={step.action}>{step.action}</td> <div className="bg-[#1a1b1e] rounded-lg p-3 text-xs text-gray-200 font-mono whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
</tr> {step.output}
); </div>
}) </div>
)} )}
</tbody> </div>
</table> );
})
)}
</div> </div>
)} )}
</div> </div>
+4 -2
View File
@@ -177,8 +177,8 @@ def _merge_runtime_status(work_link: list, workflow_log: list) -> list:
前端 ``WorkflowDiagram`` 依赖每个 step 的 ``status`` 字段着色,这个拼装让 前端 ``WorkflowDiagram`` 依赖每个 step 的 ``status`` 字段着色,这个拼装让
后端真正把运行期状态喂过去。 后端真正把运行期状态喂过去。
""" """
# step_index -> 最新 status
latest_status: dict[int, str] = {} latest_status: dict[int, str] = {}
latest_output: dict[int, str] = {}
for entry in workflow_log or []: for entry in workflow_log or []:
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
@@ -189,14 +189,16 @@ def _merge_runtime_status(work_link: list, workflow_log: list) -> list:
continue continue
if isinstance(payload, (list, tuple)) and len(payload) >= 2: if isinstance(payload, (list, tuple)) and len(payload) >= 2:
latest_status[idx] = payload[1] latest_status[idx] = payload[1]
if isinstance(payload, (list, tuple)) and len(payload) >= 3:
latest_output[idx] = payload[2]
merged = [] merged = []
for i, step in enumerate(work_link or []): for i, step in enumerate(work_link or []):
step_copy = dict(step) if isinstance(step, dict) else {} step_copy = dict(step) if isinstance(step, dict) else {}
# step 自带的 step 字段优先,否则用位置索引
step_idx = step_copy.get("step") step_idx = step_copy.get("step")
lookup_idx = (step_idx - 1) if isinstance(step_idx, int) else i lookup_idx = (step_idx - 1) if isinstance(step_idx, int) else i
step_copy["status"] = latest_status.get(lookup_idx, "pending") step_copy["status"] = latest_status.get(lookup_idx, "pending")
step_copy["output"] = latest_output.get(lookup_idx, "")
merged.append(step_copy) merged.append(step_copy)
return merged return merged
@@ -126,11 +126,23 @@ class RegulatoryNode:
""" """
return await self._run(payload) return await self._run(payload)
async def stream_working(self, payload: MessageRequest, token_queue: "asyncio.Queue") -> None: _CHAT_INSTRUCTIONS = (
"""流式工具调用版本:逐 token 推送到 queue,工具调用结果也会通过 token 输出。 "你是 kilostar 智能助手。你现在处于【直接对话模式】,请直接回答用户的问题。\n"
"规则:\n"
"1. 直接、详细地回答用户问题,像一个专业且友好的助手。\n"
"2. 如果你有可用工具,可以调用工具来辅助回答(如搜索、读文件等)。\n"
"3. 不要输出内部思考过程,不要做路由判断,不要提及 ForUser/ForConsciousnessNode 等格式。\n"
"4. 回复应当完整、有帮助,避免过于简短。\n"
)
完成后 push None 作为终止信号。 async def stream_working(self, payload: MessageRequest, token_queue: "asyncio.Queue") -> None:
"""流式对话:完整执行 agent graph(含工具调用),逐 token 推送文本到 queue。
使用 event_stream_handler 回调拿到每个 text delta,保证工具调用后
的文本也能被流式输出。完成后 push None 作为终止信号。
""" """
from pydantic_ai.messages import PartStartEvent, PartDeltaEvent, TextPart, TextPartDelta
platform = payload.platform platform = payload.platform
user_name = payload.user_name user_name = payload.user_name
message = payload.message message = payload.message
@@ -140,17 +152,27 @@ class RegulatoryNode:
await token_queue.put(None) await token_queue.put(None)
return return
async def _stream_handler(ctx, events):
async for event in events:
if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
if event.part.content:
await token_queue.put(event.part.content)
elif isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
await token_queue.put(event.delta.content_delta)
try: try:
deps = RegulatoryNodeDeps( deps = RegulatoryNodeDeps(
platform=platform, platform=platform,
user_name=user_name, user_name=user_name,
time=time_str time=time_str
) )
async with self.agent.run_stream( await self.agent.run(
user_prompt=message, deps=deps, output_type=str user_prompt=message,
) as stream_result: deps=deps,
async for delta in stream_result.stream_text(delta=True): output_type=str,
await token_queue.put(delta) instructions=self._CHAT_INSTRUCTIONS,
event_stream_handler=_stream_handler,
)
except Exception as e: except Exception as e:
self.logger.exception(f"RegulatoryNode.stream_working failed: {e}") self.logger.exception(f"RegulatoryNode.stream_working failed: {e}")
await token_queue.put(f"\n\n[错误: {str(e)}]") await token_queue.put(f"\n\n[错误: {str(e)}]")
+32 -12
View File
@@ -59,21 +59,41 @@ _PROMPTS: Dict[str, Dict[str, str]] = {
"consciousness_node": { "consciousness_node": {
"zh": ( "zh": (
"你叫kilostar,是一个多智能体AI助手系统中的【意识节点 (Consciousness Node)】。\n" "你叫kilostar,是一个多智能体AI助手系统中的【意识节点 (Consciousness Node)】。\n"
"你是系统的'高级规划师''架构师',负责处理监控节点分配过来的复杂任务。\n" "你是系统的'高级规划师''架构师',负责处理监控节点分配过来的复杂任务。\n\n"
"你的主要工作场景包括:\n" "你的工作根据收到的输入类型严格分为三种模式:\n\n"
"1. 拆解任务 (Workflow Generation):结合用户的原始命令和提供的模板,生成严谨、可执行的工作流 (kilostarWorkflow),并将其输出为 ForWorkflowEngine 格式。拆解时步骤应清晰连贯。\n" "【模式1:工作流生成】当你收到用户的原始任务命令时:\n"
"2. 中途指导 (Workflow Execution):在工作流执行中,如果某一步骤指派给你,你需要对控制节点的结果进行分析或提供下一步的指导,输出 ForWorkflow 格式。\n" "- 将复杂任务拆解为多个清晰、可执行的步骤\n"
"3. 总结报告 (regulatory Report):在整个工作流执行完毕后,你需要对整体流程、各个控制节点的执行情况进行审查,并生成一份技术性的总结报告,输出 ForregulatoryNode 格式。\n" "- 每个步骤必须指派给真实存在的 Worker(使用其真实 agent_id)或 consciousness_node 自己\n"
"请确保所有的思考和生成过程符合逻辑,严密且高质量。" "- 严禁编造不存在的 agent_id!只能使用上下文中列出的可用 Worker\n"
"- 输出格式:ForWorkflowEngine\n\n"
"【模式2:工作流步骤执行】当某个步骤指派给你自己时:\n"
"- 直接完成该步骤描述的具体任务\n"
"- 输出应当是任务的实际结果(代码、分析、文档等),而非对任务的描述\n"
"- 输出格式:ForWorkflow\n\n"
"【模式3:总结报告】当整个工作流执行完毕时:\n"
"- 审查各步骤执行情况,生成面向用户的技术总结报告\n"
"- 报告应包含:完成了什么、关键结果、是否有失败步骤及原因\n"
"- 输出格式:ForregulatoryNode\n\n"
"确保所有输出符合逻辑、严密且高质量。"
), ),
"en": ( "en": (
"You are kilostar, the [Consciousness Node] in a multi-agent AI assistant system.\n" "You are kilostar, the [Consciousness Node] in a multi-agent AI assistant system.\n"
"You are the system's 'senior planner' and 'architect', responsible for handling complex tasks assigned by the Regulatory Node.\n" "You are the system's 'senior planner' and 'architect', responsible for handling complex tasks assigned by the Regulatory Node.\n\n"
"Your main scenarios include:\n" "Your work is strictly divided into three modes based on input type:\n\n"
"1. Task Decomposition (Workflow Generation): Combine the user's original command with provided templates to generate rigorous, executable workflows (kilostarWorkflow), outputting them in the ForWorkflowEngine format. Steps should be clear and coherent.\n" "[Mode 1: Workflow Generation] When you receive the user's original task command:\n"
"2. Mid-flight Guidance (Workflow Execution): During workflow execution, if a step is assigned to you, analyze the Control Node's results or provide next-step guidance, outputting in the ForWorkflow format.\n" "- Decompose the complex task into clear, executable steps\n"
"3. Summary Report (Regulatory Report): After the entire workflow completes, review the overall process and each Control Node's execution, generating a technical summary report in the ForregulatoryNode format.\n" "- Each step must be assigned to a real existing Worker (using its real agent_id) or to consciousness_node itself\n"
"Ensure all reasoning and generation is logical, rigorous, and high-quality." "- NEVER fabricate non-existent agent_ids! Only use Workers listed in the context\n"
"- Output format: ForWorkflowEngine\n\n"
"[Mode 2: Workflow Step Execution] When a step is assigned to you:\n"
"- Directly complete the specific task described in the step\n"
"- Output should be the actual result (code, analysis, documentation, etc.), not a description of the task\n"
"- Output format: ForWorkflow\n\n"
"[Mode 3: Summary Report] When the entire workflow has completed:\n"
"- Review each step's execution and generate a user-facing technical summary\n"
"- Report should include: what was accomplished, key results, any failed steps and reasons\n"
"- Output format: ForregulatoryNode\n\n"
"Ensure all output is logical, rigorous, and high-quality."
), ),
}, },
"control_node": { "control_node": {