mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-04 08:11:14 +08:00
feat(codex): add structured task progress support in conversation flow, displaying status updates for pending, in-progress, and completed tasks
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, Brain, CheckCircle2, ChevronDown, CircleAlert, FilePenLine, FileText, ImagePlus, ListChecks, LoaderCircle, Search, Square, TerminalSquare, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { ArrowUp, Brain, CheckCircle2, ChevronDown, Circle, CircleAlert, FilePenLine, FileText, ImagePlus, ListChecks, LoaderCircle, Search, Square, TerminalSquare, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
@@ -98,6 +98,8 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
}
|
||||
|
||||
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const plan = planDetail(detail);
|
||||
if (plan) return <AgentPlanCard title={title} plan={plan} theme={theme} />;
|
||||
const state = toolCardState(title, text, detail);
|
||||
const view = userDetail(detail);
|
||||
const kind = String(objectField(detail, "kind") || "");
|
||||
@@ -123,6 +125,34 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const completed = plan.tasks.filter((item) => item.status === "completed").length;
|
||||
const state = planCardState(plan, completed);
|
||||
return (
|
||||
<div className="min-w-0 flex-1 rounded-xl border px-3 py-3 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<ListChecks className="size-4 shrink-0" style={{ color: state.color }} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>{completed}/{plan.tasks.length}</span>
|
||||
</div>
|
||||
{plan.explanation ? <div className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{plan.explanation}</div> : null}
|
||||
<div className="mt-2.5 space-y-2 border-t pt-2.5" style={{ borderColor: theme.node.stroke }}>
|
||||
{plan.tasks.map((item, index) => {
|
||||
const task = planTaskState(item.status, theme.node.muted);
|
||||
return (
|
||||
<div key={`${index}-${item.step}`} className="flex items-start gap-2 text-sm leading-5">
|
||||
<span className="mt-0.5 shrink-0" style={{ color: task.color }}>{task.icon}</span>
|
||||
<span className={`min-w-0 flex-1 ${item.status === "completed" ? "opacity-55" : item.status === "inProgress" ? "font-medium" : ""}`} style={{ color: item.status === "inProgress" ? theme.node.text : theme.node.muted }}>{item.step}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: task.color }}>{task.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkingMessage({ text, activityKey, theme }: { text: string; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -263,6 +293,8 @@ export function AgentPanelTabs<T extends string>({ value, items, theme, right, o
|
||||
);
|
||||
}
|
||||
|
||||
type PlanTask = { step: string; status: string };
|
||||
type PlanDetail = { status: string; tasks: PlanTask[]; explanation?: string };
|
||||
type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
|
||||
|
||||
function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
@@ -348,6 +380,33 @@ function toolIcon(kind: string | undefined, fallback: ReactNode) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function planCardState(plan: PlanDetail, completed: number) {
|
||||
if (plan.status === "failed") return { label: "执行失败", color: "#dc2626" };
|
||||
if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: "已停止", color: "#d97706" };
|
||||
if (completed === plan.tasks.length) return { label: "已完成", color: "#16a34a" };
|
||||
if (plan.status === "finished") return { label: "已结束", color: "#2563eb" };
|
||||
return { label: "进行中", color: "#d97706" };
|
||||
}
|
||||
|
||||
function planTaskState(status: string, muted: string) {
|
||||
if (status === "completed") return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-3.5" /> };
|
||||
if (status === "inProgress") return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-3.5 animate-spin" /> };
|
||||
return { label: "待处理", color: muted, icon: <Circle className="size-3.5" /> };
|
||||
}
|
||||
|
||||
function planDetail(value: unknown): PlanDetail | null {
|
||||
if (!value || typeof value !== "object" || objectField(value, "kind") !== "todo") return null;
|
||||
const tasks = Array.isArray(objectField(value, "tasks"))
|
||||
? (objectField(value, "tasks") as unknown[]).flatMap((item) => {
|
||||
const step = String(objectField(item, "step") || "").trim();
|
||||
return step ? [{ step, status: String(objectField(item, "status") || "pending") }] : [];
|
||||
})
|
||||
: [];
|
||||
if (!tasks.length) return null;
|
||||
const explanation = String(objectField(value, "explanation") || "").trim();
|
||||
return { status: String(objectField(value, "status") || "inProgress"), tasks, ...(explanation ? { explanation } : {}) };
|
||||
}
|
||||
|
||||
function userDetail(value: unknown): UserDetail | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const detail = value as Record<string, unknown>;
|
||||
|
||||
@@ -39,6 +39,9 @@ type AgentEventPayload = {
|
||||
item?: AgentEventItem;
|
||||
error?: { message?: string };
|
||||
message?: string;
|
||||
status?: string;
|
||||
explanation?: unknown;
|
||||
plan?: unknown;
|
||||
usage?: Record<string, unknown>;
|
||||
duration_ms?: number;
|
||||
};
|
||||
@@ -65,7 +68,7 @@ type AgentEventItem = {
|
||||
action?: unknown;
|
||||
path?: unknown;
|
||||
};
|
||||
type AgentUserDetail = { kind: string; status: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
|
||||
type AgentUserDetail = { kind: string; status: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }>; tasks?: Array<{ step: string; status: string }>; explanation?: string };
|
||||
|
||||
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
|
||||
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
|
||||
@@ -651,6 +654,16 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message)) });
|
||||
};
|
||||
|
||||
const finishPlanActivity = (turnId: string, status?: string) => {
|
||||
const id = `plan-${turnId}`;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === id);
|
||||
if (index < 0) return;
|
||||
const current = currentMessages[index];
|
||||
const detail = activityDetail(current.detail, "todo", turnPlanStatus(current.detail, status));
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) });
|
||||
};
|
||||
|
||||
const handleAgentEvent = (event: AgentEventPayload) => {
|
||||
if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) });
|
||||
const log = formatAgentEventLog(event);
|
||||
@@ -664,6 +677,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
appendActivityDelta(event.item);
|
||||
return;
|
||||
}
|
||||
if (event.type === "plan.updated" && event.turn_id) {
|
||||
const plan = formatAgentPlan(event);
|
||||
if (plan) upsertActivityMessage({ ...plan, id: `plan-${event.turn_id}` });
|
||||
return;
|
||||
}
|
||||
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) {
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.streamId === event.item?.id);
|
||||
@@ -678,7 +696,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
upsertActivityMessage({ ...activity, id: event.item.id });
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn.completed") setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) });
|
||||
if (event.type === "turn.completed") {
|
||||
if (event.turn_id) finishPlanActivity(event.turn_id, event.status);
|
||||
setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) });
|
||||
}
|
||||
const item = formatAgentEvent(event);
|
||||
if (item) addMessage(item);
|
||||
};
|
||||
@@ -1296,6 +1317,33 @@ function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatItem, "id"
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatAgentPlan(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||
const tasks = planTasks(event.plan);
|
||||
if (!tasks.length) return null;
|
||||
const completed = tasks.filter((item) => item.status === "completed").length;
|
||||
return {
|
||||
role: "tool",
|
||||
title: "任务进度",
|
||||
text: `已完成 ${completed}/${tasks.length} 项`,
|
||||
detail: { kind: "todo", status: completed === tasks.length ? "completed" : "inProgress", tasks, explanation: stringText(event.explanation) },
|
||||
};
|
||||
}
|
||||
|
||||
function planTasks(value: unknown) {
|
||||
return (Array.isArray(value) ? value : []).flatMap((item) => {
|
||||
const step = stringText(objectField(item, "step")).trim();
|
||||
return step ? [{ step, status: stringText(objectField(item, "status")) || "pending" }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function turnPlanStatus(detail: unknown, turnStatus?: string) {
|
||||
const tasks = planTasks(objectField(detail, "tasks"));
|
||||
if (turnStatus === "failed") return "failed";
|
||||
if (turnStatus === "interrupted") return "interrupted";
|
||||
if (tasks.length && tasks.every((item) => item.status === "completed")) return "completed";
|
||||
return turnStatus === "completed" ? "finished" : "inProgress";
|
||||
}
|
||||
|
||||
function activityDeltaFallback(item: AgentEventItem, delta: string): AgentChatItem {
|
||||
if (item.type === "command_execution") return { id: item.id || createId(), role: "tool", title: "执行命令", text: activityPlaceholder(item.type), detail: { kind: "command", status: "inProgress", output: delta } };
|
||||
return { id: item.id || createId(), role: "tool", title: item.type === "plan" ? "执行计划" : "思考摘要", text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } };
|
||||
@@ -1315,7 +1363,7 @@ function activityKind(type?: string) {
|
||||
|
||||
function activityDetail(value: unknown, kind: string, status: string): AgentUserDetail {
|
||||
const current = value && typeof value === "object" ? (value as Partial<AgentUserDetail>) : {};
|
||||
return { kind, status, rows: current.rows, output: current.output, files: current.files };
|
||||
return { kind, status, rows: current.rows, output: current.output, files: current.files, tasks: current.tasks, explanation: current.explanation };
|
||||
}
|
||||
|
||||
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
|
||||
@@ -1403,6 +1451,10 @@ function formatAgentEventLog(event: AgentEventPayload) {
|
||||
const item = event.item;
|
||||
if (event.type === "thread.started") return { title: "创建会话", text: shortId(event.thread_id) };
|
||||
if (event.type === "turn.started") return { title: "开始处理", text: shortId(event.turn_id) };
|
||||
if (event.type === "plan.updated") {
|
||||
const tasks = planTasks(event.plan);
|
||||
return { title: "更新任务进度", text: `已完成 ${tasks.filter((item) => item.status === "completed").length}/${tasks.length} 项` };
|
||||
}
|
||||
if (event.type === "turn.completed") return { title: "处理完成", text: turnSummary(event) };
|
||||
if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", text: event.message || event.error?.message || "未知错误" };
|
||||
if (event.type === "item.started" && isMcpToolItem(item)) return { title: "调用工具", text: toolName(String(item?.tool || "")) };
|
||||
@@ -1537,7 +1589,8 @@ function routeName(path: string) {
|
||||
|
||||
function workingActivity(item?: AgentChatItem) {
|
||||
const status = String(objectField(item?.detail, "status") || "");
|
||||
const key = `${item?.id || "waiting"}-${status}`;
|
||||
const output = stringText(objectField(item?.detail, "output"));
|
||||
const key = `${item?.id || "waiting"}-${status}-${item?.text || ""}-${output.length}`;
|
||||
if (item?.role !== "tool") return { key, text: "正在思考..." };
|
||||
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: `${item.title || "工具操作"}正在进行...` };
|
||||
if (item.title === "读取画布") return { key, text: "画布已读取,Codex 正在整理结果..." };
|
||||
|
||||
Reference in New Issue
Block a user