diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index f0310c4fa..7fd66c9fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -29,6 +29,9 @@ const CLAUDE_CODE_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); /// 不留痕的话用户与离线诊断只能看到一个卡住的回合(真实案例:ECONNRESET 重试, /// 每轮 ~60 秒,共 10 轮)。 const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30); +/// cc 回合的绝对上限。`requestTimeoutMs` 只算静默预算,事件流假活(一直有心跳但永远 +/// 不出终态)靠这个兜底;正常情况下先到的是 DirectProject 的 `maxTurnSeconds`。 +const CLAUDE_CODE_TURN_MAX_DURATION: Duration = Duration::from_secs(45 * 60); struct ActiveClaudeCodeTurn { client_turn_id: String, @@ -163,6 +166,18 @@ struct SidecarTurnResult { result: serde_json::Value, } +/// sidecar 回合的失败分类:静默超时必须先杀进程树再收口,普通失败直接透传。 +enum SidecarTurnFailure { + Silent { silent_ms: u64, events: usize }, + Message(String), +} + +impl SidecarTurnFailure { + fn message(message: String) -> Self { + Self::Message(message) + } +} + async fn run_sidecar_turn( root: &Path, llm: Option<&GameCreatorLlmConfig>, @@ -224,35 +239,58 @@ async fn run_sidecar_turn( .take() .ok_or_else(|| "Claude Agent SDK sidecar stderr 未建立".to_string())?; let stderr_task = tokio::spawn(read_capped(stderr, CLAUDE_CODE_OUTPUT_MAX_BYTES)); - let result = match tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async { + // cc 回合经常是一串模型调用 + MCP 工具调用,网关慢的时候单次响应就要几十秒; + // `requestTimeoutMs` 只当**静默预算**用:只要 sidecar 还在出事件就不算超时, + // 连续静默超过预算才按超时收口。整体上限由 `maxTurnSeconds`(DirectProject 硬上限) + // 兜底,这里再加一个更宽松的绝对上限防止事件流假活。 + let silence_budget_ms = timeout_ms.max(1); + let mut timeout_reason: Option = None; + let turn = async { let mut lines = BufReader::new(stdout).lines(); let mut events = Vec::new(); let mut terminal = None; let mut idle_rounds = 0_u64; + let mut silent_ms = 0_u64; loop { let line = match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line()) .await { - Ok(Ok(Some(line))) => line, + Ok(Ok(Some(line))) => { + silent_ms = 0; + line + } Ok(Ok(None)) => break, Ok(Err(error)) => { - return Err(format!("读取 Claude Agent SDK sidecar 输出失败:{error}")); + return Err(SidecarTurnFailure::message(format!( + "读取 Claude Agent SDK sidecar 输出失败:{error}" + ))); } Err(_) => { idle_rounds += 1; + silent_ms += CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_millis() as u64; app_log!( - "agent.direct_codex.claude_sidecar_idle idleSeconds={}", - CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds + "agent.direct_codex.claude_sidecar_idle idleSeconds={} events={}", + CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds, + events.len() ); + if silent_ms >= silence_budget_ms { + return Err(SidecarTurnFailure::Silent { + silent_ms, + events: events.len(), + }); + } continue; } }; if line.trim().is_empty() { continue; } - let value: serde_json::Value = serde_json::from_str(&line) - .map_err(|_| "Claude Agent SDK sidecar 输出不是有效 JSON".to_string())?; + let value: serde_json::Value = serde_json::from_str(&line).map_err(|_| { + SidecarTurnFailure::message( + "Claude Agent SDK sidecar 输出不是有效 JSON".to_string(), + ) + })?; match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { if let Some(event) = value.get("event") { @@ -265,29 +303,53 @@ async fn run_sidecar_turn( } Some("error") => { kill_claude_code_process_tree(pid); - return Err(value - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Claude Agent SDK sidecar 执行失败") - .to_string()); + return Err(SidecarTurnFailure::message( + value + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("Claude Agent SDK sidecar 执行失败") + .to_string(), + )); } _ => {} } } - let status = child - .wait() - .await - .map_err(|error| format!("等待 Claude Agent SDK sidecar 结束失败:{error}"))?; - let result = terminal.ok_or_else(|| "Claude Agent SDK sidecar 缺少终态".to_string())?; + let status = child.wait().await.map_err(|error| { + SidecarTurnFailure::message(format!("等待 Claude Agent SDK sidecar 结束失败:{error}")) + })?; + let result = terminal.ok_or_else(|| { + SidecarTurnFailure::message("Claude Agent SDK sidecar 缺少终态".to_string()) + })?; if !status.success() { - return Err("Claude Agent SDK sidecar 非零退出".to_string()); + return Err(SidecarTurnFailure::message( + "Claude Agent SDK sidecar 非零退出".to_string(), + )); } Ok(SidecarTurnResult { events, result }) - }) - .await - { - Ok(result) => result?, + }; + let outcome = match tokio::time::timeout(CLAUDE_CODE_TURN_MAX_DURATION, turn).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(SidecarTurnFailure::Message(message))) => Err(message), + Ok(Err(SidecarTurnFailure::Silent { silent_ms, events })) => { + timeout_reason = Some(format!( + "Claude Agent SDK sidecar 回合超时:连续 {silent_ms} ms 没有任何事件(已收到 {events} 个事件)" + )); + Err(String::new()) + } Err(_) => { + timeout_reason = Some(format!( + "Claude Agent SDK sidecar 回合超时:超过 {} 分钟硬上限", + CLAUDE_CODE_TURN_MAX_DURATION.as_secs() / 60 + )); + Err(String::new()) + } + }; + let result = match outcome { + Ok(result) => result, + Err(message) => { + let Some(reason) = timeout_reason else { + return Err(message); + }; // 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着, // stderr 等不到 EOF,这一轮就永远出不了终态。 kill_claude_code_process_tree(pid); @@ -303,7 +365,7 @@ async fn run_sidecar_turn( let detail = stderr_detail .map(|value| format!(":{value}")) .unwrap_or_default(); - return Err(format!("Claude Agent SDK sidecar 回合超时{detail}")); + return Err(format!("{reason}{detail}")); } }; let _ = stderr_task.await; @@ -370,10 +432,10 @@ pub(crate) fn game_creator_claude_code_cli_route_error( if model.is_empty() || model.chars().count() > CLAUDE_CODE_MODEL_MAX_CHARS { return Some("cc 模式需要有效的 Claude Code 模型标识".to_string()); } - if llm.api_key.trim().is_empty() - && !llm.custom_enabled - && std::env::var_os("ANTHROPIC_API_KEY").is_none() - && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() + // 官方模型只经平台网关,凭据就是平台会话;没有会话就没有凭据。这里不再退回本机 + // `ANTHROPIC_*` 环境——否则用户终端里的个人中转会静默顶掉平台路由(2026-10-02 事故)。 + if !llm.custom_enabled + && llm.api_key.trim().is_empty() && crate::platform_session::current_platform_session().is_none() { return Some("cc 模式需要先登录陶泥儿账号".to_string()); @@ -387,31 +449,109 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) { } } -fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option { - std::env::var("ANTHROPIC_BASE_URL") - .ok() - .or_else(|| { - let llm = llm?; - // Official models never talk to the Router directly: the account - // Router credential lives server-side, so the Claude sidecar goes - // through the platform gateway and appends `/v1/messages` itself. - if !llm.custom_enabled { - if let Some(session) = crate::platform_session::current_platform_session() { - return Some(format!( +/// baseUrl 不带路由与版本段,协议路径由客户端协议自己拼(Claude Agent SDK 固定 +/// 请求 `{ANTHROPIC_BASE_URL}/v1/messages`),历史凭据末尾的 `/v1` 在这里归一化掉。 +fn normalize_anthropic_base_url(value: &str) -> Option { + let normalized = value + .trim() + .trim_end_matches('/') + .trim_end_matches("/v1") + .trim_end_matches('/'); + (!normalized.is_empty()).then(|| normalized.to_string()) +} + +/// cc 的 Anthropic 路由来源,只用于诊断日志,不参与协议判断。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ClaudeCodeRouteSource { + Platform, + Custom, + Config, +} + +/// Anthropic 凭据有两种下发方式:`x-api-key`(`ANTHROPIC_API_KEY`)与 +/// `Authorization: Bearer`(`ANTHROPIC_AUTH_TOKEN`)。Router 的 `/v1/messages` +/// 只认前者,用 Bearer 会挂住不返回,所以这里必须区分,不能只留一个字符串。 +#[derive(Clone, Debug, Eq, PartialEq)] +enum ClaudeCodeCredential { + ApiKey(String), + Bearer(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ClaudeCodeRoute { + base_url: Option, + credential: Option, + source: ClaudeCodeRouteSource, +} + +/// cc 的路由与凭据只由 AGC 决定,本机 `ANTHROPIC_*` 一律不参与。 +/// +/// 2026-10-02 实测事故:用户终端里给 Claude Code 配了个人中转(`ANTHROPIC_BASE_URL` +/// + `ANTHROPIC_AUTH_TOKEN`),AGC 进程继承后,官方模型的回合全都发到那个个人中转上 +/// ——平台网关、模型目录解析、账号 Router key 全部绕过,表现是「能回但极慢」和 +/// 「卡满 requestTimeoutMs 超时」,后台怎么改都无效。个人中转要走 AGC 自己的 +/// `customEnabled` + baseUrl + apiKey,不再从进程环境隐式继承。 +fn claude_code_route( + llm: Option<&GameCreatorLlmConfig>, + session: Option<&crate::platform_session::PlatformSessionSnapshot>, +) -> ClaudeCodeRoute { + if let Some(llm) = llm { + // 官方模型一律走平台网关:客户端只出示平台 access token,账号 Router key + // 与模型名解析都留在 api-server,客户端侧不落 Router 凭据。 + if !llm.custom_enabled { + if let Some(session) = session { + return ClaudeCodeRoute { + base_url: Some(format!( "{}/api/llm/anthropic", session.api_base_url.trim_end_matches('/') - )); - } + )), + credential: Some(ClaudeCodeCredential::Bearer(session.access_token.clone())), + source: ClaudeCodeRouteSource::Platform, + }; } - Some(llm.base_url.trim_end_matches('/').to_string()) - }) - .map(|value| { - value - .trim_end_matches('/') - .trim_end_matches("/v1") - .to_string() - }) - .filter(|value| !value.is_empty()) + } + // 自定义目录(含用户自己填的 Router 地址)以 AGC 配置为准,同样不吃本机环境。 + if llm.custom_enabled || !llm.api_key.trim().is_empty() { + return ClaudeCodeRoute { + base_url: normalize_anthropic_base_url(&llm.base_url), + credential: (!llm.api_key.trim().is_empty()).then(|| { + if llm.custom_enabled { + ClaudeCodeCredential::ApiKey(llm.api_key.trim().to_string()) + } else { + ClaudeCodeCredential::Bearer(llm.api_key.trim().to_string()) + } + }), + source: if llm.custom_enabled { + ClaudeCodeRouteSource::Custom + } else { + ClaudeCodeRouteSource::Config + }, + }; + } + return ClaudeCodeRoute { + base_url: normalize_anthropic_base_url(&llm.base_url), + credential: None, + source: ClaudeCodeRouteSource::Config, + }; + } + ClaudeCodeRoute { + base_url: None, + credential: None, + source: ClaudeCodeRouteSource::Config, + } +} + +/// 诊断只写主机名,不写完整地址与凭据。 +fn claude_route_host(base_url: &str) -> String { + let without_scheme = base_url + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(base_url); + without_scheme + .split(['/', '?']) + .next() + .unwrap_or_default() + .to_string() } fn configure_claude_code_environment( @@ -438,48 +578,54 @@ fn configure_claude_code_environment( "WINDIR", "ComSpec", "PATHEXT", - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", ] { copy_env(command, name); } + let isolated_claude_home = isolated_home.join("claude"); + let _ = std::fs::create_dir_all(&isolated_claude_home); command .env("HOME", isolated_home) + // Windows 上 Node 的 `os.homedir()` 只看 `USERPROFILE`;不隔离它,Claude Code 会去读 + // 用户自己的 `~/.claude.json` / `~/.claude`(个人 MCP、插件市场、凭据),并把会话写回 + // 那里——AGC 的 cc 执行器必须与用户本机的 Claude Code 配置完全隔离。 + .env("USERPROFILE", isolated_home) + .env("CLAUDE_CONFIG_DIR", isolated_claude_home) .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") .env("DISABLE_AUTOUPDATER", "1") .env("CI", "1") .env("NO_COLOR", "1") .env("TERM", "dumb"); - if let Some(base_url) = claude_base_url(llm) { + let session = crate::platform_session::current_platform_session(); + let route = claude_code_route(llm, session.as_ref()); + app_log!( + "agent.direct_codex.claude_route source={:?} host={} auth={}", + route.source, + route + .base_url + .as_deref() + .map(claude_route_host) + .unwrap_or_default(), + match route.credential { + Some(ClaudeCodeCredential::ApiKey(_)) => "key", + Some(ClaudeCodeCredential::Bearer(_)) => "session", + None => "none", + } + ); + if let Some(base_url) = route.base_url { command.env("ANTHROPIC_BASE_URL", base_url); } + // 凭据只从当前路由下发,不继承本机环境:Claude SDK 只从子进程环境读 Anthropic + // 认证,桥接平台会话令牌时也不能落盘。 + match route.credential { + Some(ClaudeCodeCredential::ApiKey(api_key)) => { + command.env("ANTHROPIC_API_KEY", api_key); + } + Some(ClaudeCodeCredential::Bearer(token)) => { + command.env("ANTHROPIC_AUTH_TOKEN", token); + } + None => {} + } if let Some(llm) = llm { - if !llm.api_key.trim().is_empty() - && std::env::var_os("ANTHROPIC_API_KEY").is_none() - && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() - { - if llm.custom_enabled { - // 自定义 Claude 兼容端点用 Anthropic 规范的 `x-api-key`:Claude Code 只在 - // `ANTHROPIC_API_KEY` 下发这个头(`ANTHROPIC_AUTH_TOKEN` 会发 Bearer,实测 - // Router 的 `/v1/messages` 对 Bearer 会挂住不返回)。 - command.env("ANTHROPIC_API_KEY", llm.api_key.trim()); - } else { - command.env("ANTHROPIC_AUTH_TOKEN", llm.api_key.trim()); - } - } - // Official AGC model entries use the account session rather than a - // user-supplied API key. The Claude SDK only reads Anthropic auth - // from its child-process environment, so bridge the already-held - // platform token without persisting or exposing it in argv/logs. - if llm.api_key.trim().is_empty() - && !llm.custom_enabled - && std::env::var_os("ANTHROPIC_API_KEY").is_none() - && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() - { - if let Some(session) = crate::platform_session::current_platform_session() { - command.env("ANTHROPIC_AUTH_TOKEN", session.access_token); - } - } if !llm.model.trim().is_empty() { command.env("ANTHROPIC_MODEL", llm.model.trim()); } @@ -977,4 +1123,128 @@ mod tests { .expect_err("failed terminal must be rejected"); assert!(error.contains("Authentication failed")); } + + fn test_platform_session() -> crate::platform_session::PlatformSessionSnapshot { + crate::platform_session::PlatformSessionSnapshot { + user_id: "user-1".to_string(), + access_token: "platform-token".to_string(), + api_base_url: "https://api.example".to_string(), + identity_generation: 1, + revision: 1, + } + } + + #[test] + fn anthropic_base_url_normalization_drops_version_segment() { + assert_eq!( + normalize_anthropic_base_url("https://router.example/v1/").as_deref(), + Some("https://router.example") + ); + assert_eq!( + normalize_anthropic_base_url(" https://router.example/ ").as_deref(), + Some("https://router.example") + ); + assert_eq!(normalize_anthropic_base_url(" "), None); + } + + #[test] + fn official_model_routes_through_platform_gateway_even_with_personal_anthropic_env() { + let llm = GameCreatorLlmConfig { + model: "claude-opus-5-5".to_string(), + ..Default::default() + }; + // 用户终端里配着个人中转(ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN)时,官方 + // 模型仍必须走平台网关:路由函数的入参里根本没有本机环境,这是结构上的保证。 + let route = claude_code_route(Some(&llm), Some(&test_platform_session())); + assert_eq!(route.source, ClaudeCodeRouteSource::Platform); + assert_eq!( + route.base_url.as_deref(), + Some("https://api.example/api/llm/anthropic") + ); + assert_eq!( + route.credential, + Some(ClaudeCodeCredential::Bearer("platform-token".to_string())) + ); + } + + #[test] + fn custom_endpoint_keeps_agc_config_and_sends_x_api_key() { + let llm = GameCreatorLlmConfig { + custom_enabled: true, + base_url: "https://router.example/v1".to_string(), + api_key: "router-key".to_string(), + model: "claude-opus-5-5".to_string(), + ..Default::default() + }; + let route = claude_code_route(Some(&llm), Some(&test_platform_session())); + assert_eq!(route.source, ClaudeCodeRouteSource::Custom); + assert_eq!(route.base_url.as_deref(), Some("https://router.example")); + assert_eq!( + route.credential, + Some(ClaudeCodeCredential::ApiKey("router-key".to_string())) + ); + } + + #[test] + fn official_model_without_platform_session_never_falls_back_to_personal_anthropic_env() { + let llm = GameCreatorLlmConfig { + model: "claude-opus-5-5".to_string(), + ..Default::default() + }; + let route = claude_code_route(Some(&llm), None); + assert_eq!(route.source, ClaudeCodeRouteSource::Config); + // 没登录就没有凭据,路由也不会去读 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN。 + assert_eq!(route.credential, None); + assert!(!route + .base_url + .as_deref() + .unwrap_or_default() + .contains("personal.example")); + assert_eq!( + game_creator_claude_code_cli_route_error(&llm).as_deref(), + Some("cc 模式需要先登录陶泥儿账号") + ); + } + + #[test] + fn route_host_diagnostic_never_carries_path_or_credentials() { + assert_eq!( + claude_route_host("https://api.example/api/llm/anthropic"), + "api.example" + ); + assert_eq!(claude_route_host("http://127.0.0.1:8084"), "127.0.0.1:8084"); + } + + #[test] + fn sidecar_environment_is_isolated_from_the_local_claude_code_home() { + let home = std::env::temp_dir().join("agc-cc-isolated-home-test"); + let mut command = tokio::process::Command::new("node"); + configure_claude_code_environment(&mut command, None, &home); + let envs = command + .as_std() + .get_envs() + .map(|(name, value)| { + ( + name.to_string_lossy().to_string(), + value.map(|value| value.to_string_lossy().to_string()), + ) + }) + .collect::>(); + let home_text = home.to_string_lossy().to_string(); + for name in ["HOME", "USERPROFILE"] { + assert_eq!(envs.get(name), Some(&Some(home_text.clone())), "{name}"); + } + assert_eq!( + envs.get("CLAUDE_CONFIG_DIR"), + Some(&Some(home.join("claude").to_string_lossy().to_string())) + ); + // 本机个人的 Claude Code 环境(中转地址与凭据)不得进入 sidecar。 + for name in [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + ] { + assert!(!envs.contains_key(name), "{name} 不应下发到 sidecar"); + } + } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index e10829c5f..86a7b0ef3 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -1,15 +1,12 @@ -import type { UIEventHandler } from 'react'; import type { Ref } from 'react'; import { useCallback, useEffect, useImperativeHandle, useMemo, - useRef, useState, } from 'react'; -import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; @@ -106,8 +103,6 @@ export function DirectProjectChatView({ }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); - const messagesRef = useRef(null); - const shouldFollowLatestRef = useRef(true); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); @@ -137,8 +132,11 @@ export function DirectProjectChatView({ directEntries, directTurnRunning, directTurnStartedAt, + historyError, historyHasMore, + historyLoading, loadEarlierHistory, + retryEarlierHistory, localMessages, pendingTurns, startInitialTurn, @@ -197,7 +195,6 @@ export function DirectProjectChatView({ initialTurn.content, directCodexConversationMessageId(clientTurnId, 'user'), ); - shouldFollowLatestRef.current = true; startInitialTurn({ clientTurnId, ...(initialTurn.creationType @@ -209,28 +206,12 @@ export function DirectProjectChatView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [turnStatus.displayBusy, initialTurn, projectId, projectPath]); - useEffect(() => { - if (!shouldFollowLatestRef.current) return; - const list = messagesRef.current; - if (list) list.scrollTop = list.scrollHeight; - }, [directEntries, localMessages]); - useImperativeHandle(ref, () => ({ announce: (text: string) => { appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() }); }, })); - const handleScroll: UIEventHandler = (event) => { - const list = event.currentTarget; - shouldFollowLatestRef.current = - list.scrollHeight - list.scrollTop - list.clientHeight <= - AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; - if (historyHasMore && list.scrollTop <= 24) { - void loadEarlierHistory(); - } - }; - return (
void loadEarlierHistory()} - onScroll={handleScroll} + onRetryEarlierHistory={() => void retryEarlierHistory()} /> {pendingConfirmation && onConfirmConfirmation && diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx index c3d7da397..7394c030a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx @@ -18,10 +18,22 @@ function AgentReasoningImpl({ text, label = '思考过程', testId, + turnKey, + blockKey, }: { text: string; label?: string; testId?: string; + /** + * 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点定位这一块 + * (见 `DirectProjectConversation/conversationScrollAnchor.ts`)。 + */ + turnKey?: string; + /** + * 这一块的稳定块身份(`DirectChatBlock.key`):写进 `data-block-key`。锚点按「回合 key + + * 块身份」定位,不按块序号——回合收口时过程块会被折进 `
`,序号会整体后移。 + */ + blockKey?: string; }) { const [expanded, setExpanded] = useState(false); /* @@ -37,6 +49,8 @@ function AgentReasoningImpl({ className="design-agent-reasoning" aria-label={label} data-testid={testId} + data-turn-key={turnKey} + data-block-key={blockKey} onToggle={(event) => setExpanded((event.currentTarget as HTMLDetailsElement).open) } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx new file mode 100644 index 000000000..3dc7c91b2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx @@ -0,0 +1,230 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectChatToolCard } from '../../conversation/directThreadChat'; +import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; +import { DirectProjectConversation } from './DirectProjectConversation'; + +const turn = (key: string): DirectChatTurn => ({ + key, + users: [{ kind: 'user', key: `${key}-u`, text: '做一个跳跃动作', at: 10 }], + process: [], + finals: [{ kind: 'assistant', key: `${key}-f`, text: '改好了', at: 20 }], + state: 'finished', + startedAt: 10, + endedAt: 20, +}); + +/** 一个工具调用的持久化卡片:块身份来自条目 itemId,这里只要能渲染出来。 */ +const toolCall = (id: string): DirectChatToolCard => ({ + schemaVersion: 'agc-tool-call.v1', + id, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: 10, + updatedAt: 20, +}); + +/** + * 带执行过程的回合:`renderTurnProcess` 对运行中的回合把过程块平铺、对已结束的回合把它们折进一个 + * `
`,所以「收口前后同一个块的块身份是否跟着块走」只能拿这种回合验。 + */ +const processTurn = ( + state: DirectChatTurn['state'], + overrides: Partial = {}, +): DirectChatTurn => ({ + key: 't1', + users: [{ kind: 'user', key: 't1:u', text: '做一个跳跃动作', at: 10 }], + process: [ + { kind: 'tools', key: 't1:g1', calls: [toolCall('c1')] }, + { kind: 'tools', key: 't1:g2', calls: [toolCall('c2')] }, + ], + finals: [{ kind: 'assistant', key: 't1:f', text: '改好了', at: 20 }], + state, + startedAt: 10, + endedAt: state === 'finished' ? 20 : 0, + ...overrides, +}); + +/** 列表里所有块的块身份,按文档顺序;拿不到 `data-block-key` 的块是 null。 */ +function blockKeysOf(container: HTMLElement): (string | null)[] { + return Array.from(container.querySelectorAll('[data-turn-key]')).map( + (block) => block.getAttribute('data-block-key'), + ); +} + +function conversationElement(overrides: { + conversationKey?: string; + turns?: DirectChatTurn[]; + historyHasMore?: boolean; + historyLoading?: boolean; + historyError?: string | null; + onLoadEarlierHistory?: () => void; + onRetryEarlierHistory?: () => void; +}) { + return ( + undefined)} + onRetryEarlierHistory={ + overrides.onRetryEarlierHistory ?? (() => undefined) + } + /> + ); +} + +function renderConversation(overrides: { + conversationKey?: string; + turns?: DirectChatTurn[]; + historyHasMore?: boolean; + historyLoading?: boolean; + historyError?: string | null; + onLoadEarlierHistory?: () => void; + onRetryEarlierHistory?: () => void; +}) { + return render(conversationElement(overrides)); +} + +afterEach(() => { + cleanup(); +}); + +describe('更早历史的入口', () => { + it('常驻的「显示更早的对话」按钮已经删掉,改由滚动自动加载', () => { + const view = renderConversation({ historyHasMore: true }); + expect(view.queryByText('显示更早的对话')).toBeNull(); + expect(view.container.querySelector('.message-history-more')).toBeNull(); + }); + + it('加载行延迟出现,文案是「正在加载更早的对话」', () => { + vi.useFakeTimers(); + try { + const view = renderConversation({ + historyHasMore: true, + historyLoading: true, + }); + expect(view.queryByText('正在加载更早的对话')).toBeNull(); + + act(() => { + vi.advanceTimersByTime(150); + }); + expect(view.getByText('正在加载更早的对话')).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('失败挂起后列表顶部留一行内联错误,重试是唯一的手动出路', () => { + const retry = vi.fn(); + const view = renderConversation({ + historyHasMore: true, + historyError: '读取更早的对话历史失败', + onRetryEarlierHistory: retry, + }); + + expect(view.getByText('加载更早对话失败')).not.toBeNull(); + fireEvent.click(view.getByText('重试')); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('断言式 live region 里不放可交互控件:重试按钮是 alert 之外的兄弟', () => { + const retry = vi.fn(); + const view = renderConversation({ + historyHasMore: true, + historyError: '读取更早的对话历史失败', + onRetryEarlierHistory: retry, + }); + + // `role="alert"` 隐含 aria-live="assertive" + aria-atomic="true":整段会被当成一条断言性 + // 播报,交互控件嵌在里面既可能不被读屏当成可聚焦按钮,点击也落在 live region 内部。 + const alert = view.getByRole('alert'); + expect(alert.textContent).toContain('加载更早对话失败'); + expect(alert.contains(view.getByText('重试'))).toBe(false); + + // 拆开之后仍在同一行里,重试仍是唯一的手动出路。 + fireEvent.click(view.getByText('重试')); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); + +describe('前插锚点的标记', () => { + it('每个块都带上所属回合的 data-turn-key', () => { + const view = renderConversation({ turns: [turn('t1')] }); + expect( + view.container.querySelectorAll('[data-turn-key="t1"]').length, + ).toBeGreaterThan(0); + }); + + it('每个可锚定的块都带上 data-block-key,且同一回合内唯一', () => { + const view = renderConversation({ turns: [processTurn('finished')] }); + const keys = blockKeysOf(view.container); + + // 正文块用 DirectChatBlock.key,过程包装块与终态文案各有自己的字面量块身份。 + expect(keys).toContain('t1:u'); + expect(keys).toContain('t1:g1'); + expect(keys).toContain('t1:g2'); + expect(keys).toContain('t1:f'); + expect(keys).toContain('process'); + expect(keys).toContain('usage'); + // 缺块身份的块会被锚点静默跳过:宁可这里红,也不要在滚动时才发现。 + expect(keys.every((key) => key !== null && key.length > 0)).toBe(true); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('回合收口后块身份跟着块走:块序号变了,块身份不变', () => { + const view = renderConversation({ turns: [processTurn('running')] }); + // 运行中:过程块平铺,没有「执行过程」包装块。 + expect(blockKeysOf(view.container)).toEqual([ + 't1:u', + 't1:g1', + 't1:g2', + 't1:f', + ]); + + view.rerender(conversationElement({ turns: [processTurn('finished')] })); + + // 收口后过程被折进一个 `
`,同一个工具组的序号从 1 变成 3——旧锚点存的是序号, + // 会解析到隔壁块;块身份仍然只属于它自己。 + const keys = blockKeysOf(view.container); + expect(keys).toEqual([ + 't1:u', + 'process', + 't1:g1', + 't1:g2', + 't1:f', + 'usage', + ]); + }); +}); + +describe('换会话的复位方式', () => { + it('换项目不重建列表 DOM:走显式身份信号,而不是用 key 重建会话', () => { + const view = renderConversation({ turns: [turn('t1')] }); + const listBefore = view.container.querySelector( + '.project-chat-message-list', + ); + + view.rerender( + conversationElement({ + conversationKey: '/projects/other', + turns: [turn('t2')], + }), + ); + + // 复用同一个列表节点:加载行的 150ms 延迟计时与滚动所有权都由身份信号复位, + // 不靠重建组件(重建会把列表和加载行一起推倒重来)。 + expect(view.container.querySelector('.project-chat-message-list')).toBe( + listBefore, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index b9595d9ee..3009776b6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -1,28 +1,43 @@ -import type { RefObject, UIEventHandler } from 'react'; - import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent'; import { useLiveNow } from '../../../../../features/project-workspace/useLiveNow'; import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; import { formatTurnDuration } from '../ToolCallGroup/toolCallGroupPresentation'; +import { + DirectProjectHistoryErrorRow, + DirectProjectHistoryLoadingRow, +} from './DirectProjectHistoryRow'; +import { DirectProjectScrollToBottomCapsule } from './DirectProjectScrollToBottomCapsule'; import { DirectProjectTurn } from './DirectProjectTurn'; +import { useConversationScroll } from './useConversationScroll'; /** - * 会话区:回合列表、更早历史入口和运行中过程卡。 + * 会话区:回合列表、更早历史的加载 / 失败行与运行中过程卡。 + * + * **滚动归这一层所有**:贴底跟随、更早历史的自动加载、前插锚点与回到底部胶囊都在 + * `useConversationScroll` 里,视图不再自己持 `messagesRef` / `shouldFollowLatestRef` / + * `handleScroll`——三套补偿写同一份 `scrollTop`,分开持有必然互相覆盖。 * * 回合来自 DirectProject 自己的投影;这里不读历史、不发回合,只把容器给的状态渲染出来。 */ export function DirectProjectConversation({ + conversationKey, turns, - messagesRef, historyHasMore, + historyLoading, + historyError, turnInFlight, activeTurnStartedAt, onLoadEarlierHistory, - onScroll, + onRetryEarlierHistory, }: { + /** 会话身份键(项目路径):切项目即复位滚动所有权,不靠重建列表。 */ + conversationKey: string; turns: DirectChatTurn[]; - messagesRef: RefObject; historyHasMore: boolean; + /** 更早历史正在读:驱动顶部加载行(延迟 150ms 才挂载)。 */ + historyLoading: boolean; + /** 更早历史读取失败:非空即挂起自动加载,只留内联错误行的重试。 */ + historyError: string | null; /** * 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。 * @@ -33,28 +48,49 @@ export function DirectProjectConversation({ turnInFlight: boolean; activeTurnStartedAt: number; onLoadEarlierHistory: () => void; - onScroll: UIEventHandler; + onRetryEarlierHistory: () => void; }) { + const { + listRef, + onScroll, + onToggleCapture, + scrollToBottom, + showScrollToBottom, + scrollToBottomLabel, + showHistoryLoading, + } = useConversationScroll({ + conversationKey, + turns, + historyHasMore, + historyLoading, + historyError, + turnInFlight, + onLoadEarlierHistory, + }); return ( <> + {/* `overflow-anchor: none`:关掉浏览器原生 scroll anchoring,前插与展开的补偿只走 + `useConversationScroll` 这一份,避免两套补偿在同一帧里互相抵消。 */}
- {historyHasMore ? ( - + {showHistoryLoading ? : null} + {historyError ? ( + ) : null} {turns.map((turn) => ( ))} + {showScrollToBottom ? ( + + ) : null}
{turnInFlight ? ( +