实时回合按顺序交替渲染正文与工具块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 1m40s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m26s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m22s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 3m14s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m29s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m14s
Project CI / Native shell tests (pull_request) Successful in 7m29s

- App.tsx:记录"每个工具首次出现时已生成正文的长度"(切分点)并在收尾/清空对话时重置
- ProjectSupervisorView:新增 renderLiveStream,按切分点把流式正文切成若干段,工具块就地插在对应位置;同一切分点的工具合并成一个块
- 有正文时不再重复渲染独立实时工具组(避免同一批工具出现两次)
- 历史回合没有切分点:退化为"正文 → 工具块 → 轮次统计",老项目与旧数据不受影响
- tsc 通过;未跑测试
This commit is contained in:
2026-09-16 00:21:03 +08:00
parent 7d5916aa62
commit 67126a981e
2 changed files with 118 additions and 2 deletions
+46
View File
@@ -883,6 +883,14 @@ export function App({
// 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。
const [directCodexTransientReasoning, setDirectCodexTransientReasoning] =
useState('');
/** 实时回合里"某个工具首次出现时,已生成正文的长度"——用它把正文与工具交替排列。 */
const [directToolTextOffsets, setDirectToolTextOffsets] = useState<
Record<string, Record<string, number>>
>({});
const directToolTextOffsetsRef = useRef<Record<string, Record<string, number>>>(
{},
);
const directAccumulatedTextLengthRef = useRef(0);
const [
directCodexTransientReplyUpdatedAt,
setDirectCodexTransientReplyUpdatedAt,
@@ -984,6 +992,9 @@ export function App({
setDirectCodexProgressUpdatedAt(null);
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directToolTextOffsetsRef.current = {};
setDirectToolTextOffsets({});
directAccumulatedTextLengthRef.current = 0;
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
}
@@ -1083,6 +1094,9 @@ export function App({
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directToolTextOffsetsRef.current = {};
setDirectToolTextOffsets({});
directAccumulatedTextLengthRef.current = 0;
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
@@ -1117,6 +1131,9 @@ export function App({
activeDirectCodexTurnRef.current = null;
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directToolTextOffsetsRef.current = {};
setDirectToolTextOffsets({});
directAccumulatedTextLengthRef.current = 0;
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
return true;
@@ -2188,7 +2205,29 @@ export function App({
);
}
// 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。
if (typeof payload.accumulatedText === 'string') {
directAccumulatedTextLengthRef.current = payload.accumulatedText.length;
}
if (payload.toolCalls?.length) {
// 每个工具只记第一次出现时的正文长度:这就是它在对话流里的位置。
const turnOffsets =
directToolTextOffsetsRef.current[payload.turnId] ?? {};
let offsetsChanged = false;
for (const call of payload.toolCalls) {
const callId = (call as { id?: string }).id;
if (!callId || turnOffsets[callId] !== undefined) {
continue;
}
turnOffsets[callId] = directAccumulatedTextLengthRef.current;
offsetsChanged = true;
}
if (offsetsChanged) {
directToolTextOffsetsRef.current = {
...directToolTextOffsetsRef.current,
[payload.turnId]: { ...turnOffsets },
};
setDirectToolTextOffsets(directToolTextOffsetsRef.current);
}
applyDirectToolCalls(
payload.toolCalls.map((call) => ({
...call,
@@ -2212,6 +2251,9 @@ export function App({
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directToolTextOffsetsRef.current = {};
setDirectToolTextOffsets({});
directAccumulatedTextLengthRef.current = 0;
setDirectCodexTransientReplyUpdatedAt(null);
return;
}
@@ -6860,6 +6902,9 @@ export function App({
setDirectCodexProgress('正在等待陶泥儿开始');
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directToolTextOffsetsRef.current = {};
setDirectToolTextOffsets({});
directAccumulatedTextLengthRef.current = 0;
setDirectCodexProgressUpdatedAt(Date.now());
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
@@ -12594,6 +12639,7 @@ export function App({
if (projectSupervisorOnly) {
return (
<ProjectSupervisorView
directToolTextOffsets={directToolTextOffsets}
transientReasoning={directCodexTransientReasoning}
initialSupervisorMessage={initialSupervisorMessage}
activeVersionId={chatActiveVersionId}
@@ -159,6 +159,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
transientReply: string;
/** 流式思考过程(direct-codex):拿不到就为空,空则不渲染。 */
transientReasoning?: string;
/** 实时回合里"某工具首次出现时正文的长度":用于把正文与工具按顺序交替渲染。 */
directToolTextOffsets?: Record<string, Record<string, number>>;
showDesignReasoning?: boolean;
designReasoning?: string;
designReasoningEntries?: DesignReasoningEntry[];
@@ -227,6 +229,7 @@ export function ProjectSupervisorView({
showProfessionalCollaboration = true,
transientReply,
transientReasoning = '',
directToolTextOffsets = {},
showDesignReasoning = false,
designReasoning = '',
designReasoningEntries = [],
@@ -371,6 +374,69 @@ export function ProjectSupervisorView({
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
/**
* 把实时正文与工具块按"工具出现时正文长度"交替排列:
* 文本段 → 该位置的工具块 → 下一段文本……同一切分点的工具合并成一个块。
* 没有切分点(历史回合)时退化为"正文在前、工具块在后",与老项目兼容。
*/
const renderLiveStream = (liveText: string, calls: typeof liveToolCalls) => {
const offsets = directToolTextOffsets[liveToolCallTurnId] ?? {};
const groups = new Map<number, typeof calls>();
for (const call of calls) {
const offset = offsets[call.id];
const key = Number.isFinite(offset) ? Number(offset) : liveText.length;
const bucket = groups.get(key) ?? [];
bucket.push(call);
groups.set(key, bucket);
}
if (groups.size === 0) {
return null;
}
const positions = [...groups.keys()].sort((left, right) => left - right);
const parts: ReactNode[] = [];
let cursor = 0;
for (const position of positions) {
const segment = liveText.slice(cursor, Math.max(cursor, position));
if (segment.length > 0) {
parts.push(
<div
key={`live-text-${position}`}
className="message message--assistant"
aria-live="polite"
data-runtime-owned="true"
>
<ChatMarkdownMessage role="assistant" text={segment} streaming />
</div>,
);
}
const bucket = groups.get(position) ?? [];
parts.push(
<ToolCallGroup
key={`live-tools-${position}`}
calls={bucket}
userSentAt={userMessageUpdatedAtForTurn(liveToolCallTurnId)}
active
className="message-tool-call"
/>,
);
cursor = Math.max(cursor, position);
}
const tail = liveText.slice(cursor);
if (tail.length > 0) {
parts.push(
<div
key="live-text-tail"
className="message message--assistant"
aria-live="polite"
data-runtime-owned="true"
>
<ChatMarkdownMessage role="assistant" text={tail} streaming />
</div>,
);
}
return parts;
};
/** 整轮会话的结束时间与耗时(进行中时用 tick 驱的 now,所以秒数会实时跳动)。 */
const renderTurnUsage = (turnId: string) => {
if (!turnId) {
@@ -573,7 +639,10 @@ export function ProjectSupervisorView({
) : null}
{/* 直连回合的流式正文:恢复为对话区里的普通 assistant 消息(不再放进状态卡片),
这样"边生成边显示"和"状态卡片只放状态"两件事同时成立。 */}
{directCodex && transientReply ? (
{directCodex && transientReply && liveToolCalls.length > 0
? renderLiveStream(transientReply, liveToolCalls)
: null}
{directCodex && transientReply && liveToolCalls.length === 0 ? (
<div
className="message message--assistant"
aria-label="陶泥儿实时回复"
@@ -587,7 +656,8 @@ export function ProjectSupervisorView({
/>
</div>
) : null}
{liveToolCalls.length > 0 ? (
{/* 有正文时,工具块的顺序由 renderLiveStream 决定,这里不再重复渲染 */}
{liveToolCalls.length > 0 && !(directCodex && transientReply) ? (
<ToolCallGroup
calls={liveToolCalls}
userSentAt={userMessageUpdatedAtForTurn(liveToolCallTurnId)}