修复智能创作对话展示与工具调用

修复流式回复、运行状态卡片和回合耗时展示

保留创建项目时的初始提示词并调整输入区按钮

展示工具调用输入输出并清理命令输出中的 ANSI 编码
This commit is contained in:
2026-09-15 23:00:45 +08:00
parent d7f20a2f4d
commit 05b4f9bc87
10 changed files with 463 additions and 47 deletions
@@ -573,6 +573,8 @@ enum CodexTurnEvent {
pub(crate) enum DirectCodexTurnObservation {
AccumulatedText(String),
IntermediateText(String),
/// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。
Reasoning(String),
Activity(&'static str),
/// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。
ToolCall(crate::DirectToolCall),
@@ -841,6 +843,37 @@ fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String {
/// (with the concrete command/tool/path) while tools run; it does not push
/// plan/reasoning text deltas. Showing what the agent is actually doing is
/// the only reliable way to make the execution phase feel alive.
/// 从 reasoning item 里抽明文思考文本:优先 `summary[].text`,其次 `content[].text`。
///
/// Codex 的 reasoning item 形如
/// `{ "type": "reasoning", "summary": [...], "content": [{ "text": "..." }], "encrypted_content": ... }`
/// 没有 `role` 字段;明文(至少 content/summary 之一)存在时我们才展示,拿不到就返回 None。
fn direct_codex_item_reasoning_text(item: &serde_json::Value) -> Option<String> {
if item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning") {
return None;
}
let collect = |key: &str| -> Option<String> {
let parts = item
.get(key)?
.as_array()?
.iter()
.filter_map(|entry| {
entry
.get("text")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
})
.collect::<Vec<_>>();
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
};
collect("summary").or_else(|| collect("content"))
}
fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option<String> {
const MAX_ITEM_TEXT_CHARS: usize = 240;
let item_type = item
@@ -2952,6 +2985,10 @@ impl CodexAppServerConnection {
// 让执行期间聊天窗口显示“正在做什么”,而不是只
// 有活动状态来回跳动。completed 事件不再重复。
if !completed {
if let Some(reasoning) = direct_codex_item_reasoning_text(item)
{
observer(DirectCodexTurnObservation::Reasoning(reasoning));
}
if let Some(text) = direct_codex_item_intermediate_text(item) {
observer(DirectCodexTurnObservation::IntermediateText(
text,
@@ -4366,7 +4366,6 @@ async fn run_direct_game_creator_turn_inner(
let emitter = emitter.clone();
let turn_root = root.to_path_buf();
let turn_tool_calls = Arc::clone(&tool_calls);
let mut emitted_tool_call_ids: BTreeSet<String> = BTreeSet::new();
let mut observer = move |observation: DirectCodexTurnObservation| {
let status = direct_codex_observation_status(&observation, stream_enabled);
match observation {
@@ -4393,27 +4392,37 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnObservation::Activity(activity) => {
emitter.emit(status, Some(activity), None, None);
}
DirectCodexTurnObservation::Reasoning(reasoning) => {
// 思考过程按"当前累计全文"下发(前端整段替换),状态保持 running:
// streaming 已被"用户可见正文"占用。
emitter.emit_with_reasoning("running", None, None, None, Some(reasoning));
}
DirectCodexTurnObservation::ToolCall(tool_call) => {
// 每条工具调用只在采集到的那一个事件里下发一次(id 与 Codex item 一一对应),
// 这样既满足"集合变化才带",也避免每个 heartbeat 重发全量。
if !emitted_tool_call_ids.insert(tool_call.id.clone()) {
return;
}
let previous = {
let mut collected = lock_direct_tool_call_collector(&turn_tool_calls);
let previous = collected
// 同一个工具调用会被观察两次:`item/started`running)与 `item/completed`
// (终态)。这里**只在状态真的变化时**才再收集与下发一次,既能带上终态、
// 又不会在每个 heartbeat 重发同一份快照(前端按 id 幂等合并,不会多出卡片)。
//
// 曾经这里用"每个 id 只发一次"去重,结果终态观察被直接丢掉:工具调用永远
// 停在 running(实机表现为"命令都结束了还显示执行中")。
{
let collected = lock_direct_tool_call_collector(&turn_tool_calls);
let existing = collected
.iter()
.find(|existing| existing.id == tool_call.id)
.cloned();
.find(|existing| existing.id == tool_call.id);
if !super::direct_tool_calls::direct_tool_call_status_changed(
existing, &tool_call,
) {
return;
}
}
{
let mut collected = lock_direct_tool_call_collector(&turn_tool_calls);
collected.retain(|existing| existing.id != tool_call.id);
collected.push(tool_call.clone());
previous
};
emitter.emit(status, None, None, Some(vec![tool_call]));
// `started` 一落盘卡片就能在刷新后立刻出现;`completed` 覆盖同一行。
if let Some(previous) = previous {
spawn_persist_direct_tool_call(&turn_root, &previous);
}
emitter.emit(status, None, None, Some(vec![tool_call.clone()]));
// 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。
spawn_persist_direct_tool_call(&turn_root, &tool_call);
}
}
};
@@ -296,6 +296,15 @@ fn direct_tool_call_title(kind: &str, changes: &[DirectToolCallChange]) -> Strin
}
fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str {
// item 自带的显式终态优先:被策略拒绝(declined)、失败、取消的调用不能因为
// `completed == true` 就被当成成功,否则卡片会把"没执行成功"显示成"已执行"。
if let Some(status) = item.get("status").and_then(Value::as_str) {
match status {
"completed" => return "completed",
"failed" | "declined" | "cancelled" | "canceled" | "aborted" => return "failed",
_ => {}
}
}
// Codex 的退出码约定:非 0 即失败;缺席时按"已完成"处理。
if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) {
return if exit_code == 0 {
@@ -314,6 +323,17 @@ fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str {
}
}
/// 同一 id 的两次观察(`item/started` / `item/completed`)是否带来了状态变化。
///
/// 只有状态变化时才需要再收集、再下发一次:既避免同一份快照在每个心跳重复下发,
/// 又不会像"每个 id 只发一次"那样把终态丢掉(历史 bug:命令都结束了卡片仍显示"执行中")。
pub(crate) fn direct_tool_call_status_changed(
existing: Option<&DirectToolCall>,
incoming: &DirectToolCall,
) -> bool {
!existing.is_some_and(|current| current.status == incoming.status)
}
/// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。
///
/// `started_at` / `updated_at`item 自己带的 `startedAtMs` / `completedAtMs` 优先,
@@ -632,9 +652,10 @@ pub(crate) fn direct_tool_call_now_ms() -> u64 {
#[cfg(test)]
mod tests {
use super::{
direct_tool_call_from_item, direct_tool_call_now_ms, persist_direct_tool_call_at,
persist_direct_tool_calls_at, read_direct_tool_calls_at, sanitize_detail_text,
tool_calls_path, DIRECT_TOOL_CALL_LIMIT,
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall,
DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION,
};
use serde_json::json;
@@ -676,6 +697,62 @@ mod tests {
}
/// 判据:同一 item 的 started 与 completed 只落一行,completed 覆盖 status。
fn sample_tool_call(id: &str, status: &str, updated_at: u64) -> DirectToolCall {
DirectToolCall {
schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(),
id: id.to_string(),
turn_id: "turn-1".to_string(),
kind: "command".to_string(),
title: "执行命令".to_string(),
summary: "npm run build".to_string(),
status: status.to_string(),
detail: DirectToolCallDetail::default(),
started_at: 1,
updated_at,
}
}
#[test]
fn tool_call_status_change_is_detected_only_on_real_changes() {
let running = sample_tool_call("call-1", "running", 1);
let completed = sample_tool_call("call-1", "completed", 2);
assert!(
direct_tool_call_status_changed(None, &running),
"首次观察必须被收集"
);
assert!(
!direct_tool_call_status_changed(Some(&running), &running),
"状态没变时不该重复下发同一份快照"
);
assert!(
direct_tool_call_status_changed(Some(&running), &completed),
"running -> completed 的终态观察必须被收集与下发(历史 bug:这里被丢弃,卡片永远显示执行中)"
);
}
#[test]
fn explicit_declined_or_failed_status_is_not_reported_as_completed() {
for status in ["declined", "failed", "cancelled", "aborted"] {
let item = json!({
"id": "call-1",
"type": "commandExecution",
"status": status,
});
assert_eq!(
direct_tool_call_status(&item, true),
"failed",
"item 自带 {status} 时不能因为 completed=true 就被当成 completed"
);
}
let completed = json!({
"id": "call-1",
"type": "commandExecution",
"status": "completed",
});
assert_eq!(direct_tool_call_status(&completed, true), "completed");
}
#[test]
fn tool_call_upsert_is_idempotent_per_item_id() {
let root = init_tool_call_project("tool-call-upsert");
@@ -50,6 +50,18 @@ impl DirectGameCreatorTurnUpdateEmitter {
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<crate::DirectToolCall>>,
) {
self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None);
}
/// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。
pub(crate) fn emit_with_reasoning(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<crate::DirectToolCall>>,
reasoning_text: Option<String>,
) {
let status_is_allowed = matches!(
status,
@@ -94,6 +106,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
activity: activity.map(str::to_string),
accumulated_text,
tool_calls,
reasoning_text,
updated_at,
},
);
@@ -1014,6 +1014,9 @@ struct GameCreatorDirectTurnUpdateEvent {
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<crate::DirectToolCall>>,
/// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_text: Option<String>,
updated_at: u64,
}
+52 -2
View File
@@ -875,6 +875,9 @@ export function App({
const [directCodexTransientReply, setDirectCodexTransientReply] =
useState('');
const directCodexTransientReplyRef = useRef('');
// 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。
const [directCodexTransientReasoning, setDirectCodexTransientReasoning] =
useState('');
const [
directCodexTransientReplyUpdatedAt,
setDirectCodexTransientReplyUpdatedAt,
@@ -975,6 +978,7 @@ export function App({
setDirectCodexProcessKey('');
setDirectCodexProgressUpdatedAt(null);
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
}
@@ -1073,6 +1077,7 @@ export function App({
setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL);
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
@@ -1106,6 +1111,7 @@ export function App({
}
activeDirectCodexTurnRef.current = null;
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
return true;
@@ -1245,9 +1251,29 @@ export function App({
const sessionError = result.session.lastError?.summary ?? resultError;
setProjectSupervisorRuntimeError(sessionError);
if (result.conversation) {
const conversationMessages = planningMessagesToChatMessages(
let conversationMessages = planningMessagesToChatMessages(
result.conversation,
);
// 创建项目后的首条需求可能先于规划会话快照到达;不能让后到的空快照
// 把用户刚发出的内容覆盖掉。
const initialPrompt = initialSupervisorMessageLatchRef.current.prompt;
if (
initialPrompt &&
!conversationMessages.some(
(message) =>
message.role === 'user' && message.text.trim() === initialPrompt,
)
) {
conversationMessages = [
{
role: 'user',
text: initialPrompt,
runtimeOwned: true,
updatedAt: Date.now(),
},
...conversationMessages,
];
}
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
setMessages(conversationMessages);
savedConversationProjectPathRef.current = localProjectPathRef.current;
@@ -1331,7 +1357,7 @@ export function App({
texts.push(entry.text);
reasoningByMessageId.set(entry.messageId, texts);
}
return view.messages
const messages: ChatMessage[] = view.messages
.filter((message) => message.text.trim())
.map((message) => ({
role: message.role === 'user' ? 'user' : 'assistant',
@@ -1341,6 +1367,21 @@ export function App({
reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'),
updatedAt: Date.now(),
}));
const initialPrompt = initialSupervisorMessageLatchRef.current.prompt;
if (
initialPrompt &&
!messages.some(
(message) => message.role === 'user' && message.text === initialPrompt,
)
) {
messages.unshift({
role: 'user',
text: initialPrompt,
runtimeOwned: true,
updatedAt: Date.now(),
});
}
return messages;
}
function applyDesignView(view: DesignView, projectPath: string) {
@@ -2150,6 +2191,9 @@ export function App({
})),
);
}
if (typeof payload.reasoningText === 'string') {
setDirectCodexTransientReasoning(payload.reasoningText);
}
const updatedAt =
Number.isFinite(payload.updatedAt) && payload.updatedAt > 0
? payload.updatedAt
@@ -2162,6 +2206,7 @@ export function App({
setDirectCodexProgress(processDetail);
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
setDirectCodexTransientReplyUpdatedAt(null);
return;
}
@@ -6796,6 +6841,7 @@ export function App({
setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`);
setDirectCodexProgress('正在等待陶泥儿开始');
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
setDirectCodexProgressUpdatedAt(Date.now());
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
@@ -6929,6 +6975,8 @@ export function App({
setChatAgentBusy(false);
setDirectCodexTurnCancelling(false);
setDirectCodexProgress('');
setDirectCodexStatus(null);
setDirectCodexProgressUpdatedAt(null);
const activeTurn = activeDirectCodexTurnRef.current;
if (
!activeTurn ||
@@ -12528,6 +12576,8 @@ export function App({
if (projectSupervisorOnly) {
return (
<ProjectSupervisorView
transientReasoning={directCodexTransientReasoning}
initialSupervisorMessage={initialSupervisorMessage}
activeVersionId={chatActiveVersionId}
attachments={chatAttachments}
attachmentNotice={chatAttachmentNotice}
@@ -1159,6 +1159,10 @@ export interface GameCreatorDirectTurnUpdateEvent {
* 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。
*/
toolCalls?: GameCreatorDirectTurnToolCall[] | null;
/**
* 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。
*/
reasoningText?: string | null;
updatedAt: number;
}
@@ -47,7 +47,6 @@ import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentati
import { taskStatusLabels } from '../project-summary/projectSummary';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerAttachmentMenu,
ComposerPendingAttachments,
ComposerReasoningEffortSelect,
ComposerStopButton,
@@ -77,6 +76,10 @@ import {
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import {
formatTurnDuration,
turnToolCallEndedAt,
} from './toolCallGroupPresentation';
/** 与 `App.tsx` 的回合消息 id 同构:`direct-codex:<turnId>:<role>`。 */
function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') {
@@ -157,8 +160,11 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
toolCalls?: GameCreatorDirectToolCall[];
/** 当前正在跑的回合 id;卡片在 assistant 消息落盘前锚到它。 */
activeTurnId?: string | null;
initialSupervisorMessage?: string;
showProfessionalCollaboration?: boolean;
transientReply: string;
/** 流式思考过程(direct-codex):拿不到就为空,空则不渲染。 */
transientReasoning?: string;
showDesignReasoning?: boolean;
designReasoning?: string;
designReasoningEntries?: DesignReasoningEntry[];
@@ -211,7 +217,7 @@ export function ProjectSupervisorView({
onCancelTurn,
onCancelQueuedTurn,
onRemoveAttachment,
onUploadFiles,
onUploadFiles: _onUploadFiles,
queuedTurns = [],
composerNotice = '',
turnCancelling = false,
@@ -223,8 +229,10 @@ export function ProjectSupervisorView({
projectPath,
toolCalls = [],
activeTurnId = null,
initialSupervisorMessage = '',
showProfessionalCollaboration = true,
transientReply,
transientReasoning = '',
showDesignReasoning = false,
designReasoning = '',
designReasoningEntries = [],
@@ -252,9 +260,21 @@ export function ProjectSupervisorView({
const [expandedProcessKey, setExpandedProcessKey] = useState<string | null>(
null,
);
useEffect(() => {
if (!activeTurnId) {
return;
}
setTurnUsageNow(Date.now());
const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [activeTurnId]);
useEffect(() => {
setExpandedProcessKey(null);
}, [directProcessKey]);
useEffect(() => {
setActiveTurnStartedAt(activeTurnId ? Date.now() : 0);
}, [activeTurnId]);
const processDetailExpanded =
Boolean(directProcessKey) && expandedProcessKey === directProcessKey;
const submitLabel = needsUserInput
@@ -269,6 +289,9 @@ export function ProjectSupervisorView({
const modelValidateInFlightRef = useRef(false);
// 设置浮层:Codex 顶栏只剩状态与齿轮,运行配置 / 审批模式 / 钱包都收进这里。
const [settingsOpen, setSettingsOpen] = useState(false);
// 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。
const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now());
const [activeTurnStartedAt, setActiveTurnStartedAt] = useState(0);
// 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。
const [voiceNotice, setVoiceNotice] = useState('');
const [approvalOpen, setApprovalOpen] = useState(false);
@@ -316,11 +339,74 @@ export function ProjectSupervisorView({
return 0;
}
const userId = directCodexTurnMessageId(turnId, 'user');
return (
const value = Number(
visibleMessages.find((message) => message.messageId === userId)
?.updatedAt ?? 0
?.updatedAt,
);
if (!Number.isFinite(value) || value <= 0) return 0;
// 旧快照使用 Unix 秒,新消息使用毫秒;统一到毫秒,避免出现数千万分钟。
return value < 100_000_000_000 ? value * 1000 : value;
};
/** 本轮会话的结束时刻:工具快照与消息里最晚的那个 updatedAt。 */
const turnEndedAtFor = (turnId: string) => {
let endedAt = turnToolCallEndedAt(
toolCalls.filter((call) => call.turnId === turnId),
);
for (const message of visibleMessages) {
const messageTurnId = message.messageId
? directCodexTurnIdFromAssistantMessageId(message.messageId)
: null;
if (messageTurnId === turnId) {
endedAt = Math.max(endedAt, Number(message.updatedAt) || 0);
}
}
return endedAt < 100_000_000_000 ? endedAt * 1000 : endedAt;
};
const turnStartedAtFor = (turnId: string) => {
const messageStarted = userMessageUpdatedAtForTurn(turnId);
if (messageStarted) return messageStarted;
const starts = toolCalls
.filter((call) => call.turnId === turnId && Number(call.startedAt) > 0)
.map((call) => Number(call.startedAt));
return starts.length > 0 ? Math.min(...starts) : 0;
};
const clockTimeWithSeconds = (timestamp: number) => {
const date = new Date(timestamp);
const pad = (value: number) => String(value).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
/** 整轮会话的结束时间与耗时(进行中时用 tick 驱的 now,所以秒数会实时跳动)。 */
const renderTurnUsage = (turnId: string) => {
if (!turnId) {
return null;
}
const startedAt =
turnStartedAtFor(turnId) ||
(activeTurnId === turnId ? activeTurnStartedAt : 0);
if (!startedAt) {
return null;
}
const running = Boolean(activeTurnId) && turnId === activeTurnId;
const endedAt = running
? turnUsageNow
: Math.max(turnEndedAtFor(turnId), startedAt);
const duration = formatTurnDuration(endedAt - startedAt);
return (
<p
className="message-turn-usage"
data-testid="turn-usage"
data-turn-id={turnId}
data-turn-running={running ? 'true' : 'false'}
>
{running
? `本轮进行中 · 用时 ${duration ?? '—'}`
: `本轮结束于 ${endedAt ? clockTimeWithSeconds(endedAt) : '—'} · 耗时 ${duration ?? '0秒'}`}
</p>
);
};
const emptyState =
directCodex &&
visibleMessages.length === 0 &&
@@ -427,6 +513,19 @@ export function ProjectSupervisorView({
{`显示更早 · 还有 ${hiddenConversationCount} 条对话`}
</button>
) : null}
{initialSupervisorMessage.trim() &&
!visibleMessages.some(
(message) =>
message.role === 'user' &&
message.text.trim() === initialSupervisorMessage.trim(),
) ? (
<div className="message message--user" data-runtime-owned="true">
<ChatMarkdownMessage
role="user"
text={initialSupervisorMessage}
/>
</div>
) : null}
{visibleMessages.map((message, index) => {
const anchoredToolCalls = message.messageId
? (toolCallsByAnchor.get(message.messageId) ?? [])
@@ -434,6 +533,16 @@ export function ProjectSupervisorView({
const anchoredTurnId = message.messageId
? directCodexTurnIdFromAssistantMessageId(message.messageId)
: null;
const nextTurnId =
index + 1 < visibleMessages.length &&
visibleMessages[index + 1]?.messageId
? directCodexTurnIdFromAssistantMessageId(
visibleMessages[index + 1]!.messageId!,
)
: null;
// 这条消息是该回合的最后一条时,在它后面给出整轮会话的结束时间与耗时。
const isTurnEnd =
Boolean(anchoredTurnId) && nextTurnId !== anchoredTurnId;
return (
<Fragment key={message.messageId ?? `${message.role}-${index}`}>
{anchoredToolCalls.length > 0 ? (
@@ -460,6 +569,9 @@ export function ProjectSupervisorView({
</details>
) : null}
</div>
{isTurnEnd && anchoredTurnId
? renderTurnUsage(anchoredTurnId)
: null}
</Fragment>
);
})}
@@ -471,6 +583,40 @@ export function ProjectSupervisorView({
className="message-tool-call"
/>
) : null}
{liveToolCallTurnId &&
!visibleMessages.some(
(message) =>
message.messageId &&
directCodexTurnIdFromAssistantMessageId(message.messageId) ===
liveToolCallTurnId,
)
? renderTurnUsage(liveToolCallTurnId)
: null}
{directCodex && transientReasoning ? (
<details
className="design-agent-reasoning"
data-testid="live-reasoning"
>
<summary></summary>
<pre>{transientReasoning}</pre>
</details>
) : null}
{/* 直连回合的流式正文:恢复为对话区里的普通 assistant 消息(不再放进状态卡片),
这样"边生成边显示"和"状态卡片只放状态"两件事同时成立。 */}
{directCodex && transientReply ? (
<div
className="message message--assistant"
aria-label="陶泥儿实时回复"
aria-live="polite"
data-runtime-owned="true"
>
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</div>
) : null}
{showDesignReasoning &&
designReasoningEntries
.filter((entry) => !entry.messageId)
@@ -590,6 +736,21 @@ export function ProjectSupervisorView({
? directStatusTitle(directStatus)
: '陶泥儿正在处理'}
</strong>
{activeTurnId ? (
<em className="project-supervisor-process-elapsed">
{`已耗时 ${
formatTurnDuration(
Math.max(
0,
turnUsageNow -
(turnStartedAtFor(activeTurnId) ||
activeTurnStartedAt ||
turnUsageNow),
),
) ?? '0秒'
}`}
</em>
) : null}
</header>
{directProcessDetail ? (
@@ -684,13 +845,6 @@ export function ProjectSupervisorView({
{directCodex ? (
<div className="project-supervisor-composer-controls">
<div className="project-supervisor-composer-controls-left">
<ComposerAttachmentMenu
disabled={runtimePanelProps.controlBusy || needsUserInput}
onPickFiles={(files) => onUploadFiles?.(files)}
onOpenReferencePicker={() =>
composerRef?.current?.openPicker()
}
/>
<button
type="button"
className="project-supervisor-reference-trigger"
@@ -10,14 +10,12 @@ import { useEffect, useId, useState } from 'react';
import type { GameCreatorDirectToolCall } from '../../app/types';
import {
formatClockTime,
formatToolCallDuration,
formatTurnDuration,
toolCallDurationMs,
toolCallGroupSummary,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallEndedAt,
turnToolCallTimeLabel,
} from './toolCallGroupPresentation';
@@ -146,16 +144,6 @@ export function ToolCallGroup({
<ToolCallRow key={call.id} call={call} active={active} />
))}
</ul>
{/* 还有工具在跑时**不显示"结束于"**:那时的"结束"只是最后一条快照的 updatedAt
回合并没有结束。等全部落定后再显示块尾这一行。 */}
{!running && timeLabel ? (
<p
className="agent-tool-call-group-foot"
data-testid="agent-tool-call-group-end-time"
>
{`结束于 ${formatClockTime(turnToolCallEndedAt(orderedCalls))}`}
</p>
) : null}
</div>
</section>
);
@@ -235,7 +223,12 @@ function ToolCallRow({
hidden={!expanded}
>
{detailCommand ? (
<pre className="agent-tool-call-row-command">{detailCommand}</pre>
<div className="agent-tool-call-row-section">
<small></small>
<pre className="agent-tool-call-row-command">
{stripAnsi(detailCommand)}
</pre>
</div>
) : null}
{changes.length > 0 ? (
<ul className="agent-tool-call-row-changes">
@@ -248,13 +241,27 @@ function ToolCallRow({
</ul>
) : null}
{detailOutput ? (
<pre className="agent-tool-call-row-output">{detailOutput}</pre>
<div className="agent-tool-call-row-section">
<small></small>
<pre className="agent-tool-call-row-output">
{stripAnsi(detailOutput)}
</pre>
</div>
) : null}
</div>
</li>
);
}
function stripAnsi(value: string) {
// ANSI CSI / OSC 控制序列:命令输出在终端里可带颜色,聊天卡片不应显示转义码。
const ansiPattern = new RegExp(
String.raw`[\x1b\x9b]\][0-?]*[ -/]*[@-~]|\x1b\[[0-?]*[ -/]*[@-~]`,
'g',
);
return value.replace(ansiPattern, '');
}
function toolCallChangeKindLabel(kind: string) {
if (kind === 'add') {
return '新增';
+62
View File
@@ -2660,6 +2660,15 @@ textarea {
animation: project-supervisor-process-pulse 1.2s ease-in-out infinite;
}
.project-supervisor-process-elapsed {
margin-left: auto !important;
color: #795548 !important;
font-size: 13px !important;
font-style: normal;
font-weight: 500;
white-space: nowrap;
}
@keyframes project-supervisor-process-pulse {
50% {
opacity: 0.42;
@@ -11840,6 +11849,17 @@ button.design-workspace-tree__entry:hover,
padding: 6px 8px 8px;
}
.agent-tool-call-row-section {
display: grid;
gap: 4px;
}
.agent-tool-call-row-section > small {
color: #8d7668;
font-size: 11px;
font-weight: 600;
}
.agent-tool-call-row-command,
.agent-tool-call-row-output {
margin: 0;
@@ -12027,3 +12047,45 @@ button.design-workspace-tree__entry:hover,
> * + * {
margin-top: 14px;
}
/* 整轮会话的结束时间与耗时:比消息本身更轻,属于轮次级信息。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message-turn-usage {
margin: 0;
padding: 0 2px;
color: var(--platform-text-soft);
font-size: 12px;
line-height: 1.6;
font-variant-numeric: tabular-nums;
}
/* 流式思考过程:默认折叠(<details>),长文本必须换行,不允许出现横向滚动条。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
details[data-testid='live-reasoning'] {
margin: 0;
color: var(--platform-text-soft);
font-size: 12px;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
details[data-testid='live-reasoning'] > summary {
cursor: pointer;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
details[data-testid='live-reasoning'] pre {
margin: 6px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: inherit;
font-size: 12px;
line-height: 1.6;
}