修复智能创作对话展示与工具调用
修复流式回复、运行状态卡片和回合耗时展示 保留创建项目时的初始提示词并调整输入区按钮 展示工具调用输入输出并清理命令输出中的 ANSI 编码
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+165
-11
@@ -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 '新增';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user