修复回合重进状态并收起已完成执行过程
活动回合快照与Direct事件统一管理生命周期,避免历史终态回放恢复忙碌态 用户消息显示发送时间,历史信封保存时间并通过独立映射回读 完成后统一折叠中间输出和工具调用,最终回复与失败提示保持可见 同步回归用例与规范文档,保留原生实机验收待办
This commit is contained in:
@@ -5,6 +5,7 @@ use crate::project::{
|
||||
};
|
||||
use crate::{LocalConversationMessageRecord, LocalConversationResult};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -91,6 +92,10 @@ fn record(item: &Value) -> Result<String, String> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
|
||||
"payload": item,
|
||||
"recordedAt": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
}))
|
||||
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
|
||||
}
|
||||
@@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
|
||||
Ok(read_direct_project_history_entries_at(root)?
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return Ok(Vec::new());
|
||||
@@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
items.push((
|
||||
item,
|
||||
parsed
|
||||
.get("recordedAt")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
@@ -516,18 +534,30 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Value>, bool), String> {
|
||||
let items = read_direct_project_history_items_at(root)?;
|
||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
|
||||
let items = read_direct_project_history_entries_at(root)?;
|
||||
let end = match before_item_id {
|
||||
Some(item_id) => items
|
||||
.iter()
|
||||
.position(|item| item.get("id").and_then(Value::as_str) == Some(item_id))
|
||||
.position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id))
|
||||
.ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?,
|
||||
None => items.len(),
|
||||
};
|
||||
let bounded_limit = limit.clamp(1, 200);
|
||||
let start = end.saturating_sub(bounded_limit);
|
||||
Ok((items[start..end].to_vec(), start > 0))
|
||||
let slice = &items[start..end];
|
||||
let timestamps = slice
|
||||
.iter()
|
||||
.filter_map(|(item, at)| {
|
||||
let id = item.get("id").and_then(Value::as_str)?;
|
||||
(*at > 0).then(|| (id.to_string(), *at))
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
slice.iter().map(|(item, _)| item.clone()).collect(),
|
||||
start > 0,
|
||||
timestamps,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
|
||||
@@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let path = history_path(root);
|
||||
let items = read_direct_project_history_items_at(root)?;
|
||||
let items = read_direct_project_history_entries_at(root)?;
|
||||
let messages = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
.filter_map(|(item, recorded_at)| {
|
||||
let role = item.get("role").and_then(Value::as_str)?;
|
||||
if !matches!(role, "user" | "assistant") {
|
||||
return None;
|
||||
@@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at(
|
||||
content,
|
||||
agent_id: None,
|
||||
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
|
||||
updated_at: 0,
|
||||
updated_at: recorded_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -610,6 +640,40 @@ mod tests {
|
||||
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
|
||||
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
|
||||
|
||||
#[test]
|
||||
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
||||
let root = init_history_project("history-time");
|
||||
let item = json!({
|
||||
"type": "message", "role": "user", "id": "sent-message",
|
||||
"content": [{"type": "input_text", "text": "修改游戏"}],
|
||||
});
|
||||
append_direct_project_user_message_at(root.path(), &item).unwrap();
|
||||
let (items, _, timestamps) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert_eq!(items, vec![item.clone()]);
|
||||
assert!(timestamps["sent-message"] > 0);
|
||||
append_direct_project_user_message_at(root.path(), &item).unwrap();
|
||||
let (_, _, reloaded) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert_eq!(timestamps, reloaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_history_without_envelope_time_stays_unknown() {
|
||||
let root = init_history_project("history-unknown-time");
|
||||
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
||||
let (_, _, timestamps) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(timestamps.is_empty());
|
||||
assert_eq!(
|
||||
read_direct_project_chat_history_at(root.path())
|
||||
.unwrap()
|
||||
.messages[0]
|
||||
.updated_at,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
|
||||
///
|
||||
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult {
|
||||
pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) has_more: bool,
|
||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -5373,12 +5373,16 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let (items, has_more) = read_direct_project_history_items_slice_at(
|
||||
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
|
||||
root,
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
Ok(DirectThreadHistorySlice { items, has_more })
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))?
|
||||
|
||||
@@ -55,7 +55,6 @@ import type {
|
||||
DesignClarificationRequest,
|
||||
DesignEvent,
|
||||
DesignView,
|
||||
DirectActiveTurnView,
|
||||
DirectTurnCancelView,
|
||||
GameCreatorAgentRuntimeUpdateEvent,
|
||||
GameCreatorChatAgentReply,
|
||||
@@ -137,6 +136,7 @@ import {
|
||||
submitProjectSupervisorRuntimeTask,
|
||||
taskRowsFromManifest,
|
||||
} from './features/agent-runtime';
|
||||
import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns';
|
||||
import {
|
||||
type DirectCodexTurnAttachment,
|
||||
toDirectCodexTurnAttachments,
|
||||
@@ -242,8 +242,7 @@ import {
|
||||
directThreadHistoryItemsToMessages,
|
||||
type DirectThreadHistorySlice,
|
||||
type DirectThreadSubscriptionBootstrap,
|
||||
emptyDirectThreadReducerState,
|
||||
reduceDirectThreadEvents,
|
||||
isDirectTurnInProgress,
|
||||
} from './features/project-workspace/directThreadEvents';
|
||||
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
|
||||
import {
|
||||
@@ -321,8 +320,6 @@ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER =
|
||||
const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000;
|
||||
const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE =
|
||||
'该回合已无响应,可在输入盒点「终止」结束它以继续';
|
||||
const DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL =
|
||||
'已恢复正在运行的回合,正在等待陶泥儿的最新进度';
|
||||
// Platform access tokens are short lived. DirectProject can spend several
|
||||
// minutes in image generation, build and browser validation, so keep the
|
||||
// client-owned native session current while a turn is running. The singleflight
|
||||
@@ -414,9 +411,8 @@ function directCodexActivityDetail(
|
||||
case 'finalizing':
|
||||
return '正在整理结果';
|
||||
case 'completed':
|
||||
return '正在提交回复';
|
||||
case 'failed':
|
||||
return '正在记录失败原因';
|
||||
return '';
|
||||
default:
|
||||
return '正在处理任务';
|
||||
}
|
||||
@@ -449,11 +445,8 @@ function directCodexProcessDetail({
|
||||
activity?: string | null;
|
||||
status: string;
|
||||
}) {
|
||||
if (status === 'completed') {
|
||||
return '正在提交回复';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '正在记录失败原因';
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
return '';
|
||||
}
|
||||
if (status === 'streaming') {
|
||||
return '正在生成回复';
|
||||
@@ -960,9 +953,14 @@ export function App({
|
||||
turnId: string;
|
||||
lastSequence: number;
|
||||
receivedDirectUpdate: boolean;
|
||||
restored?: boolean;
|
||||
} | null>(null);
|
||||
const directActiveSnapshotVersionRef = useRef(0);
|
||||
const directTurnLifecycleRef = useRef<{
|
||||
reset: () => void;
|
||||
loadHistory: (projectPath: string) => Promise<void>;
|
||||
restore: (projectPath: string) => Promise<void>;
|
||||
} | null>(null);
|
||||
const directThreadSubscriptionIdRef = useRef<string | null>(null);
|
||||
const directThreadReducerStateRef = useRef(emptyDirectThreadReducerState());
|
||||
const lastDirectCodexActivityRef = useRef<string | null>(null);
|
||||
/**
|
||||
* 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件,
|
||||
@@ -1069,6 +1067,7 @@ export function App({
|
||||
}
|
||||
|
||||
function resetDirectCodexTurn() {
|
||||
directActiveSnapshotVersionRef.current += 1;
|
||||
clearRecoveredDirectCodexTurnWatch();
|
||||
activeDirectCodexTurnRef.current = null;
|
||||
lastDirectCodexActivityRef.current = null;
|
||||
@@ -1128,10 +1127,12 @@ export function App({
|
||||
* 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示
|
||||
* 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。
|
||||
*
|
||||
* 只读探测,不改后端回合本身;探测失败(老二进制没有这个命令、路径读不到)等于没有
|
||||
* 回合,不影响打开项目。
|
||||
* 只读探测,不改后端回合本身;探测失败保留当前已知状态,不视为没有活动回合。
|
||||
*/
|
||||
async function restoreRunningDirectCodexTurn(projectPath: string) {
|
||||
async function restoreRunningDirectCodexTurn(
|
||||
projectPath: string,
|
||||
reconcile = false,
|
||||
) {
|
||||
if (!directCodexProductRuntime || !projectPath) {
|
||||
return;
|
||||
}
|
||||
@@ -1139,50 +1140,87 @@ export function App({
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
// 本组件已经接管这个项目:`/history` 之类的重复读取不能把 lastSequence 归零。
|
||||
if (activeDirectCodexTurnRef.current?.projectPath === projectPath) {
|
||||
const owner = activeDirectCodexTurnRef.current;
|
||||
const sequence = owner?.lastSequence;
|
||||
const scopeVersion = projectScopeVersionRef.current;
|
||||
if (!reconcile && owner?.projectPath === projectPath) {
|
||||
return;
|
||||
}
|
||||
let activeView: DirectActiveTurnView | null = null;
|
||||
const readVersion = ++directActiveSnapshotVersionRef.current;
|
||||
let turns: GameCreatorDirectActiveTurn[];
|
||||
try {
|
||||
activeView = await invoke<DirectActiveTurnView | null>(
|
||||
'read_direct_codex_active_turn',
|
||||
{ projectPath },
|
||||
turns = await invoke<GameCreatorDirectActiveTurn[]>(
|
||||
'list_game_creator_direct_active_turns',
|
||||
);
|
||||
} catch {
|
||||
// 读取失败不等于没有活动回合。
|
||||
return;
|
||||
}
|
||||
const clientTurnId = activeView?.clientTurnId?.trim();
|
||||
if (!clientTurnId) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(turns)) return;
|
||||
if (
|
||||
localProjectPathRef.current !== projectPath ||
|
||||
activeDirectCodexTurnRef.current?.projectPath === projectPath
|
||||
planningV2ActiveRef.current ||
|
||||
designAgentLaneRef.current ||
|
||||
projectScopeVersionRef.current !== scopeVersion ||
|
||||
directActiveSnapshotVersionRef.current !== readVersion ||
|
||||
activeDirectCodexTurnRef.current !== owner ||
|
||||
owner?.lastSequence !== sequence
|
||||
) {
|
||||
return;
|
||||
}
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath,
|
||||
turnId: clientTurnId,
|
||||
// 与下面发起回合的两处赋值同形(`lastSequence: -1`):Rust 侧 emitter 的 sequence
|
||||
// 从 1 开始,所以恢复后到达的第一批事件不会被 sequence 过滤丢掉。
|
||||
lastSequence: -1,
|
||||
receivedDirectUpdate: false,
|
||||
};
|
||||
const activeView = turns.find(
|
||||
(turn) =>
|
||||
projectPathsMatchForInvalidation(turn.projectPath, projectPath) &&
|
||||
isDirectTurnInProgress(turn.status),
|
||||
);
|
||||
if (!activeView || !isDirectTurnInProgress(activeView.status)) {
|
||||
// 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。
|
||||
if (owner && !owner.restored && owner.lastSequence < 0) return;
|
||||
resetDirectCodexTurn();
|
||||
setChatAgentBusy(false);
|
||||
if (owner && reconcile) {
|
||||
void loadProjectConversation(projectPath, false, 'replace');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const matchingOwner = owner?.turnId === activeView.turnId ? owner : null;
|
||||
if (owner && !matchingOwner) {
|
||||
if (!owner.restored && owner.lastSequence < 0) return;
|
||||
resetDirectCodexTurn();
|
||||
}
|
||||
if (!matchingOwner) {
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath,
|
||||
turnId: activeView.turnId,
|
||||
lastSequence: -1,
|
||||
receivedDirectUpdate: false,
|
||||
restored: true,
|
||||
};
|
||||
setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
watchRecoveredDirectCodexTurn(projectPath, activeView.turnId);
|
||||
}
|
||||
setChatAgentBusy(true);
|
||||
setDirectCodexStatus('running');
|
||||
setDirectCodexProcessKey(`${projectPath}\u0000${clientTurnId}`);
|
||||
setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL);
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
setDirectCodexTransientReply('');
|
||||
setDirectCodexTransientReasoning('');
|
||||
directCodexTransientReplyRef.current = '';
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
watchRecoveredDirectCodexTurn(projectPath, clientTurnId);
|
||||
// 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。
|
||||
if (
|
||||
!matchingOwner?.receivedDirectUpdate ||
|
||||
activeView.sequence > matchingOwner.lastSequence
|
||||
) {
|
||||
setDirectCodexStatus(activeView.status);
|
||||
setDirectCodexProgress(
|
||||
directCodexActivityDetail(activeView.activity, activeView.status),
|
||||
);
|
||||
setDirectCodexProgressUpdatedAt(activeView.updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
directTurnLifecycleRef.current = {
|
||||
reset: resetDirectCodexTurn,
|
||||
loadHistory: (projectPath) =>
|
||||
loadProjectConversation(projectPath, false, 'replace'),
|
||||
restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true),
|
||||
};
|
||||
|
||||
// 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态,
|
||||
// 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。
|
||||
const [directTurnStream, setDirectTurnStream] = useState<TurnStreamItem[]>(
|
||||
@@ -2306,15 +2344,10 @@ export function App({
|
||||
? payload.updatedAt
|
||||
: Date.now();
|
||||
const processDetail = directCodexProcessDetail(payload);
|
||||
if (payload.status === 'failed') {
|
||||
activeDirectCodexTurnRef.current = null;
|
||||
lastDirectCodexActivityRef.current = null;
|
||||
setDirectCodexStatus(payload.status);
|
||||
setDirectCodexProgress(processDetail);
|
||||
setDirectCodexProgressUpdatedAt(updatedAt);
|
||||
setDirectCodexTransientReply('');
|
||||
setDirectCodexTransientReasoning('');
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
if (payload.status === 'failed' || payload.status === 'completed') {
|
||||
directTurnLifecycleRef.current?.reset();
|
||||
setChatAgentBusy(false);
|
||||
void directTurnLifecycleRef.current?.loadHistory(payload.projectPath);
|
||||
return;
|
||||
}
|
||||
setDirectCodexStatus(payload.status);
|
||||
@@ -2374,123 +2407,77 @@ export function App({
|
||||
useEffect(() => {
|
||||
const projectPath = localProject?.projectPath ?? null;
|
||||
const directInvoke = resolveTauriInvoke();
|
||||
if (!directCodexProductRuntime || !projectPath || !directInvoke) {
|
||||
directThreadSubscriptionIdRef.current = null;
|
||||
directThreadReducerStateRef.current = emptyDirectThreadReducerState();
|
||||
return;
|
||||
}
|
||||
if (!directCodexProductRuntime || !projectPath || !directInvoke) return;
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let subscriptionId: string | null = null;
|
||||
let consuming = false;
|
||||
let consumeAgain = false;
|
||||
|
||||
const applyReducerState = (
|
||||
state: ReturnType<typeof emptyDirectThreadReducerState>,
|
||||
) => {
|
||||
if (disposed) return;
|
||||
directThreadReducerStateRef.current = state;
|
||||
const running =
|
||||
state.status === 'accepted' ||
|
||||
state.status === 'running' ||
|
||||
state.status === 'streaming' ||
|
||||
state.status === 'finalizing';
|
||||
setChatAgentBusy(running);
|
||||
setDirectCodexStatus(state.status);
|
||||
setDirectCodexProgress(state.progress);
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
if (state.accumulatedText) {
|
||||
setDirectCodexTransientReply(state.accumulatedText);
|
||||
directCodexTransientReplyRef.current = state.accumulatedText;
|
||||
setDirectCodexTransientReplyUpdatedAt(Date.now());
|
||||
} else {
|
||||
setDirectCodexTransientReply('');
|
||||
directCodexTransientReplyRef.current = '';
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
}
|
||||
// Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。
|
||||
const refreshActive = () => {
|
||||
if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath);
|
||||
};
|
||||
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
const activeTurns = await directInvoke<GameCreatorDirectActiveTurn[]>(
|
||||
'list_game_creator_direct_active_turns',
|
||||
);
|
||||
const activeTurn = activeTurns.find((turn) =>
|
||||
projectPathsMatchForInvalidation(turn.projectPath, projectPath),
|
||||
);
|
||||
if (activeTurn && !activeDirectCodexTurnRef.current) {
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath,
|
||||
turnId: activeTurn.turnId,
|
||||
lastSequence: -1,
|
||||
receivedDirectUpdate: true,
|
||||
};
|
||||
setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`);
|
||||
setChatAgentBusy(true);
|
||||
setDirectCodexStatus('running');
|
||||
setDirectCodexProgress('正在处理');
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
}
|
||||
} catch {
|
||||
// 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。
|
||||
}
|
||||
const result = await directInvoke<DirectThreadSubscriptionBootstrap>(
|
||||
'subscribe_direct_project_thread',
|
||||
{ projectPath },
|
||||
);
|
||||
if (disposed) return;
|
||||
directThreadSubscriptionIdRef.current = result.subscriptionId;
|
||||
const state = reduceDirectThreadEvents(
|
||||
result.events,
|
||||
emptyDirectThreadReducerState(),
|
||||
);
|
||||
applyReducerState(state);
|
||||
if (state.turnId && !activeDirectCodexTurnRef.current) {
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath,
|
||||
turnId: state.turnId,
|
||||
// Thread Manager 的 seq 与回合展示事件的 sequence 是两个独立序列。
|
||||
lastSequence: -1,
|
||||
receivedDirectUpdate: true,
|
||||
};
|
||||
setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`);
|
||||
}
|
||||
subscriptionId = result.subscriptionId;
|
||||
refreshActive();
|
||||
};
|
||||
|
||||
const consume = async () => {
|
||||
const subscriptionId = directThreadSubscriptionIdRef.current;
|
||||
if (!subscriptionId || disposed) return;
|
||||
if (consuming) {
|
||||
consumeAgain = true;
|
||||
return;
|
||||
}
|
||||
consuming = true;
|
||||
try {
|
||||
const result = await directInvoke<DirectThreadConsumeResult>(
|
||||
'consume_direct_project_thread',
|
||||
{ subscriptionId },
|
||||
);
|
||||
if (disposed) return;
|
||||
const state = reduceDirectThreadEvents(
|
||||
result.events,
|
||||
directThreadReducerStateRef.current,
|
||||
);
|
||||
applyReducerState(state);
|
||||
do {
|
||||
consumeAgain = false;
|
||||
const result = await directInvoke<DirectThreadConsumeResult>(
|
||||
'consume_direct_project_thread',
|
||||
{ subscriptionId },
|
||||
);
|
||||
if (disposed) return;
|
||||
if (
|
||||
result.events.some(
|
||||
(event) =>
|
||||
event.type === 'turn.started' ||
|
||||
event.type === 'turn.completed',
|
||||
)
|
||||
) {
|
||||
refreshActive();
|
||||
}
|
||||
if (
|
||||
result.events.some((event) => event.type === 'turn.completed') &&
|
||||
!activeDirectCodexTurnRef.current?.receivedDirectUpdate
|
||||
) {
|
||||
// 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。
|
||||
void directTurnLifecycleRef.current?.loadHistory(projectPath);
|
||||
}
|
||||
} while (consumeAgain && !disposed);
|
||||
} catch (error) {
|
||||
if (String(error).includes('SUBSCRIPTION_EXPIRED')) {
|
||||
directThreadSubscriptionIdRef.current = null;
|
||||
if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) {
|
||||
subscriptionId = null;
|
||||
try {
|
||||
await bootstrap();
|
||||
} catch {
|
||||
// A later project activation or notification will retry bootstrap.
|
||||
/* 活动快照轮询仍然有效。 */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
consuming = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>(
|
||||
'game-creator-direct-thread-notify',
|
||||
(event) => {
|
||||
if (
|
||||
event.payload.subscriptionId ===
|
||||
directThreadSubscriptionIdRef.current
|
||||
) {
|
||||
void consume();
|
||||
}
|
||||
if (event.payload.subscriptionId === subscriptionId) void consume();
|
||||
},
|
||||
);
|
||||
if (disposed) {
|
||||
@@ -2499,15 +2486,21 @@ export function App({
|
||||
}
|
||||
cleanup = unlisten;
|
||||
await bootstrap();
|
||||
await consume();
|
||||
} catch {
|
||||
// The history view remains usable when the runtime subscription is unavailable.
|
||||
// 历史仍可使用;订阅失败不伪造忙碌态。
|
||||
}
|
||||
};
|
||||
refreshActive();
|
||||
const timer = window.setInterval(
|
||||
refreshActive,
|
||||
DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
|
||||
);
|
||||
void setup();
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
directThreadSubscriptionIdRef.current = null;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [directCodexProductRuntime, localProject?.projectPath]);
|
||||
|
||||
@@ -4118,7 +4111,10 @@ export function App({
|
||||
return {
|
||||
path: nextProjectPath,
|
||||
agentId: null,
|
||||
messages: directThreadHistoryItemsToMessages(slice.items),
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
} satisfies LocalConversationResult;
|
||||
});
|
||||
})()
|
||||
@@ -7211,9 +7207,6 @@ export function App({
|
||||
setMessages((current) =>
|
||||
appendDirectAssistantMessage(current, reply),
|
||||
);
|
||||
setDirectCodexStatus('finalizing');
|
||||
setDirectCodexProgress('正在同步项目文件');
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
await refreshDirectProjectManifest(directProjectPath);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -7291,21 +7284,19 @@ export function App({
|
||||
await refreshManifest(directProjectPath);
|
||||
}
|
||||
} finally {
|
||||
setChatAgentBusy(false);
|
||||
setDirectCodexTurnCancelling(false);
|
||||
setDirectCodexProgress('');
|
||||
setDirectCodexStatus(null);
|
||||
setDirectCodexProgressUpdatedAt(null);
|
||||
const activeTurn = activeDirectCodexTurnRef.current;
|
||||
if (
|
||||
!activeTurn ||
|
||||
(activeTurn.projectPath === directProjectPath &&
|
||||
activeTurn.turnId === clientTurnId)
|
||||
localProjectPathRef.current === directProjectPath &&
|
||||
(!activeTurn ||
|
||||
(activeTurn.projectPath === directProjectPath &&
|
||||
activeTurn.turnId === clientTurnId))
|
||||
) {
|
||||
setChatAgentBusy(false);
|
||||
setDirectCodexTurnCancelling(false);
|
||||
resetDirectCodexTurn();
|
||||
// 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。
|
||||
dispatchNextQueuedChatTurn();
|
||||
}
|
||||
// 队列:本回合确实结束后,按 FIFO 自动发出下一条(不丢、不乱序)。
|
||||
dispatchNextQueuedChatTurn();
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -12492,18 +12483,19 @@ export function App({
|
||||
if (localProjectPathRef.current !== projectPath) {
|
||||
return;
|
||||
}
|
||||
const older = directThreadHistoryItemsToMessages(slice.items).map(
|
||||
(message) => ({
|
||||
role:
|
||||
message.role === 'user'
|
||||
? ('user' as const)
|
||||
: ('assistant' as const),
|
||||
text: message.content,
|
||||
runtimeOwned: true,
|
||||
messageId: message.messageId,
|
||||
updatedAt: message.updatedAt,
|
||||
}),
|
||||
);
|
||||
const older = directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
).map((message) => ({
|
||||
role:
|
||||
message.role === 'user'
|
||||
? ('user' as const)
|
||||
: ('assistant' as const),
|
||||
text: message.content,
|
||||
runtimeOwned: true,
|
||||
messageId: message.messageId,
|
||||
updatedAt: message.updatedAt,
|
||||
}));
|
||||
setMessages((current) => [...older, ...current]);
|
||||
setConversationVisibleCount((current) => current + older.length);
|
||||
setDirectHistoryHasMore(slice.hasMore);
|
||||
|
||||
+108
-60
@@ -58,7 +58,9 @@ import {
|
||||
} from './DesignAgentSurface';
|
||||
import {
|
||||
buildDirectTurnPresentations,
|
||||
directMessageTimestamp,
|
||||
type DirectTurnPresentation,
|
||||
splitDirectTurnContent,
|
||||
} from './directTurnPresentation';
|
||||
import { PlanGddSurface } from './GddApprovalCard';
|
||||
import {
|
||||
@@ -75,7 +77,10 @@ import {
|
||||
} from './ResourceReferenceInput';
|
||||
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import { formatTurnDuration } from './toolCallGroupPresentation';
|
||||
import {
|
||||
formatClockTime,
|
||||
formatTurnDuration,
|
||||
} from './toolCallGroupPresentation';
|
||||
|
||||
/** 相邻的工具流项合并成一个块;中间夹了文本段就另起一块。 */
|
||||
type TurnStreamToolRun = {
|
||||
@@ -397,8 +402,7 @@ export function ProjectSupervisorView({
|
||||
setApprovalOpen(false);
|
||||
setApprovalNotice('');
|
||||
}, [settingsOpen]);
|
||||
const runBusy =
|
||||
runtimePanelProps.controlBusy || Boolean(directProcessDetail) || submitting;
|
||||
const runBusy = runtimePanelProps.controlBusy || submitting;
|
||||
const directTurns = directCodex
|
||||
? buildDirectTurnPresentations({
|
||||
messages: conversationMessages,
|
||||
@@ -433,26 +437,39 @@ export function ProjectSupervisorView({
|
||||
</p>
|
||||
);
|
||||
};
|
||||
const renderMessage = (message: ChatMessage, index: number) => (
|
||||
<div
|
||||
key={message.messageId ?? `${message.role}-${index}`}
|
||||
className={`message message--${message.role}`}
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role={message.role}
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
/>
|
||||
{showDesignReasoning && message.reasoningText ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{message.reasoningText}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
const renderMessage = (message: ChatMessage, index: number) => {
|
||||
const sentAt =
|
||||
message.role === 'user' ? directMessageTimestamp(message.updatedAt) : 0;
|
||||
return (
|
||||
<div
|
||||
key={message.messageId ?? `${message.role}-${index}`}
|
||||
className={`message message--${message.role}`}
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role={message.role}
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
/>
|
||||
{sentAt > 0 ? (
|
||||
<time
|
||||
className="message-sent-at"
|
||||
dateTime={new Date(sentAt).toISOString()}
|
||||
title={`发送于 ${new Date(sentAt).toLocaleString('zh-CN', { hour12: false })}`}
|
||||
>
|
||||
{formatClockTime(sentAt)}
|
||||
</time>
|
||||
) : null}
|
||||
{showDesignReasoning && message.reasoningText ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{message.reasoningText}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const submitButton = (
|
||||
<button
|
||||
@@ -559,21 +576,19 @@ export function ProjectSupervisorView({
|
||||
</div>
|
||||
) : null}
|
||||
{directCodex
|
||||
? directTurns.map((turn) => (
|
||||
<Fragment key={turn.key}>
|
||||
{turn.messages
|
||||
.filter((message) => message.role === 'user')
|
||||
.map(renderMessage)}
|
||||
{turn.source === 'stream' ? (
|
||||
<>
|
||||
<TurnStreamSequence
|
||||
items={turn.items}
|
||||
toolCalls={turn.calls}
|
||||
active={turn.active}
|
||||
userSentAt={turn.startedAt}
|
||||
/>
|
||||
{turn.notices.map(renderMessage)}
|
||||
</>
|
||||
? directTurns.map((turn) => {
|
||||
const content = splitDirectTurnContent(turn);
|
||||
const renderStream = (items: TurnStreamItem[]) => (
|
||||
<TurnStreamSequence
|
||||
items={items}
|
||||
toolCalls={turn.calls}
|
||||
active={turn.active}
|
||||
userSentAt={turn.startedAt}
|
||||
/>
|
||||
);
|
||||
const process =
|
||||
turn.source === 'stream' ? (
|
||||
renderStream(content.processItems)
|
||||
) : (
|
||||
<>
|
||||
{turn.calls.length > 0 ? (
|
||||
@@ -584,28 +599,59 @@ export function ProjectSupervisorView({
|
||||
className="message-tool-call"
|
||||
/>
|
||||
) : null}
|
||||
{turn.messages
|
||||
.filter((message) => message.role !== 'user')
|
||||
.map(renderMessage)}
|
||||
{turn.transientReply ? (
|
||||
<div
|
||||
className="message message--assistant"
|
||||
aria-label="陶泥儿实时回复"
|
||||
aria-live="polite"
|
||||
data-runtime-owned="true"
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={turn.transientReply}
|
||||
streaming
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{content.processMessages.map(renderMessage)}
|
||||
</>
|
||||
)}
|
||||
{renderTurnUsage(turn)}
|
||||
</Fragment>
|
||||
))
|
||||
);
|
||||
const hasProcess =
|
||||
turn.source === 'stream'
|
||||
? content.processItems.length > 0
|
||||
: turn.calls.length > 0 ||
|
||||
content.processMessages.length > 0;
|
||||
return (
|
||||
<Fragment key={turn.key}>
|
||||
{turn.messages
|
||||
.filter((message) => message.role === 'user')
|
||||
.map(renderMessage)}
|
||||
{turn.active ? (
|
||||
process
|
||||
) : hasProcess ? (
|
||||
<details
|
||||
className="message-turn-process"
|
||||
data-testid="turn-process"
|
||||
data-turn-id={turn.turnId}
|
||||
>
|
||||
<summary>执行过程</summary>
|
||||
<div className="message-turn-process-body">
|
||||
{process}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
{turn.source === 'stream' ? (
|
||||
<>
|
||||
{renderStream(content.finalItems)}
|
||||
{turn.notices.map(renderMessage)}
|
||||
</>
|
||||
) : (
|
||||
content.finalMessages.map(renderMessage)
|
||||
)}
|
||||
{turn.transientReply ? (
|
||||
<div
|
||||
className="message message--assistant"
|
||||
aria-label="陶泥儿实时回复"
|
||||
aria-live="polite"
|
||||
data-runtime-owned="true"
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={turn.transientReply}
|
||||
streaming
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{renderTurnUsage(turn)}
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
: visibleMessages.map(renderMessage)}
|
||||
{directCodex && transientReasoning ? (
|
||||
<details
|
||||
@@ -719,7 +765,9 @@ export function ProjectSupervisorView({
|
||||
/>
|
||||
) : null}
|
||||
{directCodex &&
|
||||
(runtimePanelProps.controlBusy || Boolean(directProcessDetail)) ? (
|
||||
directStatus !== 'completed' &&
|
||||
directStatus !== 'failed' &&
|
||||
(runtimePanelProps.controlBusy || Boolean(activeTurnId)) ? (
|
||||
<section
|
||||
className={`project-supervisor-process-card${runtimePanelProps.controlBusy ? ' is-active' : ''}`}
|
||||
aria-label="陶泥儿执行过程"
|
||||
|
||||
@@ -21,10 +21,12 @@ export type DirectThreadConsumeResult = {
|
||||
export type DirectThreadHistorySlice = {
|
||||
items: unknown[];
|
||||
hasMore: boolean;
|
||||
itemTimestamps?: Record<string, number>;
|
||||
};
|
||||
|
||||
export function directThreadHistoryItemsToMessages(
|
||||
items: unknown[],
|
||||
itemTimestamps: Readonly<Record<string, number>> = {},
|
||||
): LocalConversationMessageRecord[] {
|
||||
return items.flatMap((raw) => {
|
||||
if (!raw || typeof raw !== 'object') return [];
|
||||
@@ -51,113 +53,20 @@ export function directThreadHistoryItemsToMessages(
|
||||
content,
|
||||
agentId: null,
|
||||
messageId,
|
||||
updatedAt: 0,
|
||||
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export type DirectThreadReducerState = {
|
||||
lastSeq: number;
|
||||
turnId: string | null;
|
||||
status:
|
||||
| 'accepted'
|
||||
| 'running'
|
||||
| 'streaming'
|
||||
| 'finalizing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| null;
|
||||
activeItemIds: Set<string>;
|
||||
accumulatedText: string;
|
||||
progress: string;
|
||||
};
|
||||
|
||||
export const emptyDirectThreadReducerState = (): DirectThreadReducerState => ({
|
||||
lastSeq: 0,
|
||||
turnId: null,
|
||||
status: null,
|
||||
activeItemIds: new Set(),
|
||||
accumulatedText: '',
|
||||
progress: '',
|
||||
});
|
||||
|
||||
function itemType(event: DirectThreadRawEvent) {
|
||||
const value = event.payload.itemType;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function activityDetail(type: string) {
|
||||
switch (type) {
|
||||
case 'mcpToolCall':
|
||||
return '正在调用工具';
|
||||
case 'commandExecution':
|
||||
return '正在执行命令';
|
||||
case 'fileChange':
|
||||
return '正在写入文件';
|
||||
case 'webSearch':
|
||||
return '正在搜索资料';
|
||||
case 'contextCompaction':
|
||||
return '正在整理上下文';
|
||||
default:
|
||||
return '正在处理';
|
||||
}
|
||||
}
|
||||
|
||||
export function reduceDirectThreadEvent(
|
||||
state: DirectThreadReducerState,
|
||||
event: DirectThreadRawEvent,
|
||||
): DirectThreadReducerState {
|
||||
if (!Number.isSafeInteger(event.seq) || event.seq <= state.lastSeq) {
|
||||
return state;
|
||||
}
|
||||
const next: DirectThreadReducerState = {
|
||||
...state,
|
||||
lastSeq: event.seq,
|
||||
turnId: event.turnId || state.turnId,
|
||||
activeItemIds: new Set(state.activeItemIds),
|
||||
};
|
||||
switch (event.type) {
|
||||
case 'turn.started':
|
||||
next.status = 'running';
|
||||
next.progress = '正在处理';
|
||||
next.accumulatedText = '';
|
||||
next.activeItemIds = new Set();
|
||||
break;
|
||||
case 'item.started':
|
||||
if (event.itemId) next.activeItemIds.add(event.itemId);
|
||||
next.status = 'running';
|
||||
next.progress = activityDetail(itemType(event));
|
||||
break;
|
||||
case 'item.delta': {
|
||||
const delta = event.payload.delta;
|
||||
if (typeof delta === 'string' && delta) {
|
||||
next.accumulatedText += delta;
|
||||
next.status = 'streaming';
|
||||
next.progress = '正在生成回复';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'item.completed':
|
||||
if (event.itemId) next.activeItemIds.delete(event.itemId);
|
||||
if (next.status === null) next.status = 'running';
|
||||
break;
|
||||
case 'turn.completed': {
|
||||
const status = event.payload.status;
|
||||
next.status = status === 'completed' ? 'completed' : 'failed';
|
||||
next.progress =
|
||||
status === 'completed' ? '正在提交回复' : '正在记录失败原因';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function reduceDirectThreadEvents(
|
||||
events: DirectThreadRawEvent[],
|
||||
initial = emptyDirectThreadReducerState(),
|
||||
) {
|
||||
return events.reduce(reduceDirectThreadEvent, initial);
|
||||
/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */
|
||||
export function isDirectTurnInProgress(
|
||||
status: string | null | undefined,
|
||||
): status is 'accepted' | 'running' | 'streaming' | 'finalizing' {
|
||||
return (
|
||||
status === 'accepted' ||
|
||||
status === 'running' ||
|
||||
status === 'streaming' ||
|
||||
status === 'finalizing'
|
||||
);
|
||||
}
|
||||
|
||||
+53
-12
@@ -26,12 +26,51 @@ export type DirectTurnPresentation = {
|
||||
endedAt: number;
|
||||
};
|
||||
|
||||
function milliseconds(value: number | undefined) {
|
||||
return value && Number.isFinite(value) && value > 0
|
||||
? value < 100_000_000_000
|
||||
? value * 1000
|
||||
: value
|
||||
: 0;
|
||||
export function directMessageTimestamp(value: number | undefined) {
|
||||
const timestamp =
|
||||
value && Number.isFinite(value) && value > 0
|
||||
? value < 100_000_000_000
|
||||
? value * 1000
|
||||
: value
|
||||
: 0;
|
||||
return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : 0;
|
||||
}
|
||||
|
||||
/** 从唯一呈现来源分区,完成后只把最终回复及失败提示留在过程折叠区之外。 */
|
||||
export function splitDirectTurnContent(turn: DirectTurnPresentation) {
|
||||
const assistants = turn.messages.filter(
|
||||
(message) => message.role === 'assistant',
|
||||
);
|
||||
const isFailureMessage = (message: ChatMessage) =>
|
||||
Boolean(
|
||||
message.messageId?.endsWith(':failure') &&
|
||||
directConversationTurnId(message.messageId),
|
||||
);
|
||||
const isFailureItem = (item: TurnStreamItem) =>
|
||||
item.id === `text:${turn.turnId}:failure`;
|
||||
const failed =
|
||||
assistants.some(isFailureMessage) || turn.items.some(isFailureItem);
|
||||
const lastText =
|
||||
!turn.active && !failed
|
||||
? turn.items
|
||||
.filter((item) => item.kind === 'text' && item.text.trim())
|
||||
.at(-1)
|
||||
: undefined;
|
||||
const lastMessage = !turn.active && !failed ? assistants.at(-1) : undefined;
|
||||
return {
|
||||
processItems: turn.items.filter(
|
||||
(item) => item !== lastText && !isFailureItem(item),
|
||||
),
|
||||
finalItems: turn.items.filter(
|
||||
(item) => item === lastText || isFailureItem(item),
|
||||
),
|
||||
processMessages: assistants.filter(
|
||||
(message) => message !== lastMessage && !isFailureMessage(message),
|
||||
),
|
||||
finalMessages: assistants.filter(
|
||||
(message) => message === lastMessage || isFailureMessage(message),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */
|
||||
@@ -207,16 +246,18 @@ export function buildDirectTurnPresentations({
|
||||
const starts = [
|
||||
...row.messages
|
||||
.filter((message) => message.role === 'user')
|
||||
.map((message) => milliseconds(message.updatedAt)),
|
||||
...row.items.map((item) => milliseconds(item.at)),
|
||||
...row.calls.map((call) => milliseconds(call.startedAt)),
|
||||
.map((message) => directMessageTimestamp(message.updatedAt)),
|
||||
...row.items.map((item) => directMessageTimestamp(item.at)),
|
||||
...row.calls.map((call) => directMessageTimestamp(call.startedAt)),
|
||||
].filter((at) => at > 0);
|
||||
row.startedAt = starts.length ? Math.min(...starts) : 0;
|
||||
row.endedAt = Math.max(
|
||||
0,
|
||||
...row.messages.map((message) => milliseconds(message.updatedAt)),
|
||||
...row.items.map((item) => milliseconds(item.updatedAt)),
|
||||
...row.calls.map((call) => milliseconds(call.updatedAt)),
|
||||
...row.messages.map((message) =>
|
||||
directMessageTimestamp(message.updatedAt),
|
||||
),
|
||||
...row.items.map((item) => directMessageTimestamp(item.updatedAt)),
|
||||
...row.calls.map((call) => directMessageTimestamp(call.updatedAt)),
|
||||
);
|
||||
}
|
||||
return [...rows.values()].filter(
|
||||
|
||||
@@ -275,6 +275,7 @@ export function formatClockTime(timestamp: number | null | undefined) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isFinite(date.getTime())) return null;
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
@@ -12148,6 +12148,34 @@ button.design-workspace-tree__entry:hover,
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* 发送时间与完成后的过程区沿用对话面板的次要信息样式。 */
|
||||
.message-sent-at {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.message-turn-process {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-turn-process > summary {
|
||||
padding: 6px 0;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-turn-process-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* 整轮会话的结束时间与耗时:比消息本身更轻,属于轮次级信息。 */
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
directThreadHistoryItemsToMessages,
|
||||
isDirectTurnInProgress,
|
||||
} from '../src/features/project-workspace/directThreadEvents';
|
||||
|
||||
describe('Direct 回合状态与历史时间', () => {
|
||||
it('终态和空状态不恢复为活动回合', () => {
|
||||
for (const status of [
|
||||
'completed',
|
||||
'failed',
|
||||
'interrupted',
|
||||
null,
|
||||
undefined,
|
||||
]) {
|
||||
expect(isDirectTurnInProgress(status)).toBe(false);
|
||||
}
|
||||
for (const status of ['accepted', 'running', 'streaming', 'finalizing']) {
|
||||
expect(isDirectTurnInProgress(status)).toBe(true);
|
||||
}
|
||||
});
|
||||
it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => {
|
||||
const items = [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
id: 'direct-codex:turn:user',
|
||||
content: [{ type: 'input_text', text: '帮我修改游戏' }],
|
||||
},
|
||||
];
|
||||
const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 };
|
||||
expect(
|
||||
directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt,
|
||||
).toBe(1_800_000_000_001);
|
||||
expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0);
|
||||
expect(items[0]).not.toHaveProperty('recordedAt');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@ import type {
|
||||
GameCreatorDirectToolCall,
|
||||
TurnStreamItem,
|
||||
} from '../src/app/types';
|
||||
import { buildDirectTurnPresentations } from '../src/features/project-workspace/directTurnPresentation';
|
||||
import {
|
||||
buildDirectTurnPresentations,
|
||||
directMessageTimestamp,
|
||||
splitDirectTurnContent,
|
||||
} from '../src/features/project-workspace/directTurnPresentation';
|
||||
|
||||
const user = (turn: string): ChatMessage => ({
|
||||
role: 'user',
|
||||
@@ -158,6 +162,56 @@ describe('DirectProject 回合唯一呈现', () => {
|
||||
const rows = build([user('one')], [last, marker, first, first]);
|
||||
expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]);
|
||||
});
|
||||
it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => {
|
||||
const marker: TurnStreamItem = {
|
||||
...text('one', 'unused', 2),
|
||||
kind: 'tool',
|
||||
id: 'tool:one:call',
|
||||
callId: 'call',
|
||||
};
|
||||
const row = build(
|
||||
[user('one')],
|
||||
[text('one', 'start'), marker, text('one', 'final', 3)],
|
||||
)[0];
|
||||
const parts = splitDirectTurnContent(row);
|
||||
expect(parts.processItems.map((item) => item.id)).toEqual([
|
||||
'text:one:start',
|
||||
'tool:one:call',
|
||||
]);
|
||||
expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']);
|
||||
const active = splitDirectTurnContent({ ...row, active: true });
|
||||
expect(active.processItems).toHaveLength(3);
|
||||
expect(active.finalItems).toEqual([]);
|
||||
});
|
||||
it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => {
|
||||
const row = build(
|
||||
[user('one'), assistant('direct-codex:one:failure', '失败')],
|
||||
[text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }],
|
||||
)[0];
|
||||
const parts = splitDirectTurnContent(row);
|
||||
expect(parts.processItems.map((item) => item.id)).toEqual([
|
||||
'text:one:progress',
|
||||
]);
|
||||
expect(parts.finalItems.map((item) => item.id)).toEqual([
|
||||
'text:one:failure',
|
||||
]);
|
||||
});
|
||||
it('无流历史只保留最后一条回复,发送时间不从工具推断', () => {
|
||||
const row = build(
|
||||
[user('one'), assistant('progress'), assistant('final')],
|
||||
[],
|
||||
)[0];
|
||||
const parts = splitDirectTurnContent(row);
|
||||
expect(parts.processMessages.map((message) => message.messageId)).toEqual([
|
||||
'progress',
|
||||
]);
|
||||
expect(parts.finalMessages.map((message) => message.messageId)).toEqual([
|
||||
'final',
|
||||
]);
|
||||
expect(directMessageTimestamp(undefined)).toBe(0);
|
||||
expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0);
|
||||
expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001);
|
||||
});
|
||||
it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => {
|
||||
expect(
|
||||
build([user('one')], [], {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
对应:[对话回合唯一投影](./【里程碑】对话回合唯一投影-2026-09-16.md)。
|
||||
|
||||
本次增量顺序:先移除 Provider 回放对活动 client 回合的写入,复用活动快照同步并处理过期异步结果;再为历史信封及切片增加可选时间映射;最后在现有回合投影中分离最终回复和可折叠过程,复用 details 与工具组件。新增恢复、时间、折叠边界用例,只运行静态检查,不运行测试;按最新授权本地提交,不推送。
|
||||
|
||||
1. 前端抽取纯回合呈现投影,完整历史关联后分页;删除旧的消息锚定/实时/未归属独立渲染分支。
|
||||
2. Rust 按 item 完成边界冲刷,段切换返回全部快照;收尾等待写任务,修正 upsert 与跨回合裁剪。
|
||||
3. 核对 MCP 输入输出与同状态更新,保留现有脱敏。
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# 对话回合唯一投影
|
||||
|
||||
- Version: 1
|
||||
- Version: 2
|
||||
- Status: implemented-awaiting-runtime-acceptance
|
||||
- Date: 2026-09-16
|
||||
- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
|
||||
|
||||
## 范围与评审
|
||||
|
||||
单里程碑修复回合展示和流写入的一致性;不改工具执行、鉴权或原始历史格式。结构评审确认:唯一 turn owner、item 顺序、历史身份与分页、失败保留、工具详情更新均有明确归属;无须新增数据库或迁移历史。没有身份的旧记录不得做位置猜配。
|
||||
单里程碑修复回合展示和流写入的一致性;补充终态重进恢复、用户发送时间和完成后的过程折叠。评审确认:活动快照及 Direct 事件拥有生命周期,Provider 回放不创建 client 回合;JSONL 信封可选时间字段不污染原始 item,无须数据库或旧数据迁移;最终回复沿用 Runtime 的最后 assistant item 合同,失败提示不折叠。没有身份的旧记录不得做位置猜配。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
2. item 增量、完成、持久化和回读保持相同身份与固定顺序。
|
||||
3. 工具输入输出保留,重复快照不重复渲染。
|
||||
4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认。
|
||||
5. 已结束回合重进不显示提交中,真实运行回合可恢复;跨项目/新回合迟到快照无效。
|
||||
6. 用户消息时间可刷新恢复,旧无时间记录不造值;完成后中间正文和工具统一折叠,最终回复及失败提示保持可见。
|
||||
|
||||
依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。
|
||||
|
||||
@@ -24,3 +26,4 @@
|
||||
- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证。
|
||||
- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。
|
||||
- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。
|
||||
- 本次增量已完成生命周期来源收敛、信封发送时间和完成过程折叠;定向 TypeScript、ESLint、Cargo check、文档索引通过。新增时间幂等/旧记录、终态分类及内容分区用例但未运行;主页进入项目、重新发送/切项目竞态和自动折叠仍待原生实机验收,仅本地提交,不推送。
|
||||
|
||||
@@ -52,6 +52,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
|
||||
## AGC DirectProject 与 UI workflow
|
||||
|
||||
- DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。
|
||||
- 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。
|
||||
|
||||
- AGC 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。
|
||||
|
||||
|
||||
@@ -1416,8 +1416,15 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执
|
||||
|
||||
## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性
|
||||
|
||||
Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
|
||||
Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,只读活动回合快照负责恢复 clientTurnId 与运行状态,Direct 回合事件负责更新进度。Thread Manager 的 Provider turnId 与事件序列不得当成 clientTurnId 或展示事件序列;其通知只触发快照和历史同步,回放的终态不能重新创建活动回合或“正在提交回复”。定期复核同一份活动快照以弥补页面切换时丢失的结束事件;失败读取保留已知状态,过期读取不能覆盖新回合。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
|
||||
|
||||
壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
|
||||
|
||||
活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。
|
||||
|
||||
### 消息时间与完成回合的过程折叠
|
||||
|
||||
- 用户消息显示发送时刻,精确到秒。新历史信封记录接收时刻 `recordedAt`(Unix 毫秒),原始 Codex item 不增加宿主字段;历史切片通过独立 `itemTimestamps` 映射返回。幂等重复写不刷新时刻,缺时间的旧记录保持未知,不使用打开页面时刻或工具开始时刻补造。
|
||||
- 运行中的正文和工具按原有唯一回合流实时显示;完成后,除最终回复和失败提示外,中间文本与所有工具调用统一放入默认收起的“执行过程”,允许手动展开,刷新或重新进入仍默认收起。
|
||||
- 最终回复沿用 Runtime 的最后一个 assistant item 合同,不按文本长度或相似度判断。失败回合不把最后一句过程输出伪装成最终回复。无流历史按同一用户消息边界划分,只保留最后一条 assistant 回复在外;用户消息与失败提示始终保留。
|
||||
- 验收覆盖已完成回合重进、真实活动回合恢复、跨项目迟到快照、运行到完成自动收起、历史无流、失败、发送时间刷新和旧记录时间缺失。不改变实际工具执行、鉴权、数据库或用户项目内容。
|
||||
|
||||
@@ -58,6 +58,7 @@ toolCalls?: DirectTurnToolCall[] | null;
|
||||
### 4. 前端合并与渲染(回合唯一归属,连续工具成块)
|
||||
|
||||
- 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。
|
||||
- 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。
|
||||
- 实时与回读共用同一投影,正文、工具和耗时不另建实时/未归属渲染出口。先在完整历史按消息身份关联,再分页;禁止按第 N 个工具回合匹配第 N 条用户消息。详情通过当前回合 `callId` 关联;同项目回读与实时增量幂等合并,切项目清空旧状态。完整合同见 [AGC 实施计划](./【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“DirectProject 回合展示唯一归属”。
|
||||
- 块 DOM 与交互(对齐 Codex):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user