修复策划回复重复播放并保留伪流式
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / Backend tests (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m7s
Project CI / Native shell tests (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / Repository checks (pull_request) Successful in 2m18s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / Backend tests (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m7s
Project CI / Native shell tests (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / Repository checks (pull_request) Successful in 2m18s
按消息 ID 合并实时正文与正式消息,保留逐步显示进度 分离请求收尾与动画结束,隔离旧回合事件及异步返回 独立展示工具状态,保留 Provider 重试的正文重置语义 补充重复终态、整块回复、多消息及回合隔离回归测试 同步更新策划展示规范与排障记录
This commit is contained in:
@@ -116,6 +116,7 @@ import {
|
||||
type DirectProjectInitialTurn,
|
||||
} from './view/project-development/chat/DirectProjectChatView';
|
||||
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
|
||||
import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation';
|
||||
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
|
||||
|
||||
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
|
||||
@@ -366,23 +367,31 @@ export function App({
|
||||
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
|
||||
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
|
||||
const [projectChatError, setProjectChatError] = useState('');
|
||||
const [designAgentTransientReply, setDesignAgentTransientReplyVisible] =
|
||||
useState('');
|
||||
const designAgentTransientReplyTargetRef = useRef('');
|
||||
const designAgentVisibleReplyRef = useRef('');
|
||||
const designAgentPendingViewRef = useRef<{
|
||||
clientTurnId: string;
|
||||
projectPath: string;
|
||||
view: DesignView;
|
||||
} | null>(null);
|
||||
const designReplyAnimation = useDesignReplyAnimation();
|
||||
const [designAgentStatus, setDesignAgentStatus] = useState('');
|
||||
const [designAgentReasoning, setDesignAgentReasoning] = useState('');
|
||||
|
||||
function setDesignAgentTransientReplyTarget(next: string) {
|
||||
designAgentTransientReplyTargetRef.current = next;
|
||||
if (!next) {
|
||||
designAgentVisibleReplyRef.current = '';
|
||||
setDesignAgentTransientReplyVisible('');
|
||||
}
|
||||
function isCurrentDesignTurn(projectPath: string, clientTurnId: string) {
|
||||
const tracked = designAgentTurnRef.current;
|
||||
return (
|
||||
localProjectPathRef.current === projectPath &&
|
||||
tracked?.projectPath === projectPath &&
|
||||
tracked.clientTurnId === clientTurnId
|
||||
);
|
||||
}
|
||||
|
||||
function beginDesignTurn(projectPath: string, clientTurnId: string) {
|
||||
designAgentTurnRef.current = { projectPath, clientTurnId };
|
||||
designAgentReasoningTurnRef.current = { projectPath, clientTurnId };
|
||||
designReplyAnimation.reset(
|
||||
latestMessagesRef.current.flatMap((message) =>
|
||||
message.messageId ? [message.messageId] : [],
|
||||
),
|
||||
);
|
||||
setDesignAgentStatus('');
|
||||
setDesignAgentReasoning('');
|
||||
setProjectChatError('');
|
||||
setChatAgentBusy(true);
|
||||
}
|
||||
|
||||
function designAgentEventSubscriptionReady() {
|
||||
@@ -407,35 +416,12 @@ export function App({
|
||||
designAgentEventSubscriptionResolveRef.current = null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const target = designAgentTransientReplyTargetRef.current;
|
||||
setDesignAgentTransientReplyVisible((current) => {
|
||||
if (!target) {
|
||||
designAgentVisibleReplyRef.current = '';
|
||||
return '';
|
||||
}
|
||||
const prefix = target.startsWith(current) ? current : '';
|
||||
if (prefix === target) {
|
||||
designAgentVisibleReplyRef.current = target;
|
||||
return target;
|
||||
}
|
||||
const remaining = target.length - prefix.length;
|
||||
const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1;
|
||||
const next = target.slice(0, prefix.length + step);
|
||||
designAgentVisibleReplyRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, 50);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。
|
||||
*
|
||||
* 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()`
|
||||
* 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合时
|
||||
* 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上。
|
||||
* 让回合等监听器挂好再开始,避免开头几个事件丢掉。正文和视图严格匹配活动回合,
|
||||
* reasoning 另按原回合接收迟到补充,不能让过期视图重播正文。
|
||||
*/
|
||||
useEffect(() => {
|
||||
const ready = createDesignAgentEventSubscriptionReady();
|
||||
@@ -452,19 +438,7 @@ export function App({
|
||||
let disposed = false;
|
||||
void subscribeTauriEvent<DesignEvent>('design-agent-update', (event) => {
|
||||
const payload = event.payload;
|
||||
const tracked = designAgentTurnRef.current;
|
||||
if (
|
||||
payload.projectPath !== localProjectPathRef.current ||
|
||||
(tracked && payload.clientTurnId !== tracked.clientTurnId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(payload.kind === 'text' || payload.kind === 'tool') &&
|
||||
payload.text
|
||||
) {
|
||||
setDesignAgentTransientReplyTarget(payload.text);
|
||||
}
|
||||
if (payload.projectPath !== localProjectPathRef.current) return;
|
||||
if (payload.reasoningText != null) {
|
||||
const reasoningTurn = designAgentReasoningTurnRef.current;
|
||||
if (
|
||||
@@ -474,8 +448,21 @@ export function App({
|
||||
setDesignAgentReasoning(payload.reasoningText);
|
||||
}
|
||||
}
|
||||
if (!isCurrentDesignTurn(payload.projectPath, payload.clientTurnId))
|
||||
return;
|
||||
if (
|
||||
payload.kind === 'text' &&
|
||||
payload.messageId &&
|
||||
payload.text != null
|
||||
) {
|
||||
designReplyAnimation.receiveText(payload.messageId, payload.text);
|
||||
if (payload.text) setDesignAgentStatus('');
|
||||
}
|
||||
if (payload.kind === 'tool' && payload.text != null) {
|
||||
setDesignAgentStatus(payload.text);
|
||||
}
|
||||
if (payload.view) {
|
||||
applyDesignAgentViewAfterTransient(
|
||||
applyDesignAgentTurnView(
|
||||
payload.view,
|
||||
payload.projectPath,
|
||||
payload.clientTurnId,
|
||||
@@ -557,67 +544,18 @@ export function App({
|
||||
latestMessagesRef.current = conversation;
|
||||
}
|
||||
|
||||
function commitDesignAgentView(view: DesignView, projectPath: string) {
|
||||
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
|
||||
designAgentPendingViewRef.current = null;
|
||||
applyDesignView(view, projectPath);
|
||||
setDesignAgentReasoning('');
|
||||
setDesignAgentTransientReplyTarget('');
|
||||
if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) {
|
||||
designAgentTurnRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDesignAgentViewAfterTransient(
|
||||
function applyDesignAgentTurnView(
|
||||
view: DesignView,
|
||||
projectPath: string,
|
||||
clientTurnId: string,
|
||||
) {
|
||||
let target = designAgentTransientReplyTargetRef.current;
|
||||
const tracked = designAgentTurnRef.current;
|
||||
if (!target.trim() && !view.running) {
|
||||
const latestAssistantText = [...view.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role !== 'user' && message.text.trim())
|
||||
?.text.trim();
|
||||
if (latestAssistantText) {
|
||||
setDesignAgentTransientReplyTarget(latestAssistantText);
|
||||
target = latestAssistantText;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!view.running &&
|
||||
tracked?.clientTurnId === clientTurnId &&
|
||||
target.trim() &&
|
||||
designAgentVisibleReplyRef.current !== target
|
||||
) {
|
||||
designAgentPendingViewRef.current = {
|
||||
clientTurnId,
|
||||
projectPath,
|
||||
view,
|
||||
};
|
||||
return;
|
||||
}
|
||||
commitDesignAgentView(view, projectPath);
|
||||
if (!isCurrentDesignTurn(projectPath, clientTurnId)) return;
|
||||
designReplyAnimation.receiveView(view);
|
||||
applyDesignView(view, projectPath);
|
||||
setDesignAgentReasoning('');
|
||||
if (!view.running) setDesignAgentStatus('');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const pending = designAgentPendingViewRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const target = designAgentTransientReplyTargetRef.current;
|
||||
if (target && designAgentVisibleReplyRef.current !== target) {
|
||||
return;
|
||||
}
|
||||
commitDesignAgentView(pending.view, pending.projectPath);
|
||||
}, 50);
|
||||
return () => window.clearInterval(timer);
|
||||
// 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function hydrateDesignAgentSession(nextProjectPath: string) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || !nextProjectPath.trim()) {
|
||||
@@ -647,32 +585,18 @@ export function App({
|
||||
setProjectChatError('需要在 Tauri App 内运行。');
|
||||
return;
|
||||
}
|
||||
designAgentTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
designAgentReasoningTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
designAgentPendingViewRef.current = null;
|
||||
beginDesignTurn(nextProjectPath, clientTurnId);
|
||||
await designAgentEventSubscriptionReady();
|
||||
setChatAgentBusy(true);
|
||||
setProjectChatError('');
|
||||
setDesignAgentTransientReplyTarget('');
|
||||
setDesignAgentReasoning('');
|
||||
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return;
|
||||
try {
|
||||
const view = await invoke<DesignView>('continue_design_agent_session', {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
input,
|
||||
});
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
|
||||
applyDesignAgentTurnView(view, nextProjectPath, clientTurnId);
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -680,12 +604,13 @@ export function App({
|
||||
requestRuntimeConfigOpen();
|
||||
}
|
||||
setProjectChatError(message);
|
||||
designReplyAnimation.discardUnpersisted();
|
||||
} finally {
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
if (isCurrentDesignTurn(nextProjectPath, clientTurnId)) {
|
||||
designAgentTurnRef.current = null;
|
||||
setDesignAgentTransientReplyTarget('');
|
||||
setDesignAgentStatus('');
|
||||
setChatAgentBusy(false);
|
||||
}
|
||||
setChatAgentBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,7 +801,8 @@ export function App({
|
||||
}, [
|
||||
messages,
|
||||
projectChatError,
|
||||
designAgentTransientReply,
|
||||
designReplyAnimation.replies,
|
||||
designAgentStatus,
|
||||
designAgentReasoning,
|
||||
designAgentView,
|
||||
pendingUiConfirmation,
|
||||
@@ -1369,8 +1295,8 @@ export function App({
|
||||
setChatFilesImporting(false);
|
||||
setChatFileImportNotice('');
|
||||
setProjectChatError('');
|
||||
setDesignAgentTransientReplyTarget('');
|
||||
designAgentPendingViewRef.current = null;
|
||||
designReplyAnimation.reset();
|
||||
setDesignAgentStatus('');
|
||||
setDesignAgentReasoning('');
|
||||
setDesignAgentActive(planningStartMode);
|
||||
designAgentActiveRef.current = planningStartMode;
|
||||
@@ -2295,7 +2221,8 @@ export function App({
|
||||
pendingConfirmation={pendingUiConfirmation}
|
||||
projectPath={localProject?.projectPath ?? projectPath}
|
||||
conversationMessages={messages}
|
||||
transientReply={designAgentTransientReply}
|
||||
replyAnimations={designReplyAnimation.replies}
|
||||
designStatus={designAgentStatus}
|
||||
showDesignReasoning={designAgentActive}
|
||||
designReasoning={designAgentReasoning}
|
||||
designReasoningEntries={
|
||||
@@ -2313,66 +2240,37 @@ export function App({
|
||||
return;
|
||||
}
|
||||
const clientTurnId = createAgentChatRunId('design-agent-turn');
|
||||
designAgentTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
designAgentReasoningTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
designAgentPendingViewRef.current = null;
|
||||
setDesignAgentTransientReplyTarget('');
|
||||
setDesignAgentReasoning('');
|
||||
setChatAgentBusy(true);
|
||||
beginDesignTurn(nextProjectPath, clientTurnId);
|
||||
void designAgentEventSubscriptionReady()
|
||||
.then(() =>
|
||||
invoke<DesignView>('decide_design_phase', {
|
||||
.then(() => {
|
||||
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
|
||||
return null;
|
||||
return invoke<DesignView>('decide_design_phase', {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
requestId,
|
||||
approved,
|
||||
}),
|
||||
)
|
||||
});
|
||||
})
|
||||
.then((view) => {
|
||||
if (
|
||||
localProjectPathRef.current !== nextProjectPath ||
|
||||
designAgentTurnRef.current?.projectPath !==
|
||||
nextProjectPath ||
|
||||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyDesignAgentViewAfterTransient(
|
||||
if (!view) return;
|
||||
applyDesignAgentTurnView(
|
||||
view,
|
||||
nextProjectPath,
|
||||
clientTurnId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (
|
||||
localProjectPathRef.current !== nextProjectPath ||
|
||||
designAgentTurnRef.current?.projectPath !==
|
||||
nextProjectPath ||
|
||||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
|
||||
) {
|
||||
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
|
||||
return;
|
||||
}
|
||||
setProjectChatError(String(error));
|
||||
designReplyAnimation.discardUnpersisted();
|
||||
})
|
||||
.finally(() => {
|
||||
if (
|
||||
localProjectPathRef.current !== nextProjectPath ||
|
||||
designAgentTurnRef.current?.projectPath !==
|
||||
nextProjectPath ||
|
||||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
|
||||
) {
|
||||
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
|
||||
return;
|
||||
}
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
designAgentTurnRef.current = null;
|
||||
setDesignAgentTransientReplyTarget('');
|
||||
}
|
||||
designAgentTurnRef.current = null;
|
||||
setDesignAgentStatus('');
|
||||
setChatAgentBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
+69
-44
@@ -31,6 +31,7 @@ import {
|
||||
DesignAgentPendingActions,
|
||||
DesignAgentPhaseStatus,
|
||||
} from './DesignAgentSurface';
|
||||
import type { DesignReplyAnimation } from './useDesignReplyAnimation';
|
||||
|
||||
/** 后台任务失败文案要过一遍运行态错误解释器再给用户看。 */
|
||||
function planningMessageText(message: Pick<ChatMessage, 'text' | 'role'>) {
|
||||
@@ -80,7 +81,8 @@ type PlanningChatViewProps = {
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
pendingConfirmation: PendingUiConfirmation | null;
|
||||
projectPath: string;
|
||||
transientReply: string;
|
||||
replyAnimations?: DesignReplyAnimation[];
|
||||
designStatus?: string;
|
||||
showDesignReasoning?: boolean;
|
||||
designReasoning?: string;
|
||||
designReasoningEntries?: DesignReasoningEntry[];
|
||||
@@ -117,7 +119,8 @@ export function PlanningChatView({
|
||||
onSubmit,
|
||||
pendingConfirmation,
|
||||
projectPath,
|
||||
transientReply,
|
||||
replyAnimations = [],
|
||||
designStatus = '',
|
||||
showDesignReasoning = false,
|
||||
designReasoning = '',
|
||||
designReasoningEntries = [],
|
||||
@@ -169,34 +172,67 @@ export function PlanningChatView({
|
||||
const handleDesignClarify = onDesignClarify ?? (() => undefined);
|
||||
const handleDesignRetry = onDesignRetry ?? (() => undefined);
|
||||
|
||||
const renderMessage = (message: ChatMessage, index: number) => (
|
||||
<div
|
||||
key={message.messageId ?? `${message.role}-${index}`}
|
||||
className={`message message--${message.role}`}
|
||||
>
|
||||
<AgentMessageContent tone="body">
|
||||
<ChatMarkdownMessage
|
||||
role={message.role}
|
||||
text={planningMessageText(message)}
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
{showDesignReasoning && message.reasoningText ? (
|
||||
<AgentReasoning
|
||||
text={message.reasoningText}
|
||||
label="策划 Agent 思考过程"
|
||||
/>
|
||||
) : null}
|
||||
{message.role === 'user' && message.updatedAt ? (
|
||||
<time
|
||||
className="message-sent-at"
|
||||
dateTime={new Date(message.updatedAt).toISOString()}
|
||||
title={`发送于 ${new Date(message.updatedAt).toLocaleString('zh-CN', { hour12: false })}`}
|
||||
>
|
||||
{formatClockTime(message.updatedAt)}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
const animationsById = new Map(
|
||||
replyAnimations.map((reply) => [reply.messageId, reply]),
|
||||
);
|
||||
const displayedMessages = [...visibleMessages];
|
||||
const conversationIds = new Set(
|
||||
conversationMessages.map((message) => message.messageId),
|
||||
);
|
||||
for (const reply of replyAnimations) {
|
||||
if (
|
||||
!reply.persisted &&
|
||||
reply.target &&
|
||||
!conversationIds.has(reply.messageId)
|
||||
) {
|
||||
displayedMessages.push({
|
||||
role: 'assistant',
|
||||
messageId: reply.messageId,
|
||||
text: reply.target,
|
||||
});
|
||||
}
|
||||
}
|
||||
const renderMessage = (message: ChatMessage, index: number) => {
|
||||
const animation = message.messageId
|
||||
? animationsById.get(message.messageId)
|
||||
: undefined;
|
||||
const streaming = Boolean(
|
||||
animation &&
|
||||
(!animation.persisted || animation.visible !== animation.target),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={message.messageId ?? `${message.role}-${index}`}
|
||||
className={`message message--${message.role}`}
|
||||
aria-label={streaming ? '策划 Agent 实时回复' : undefined}
|
||||
aria-live={streaming ? 'polite' : undefined}
|
||||
data-message-id={message.messageId}
|
||||
>
|
||||
<AgentMessageContent tone="body">
|
||||
<ChatMarkdownMessage
|
||||
role={message.role}
|
||||
text={animation ? animation.visible : planningMessageText(message)}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
{showDesignReasoning && message.reasoningText ? (
|
||||
<AgentReasoning
|
||||
text={message.reasoningText}
|
||||
label="策划 Agent 思考过程"
|
||||
/>
|
||||
) : null}
|
||||
{message.role === 'user' && message.updatedAt ? (
|
||||
<time
|
||||
className="message-sent-at"
|
||||
dateTime={new Date(message.updatedAt).toISOString()}
|
||||
title={`发送于 ${new Date(message.updatedAt).toLocaleString('zh-CN', { hour12: false })}`}
|
||||
>
|
||||
{formatClockTime(message.updatedAt)}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="project-chat-surface" aria-label="立项策划对话">
|
||||
@@ -251,7 +287,7 @@ export function PlanningChatView({
|
||||
<ChatMarkdownMessage role="user" text={initialPlanningPrompt} />
|
||||
</div>
|
||||
) : null}
|
||||
{visibleMessages.map(renderMessage)}
|
||||
{displayedMessages.map(renderMessage)}
|
||||
{showDesignReasoning
|
||||
? designReasoningEntries
|
||||
.filter((entry) => !entry.messageId)
|
||||
@@ -269,20 +305,9 @@ export function PlanningChatView({
|
||||
label="策划 Agent 思考过程"
|
||||
/>
|
||||
) : null}
|
||||
{transientReply ? (
|
||||
<div
|
||||
className="message message--assistant"
|
||||
aria-label="策划 Agent 实时回复"
|
||||
aria-live="polite"
|
||||
data-runtime-owned="true"
|
||||
>
|
||||
<AgentMessageContent>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={transientReply}
|
||||
streaming
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
{designStatus ? (
|
||||
<div role="status" aria-label="策划 Agent 工具状态">
|
||||
{designStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { DesignView } from '../../../app/types';
|
||||
|
||||
export type DesignReplyAnimation = {
|
||||
messageId: string;
|
||||
target: string;
|
||||
visible: string;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
/** 正文动画只管理显示进度,不延迟正式会话状态或参与请求收尾。 */
|
||||
export function useDesignReplyAnimation() {
|
||||
const entriesRef = useRef<DesignReplyAnimation[]>([]);
|
||||
const historyIdsRef = useRef(new Set<string>());
|
||||
const [replies, setReplies] = useState<DesignReplyAnimation[]>([]);
|
||||
const publish = useCallback((next: DesignReplyAnimation[]) => {
|
||||
entriesRef.current = next;
|
||||
setReplies(next);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(
|
||||
(historyIds: string[] = []) => {
|
||||
historyIdsRef.current = new Set(historyIds);
|
||||
publish([]);
|
||||
},
|
||||
[publish],
|
||||
);
|
||||
|
||||
const receiveText = useCallback(
|
||||
(messageId: string, target: string) => {
|
||||
if (historyIdsRef.current.has(messageId)) return;
|
||||
const previous = entriesRef.current.find(
|
||||
(entry) => entry.messageId === messageId,
|
||||
);
|
||||
// 空文本是同一 Provider 请求的 attempt 重置,不能清掉正式回复。
|
||||
if (previous?.persisted) return;
|
||||
const next = {
|
||||
messageId,
|
||||
target,
|
||||
visible: target.startsWith(previous?.visible ?? '')
|
||||
? (previous?.visible ?? '')
|
||||
: '',
|
||||
persisted: false,
|
||||
};
|
||||
publish(
|
||||
previous
|
||||
? entriesRef.current.map((entry) =>
|
||||
entry.messageId === messageId ? next : entry,
|
||||
)
|
||||
: [...entriesRef.current, next],
|
||||
);
|
||||
},
|
||||
[publish],
|
||||
);
|
||||
|
||||
const receiveView = useCallback(
|
||||
(view: DesignView) => {
|
||||
let next = [...entriesRef.current];
|
||||
for (const message of view.messages) {
|
||||
if (
|
||||
message.role !== 'assistant' ||
|
||||
historyIdsRef.current.has(message.id)
|
||||
)
|
||||
continue;
|
||||
const index = next.findIndex((entry) => entry.messageId === message.id);
|
||||
const previous = next[index];
|
||||
const entry = {
|
||||
messageId: message.id,
|
||||
target: message.text,
|
||||
visible: message.text.startsWith(previous?.visible ?? '')
|
||||
? (previous?.visible ?? '')
|
||||
: '',
|
||||
persisted: true,
|
||||
};
|
||||
if (index < 0) next.push(entry);
|
||||
else next[index] = entry;
|
||||
}
|
||||
if (!view.running) {
|
||||
const persistedIds = new Set(
|
||||
view.messages.map((message) => message.id),
|
||||
);
|
||||
next = next.filter((entry) => persistedIds.has(entry.messageId));
|
||||
}
|
||||
publish(next);
|
||||
},
|
||||
[publish],
|
||||
);
|
||||
|
||||
const discardUnpersisted = useCallback(() => {
|
||||
publish(entriesRef.current.filter((entry) => entry.persisted));
|
||||
}, [publish]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
let changed = false;
|
||||
const next = entriesRef.current.map((entry) => {
|
||||
const remaining = entry.target.length - entry.visible.length;
|
||||
if (remaining <= 0) return entry;
|
||||
changed = true;
|
||||
const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1;
|
||||
return {
|
||||
...entry,
|
||||
visible: entry.target.slice(0, entry.visible.length + step),
|
||||
};
|
||||
});
|
||||
if (changed) publish(next);
|
||||
}, 50);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [publish]);
|
||||
|
||||
return { replies, reset, receiveText, receiveView, discardUnpersisted };
|
||||
}
|
||||
@@ -405,11 +405,159 @@ export function registerDesignAgentSurfaceTests() {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps one Design Agent reply when repeated completion arrives after typing catches up', async () => {
|
||||
const harness = createProjectChatRuntimeHarness({
|
||||
designAgentView: designConversationView(),
|
||||
});
|
||||
const originalInvoke = harness.invoke.getMockImplementation()!;
|
||||
let finish!: (view: unknown) => void;
|
||||
harness.invoke.mockImplementation((command, args) =>
|
||||
command === 'continue_design_agent_session'
|
||||
? new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
: originalInvoke(command, args),
|
||||
);
|
||||
renderDesignAgent(harness);
|
||||
const input = await screen.findByLabelText('项目需求');
|
||||
await expectDesignModelReady();
|
||||
await setComposerText(input, '只回复 pong');
|
||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||||
await waitFor(() => expect(finish).toBeDefined());
|
||||
const call = harness.invoke.mock.calls.find(
|
||||
([command]) => command === 'continue_design_agent_session',
|
||||
)!;
|
||||
const clientTurnId = String(
|
||||
(call[1] as { clientTurnId: string }).clientTurnId,
|
||||
);
|
||||
const messageId = `${clientTurnId}:response:0`;
|
||||
const view = {
|
||||
...designConversationView(),
|
||||
messages: [{ id: messageId, role: 'assistant', text: 'pong' }],
|
||||
};
|
||||
const emit = (payload: Record<string, unknown>) =>
|
||||
harness.emitDesignAgentEvent({
|
||||
projectPath: harness.projectPath,
|
||||
clientTurnId,
|
||||
...payload,
|
||||
});
|
||||
act(() => emit({ kind: 'text', messageId, text: 'pong' }));
|
||||
await screen.findByText('pong');
|
||||
act(() => emit({ kind: 'state', view }));
|
||||
act(() => emit({ kind: 'state', view }));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
expect(screen.getAllByText('pong')).toHaveLength(1);
|
||||
expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull();
|
||||
await act(async () => finish(view));
|
||||
expect(screen.getAllByText('pong')).toHaveLength(1);
|
||||
expect(
|
||||
harness.invoke.mock.calls.filter(
|
||||
([command]) => command === 'continue_design_agent_session',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
for (const firstDelivery of ['event', 'command'] as const) {
|
||||
it(`animates a whole Design Agent reply once when ${firstDelivery} completes first`, async () => {
|
||||
const history = { id: 'history', role: 'assistant', text: '历史回复' };
|
||||
const harness = createProjectChatRuntimeHarness({
|
||||
designAgentView: { ...designConversationView(), messages: [history] },
|
||||
});
|
||||
const originalInvoke = harness.invoke.getMockImplementation()!;
|
||||
const requests: {
|
||||
clientTurnId: string;
|
||||
finish: (view: unknown) => void;
|
||||
}[] = [];
|
||||
harness.invoke.mockImplementation((command, args) =>
|
||||
command === 'continue_design_agent_session'
|
||||
? new Promise((resolve) => {
|
||||
requests.push({
|
||||
clientTurnId: String(args?.clientTurnId),
|
||||
finish: resolve,
|
||||
});
|
||||
})
|
||||
: originalInvoke(command, args),
|
||||
);
|
||||
renderDesignAgent(harness);
|
||||
await screen.findByText(history.text);
|
||||
await expectDesignModelReady();
|
||||
const input = screen.getByLabelText('项目需求');
|
||||
await setComposerText(input, '继续');
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
const request = requests[0];
|
||||
const messageId = `${request.clientTurnId}:response:0`;
|
||||
const reply = '整块返回也逐步显示';
|
||||
const terminal = {
|
||||
...designConversationView(),
|
||||
messages: [history, { id: messageId, role: 'assistant', text: reply }],
|
||||
};
|
||||
const emit = (payload: Record<string, unknown>) =>
|
||||
harness.emitDesignAgentEvent({
|
||||
projectPath: harness.projectPath,
|
||||
clientTurnId: request.clientTurnId,
|
||||
...payload,
|
||||
});
|
||||
if (firstDelivery === 'event')
|
||||
act(() => emit({ kind: 'state', view: terminal }));
|
||||
else await act(async () => request.finish(terminal));
|
||||
const bubble = screen.getByLabelText('策划 Agent 实时回复');
|
||||
expect(bubble.textContent).not.toBe(reply);
|
||||
expect(screen.getAllByText(history.text)).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
const prefix = bubble.textContent;
|
||||
expect(prefix?.length).toBeGreaterThan(0);
|
||||
act(() => {
|
||||
emit({ kind: 'state', view: terminal });
|
||||
emit({ kind: 'state', view: terminal });
|
||||
emit({ kind: 'text', messageId, text: '' });
|
||||
});
|
||||
expect(bubble.textContent).toBe(prefix);
|
||||
await screen.findByText(reply);
|
||||
expect(screen.getByText(reply).closest('.message')).toBe(bubble);
|
||||
expect(
|
||||
document.querySelectorAll(`[data-message-id="${messageId}"]`),
|
||||
).toHaveLength(1);
|
||||
expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull();
|
||||
|
||||
// 用户可在旧 invoke 尚未返回时开始下一轮;旧 finally 不能解锁新回合。
|
||||
await setComposerText(input, '下一轮');
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
act(() => {
|
||||
emit({ kind: 'text', messageId: 'stale', text: '迟到旧回复' });
|
||||
emit({ kind: 'state', view: terminal });
|
||||
});
|
||||
await act(async () => request.finish(terminal));
|
||||
expect(
|
||||
screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
expect(screen.queryByText('迟到旧回复')).toBeNull();
|
||||
await act(async () => requests[1].finish(terminal));
|
||||
});
|
||||
}
|
||||
|
||||
it('follows streamed Design Agent content until the user scrolls up', async () => {
|
||||
const harness = createProjectChatRuntimeHarness({
|
||||
designAgentView: designConversationView(),
|
||||
designAgentContinueView: designConversationView(),
|
||||
});
|
||||
const originalInvoke = harness.invoke.getMockImplementation()!;
|
||||
let finish!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
harness.invoke.mockImplementation(async (command, args) => {
|
||||
if (command === 'continue_design_agent_session') await gate;
|
||||
return originalInvoke(command, args);
|
||||
});
|
||||
renderDesignAgent(harness);
|
||||
|
||||
const input = await screen.findByLabelText('项目需求');
|
||||
@@ -459,11 +607,26 @@ export function registerDesignAgentSurfaceTests() {
|
||||
projectPath: harness.projectPath,
|
||||
clientTurnId,
|
||||
kind: 'text',
|
||||
messageId: `${clientTurnId}:response:0`,
|
||||
text: '正在补充关卡节奏',
|
||||
}),
|
||||
);
|
||||
await screen.findByLabelText('策划 Agent 实时回复');
|
||||
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
|
||||
const replyBeforeTool = screen.getByLabelText('策划 Agent 实时回复');
|
||||
const prefixBeforeTool = replyBeforeTool.textContent;
|
||||
act(() =>
|
||||
harness.emitDesignAgentEvent({
|
||||
projectPath: harness.projectPath,
|
||||
clientTurnId,
|
||||
kind: 'tool',
|
||||
text: '正在读取方案文件',
|
||||
}),
|
||||
);
|
||||
expect(screen.getByLabelText('策划 Agent 工具状态').textContent).toBe(
|
||||
'正在读取方案文件',
|
||||
);
|
||||
expect(replyBeforeTool.textContent).toBe(prefixBeforeTool);
|
||||
|
||||
messageList.scrollTop = 120;
|
||||
fireEvent.scroll(messageList);
|
||||
@@ -478,6 +641,7 @@ export function registerDesignAgentSurfaceTests() {
|
||||
projectPath: harness.projectPath,
|
||||
clientTurnId,
|
||||
kind: 'text',
|
||||
messageId: `${clientTurnId}:response:0`,
|
||||
text: '正在补充关卡节奏与多人规则',
|
||||
});
|
||||
});
|
||||
@@ -488,6 +652,7 @@ export function registerDesignAgentSurfaceTests() {
|
||||
).toContain('多人规则'),
|
||||
);
|
||||
expect(messageList.scrollTop).toBe(120);
|
||||
await act(async () => finish());
|
||||
});
|
||||
|
||||
it('shows only known optimistic send times and does not invent persisted times', async () => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DesignView } from '../src/app/types';
|
||||
import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation';
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function view(messages: DesignView['messages'], running = false): DesignView {
|
||||
return {
|
||||
session: {
|
||||
sessionId: 's',
|
||||
projectId: 'p',
|
||||
currentPhase: 'concept',
|
||||
approvedPhases: [],
|
||||
pendingApproval: null,
|
||||
pendingClarification: null,
|
||||
turnIndex: 1,
|
||||
lastError: null,
|
||||
},
|
||||
messages,
|
||||
running,
|
||||
canRetry: false,
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps each reply progress across tool snapshots and repeated completion', () => {
|
||||
const { result } = renderHook(useDesignReplyAnimation);
|
||||
const history = { id: 'history', role: 'assistant', text: '历史' };
|
||||
const first = { id: 't:response:0', role: 'assistant', text: '第一条回复' };
|
||||
const second = { id: 't:response:1', role: 'assistant', text: '第二条回复' };
|
||||
act(() => {
|
||||
result.current.reset([history.id]);
|
||||
result.current.receiveText(first.id, first.text);
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.replies[0].visible).toBe('第一');
|
||||
act(() =>
|
||||
result.current.receiveView(
|
||||
view(
|
||||
[history, first, { id: 'tool', role: 'tool', text: '工具已完成' }],
|
||||
true,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(result.current.replies).toHaveLength(1);
|
||||
expect(result.current.replies[0].visible).toBe('第一');
|
||||
act(() => result.current.receiveText(second.id, second.text));
|
||||
const terminal = view([history, first, second]);
|
||||
act(() => {
|
||||
result.current.receiveView(terminal);
|
||||
result.current.receiveView(terminal);
|
||||
});
|
||||
expect(result.current.replies.map((reply) => reply.visible)).toEqual([
|
||||
'第一',
|
||||
'',
|
||||
]);
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
act(() => result.current.receiveView(terminal));
|
||||
expect(result.current.replies.map((reply) => reply.visible)).toEqual([
|
||||
first.text,
|
||||
second.text,
|
||||
]);
|
||||
});
|
||||
|
||||
it('resets only an unpersisted retry attempt and discards failed partial output', () => {
|
||||
const { result } = renderHook(useDesignReplyAnimation);
|
||||
act(() => {
|
||||
result.current.receiveText('t:response:0', '尝试失败');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.replies[0].visible).toBe('尝试');
|
||||
act(() => result.current.receiveText('t:response:0', ''));
|
||||
expect(result.current.replies[0].visible).toBe('');
|
||||
const saved = { id: 't:response:0', role: 'assistant', text: '成功' };
|
||||
act(() => {
|
||||
result.current.receiveText(saved.id, saved.text);
|
||||
result.current.receiveView(view([saved], true));
|
||||
vi.advanceTimersByTime(100);
|
||||
result.current.receiveText(saved.id, '');
|
||||
result.current.receiveText('t:response:1', '未被接受的文本');
|
||||
result.current.receiveView(view([saved]));
|
||||
});
|
||||
expect(result.current.replies).toEqual([
|
||||
{ messageId: saved.id, target: '成功', visible: '成功', persisted: true },
|
||||
]);
|
||||
act(() => {
|
||||
result.current.receiveText('t:response:2', '连接中断');
|
||||
result.current.discardUnpersisted();
|
||||
});
|
||||
expect(result.current.replies).toHaveLength(1);
|
||||
act(() => result.current.reset([saved.id]));
|
||||
expect(result.current.replies).toEqual([]);
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 策划回复的重复终态不能重新启动伪流式
|
||||
|
||||
策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||
|
||||
## Direct 宿主继续请求不能重发原始用户条目
|
||||
|
||||
原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。
|
||||
|
||||
@@ -255,6 +255,11 @@ Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留
|
||||
- 工作区状态条复用现有聊天状态条的字号、间距和状态点。长项目名允许换行,不能撑宽面板;长错误、澄清和批量文件导入结果在受限区域内完整可读,不能挤走输入框。输入编辑器保留原有高度上限,模型/推理菜单继续允许弹出面板。
|
||||
- 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。
|
||||
- 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。
|
||||
- 策划正文保留伪流式,实时文本与正式消息按后端 `messageId` 共用一个显示位置;事件归属由项目与 `clientTurnId` 约束。同一消息的重复状态事件及命令返回只更新正文目标,不重置播放进度,不能从最后一条历史消息猜测本轮回复。没有流事件的整块新回复也逐步显示;已加载历史不重播。
|
||||
- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;上轮迟到事件和命令返回不得污染新回合。
|
||||
- 终态以正式视图为准,丢弃没有进入正式消息的失败尝试文本,错误继续由既有错误区显示。正文呈现回归使用模拟原生事件与真实 React 组件,覆盖动画已追平/未追平时的重复终态、命令先返回、整块输出、工具间多条正文、重试清空、历史恢复和迟到回合。此项不改 Provider 重试、后端协议或持久数据,无迁移要求。
|
||||
|
||||
正文呈现验收由 `appSurface/design-agent.suite.ts` 与 `designReplyAnimation.test.tsx` 覆盖:整块返回仍播放、同 ID 气泡原位接管、重复终态与命令返回幂等、多正文与重试空串、工具提示不覆盖正文、上轮请求不能结束新回合。重复终态复现用例在修复前实现上失败;`appSurface.test.ts`、动画 hook 和会话恢复测试合计 217 项通过、9 项原有跳过。App typecheck(含配置检查)、定向 ESLint、编码和文档索引检查通过。本次使用模拟原生事件及 React/jsdom,不含真实 Provider 或安装包 GUI 演练。
|
||||
- ≤760px 时资源区与对话区单列排列,策划工作台在固定外壳内纵向滚动,用户向下滚动可到达输入区。布局使用内容高度,并以同等或更高选择器优先级覆盖外壳的 `height: 100%`;资源区明确为 560px,对话区高度为 `clamp(560px, calc(100dvh - 154px), 900px)`。文件树与消息列表各自内部滚动,长内容不增加两块面板高度。>760px 继续共用外壳剩余高度,不启用工作台整体滚动。正式主窗最小宽度不变,窄屏验收覆盖浏览器响应式布局。
|
||||
- 审批、澄清、导入期间禁用、错误重试、项目归属和发送权限沿用现有行为。此次调整不改变 Runtime、API、持久协议或数据库,无数据迁移;不重做开发 Agent 的对话布局。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user