diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index e0ba84d09..707e5e29a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -203,12 +203,51 @@ pub(in crate::agent) fn game_creator_codex_cli_version_at( Ok(version.to_string()) } +/// 开发态允许从宿主 PATH 里找到 Codex,但宿主必须持有可锚定的绝对文件: +/// 裸命令名按 PATH 解析成真实路径,否则 `bind_codex_executor` 的 canonicalize 会按 CWD 解析并失败。 +/// 发行构建不走这段,候选顺序、校验与返回值都与原先一致(打包环境用内置侧车/npm 绝对路径)。 +#[cfg(debug_assertions)] +fn anchor_codex_cli_executable_candidate( + candidate: &Path, + path: Option<&std::ffi::OsStr>, +) -> Option { + let is_bare_command_name = candidate + .parent() + .is_some_and(|parent| parent.as_os_str().is_empty()); + if !is_bare_command_name { + return candidate.is_file().then(|| candidate.to_path_buf()); + } + let name = candidate.as_os_str(); + for directory in path.into_iter().flat_map(std::env::split_paths) { + if directory.as_os_str().is_empty() { + continue; + } + let target = directory.join(name); + if target.is_file() { + // 第一个实际命中的项就是 OS 会执行的项;锚定失败时不再从 PATH 里换另一个。 + return target.canonicalize().ok(); + } + } + None +} + pub(crate) fn game_creator_codex_cli_executable_path() -> Result { let mut last_error = None; let mut seen = std::collections::HashSet::new(); let bundled = game_creator_bundled_codex_cli_path(game_creator_bundled_resource_dir().as_deref()); for candidate in game_creator_codex_cli_executable_candidates() { + #[cfg(debug_assertions)] + let candidate = match anchor_codex_cli_executable_candidate( + &candidate, + std::env::var_os("PATH").as_deref(), + ) { + Some(candidate) => candidate, + None => { + last_error = Some("候选执行器不是可锚定的文件".to_string()); + continue; + } + }; let identity = candidate.to_string_lossy().to_ascii_lowercase(); if !seen.insert(identity) { continue; diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index d8acce153..543351e25 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -6,6 +6,7 @@ import { Component, createContext, isValidElement, + memo, useContext, } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; @@ -302,7 +303,15 @@ const streamingMarkdownComponents: Components = { p: StreamingMarkdownParagraph, }; -export function ChatMarkdownMessage({ +/** + * 解析器的输入只有 `text` / `role` / `streaming` / `preserveBlankLines` 这几个标量,所以 + * 内容没变的旧消息在父级重渲染时可以直接跳过:Markdown 解析与语法高亮是这个组件里最贵的 + * 两件事(`react-markdown` 每次渲染都会重建 `unified()` 处理器并重跑全部插件),而旧消息 + * 的文本永远不会再改。 + */ +export const ChatMarkdownMessage = memo(ChatMarkdownMessageImpl); + +function ChatMarkdownMessageImpl({ text, role, streaming = false, @@ -317,8 +326,13 @@ export function ChatMarkdownMessage({ MAX_HIGHLIGHT_CHARACTERS + ? [] + : [rehypeHighlight] } components={ streaming ? streamingMarkdownComponents : markdownComponents 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 f2d836663..c3d7da397 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 @@ -1,5 +1,5 @@ import { ChevronDown, Lightbulb } from 'lucide-react'; -import { useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent'; import { AgentProcessSummary } from '../../../../../../../../packages/shared/src/components/AgentProcessSummary'; @@ -12,7 +12,9 @@ import { agentProcessPreview } from '../../../../../features/project-workspace/a * 折叠态是单行纯文本预览(Markdown 只取可见文字,符号不进预览);展开态复用助手正文的 * 安全 Markdown 链路。这里只有表现与展开态,思考内容本身由两条产品路径各自的事实源提供。 */ -export function AgentReasoning({ +export const AgentReasoning = memo(AgentReasoningImpl); + +function AgentReasoningImpl({ text, label = '思考过程', testId, @@ -22,7 +24,12 @@ export function AgentReasoning({ testId?: string; }) { const [expanded, setExpanded] = useState(false); - const preview = agentProcessPreview(text); + /* + 折叠态那一行预览是**完整 Markdown AST 解析**(`remark-parse` + `unified`),和展开态 + `react-markdown` 那次解析是两笔开销;上面那层 `memo` 让内容没变的思考块整个跳过, + 这里的 `useMemo` 再兜一层,避免同一文本在真正重渲染时被重算。 + */ + const preview = useMemo(() => agentProcessPreview(text), [text]); return ( ' ``` diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 000ce042f..bb3a362a7 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -96,6 +96,8 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Gitea CI 依赖闭合 +Buildx 0.30.1 的 `inspect` 不支持 `--format`,builder 驱动校验读取普通输出的 `Driver:` 字段。相关命令须在宿主真实插件上验证;测试替身应拒绝不支持的参数,避免把模拟命令成功误当兼容性证据。 + Gitea 基础镜像通过专用 `genarrative-ci-images` Buildx builder 持久复用 Cargo/npm 下载缓存;稳定 cache mount 与 commit、lock 哈希无关,以 `sharing=locked` 隔离并发写入,仅供可信宿主构建、不开放给 PR。最终镜像显式物化当前依赖下载快照,仍不包含 node_modules/target 或上一版 sccache 层。首次可用 `seed-downloads` 从可信完整 Image ID 提取包缓存,操作账号须与维护服务一致;部署要求及 builder GC 空间目标见 `deploy/container/README.md`。构建上下文必须覆盖 AGC vendor 与编辑器 bridge 的全部本地 path manifest,普通源码变化不应使依赖层失效。维护 journal 提供阶段耗时和失败 build.log 定位。 Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一 master push run 六个 Rust job 的原生 V4 缓存产物,不重复执行 Cargo 预热。只传本轮新 key,命中对象只传使用时间;宿主与真实来源镜像对象合并、去重、按新近使用时间裁剪到 4 GiB,从无对象缓存基础镜像重新组装。源 run 不要求全绿,但取消、缺组、旧 attempt、未完成上传或混用来源镜像不得采用。网关暂停新 FetchTask、在途领取结束、持久化任务账本清空且内层活动容器为空才切换,不打断运行中的 CI。首次接入/升级网关需空闲窗口;Token 只需普通仓库 `write:repository`,不查管理员 API。候选装载后清理已收集 artifact,遗留项保留 7 天;真实 master CI 验证后才清理旧镜像,保留当前、一个回滚版、基础镜像及容器引用。部署入口见 `deploy/container/README.md`,合并代码不等于服务启用。 diff --git a/scripts/gitea-ci-job-image.sh b/scripts/gitea-ci-job-image.sh index c0df68721..526c0ad7b 100644 --- a/scripts/gitea-ci-job-image.sh +++ b/scripts/gitea-ci-job-image.sh @@ -18,7 +18,7 @@ prepare_builder() { --driver-opt image=moby/buildkit:v0.23.2@sha256:ddd1ca44b21eda906e81ab14a3d467fa6c39cd73b9a39df1196210edcb8db59e \ --buildkitd-config "${repo_root}/deploy/container/gitea-ci-buildkitd.toml" fi - if [[ "$(docker buildx inspect "${builder_name}" --format '{{.Driver}}')" != docker-container ]]; then + if [[ "$(docker buildx inspect "${builder_name}" | awk '$1 == "Driver:" { print $2 }')" != docker-container ]]; then echo "${builder_name} must use the isolated docker-container driver" >&2 return 1 fi diff --git a/scripts/test_gitea_cache_image_context.py b/scripts/test_gitea_cache_image_context.py index 82e6ec75f..eea8286b8 100644 --- a/scripts/test_gitea_cache_image_context.py +++ b/scripts/test_gitea_cache_image_context.py @@ -90,7 +90,8 @@ class GiteaCiImageContextTest(unittest.TestCase): set -eu if [[ "$1" == buildx && "$2" == version ]]; then exit 0; fi if [[ "$1" == buildx && "$2" == inspect ]]; then - if [[ " $* " == *" --format "* ]]; then printf 'docker-container\\n'; fi + if [[ "$#" != 3 ]]; then echo 'unsupported buildx inspect arguments' >&2; exit 2; fi + printf 'Name: genarrative-ci-images\\nDriver: %s\\n' "${FAKE_BUILDX_DRIVER:-docker-container}" exit 0 fi if [[ "$1" == buildx && "$2" == build ]]; then @@ -108,8 +109,10 @@ class GiteaCiImageContextTest(unittest.TestCase): ).encode("utf-8")) docker.chmod(docker.stat().st_mode | stat.S_IXUSR) - def run_script(self, script: Path, *arguments: str, capture_context: bool = False) -> subprocess.CompletedProcess[str]: + def run_script(self, script: Path, *arguments: str, capture_context: bool = False, + builder_driver: str = "docker-container") -> subprocess.CompletedProcess[str]: exports = [f"export PATH={shlex.quote(self.execution_bin)}:\"$PATH\""] + exports.append(f"export FAKE_BUILDX_DRIVER={shlex.quote(builder_driver)}") if capture_context: exports.append(f"export TAR_CAPTURE={shlex.quote(self.wsl_path(self.context_archive))}") command = "; ".join(exports) + "; cd /; exec bash " + shlex.quote(self.wsl_path(script)) @@ -129,6 +132,12 @@ class GiteaCiImageContextTest(unittest.TestCase): yield dependency["path"] yield from GiteaCiImageContextTest.dependency_paths(child) + def test_build_rejects_a_builder_with_the_wrong_driver(self) -> None: + result = self.run_script(IMAGE_SCRIPT, "build", capture_context=True, builder_driver="docker") + self.assertNotEqual(result.returncode, 0) + self.assertIn("must use the isolated docker-container driver", result.stderr) + self.assertFalse(self.context_archive.exists()) + def test_build_context_contains_all_local_dependency_manifests_and_no_source(self) -> None: result = self.run_script(IMAGE_SCRIPT, "build", capture_context=True) self.assertEqual(result.returncode, 0, result.stderr)