feat(task): 添加任务修订功能和超时处理机制
添加了 `revise_task` 路由动作类型,允许用户修改、纠正或重新执行最新活动任务结果。 实现了工具失败指导原则,防止相同类别工具重复失败。 为任务规划器添加了超时处理机制,避免长时间等待。 BREAKING CHANGE: 任务路由逻辑已更新,新增 `revise_task` 动作类型。 fix(api): 修复任务详情API返回完整流程投影 修复了任务详情API端点,现在会包含过滤后的流程运行、事件和工件信息, 并确保时间戳字段正确序列化。 refactor(engine): 优化任务技能解析器摘要节点处理 改进了任务技能解析器对摘要节点的处理逻辑,对于仅依赖文本生成功能的摘要节 点不再分配具体技能,直接使用依赖项输出进行汇总。 test: 增加任务修订和超时处理测试用例 添加了测试用例验证任务修订输入记录反馈、超时回退到单模式以及 摘要节点技能解析等新功能。
This commit is contained in:
@ -20,6 +20,13 @@ from beaver.tools import ToolContext
|
|||||||
from .loader import EngineLoader, EngineLoadResult
|
from .loader import EngineLoader, EngineLoadResult
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_FAILURE_GUIDANCE_PROMPT = (
|
||||||
|
"# Tool Failure Guidance\n\n"
|
||||||
|
"If the same class of tools fails repeatedly in a run, stop retrying with query variants. "
|
||||||
|
"Use available materials, state uncertainty clearly, and provide partial confirmed results."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AgentProfile:
|
class AgentProfile:
|
||||||
"""Runtime profile for a Beaver agent instance."""
|
"""Runtime profile for a Beaver agent instance."""
|
||||||
@ -548,6 +555,7 @@ class AgentLoop:
|
|||||||
parent_session_id=parent_session_id,
|
parent_session_id=parent_session_id,
|
||||||
),
|
),
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
|
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
|
||||||
)
|
)
|
||||||
context_result = context_builder.build_messages(build_input)
|
context_result = context_builder.build_messages(build_input)
|
||||||
if skill_selection_context:
|
if skill_selection_context:
|
||||||
|
|||||||
@ -75,6 +75,8 @@ class MessageRecord:
|
|||||||
"role": self.role,
|
"role": self.role,
|
||||||
"content": self.content,
|
"content": self.content,
|
||||||
}
|
}
|
||||||
|
if self.timestamp is not None:
|
||||||
|
payload["timestamp"] = self.timestamp
|
||||||
if self.run_id:
|
if self.run_id:
|
||||||
payload["run_id"] = self.run_id
|
payload["run_id"] = self.run_id
|
||||||
if self.event_payload:
|
if self.event_payload:
|
||||||
|
|||||||
@ -1635,6 +1635,8 @@ def create_app(
|
|||||||
|
|
||||||
@app.get("/api/tasks/{task_id}")
|
@app.get("/api/tasks/{task_id}")
|
||||||
async def get_task(task_id: str, request: Request) -> dict[str, Any]:
|
async def get_task(task_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
from beaver.services.process_service import SessionProcessProjector
|
||||||
|
|
||||||
loaded = get_agent_service(request).create_loop().boot()
|
loaded = get_agent_service(request).create_loop().boot()
|
||||||
task_service = loaded.task_service
|
task_service = loaded.task_service
|
||||||
if task_service is None:
|
if task_service is None:
|
||||||
@ -1642,10 +1644,18 @@ def create_app(
|
|||||||
task = task_service.get_task(task_id)
|
task = task_service.get_task(task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
process_projection = SessionProcessProjector(
|
||||||
|
loaded.session_manager,
|
||||||
|
loaded.run_memory_store,
|
||||||
|
).project(task.session_id)
|
||||||
|
filtered_process = _filter_task_process_projection(process_projection, task_id)
|
||||||
return {
|
return {
|
||||||
**task_service.to_api_dict(task),
|
**task_service.to_api_dict(task),
|
||||||
"events": [event.to_dict() for event in task_service.list_events(task_id)],
|
"events": [event.to_dict() for event in task_service.list_events(task_id)],
|
||||||
"runs": _task_run_views(task, task_service.list_events(task_id), loaded.session_manager, loaded.run_memory_store), # type: ignore[arg-type]
|
"runs": _task_run_views(task, task_service.list_events(task_id), loaded.session_manager, loaded.run_memory_store), # type: ignore[arg-type]
|
||||||
|
"process_runs": filtered_process["runs"],
|
||||||
|
"process_events": filtered_process["events"],
|
||||||
|
"process_artifacts": filtered_process["artifacts"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.delete("/api/tasks/{task_id}")
|
@app.delete("/api/tasks/{task_id}")
|
||||||
@ -2153,6 +2163,33 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
|
|||||||
return views
|
return views
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_task_process_projection(projection: dict[str, Any], task_id: str) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
def belongs_to_task(item: dict[str, Any]) -> bool:
|
||||||
|
metadata = item.get("metadata")
|
||||||
|
return isinstance(metadata, dict) and metadata.get("task_id") == task_id
|
||||||
|
|
||||||
|
def with_task_metadata(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
copied = dict(item)
|
||||||
|
metadata = dict(copied.get("metadata") or {})
|
||||||
|
metadata.setdefault("task_id", task_id)
|
||||||
|
copied["metadata"] = metadata
|
||||||
|
return copied
|
||||||
|
|
||||||
|
runs = [with_task_metadata(item) for item in projection.get("runs", []) if isinstance(item, dict) and belongs_to_task(item)]
|
||||||
|
run_ids = {str(item.get("run_id")) for item in runs if item.get("run_id")}
|
||||||
|
events = [
|
||||||
|
with_task_metadata(item)
|
||||||
|
for item in projection.get("events", [])
|
||||||
|
if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
|
||||||
|
]
|
||||||
|
artifacts = [
|
||||||
|
with_task_metadata(item)
|
||||||
|
for item in projection.get("artifacts", [])
|
||||||
|
if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
|
||||||
|
]
|
||||||
|
return {"runs": runs, "events": events, "artifacts": artifacts}
|
||||||
|
|
||||||
|
|
||||||
def _agent_labels_for_task_events(events: list[Any]) -> dict[str, str]:
|
def _agent_labels_for_task_events(events: list[Any]) -> dict[str, str]:
|
||||||
labels: dict[str, str] = {}
|
labels: dict[str, str] = {}
|
||||||
for event in events:
|
for event in events:
|
||||||
|
|||||||
@ -581,8 +581,96 @@ class AgentService:
|
|||||||
if active_task is None or decision.starts_new_task
|
if active_task is None or decision.starts_new_task
|
||||||
else active_task
|
else active_task
|
||||||
)
|
)
|
||||||
|
if active_task is not None and decision.action == "revise_task" and task.task_id == active_task.task_id:
|
||||||
|
task = self._record_revision_feedback_for_task(
|
||||||
|
loaded,
|
||||||
|
task=task,
|
||||||
|
session_id=session_id,
|
||||||
|
comment=message,
|
||||||
|
)
|
||||||
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
|
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
|
||||||
|
|
||||||
|
def _record_revision_feedback_for_task(
|
||||||
|
self,
|
||||||
|
loaded: Any,
|
||||||
|
*,
|
||||||
|
task: TaskRecord,
|
||||||
|
session_id: str,
|
||||||
|
comment: str,
|
||||||
|
) -> TaskRecord:
|
||||||
|
"""Mark the latest feedback-eligible run as revised before continuing a task."""
|
||||||
|
|
||||||
|
if task.status not in {"awaiting_feedback", "needs_revision"}:
|
||||||
|
return task
|
||||||
|
run_id = next((item for item in reversed(task.run_ids) if item), None)
|
||||||
|
if not run_id:
|
||||||
|
return task
|
||||||
|
|
||||||
|
existing = next((item for item in task.feedback if item.get("run_id") == run_id), None)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.get("feedback_type") != "revise":
|
||||||
|
return task
|
||||||
|
updated = task
|
||||||
|
already_recorded = True
|
||||||
|
else:
|
||||||
|
task_service = self._require_loaded(loaded, "task_service")
|
||||||
|
updated = task_service.add_feedback(
|
||||||
|
task.task_id,
|
||||||
|
feedback_type="revise",
|
||||||
|
comment=comment,
|
||||||
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
already_recorded = False
|
||||||
|
|
||||||
|
session_manager = self._require_loaded(loaded, "session_manager")
|
||||||
|
session_manager.update_latest_assistant_event_payload(
|
||||||
|
session_id,
|
||||||
|
run_id,
|
||||||
|
{
|
||||||
|
"task_id": updated.task_id,
|
||||||
|
"task_status": updated.status,
|
||||||
|
"feedback_state": "revise",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if already_recorded:
|
||||||
|
return updated
|
||||||
|
|
||||||
|
session_manager.append_message(
|
||||||
|
session_id,
|
||||||
|
run_id=run_id,
|
||||||
|
role="system",
|
||||||
|
event_type="task_feedback_recorded",
|
||||||
|
event_payload={
|
||||||
|
"task_id": updated.task_id,
|
||||||
|
"feedback_type": "revise",
|
||||||
|
"comment": comment,
|
||||||
|
"task_status": updated.status,
|
||||||
|
"auto_recorded": True,
|
||||||
|
},
|
||||||
|
content=comment,
|
||||||
|
context_visible=False,
|
||||||
|
)
|
||||||
|
validation = ValidationResult.from_dict(updated.validation_result)
|
||||||
|
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
||||||
|
run_memory_store.update_run_record(
|
||||||
|
run_id,
|
||||||
|
success=False,
|
||||||
|
feedback={
|
||||||
|
"feedback_type": "revise",
|
||||||
|
"comment": comment,
|
||||||
|
"task_status": updated.status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
run_memory_store.update_skill_effects_for_run(
|
||||||
|
run_id,
|
||||||
|
success=False,
|
||||||
|
feedback_score=self._feedback_score_for_learning("revise", validation),
|
||||||
|
notes=comment.strip() or "revise",
|
||||||
|
)
|
||||||
|
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
||||||
|
skill_learning_service.rescore_skill_versions()
|
||||||
|
return updated
|
||||||
|
|
||||||
async def _run_task_mode(
|
async def _run_task_mode(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
@ -1018,7 +1106,11 @@ class AgentService:
|
|||||||
if plan.final_synthesis_instruction
|
if plan.final_synthesis_instruction
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"Use the team outputs as internal evidence. Produce the final user-facing answer yourself.",
|
(
|
||||||
|
"Use successful team outputs as internal evidence. If one or more nodes failed, "
|
||||||
|
"do not blindly repeat failed tool calls. Produce a user-visible fallback answer "
|
||||||
|
"with available evidence and clearly state any missing or uncertain data."
|
||||||
|
),
|
||||||
]
|
]
|
||||||
if item
|
if item
|
||||||
)
|
)
|
||||||
@ -1031,7 +1123,11 @@ class AgentService:
|
|||||||
f"Planner reason: {plan.reason}",
|
f"Planner reason: {plan.reason}",
|
||||||
f"Strategy: {plan.graph.strategy if plan.graph else ''}",
|
f"Strategy: {plan.graph.strategy if plan.graph else ''}",
|
||||||
f"Error: {error}",
|
f"Error: {error}",
|
||||||
"Proceed as the main agent and produce the best possible final answer.",
|
(
|
||||||
|
"Proceed as the main agent. Do not blindly repeat failed tool calls; "
|
||||||
|
"produce a user-visible fallback answer with available evidence and clearly "
|
||||||
|
"state any missing or uncertain data."
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ Your only job is to classify the current user message into one routing decision:
|
|||||||
|
|
||||||
- `simple_chat`
|
- `simple_chat`
|
||||||
- `continue_task`
|
- `continue_task`
|
||||||
|
- `revise_task`
|
||||||
- `new_task`
|
- `new_task`
|
||||||
- `close_task`
|
- `close_task`
|
||||||
- `abandon_task`
|
- `abandon_task`
|
||||||
@ -27,6 +28,23 @@ Choose `new_task` when the user asks for anything that needs the main Task agent
|
|||||||
|
|
||||||
The Intent Agent has no tools. If a request needs a tool, do not apologize and do not say you cannot access it. Route it to Task mode so the main agent can use tools.
|
The Intent Agent has no tools. If a request needs a tool, do not apologize and do not say you cannot access it. Route it to Task mode so the main agent can use tools.
|
||||||
|
|
||||||
|
When there is an active task, do not force every new user message into that task. Use the active task and recent conversation to decide:
|
||||||
|
|
||||||
|
- Choose `revise_task` when the user asks to change, correct, refine, expand, reformat, or redo the latest active task result.
|
||||||
|
- Choose `continue_task` for neutral follow-up questions or additional next steps that still belong to the active task.
|
||||||
|
- Choose `new_task` when the user asks for clearly unrelated work.
|
||||||
|
- Choose `close_task` when the user says the task is satisfactory or finished, such as "可以了", "就这样", or "that's good".
|
||||||
|
- Choose `abandon_task` when the user says to stop, cancel, or no longer do the active task.
|
||||||
|
|
||||||
|
Examples with an active weather task:
|
||||||
|
|
||||||
|
- "再详细一点" -> `revise_task`
|
||||||
|
- "加上明后天穿衣建议" -> `revise_task`
|
||||||
|
- "顺便查一下深圳" -> `continue_task`
|
||||||
|
- "帮我写一个采购合同" -> `new_task`
|
||||||
|
- "可以了" -> `close_task`
|
||||||
|
- "不用了" -> `abandon_task`
|
||||||
|
|
||||||
## Must Create Task
|
## Must Create Task
|
||||||
|
|
||||||
Choose `new_task` when there is no active task and the request asks to:
|
Choose `new_task` when there is no active task and the request asks to:
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@ -77,6 +78,7 @@ class TaskExecutionPlanner:
|
|||||||
attempt_index: int,
|
attempt_index: int,
|
||||||
latest_validation: ValidationResult | None = None,
|
latest_validation: ValidationResult | None = None,
|
||||||
provider_bundle: ProviderBundle | None = None,
|
provider_bundle: ProviderBundle | None = None,
|
||||||
|
timeout_seconds: float = 30.0,
|
||||||
) -> TaskExecutionPlan:
|
) -> TaskExecutionPlan:
|
||||||
provider = None
|
provider = None
|
||||||
model = None
|
model = None
|
||||||
@ -87,7 +89,8 @@ class TaskExecutionPlanner:
|
|||||||
if provider is None:
|
if provider is None:
|
||||||
return TaskExecutionPlan.single("planner_provider_unavailable")
|
return TaskExecutionPlan.single("planner_provider_unavailable")
|
||||||
try:
|
try:
|
||||||
response = await provider.chat(
|
response = await asyncio.wait_for(
|
||||||
|
provider.chat(
|
||||||
messages=[
|
messages=[
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@ -110,6 +113,8 @@ class TaskExecutionPlanner:
|
|||||||
model=model,
|
model=model,
|
||||||
max_tokens=4096,
|
max_tokens=4096,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
|
),
|
||||||
|
timeout=timeout_seconds,
|
||||||
)
|
)
|
||||||
plan = self.from_json(response.content or "")
|
plan = self.from_json(response.content or "")
|
||||||
return await self._resolve_plan(
|
return await self._resolve_plan(
|
||||||
@ -120,7 +125,9 @@ class TaskExecutionPlanner:
|
|||||||
provider_bundle=provider_bundle,
|
provider_bundle=provider_bundle,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return TaskExecutionPlan.single("planner_failed", fallback_error=str(exc))
|
detail = str(exc)
|
||||||
|
error = f"{type(exc).__name__}: {detail}" if detail else type(exc).__name__
|
||||||
|
return TaskExecutionPlan.single("planner_failed", fallback_error=error)
|
||||||
|
|
||||||
async def _resolve_plan(
|
async def _resolve_plan(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -69,6 +69,14 @@ class MainAgentRouter:
|
|||||||
reason = str(payload.get("reason") or raw_action or "llm_router")
|
reason = str(payload.get("reason") or raw_action or "llm_router")
|
||||||
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
|
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
|
||||||
|
|
||||||
|
if raw_action in {"revise_task", "revise", "revision", "needs_revision"}:
|
||||||
|
return MainAgentDecision(
|
||||||
|
mode="task",
|
||||||
|
reason=reason,
|
||||||
|
starts_new_task=active_task is None,
|
||||||
|
short_title=short_title,
|
||||||
|
action="revise_task" if active_task is not None else "create_task",
|
||||||
|
)
|
||||||
if raw_action in {"continue_task", "continue", "task"}:
|
if raw_action in {"continue_task", "continue", "task"}:
|
||||||
return MainAgentDecision(
|
return MainAgentDecision(
|
||||||
mode="task",
|
mode="task",
|
||||||
@ -146,13 +154,16 @@ class MainAgentRouter:
|
|||||||
"Actions:\n"
|
"Actions:\n"
|
||||||
"- simple_chat: no Task should be created or continued.\n"
|
"- simple_chat: no Task should be created or continued.\n"
|
||||||
"- continue_task: keep the user in the active Task.\n"
|
"- continue_task: keep the user in the active Task.\n"
|
||||||
|
"- revise_task: user asks to change, correct, refine, expand, reformat, or redo the latest active Task result.\n"
|
||||||
"- new_task: start a separate new Task.\n"
|
"- new_task: start a separate new Task.\n"
|
||||||
"- close_task: user explicitly says the active Task is done/satisfactory/finished.\n"
|
"- close_task: user explicitly says the active Task is done/satisfactory/finished.\n"
|
||||||
"- abandon_task: user explicitly says to stop, cancel, abandon, or no longer do the active Task.\n\n"
|
"- abandon_task: user explicitly says to stop, cancel, abandon, or no longer do the active Task.\n\n"
|
||||||
"Critical policy:\n"
|
"Critical policy:\n"
|
||||||
"- If there is an active Task, choose continue_task unless the user's topic is completely unrelated "
|
"- If there is an active Task, choose continue_task or revise_task unless the user's topic is completely unrelated "
|
||||||
"to that Task or the user explicitly closes/abandons it.\n"
|
"to that Task or the user explicitly closes/abandons it.\n"
|
||||||
"- Follow-up questions, corrections, partial changes, extra constraints, and result discussion stay in continue_task.\n"
|
"- Choose revise_task when the active Task is awaiting feedback or needs revision and the user asks for changes "
|
||||||
|
"such as '改一下', '加上', '删除', '换成', '再详细点', '格式改成', '不要', or equivalent wording.\n"
|
||||||
|
"- Choose continue_task for neutral follow-up questions or additional next steps that do not imply dissatisfaction with the previous result.\n"
|
||||||
"- Use new_task only when the user clearly asks to start a different task.\n"
|
"- Use new_task only when the user clearly asks to start a different task.\n"
|
||||||
"- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, "
|
"- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, "
|
||||||
"implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n"
|
"implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n"
|
||||||
|
|||||||
@ -93,6 +93,29 @@ class TaskSkillResolver:
|
|||||||
for item in node.agent.metadata.get("required_capabilities", [])
|
for item in node.agent.metadata.get("required_capabilities", [])
|
||||||
if str(item).strip()
|
if str(item).strip()
|
||||||
]
|
]
|
||||||
|
if self._is_summary_only_node(node, skill_query=skill_query, required_capabilities=required_capabilities):
|
||||||
|
resolved = self._generic_node(
|
||||||
|
node,
|
||||||
|
pinned_skill_names=[],
|
||||||
|
pinned_skill_contexts=[],
|
||||||
|
metadata={
|
||||||
|
**node.agent.metadata,
|
||||||
|
"skill_query": skill_query,
|
||||||
|
"required_capabilities": required_capabilities,
|
||||||
|
"selected_skill_names": [],
|
||||||
|
"ephemeral_skill_names": [],
|
||||||
|
"summary_uses_dependency_outputs_only": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return resolved, SkillResolutionReport(
|
||||||
|
node_id=node.node_id,
|
||||||
|
skill_query=skill_query,
|
||||||
|
required_capabilities=required_capabilities,
|
||||||
|
selected_skill_names=[],
|
||||||
|
ephemeral_used=False,
|
||||||
|
reason="summary node uses dependency outputs directly",
|
||||||
|
)
|
||||||
|
|
||||||
selected = await self._select_published_skills(
|
selected = await self._select_published_skills(
|
||||||
query="\n".join(
|
query="\n".join(
|
||||||
part
|
part
|
||||||
@ -226,6 +249,34 @@ class TaskSkillResolver:
|
|||||||
selected.append(name)
|
selected.append(name)
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_summary_only_node(
|
||||||
|
node: ExecutionNode,
|
||||||
|
*,
|
||||||
|
skill_query: str,
|
||||||
|
required_capabilities: list[str],
|
||||||
|
) -> bool:
|
||||||
|
node_id = node.node_id.strip().lower()
|
||||||
|
query = skill_query.strip().lower()
|
||||||
|
capabilities = {item.strip().lower() for item in required_capabilities}
|
||||||
|
task_text = node.task.strip().lower()
|
||||||
|
summary_identity = node_id in {"summarize", "summary", "synthesis"} or query in {
|
||||||
|
"summarization",
|
||||||
|
"summary",
|
||||||
|
"synthesis",
|
||||||
|
"final synthesis",
|
||||||
|
}
|
||||||
|
text_only_capabilities = not capabilities or capabilities.issubset(
|
||||||
|
{"text generation", "summarization", "summary", "synthesis"}
|
||||||
|
)
|
||||||
|
dependency_summary_task = (
|
||||||
|
"summary" in task_text
|
||||||
|
or "summarize" in task_text
|
||||||
|
or "synthesis" in task_text
|
||||||
|
or "compile" in task_text
|
||||||
|
)
|
||||||
|
return summary_identity and text_only_capabilities and dependency_summary_task
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generic_node(
|
def _generic_node(
|
||||||
node: ExecutionNode,
|
node: ExecutionNode,
|
||||||
@ -246,7 +297,9 @@ class TaskSkillResolver:
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
inherited_pinned_skills=pinned_skill_names,
|
inherited_pinned_skills=pinned_skill_names,
|
||||||
inherited_pinned_skill_contexts=list(pinned_skill_contexts or node.inherited_pinned_skill_contexts),
|
inherited_pinned_skill_contexts=list(
|
||||||
|
node.inherited_pinned_skill_contexts if pinned_skill_contexts is None else pinned_skill_contexts
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -78,3 +78,81 @@ def test_task_delete_api_removes_backend_task(tmp_path: Path) -> None:
|
|||||||
assert deleted.json()["task_id"] == task.task_id
|
assert deleted.json()["task_id"] == task.task_id
|
||||||
assert all(item["task_id"] != task.task_id for item in listed.json())
|
assert all(item["task_id"] != task.task_id for item in listed.json())
|
||||||
assert missing.status_code == 404
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_detail_api_includes_filtered_process_projection(tmp_path: Path) -> None:
|
||||||
|
service = AgentService(workspace=tmp_path)
|
||||||
|
loaded = service.create_loop().boot()
|
||||||
|
task = loaded.task_service.create_task( # type: ignore[union-attr]
|
||||||
|
session_id="web:detail",
|
||||||
|
description="补充赛事数据",
|
||||||
|
)
|
||||||
|
other_task = loaded.task_service.create_task( # type: ignore[union-attr]
|
||||||
|
session_id="web:detail",
|
||||||
|
description="不相关任务",
|
||||||
|
)
|
||||||
|
loaded.session_manager.append_message(
|
||||||
|
"web:detail",
|
||||||
|
role="system",
|
||||||
|
event_type="task_execution_planned",
|
||||||
|
event_payload={
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"attempt_index": 2,
|
||||||
|
"plan_mode": "team",
|
||||||
|
"strategy": "parallel",
|
||||||
|
"node_ids": ["search_match_result", "search_match_stats"],
|
||||||
|
"reason": "needs separate evidence gathering",
|
||||||
|
},
|
||||||
|
context_visible=False,
|
||||||
|
)
|
||||||
|
loaded.session_manager.append_message(
|
||||||
|
"web:detail",
|
||||||
|
role="system",
|
||||||
|
event_type="task_team_run_failed",
|
||||||
|
event_payload={
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"attempt_index": 2,
|
||||||
|
"plan_mode": "team",
|
||||||
|
"strategy": "parallel",
|
||||||
|
"team_success": False,
|
||||||
|
"team_run_ids": ["sub-run"],
|
||||||
|
"node_results": [
|
||||||
|
{
|
||||||
|
"node_id": "search_match_stats",
|
||||||
|
"success": False,
|
||||||
|
"output_text": "",
|
||||||
|
"run_id": "sub-run",
|
||||||
|
"finish_reason": "max_tool_iterations",
|
||||||
|
"error": "max_tool_iterations",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"error": "one or more team nodes failed",
|
||||||
|
},
|
||||||
|
context_visible=False,
|
||||||
|
)
|
||||||
|
loaded.session_manager.append_message(
|
||||||
|
"web:detail",
|
||||||
|
role="system",
|
||||||
|
event_type="task_execution_planned",
|
||||||
|
event_payload={
|
||||||
|
"task_id": other_task.task_id,
|
||||||
|
"attempt_index": 1,
|
||||||
|
"plan_mode": "single",
|
||||||
|
"strategy": None,
|
||||||
|
"node_ids": [],
|
||||||
|
},
|
||||||
|
context_visible=False,
|
||||||
|
)
|
||||||
|
app = create_app(service=service, manage_service_lifecycle=False)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get(f"/api/tasks/{task.task_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert [run["run_id"] for run in payload["process_runs"]] == [
|
||||||
|
f"task:{task.task_id}:attempt:2",
|
||||||
|
"sub-run",
|
||||||
|
]
|
||||||
|
assert {event["actor_name"] for event in payload["process_events"]} == {"Task Planner", "Task Team", "search_match_stats"}
|
||||||
|
assert all(event["metadata"]["task_id"] == task.task_id for event in payload["process_events"])
|
||||||
|
|||||||
@ -103,6 +103,20 @@ def test_router_continues_active_task_from_llm_decision() -> None:
|
|||||||
assert provider.calls[0]["max_tokens"] == 256
|
assert provider.calls[0]["max_tokens"] == 256
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_marks_revision_from_llm_decision() -> None:
|
||||||
|
decision = asyncio.run(
|
||||||
|
MainAgentRouter().classify(
|
||||||
|
"再详细一点,并加上表格",
|
||||||
|
active_task=_task(),
|
||||||
|
provider=RouterProvider('{"action":"revise_task","reason":"user requested changes","short_title":"任务连续性"}'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decision.is_task
|
||||||
|
assert decision.starts_new_task is False
|
||||||
|
assert decision.action == "revise_task"
|
||||||
|
|
||||||
|
|
||||||
def test_router_receives_thinking_mode() -> None:
|
def test_router_receives_thinking_mode() -> None:
|
||||||
provider = RouterProvider('{"action":"simple_chat","reason":"simple"}')
|
provider = RouterProvider('{"action":"simple_chat","reason":"simple"}')
|
||||||
decision = asyncio.run(
|
decision = asyncio.run(
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
from beaver.engine.session.models import MessageRecord
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversation_message_preserves_timestamp() -> None:
|
||||||
|
record = MessageRecord(
|
||||||
|
role="user",
|
||||||
|
content="hello",
|
||||||
|
timestamp=1_779_329_600.0,
|
||||||
|
message_id=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert record.to_conversation_message()["timestamp"] == 1_779_329_600.0
|
||||||
@ -27,6 +27,22 @@ class PlannerProvider(LLMProvider):
|
|||||||
return "stub-model"
|
return "stub-model"
|
||||||
|
|
||||||
|
|
||||||
|
class HangingPlannerProvider(LLMProvider):
|
||||||
|
async def chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_tokens: int = 4096,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
) -> LLMResponse:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
return LLMResponse(content='{"mode":"team"}', finish_reason="stop", provider_name="stub", model="stub-model")
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return "stub-model"
|
||||||
|
|
||||||
|
|
||||||
def _task() -> TaskRecord:
|
def _task() -> TaskRecord:
|
||||||
return TaskRecord(
|
return TaskRecord(
|
||||||
task_id="task-1",
|
task_id="task-1",
|
||||||
@ -49,6 +65,13 @@ def _bundle(response: str) -> ProviderBundle:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hanging_bundle() -> ProviderBundle:
|
||||||
|
return ProviderBundle(
|
||||||
|
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||||
|
main_provider=HangingPlannerProvider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_planner_selects_single_mode() -> None:
|
def test_planner_selects_single_mode() -> None:
|
||||||
plan = asyncio.run(
|
plan = asyncio.run(
|
||||||
TaskExecutionPlanner().plan(
|
TaskExecutionPlanner().plan(
|
||||||
@ -95,6 +118,22 @@ def test_planner_builds_team_graph() -> None:
|
|||||||
assert plan.final_synthesis_instruction == "merge the findings"
|
assert plan.final_synthesis_instruction == "merge the findings"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_timeout_falls_back_to_single() -> None:
|
||||||
|
plan = asyncio.run(
|
||||||
|
TaskExecutionPlanner().plan(
|
||||||
|
task=_task(),
|
||||||
|
user_message="implement workflow",
|
||||||
|
attempt_index=1,
|
||||||
|
provider_bundle=_hanging_bundle(),
|
||||||
|
timeout_seconds=0.01,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.mode == "single"
|
||||||
|
assert plan.reason == "planner_failed"
|
||||||
|
assert "TimeoutError" in (plan.fallback_error or "")
|
||||||
|
|
||||||
|
|
||||||
def test_planner_team_nodes_can_target_skills_without_agent_roles() -> None:
|
def test_planner_team_nodes_can_target_skills_without_agent_roles() -> None:
|
||||||
plan = TaskExecutionPlanner().from_json(
|
plan = TaskExecutionPlanner().from_json(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -290,6 +290,109 @@ def test_active_task_continues_until_llm_closes_it(tmp_path: Path) -> None:
|
|||||||
assert loaded.task_service.active_task_view("web:continue") is None
|
assert loaded.task_service.active_task_view("web:continue") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_task_revision_input_records_feedback_and_reruns(tmp_path: Path) -> None:
|
||||||
|
service = AgentService(
|
||||||
|
loader=EngineLoader(
|
||||||
|
workspace=tmp_path,
|
||||||
|
task_execution_planner=_single_planner(),
|
||||||
|
validation_service=StubValidationService(
|
||||||
|
[
|
||||||
|
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||||
|
ValidationResult(passed=True, score=0.95, validator="test"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
first = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"查询珠海天气",
|
||||||
|
session_id="web:revise-direct",
|
||||||
|
provider_bundle=_bundle("珠海天气概览", route_action="new_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
second = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"再详细一点,并加上明后天穿衣建议",
|
||||||
|
session_id="web:revise-direct",
|
||||||
|
provider_bundle=_bundle("更新后的珠海天气和穿衣建议", route_action="revise_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loaded = service.create_loop().boot()
|
||||||
|
task = loaded.task_service.get_task(first.task_id)
|
||||||
|
messages = loaded.session_manager.get_messages_as_conversation(first.session_id)
|
||||||
|
first_assistant = [
|
||||||
|
message
|
||||||
|
for message in messages
|
||||||
|
if message.get("role") == "assistant" and message.get("run_id") == first.run_id
|
||||||
|
][-1]
|
||||||
|
user_messages = [message.get("content") for message in messages if message.get("role") == "user"]
|
||||||
|
|
||||||
|
assert second.task_id == first.task_id
|
||||||
|
assert task is not None
|
||||||
|
assert task.status == "awaiting_feedback"
|
||||||
|
assert len(task.run_ids) == 2
|
||||||
|
assert task.feedback == [
|
||||||
|
{
|
||||||
|
"feedback_type": "revise",
|
||||||
|
"comment": "再详细一点,并加上明后天穿衣建议",
|
||||||
|
"run_id": first.run_id,
|
||||||
|
"created_at": task.feedback[0]["created_at"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert first_assistant["feedback_state"] == "revise"
|
||||||
|
assert "再详细一点,并加上明后天穿衣建议" in user_messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_revision_feedback_then_input_reruns_without_duplicate_feedback(tmp_path: Path) -> None:
|
||||||
|
service = AgentService(
|
||||||
|
loader=EngineLoader(
|
||||||
|
workspace=tmp_path,
|
||||||
|
task_execution_planner=_single_planner(),
|
||||||
|
validation_service=StubValidationService(
|
||||||
|
[
|
||||||
|
ValidationResult(passed=True, score=0.9, validator="test"),
|
||||||
|
ValidationResult(passed=True, score=0.95, validator="test"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
first = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"查询珠海天气",
|
||||||
|
session_id="web:explicit-revise",
|
||||||
|
provider_bundle=_bundle("珠海天气概览", route_action="new_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
feedback = asyncio.run(
|
||||||
|
service.submit_feedback(
|
||||||
|
session_id=first.session_id,
|
||||||
|
run_id=first.run_id,
|
||||||
|
feedback_type="revise",
|
||||||
|
comment="准备补充穿衣建议",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
second = asyncio.run(
|
||||||
|
service.process_direct(
|
||||||
|
"加上明后天穿衣建议",
|
||||||
|
session_id="web:explicit-revise",
|
||||||
|
provider_bundle=_bundle("更新后的珠海天气和穿衣建议", route_action="revise_task"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loaded = service.create_loop().boot()
|
||||||
|
task = loaded.task_service.get_task(first.task_id)
|
||||||
|
|
||||||
|
assert feedback["task_status"] == "needs_revision"
|
||||||
|
assert second.task_id == first.task_id
|
||||||
|
assert task is not None
|
||||||
|
assert task.status == "awaiting_feedback"
|
||||||
|
assert len(task.run_ids) == 2
|
||||||
|
assert len(task.feedback) == 1
|
||||||
|
assert task.feedback[0]["feedback_type"] == "revise"
|
||||||
|
assert task.feedback[0]["comment"] == "准备补充穿衣建议"
|
||||||
|
|
||||||
|
|
||||||
def test_validation_failure_retries_once(tmp_path: Path) -> None:
|
def test_validation_failure_retries_once(tmp_path: Path) -> None:
|
||||||
service = AgentService(
|
service = AgentService(
|
||||||
loader=EngineLoader(
|
loader=EngineLoader(
|
||||||
@ -545,6 +648,8 @@ def test_task_mode_team_failure_still_uses_main_synthesis(tmp_path: Path) -> Non
|
|||||||
assert result.output_text == "fallback synthesized answer"
|
assert result.output_text == "fallback synthesized answer"
|
||||||
assert any(event.event_type == "task_team_run_failed" for event in events)
|
assert any(event.event_type == "task_team_run_failed" for event in events)
|
||||||
assert "sub-agent unavailable" in main_provider.calls[0][0]["content"]
|
assert "sub-agent unavailable" in main_provider.calls[0][0]["content"]
|
||||||
|
assert "same class of tools fails repeatedly" in main_provider.calls[0][0]["content"]
|
||||||
|
assert "user-visible fallback answer" in main_provider.calls[0][0]["content"]
|
||||||
|
|
||||||
|
|
||||||
def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None:
|
def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None:
|
||||||
|
|||||||
@ -65,8 +65,8 @@ def _publish_skill(workspace: Path, *, skill_name: str) -> None:
|
|||||||
store = SkillSpecStore(workspace)
|
store = SkillSpecStore(workspace)
|
||||||
draft = DraftService(store).create_new_skill_draft(
|
draft = DraftService(store).create_new_skill_draft(
|
||||||
skill_name=skill_name,
|
skill_name=skill_name,
|
||||||
proposed_content="# API Contract Review\n\nCheck schema compatibility and breaking changes.",
|
proposed_content=f"# {skill_name}\n\nCheck schema compatibility and breaking changes.",
|
||||||
proposed_frontmatter={"description": "API contract compatibility review", "tools": []},
|
proposed_frontmatter={"description": f"{skill_name} capability", "tools": []},
|
||||||
created_by="tester",
|
created_by="tester",
|
||||||
reason="test",
|
reason="test",
|
||||||
)
|
)
|
||||||
@ -174,3 +174,51 @@ def test_task_skill_resolver_generates_ephemeral_guidance_when_missing(tmp_path:
|
|||||||
assert reports[0].ephemeral_guidance_id is not None
|
assert reports[0].ephemeral_guidance_id is not None
|
||||||
assert reports[0].ephemeral_guidance_name == "api-compatibility-review"
|
assert reports[0].ephemeral_guidance_name == "api-compatibility-review"
|
||||||
assert reports[0].ephemeral_used is True
|
assert reports[0].ephemeral_used is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_skill_resolver_keeps_summary_nodes_skillless(tmp_path: Path) -> None:
|
||||||
|
_publish_skill(tmp_path, skill_name="multi-search-engine")
|
||||||
|
provider = RecordingProvider(['["multi-search-engine"]'])
|
||||||
|
resolver = TaskSkillResolver(
|
||||||
|
skills_loader=SkillsLoader(tmp_path),
|
||||||
|
draft_service=DraftService(SkillSpecStore(tmp_path)),
|
||||||
|
)
|
||||||
|
graph = ExecutionGraph(
|
||||||
|
strategy="dag",
|
||||||
|
nodes=[
|
||||||
|
ExecutionNode(
|
||||||
|
"summarize",
|
||||||
|
"Compile a clear, concise summary from dependency outputs for the user.",
|
||||||
|
AgentDescriptor(
|
||||||
|
name="summarize",
|
||||||
|
metadata={
|
||||||
|
"skill_query": "Summarization",
|
||||||
|
"required_capabilities": ["text generation"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
depends_on=["verify_result"],
|
||||||
|
inherited_pinned_skills=["multi-search-engine"],
|
||||||
|
inherited_pinned_skill_contexts=[
|
||||||
|
SkillContext(name="ephemeral:search-guidance", content="Search again.")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved, reports = asyncio.run(
|
||||||
|
resolver.resolve_graph(
|
||||||
|
graph,
|
||||||
|
task=_task(),
|
||||||
|
user_message="summarize result",
|
||||||
|
attempt_index=2,
|
||||||
|
provider_bundle=_bundle(provider),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved.nodes[0].inherited_pinned_skills == []
|
||||||
|
assert resolved.nodes[0].inherited_pinned_skill_contexts == []
|
||||||
|
assert resolved.nodes[0].agent.metadata["selected_skill_names"] == []
|
||||||
|
assert reports[0].selected_skill_names == []
|
||||||
|
assert reports[0].ephemeral_used is False
|
||||||
|
assert reports[0].reason == "summary node uses dependency outputs directly"
|
||||||
|
assert provider.calls == []
|
||||||
|
|||||||
@ -150,6 +150,7 @@ export default function NotificationDetailPage() {
|
|||||||
selectedRunId={null}
|
selectedRunId={null}
|
||||||
onSelectRun={() => {}}
|
onSelectRun={() => {}}
|
||||||
onFeedback={() => {}}
|
onFeedback={() => {}}
|
||||||
|
onRequestRevision={() => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -18,41 +18,12 @@ import {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
wsManager,
|
wsManager,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import { mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||||
|
|
||||||
function messageFingerprint(msg: ChatMessage): string {
|
|
||||||
const attachmentKey = (msg.attachments ?? [])
|
|
||||||
.map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`)
|
|
||||||
.join('|');
|
|
||||||
return `${msg.role}::${String(msg.content)}::${attachmentKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const message of serverMessages) {
|
|
||||||
const key = messageFingerprint(message);
|
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingUsers: ChatMessage[] = [];
|
|
||||||
for (const message of localMessages) {
|
|
||||||
const key = messageFingerprint(message);
|
|
||||||
const count = counts.get(key) ?? 0;
|
|
||||||
if (count > 0) {
|
|
||||||
counts.set(key, count - 1);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (message.role === 'user') {
|
|
||||||
pendingUsers.push(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...serverMessages, ...pendingUsers];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
|
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
|
||||||
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||||||
}
|
}
|
||||||
@ -101,11 +72,13 @@ export default function ChatPage() {
|
|||||||
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
||||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||||
|
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const loadSessionReqSeq = useRef(0);
|
const loadSessionReqSeq = useRef(0);
|
||||||
|
const loadedSessionIdRef = useRef<string | null>(null);
|
||||||
const refreshSessionOnReconnectRef = useRef(false);
|
const refreshSessionOnReconnectRef = useRef(false);
|
||||||
const hasConnectedRef = useRef(false);
|
const hasConnectedRef = useRef(false);
|
||||||
const shouldSnapToLatestRef = useRef(true);
|
const shouldSnapToLatestRef = useRef(true);
|
||||||
@ -185,10 +158,15 @@ export default function ChatPage() {
|
|||||||
}, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]);
|
}, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const didSwitchSession = loadedSessionIdRef.current !== null && loadedSessionIdRef.current !== sessionId;
|
||||||
|
loadedSessionIdRef.current = sessionId;
|
||||||
|
if (didSwitchSession) {
|
||||||
clearMessages();
|
clearMessages();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsThinking(false);
|
setIsThinking(false);
|
||||||
|
}
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
|
setRevisionTargetRunId(null);
|
||||||
void loadSessionMessages(sessionId);
|
void loadSessionMessages(sessionId);
|
||||||
void loadActiveTask(sessionId);
|
void loadActiveTask(sessionId);
|
||||||
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||||||
@ -304,10 +282,33 @@ export default function ChatPage() {
|
|||||||
size: item.file.size,
|
size: item.file.size,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
||||||
|
|
||||||
|
if (revisionTargetRunId && text) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsThinking(false);
|
||||||
|
updateMessageFeedback(revisionTargetRunId, 'revise');
|
||||||
|
try {
|
||||||
|
await submitChatFeedback({
|
||||||
|
sessionId,
|
||||||
|
runId: revisionTargetRunId,
|
||||||
|
feedbackType: 'revise',
|
||||||
|
comment: msgContent,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsThinking(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
updateMessageFeedback(revisionTargetRunId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed'));
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setRevisionTargetRunId(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRevisionTargetRunId(null);
|
||||||
|
}
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
|
|
||||||
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
|
||||||
addMessage({
|
addMessage({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: msgContent,
|
content: msgContent,
|
||||||
@ -371,7 +372,7 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled]);
|
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
|
||||||
|
|
||||||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
||||||
updateMessageFeedback(runId, feedbackType);
|
updateMessageFeedback(runId, feedbackType);
|
||||||
@ -391,6 +392,11 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
|
}, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
|
||||||
|
|
||||||
|
const handleRequestRevision = useCallback((runId: string) => {
|
||||||
|
setRevisionTargetRunId(runId);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -426,6 +432,7 @@ export default function ChatPage() {
|
|||||||
setSessionId(id);
|
setSessionId(id);
|
||||||
setSelectedRunId(null);
|
setSelectedRunId(null);
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
|
setRevisionTargetRunId(null);
|
||||||
clearMessages();
|
clearMessages();
|
||||||
useChatStore.getState().resetProcessState();
|
useChatStore.getState().resetProcessState();
|
||||||
try {
|
try {
|
||||||
@ -444,6 +451,7 @@ export default function ChatPage() {
|
|||||||
if (key === sessionId) {
|
if (key === sessionId) {
|
||||||
setSessionId('web:default');
|
setSessionId('web:default');
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
|
setRevisionTargetRunId(null);
|
||||||
clearMessages();
|
clearMessages();
|
||||||
useChatStore.getState().resetProcessState();
|
useChatStore.getState().resetProcessState();
|
||||||
}
|
}
|
||||||
@ -460,6 +468,7 @@ export default function ChatPage() {
|
|||||||
const handleSelectSession = (key: string) => {
|
const handleSelectSession = (key: string) => {
|
||||||
setSelectedRunId(null);
|
setSelectedRunId(null);
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
|
setRevisionTargetRunId(null);
|
||||||
setSessionId(key);
|
setSessionId(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -554,24 +563,29 @@ export default function ChatPage() {
|
|||||||
selectedRunId={selectedSessionRunId}
|
selectedRunId={selectedSessionRunId}
|
||||||
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
|
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
|
||||||
onFeedback={handleFeedback}
|
onFeedback={handleFeedback}
|
||||||
|
onRequestRevision={handleRequestRevision}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background px-8 pb-8 pt-4">
|
<div className="bg-background px-8 pb-8 pt-4">
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
{activeTask && (
|
{(activeTask || revisionTargetRunId) && (
|
||||||
<div className="mb-2 flex">
|
<div className="mb-2 flex">
|
||||||
|
{activeTask ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
|
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
|
||||||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
||||||
title={activeTask.description}
|
title={activeTask.description}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 text-muted-foreground">{pickAppText(locale, '当前任务', 'Current task')}:</span>
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
{revisionTargetRunId ? pickAppText(locale, '修改任务', 'Revising task') : pickAppText(locale, '当前任务', 'Current task')}:
|
||||||
|
</span>
|
||||||
<span className="truncate font-medium">{activeTask.short_title}</span>
|
<span className="truncate font-medium">{activeTask.short_title}</span>
|
||||||
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[11px] text-muted-foreground">
|
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
{activeTaskStatusLabel(activeTask.status, locale)}
|
{revisionTargetRunId ? pickAppText(locale, '待输入修改要求', 'Awaiting revision') : activeTaskStatusLabel(activeTask.status, locale)}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pendingFiles.length > 0 && (
|
{pendingFiles.length > 0 && (
|
||||||
@ -607,7 +621,11 @@ export default function ChatPage() {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')}
|
placeholder={
|
||||||
|
revisionTargetRunId
|
||||||
|
? pickAppText(locale, '请输入修改要求', 'Describe the requested changes')
|
||||||
|
: pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')
|
||||||
|
}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
style={{ minHeight: '72px', maxHeight: '200px' }}
|
style={{ minHeight: '72px', maxHeight: '200px' }}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { pickAppText } from '@/lib/i18n/core';
|
|||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
|
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types';
|
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||||
|
|
||||||
type TaskFeedbackType = 'satisfied' | 'revise' | 'abandon';
|
type TaskFeedbackType = 'satisfied' | 'revise' | 'abandon';
|
||||||
type TaskFeedbackItem = {
|
type TaskFeedbackItem = {
|
||||||
@ -217,6 +217,8 @@ export default function TaskDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BackendExecutionStages task={backendTask} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
||||||
@ -549,6 +551,80 @@ function Metric({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BackendExecutionStages({ task }: { task: BackendTask }) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
const runs = task.process_runs ?? [];
|
||||||
|
const events = task.process_events ?? [];
|
||||||
|
const eventsByRun = React.useMemo(() => {
|
||||||
|
const map = new Map<string, ProcessEvent[]>();
|
||||||
|
for (const event of events) {
|
||||||
|
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{pickAppText(locale, '执行阶段', 'Execution stages')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{runs.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无执行阶段记录', 'No execution stage records yet')}</div>
|
||||||
|
) : (
|
||||||
|
runs.map((run) => (
|
||||||
|
<BackendProcessRun key={run.run_id} run={run} events={eventsByRun.get(run.run_id) ?? []} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackendProcessRun({ run, events }: { run: ProcessRun; events: ProcessEvent[] }) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
const metadata = run.metadata ?? {};
|
||||||
|
const details = [
|
||||||
|
metadata.attempt_index ? `${pickAppText(locale, '尝试', 'Attempt')} ${String(metadata.attempt_index)}` : null,
|
||||||
|
metadata.plan_mode ? `${pickAppText(locale, '模式', 'Mode')}: ${String(metadata.plan_mode)}` : null,
|
||||||
|
metadata.strategy ? `${pickAppText(locale, '策略', 'Strategy')}: ${String(metadata.strategy)}` : null,
|
||||||
|
metadata.node_id ? `${pickAppText(locale, '节点', 'Node')}: ${String(metadata.node_id)}` : null,
|
||||||
|
metadata.finish_reason ? `${pickAppText(locale, '结束原因', 'Finish')}: ${String(metadata.finish_reason)}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const error = typeof metadata.error === 'string' && metadata.error ? metadata.error : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-background p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium">{run.title || run.actor_name}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{run.actor_name}
|
||||||
|
{run.started_at ? ` · ${formatTaskRuntimeTime(run.started_at, locale)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TaskRuntimeStatusBadge status={run.status} />
|
||||||
|
</div>
|
||||||
|
{details.length > 0 ? <div className="mt-2 text-xs text-muted-foreground">{details.join(' · ')}</div> : null}
|
||||||
|
{run.summary ? <p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{run.summary}</p> : null}
|
||||||
|
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div key={event.event_id} className="rounded-md bg-muted/30 px-3 py-2 text-xs">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{event.actor_name}</span>
|
||||||
|
<span className="text-muted-foreground">{formatTaskRuntimeTime(event.created_at, locale)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-muted-foreground">{event.text || event.kind}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TaskFeedbackPanel({
|
function TaskFeedbackPanel({
|
||||||
sessionId,
|
sessionId,
|
||||||
runId,
|
runId,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export function ChatWorkbench({
|
|||||||
selectedRunId,
|
selectedRunId,
|
||||||
onSelectRun,
|
onSelectRun,
|
||||||
onFeedback,
|
onFeedback,
|
||||||
|
onRequestRevision,
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
isThinking: boolean;
|
isThinking: boolean;
|
||||||
@ -27,6 +28,7 @@ export function ChatWorkbench({
|
|||||||
selectedRunId: string | null;
|
selectedRunId: string | null;
|
||||||
onSelectRun: (runId: string) => void;
|
onSelectRun: (runId: string) => void;
|
||||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||||
|
onRequestRevision: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
@ -41,6 +43,7 @@ export function ChatWorkbench({
|
|||||||
selectedRunId={selectedRunId}
|
selectedRunId={selectedRunId}
|
||||||
onSelectRun={onSelectRun}
|
onSelectRun={onSelectRun}
|
||||||
onFeedback={onFeedback}
|
onFeedback={onFeedback}
|
||||||
|
onRequestRevision={onRequestRevision}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, Thumbs
|
|||||||
|
|
||||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||||
|
import { getTaskCardMessageIndexes } from '@/lib/chat-messages';
|
||||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@ -40,17 +41,21 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
|
|||||||
|
|
||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
|
showTaskCard,
|
||||||
canSendFeedback,
|
canSendFeedback,
|
||||||
onFeedback,
|
onFeedback,
|
||||||
|
onRequestRevision,
|
||||||
}: {
|
}: {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
|
showTaskCard: boolean;
|
||||||
canSendFeedback: boolean;
|
canSendFeedback: boolean;
|
||||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||||
|
onRequestRevision: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
|
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
|
||||||
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | 'revise' | null>(null);
|
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | null>(null);
|
||||||
const [feedbackComment, setFeedbackComment] = React.useState('');
|
const [feedbackComment, setFeedbackComment] = React.useState('');
|
||||||
const validationFailed = message.validation_status === 'failed';
|
const validationFailed = message.validation_status === 'failed';
|
||||||
const validationDetails =
|
const validationDetails =
|
||||||
@ -118,7 +123,7 @@ function MessageBubble({
|
|||||||
) : (
|
) : (
|
||||||
<MarkdownContent content={textContent} />
|
<MarkdownContent content={textContent} />
|
||||||
)}
|
)}
|
||||||
{!isUser && message.task_id && (
|
{!isUser && showTaskCard && message.task_id && (
|
||||||
<div className="mt-3 rounded-md border border-border bg-muted/35 p-3">
|
<div className="mt-3 rounded-md border border-border bg-muted/35 p-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -145,7 +150,7 @@ function MessageBubble({
|
|||||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p>
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
{!isUser && canSendFeedback && message.run_id && (
|
{!isUser && (canSendFeedback || message.feedback_state) && message.run_id && (
|
||||||
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
|
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
|
||||||
{message.feedback_state ? (
|
{message.feedback_state ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
@ -171,7 +176,7 @@ function MessageBubble({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFeedbackMode('revise')}
|
onClick={() => onRequestRevision(message.run_id!)}
|
||||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="h-3.5 w-3.5" />
|
<RefreshCcw className="h-3.5 w-3.5" />
|
||||||
@ -191,11 +196,7 @@ function MessageBubble({
|
|||||||
<textarea
|
<textarea
|
||||||
value={feedbackComment}
|
value={feedbackComment}
|
||||||
onChange={(event) => setFeedbackComment(event.target.value)}
|
onChange={(event) => setFeedbackComment(event.target.value)}
|
||||||
placeholder={
|
placeholder={pickAppText(locale, '可选:补充说明...', 'Optional note...')}
|
||||||
feedbackMode === 'revise'
|
|
||||||
? pickAppText(locale, '写下需要修改的地方...', 'Describe what needs to change...')
|
|
||||||
: pickAppText(locale, '可选:补充说明...', 'Optional note...')
|
|
||||||
}
|
|
||||||
className="min-h-20 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
className="min-h-20 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@ -330,6 +331,7 @@ export function MessageList({
|
|||||||
selectedRunId,
|
selectedRunId,
|
||||||
onSelectRun,
|
onSelectRun,
|
||||||
onFeedback,
|
onFeedback,
|
||||||
|
onRequestRevision,
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
isThinking: boolean;
|
isThinking: boolean;
|
||||||
@ -341,6 +343,7 @@ export function MessageList({
|
|||||||
selectedRunId: string | null;
|
selectedRunId: string | null;
|
||||||
onSelectRun: (runId: string) => void;
|
onSelectRun: (runId: string) => void;
|
||||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||||
|
onRequestRevision: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const visibleMessages = React.useMemo(
|
const visibleMessages = React.useMemo(
|
||||||
@ -361,6 +364,7 @@ export function MessageList({
|
|||||||
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
|
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
|
||||||
order: index,
|
order: index,
|
||||||
message,
|
message,
|
||||||
|
messageIndex: index,
|
||||||
}));
|
}));
|
||||||
const teamItems = teamGroups.map((group, index) => ({
|
const teamItems = teamGroups.map((group, index) => ({
|
||||||
kind: 'team' as const,
|
kind: 'team' as const,
|
||||||
@ -377,9 +381,18 @@ export function MessageList({
|
|||||||
return a.order - b.order;
|
return a.order - b.order;
|
||||||
});
|
});
|
||||||
}, [teamGroups, visibleMessages]);
|
}, [teamGroups, visibleMessages]);
|
||||||
const latestAssistantRunId = [...visibleMessages]
|
const taskCardMessageIndexes = React.useMemo(
|
||||||
|
() => getTaskCardMessageIndexes(visibleMessages),
|
||||||
|
[visibleMessages]
|
||||||
|
);
|
||||||
|
const latestFeedbackRunId = [...visibleMessages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.role === 'assistant' && message.run_id && message.task_id)?.run_id;
|
.find((message) =>
|
||||||
|
message.role === 'assistant'
|
||||||
|
&& message.run_id
|
||||||
|
&& message.task_id
|
||||||
|
&& message.task_status === 'awaiting_feedback'
|
||||||
|
)?.run_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
||||||
@ -397,8 +410,10 @@ export function MessageList({
|
|||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={item.key}
|
key={item.key}
|
||||||
message={item.message}
|
message={item.message}
|
||||||
canSendFeedback={Boolean(latestAssistantRunId && item.message.run_id === latestAssistantRunId)}
|
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
|
||||||
|
canSendFeedback={Boolean(latestFeedbackRunId && item.message.run_id === latestFeedbackRunId)}
|
||||||
onFeedback={onFeedback}
|
onFeedback={onFeedback}
|
||||||
|
onRequestRevision={onRequestRevision}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AgentTeamBlock
|
<AgentTeamBlock
|
||||||
|
|||||||
74
app-instance/frontend/lib/chat-messages.test.ts
Normal file
74
app-instance/frontend/lib/chat-messages.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
||||||
|
import type { ChatMessage } from '@/types';
|
||||||
|
|
||||||
|
describe('chat message helpers', () => {
|
||||||
|
it('keeps pending local user messages after server refreshes', () => {
|
||||||
|
const serverMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Earlier answer',
|
||||||
|
timestamp: '2026-05-21T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const localMessages: ChatMessage[] = [
|
||||||
|
...serverMessages,
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Please continue',
|
||||||
|
timestamp: '2026-05-21T08:01:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(mergeServerWithPendingUsers(serverMessages, localMessages)).toEqual([
|
||||||
|
...serverMessages,
|
||||||
|
localMessages[1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate local user messages already persisted by the server', () => {
|
||||||
|
const serverMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Please continue',
|
||||||
|
timestamp: '2026-05-21T08:01:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const localMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Please continue',
|
||||||
|
timestamp: '2026-05-21T08:01:01.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(mergeServerWithPendingUsers(serverMessages, localMessages)).toEqual(serverMessages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a task card only on the latest assistant message for the same task', () => {
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'I will start.',
|
||||||
|
run_id: 'run-1',
|
||||||
|
task_id: 'task-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'I found data.',
|
||||||
|
run_id: 'run-1',
|
||||||
|
task_id: 'task-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Final answer.',
|
||||||
|
run_id: 'run-1',
|
||||||
|
task_id: 'task-1',
|
||||||
|
task_status: 'awaiting_feedback',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(Array.from(getTaskCardMessageIndexes(messages))).toEqual([2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
app-instance/frontend/lib/chat-messages.ts
Normal file
44
app-instance/frontend/lib/chat-messages.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import type { ChatMessage } from '@/types';
|
||||||
|
|
||||||
|
export function messageFingerprint(msg: ChatMessage): string {
|
||||||
|
const attachmentKey = (msg.attachments ?? [])
|
||||||
|
.map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`)
|
||||||
|
.join('|');
|
||||||
|
return `${msg.role}::${String(msg.content)}::${attachmentKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const message of serverMessages) {
|
||||||
|
const key = messageFingerprint(message);
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingUsers: ChatMessage[] = [];
|
||||||
|
for (const message of localMessages) {
|
||||||
|
const key = messageFingerprint(message);
|
||||||
|
const count = counts.get(key) ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
counts.set(key, count - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.role === 'user') {
|
||||||
|
pendingUsers.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...serverMessages, ...pendingUsers];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskCardMessageIndexes(messages: ChatMessage[]): Set<number> {
|
||||||
|
const latestByTask = new Map<string, number>();
|
||||||
|
|
||||||
|
messages.forEach((message, index) => {
|
||||||
|
if (message.role !== 'assistant' || !message.task_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
latestByTask.set(message.task_id, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Set(latestByTask.values());
|
||||||
|
}
|
||||||
@ -342,6 +342,9 @@ export interface BackendTask {
|
|||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
events?: BackendTaskEvent[];
|
events?: BackendTaskEvent[];
|
||||||
runs?: BackendTaskRun[];
|
runs?: BackendTaskRun[];
|
||||||
|
process_runs?: ProcessRun[];
|
||||||
|
process_events?: ProcessEvent[];
|
||||||
|
process_artifacts?: ProcessArtifact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveTask {
|
export interface ActiveTask {
|
||||||
|
|||||||
Reference in New Issue
Block a user