Compare commits

..

5 Commits

Author SHA1 Message Date
k88936 072bf691d9 修复:粘贴解析注释里的 provider 成员名与实际 API 对齐
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
buildContentFromPastedText 的注释仍写着重命名前的 ReferenceProvider.candidates,改成 lookup
顺带写明候选来自调用方传入的引用清单,避免把「谁去查候选」误记进这个纯函数
2026-09-22 19:56:21 +08:00
k88936 14f11e77a2 修复:粘贴解析出的引用若解析不出来,退化成 token 文本而不是整条丢掉
$insertContentAtSelection 的引用分支补 else:referenceFromPart 返回 null 时插入该 part 的 token 文本
此前上游 buildContentFromPastedText 已把 token 摘走,插入端再丢一次就会让这段内容静默消失
新增用例:provider 认得出 token 但插入时已解析不出引用,断言正文保持 `看 @hero 一眼`、不产生芯片
变异验证:去掉该兜底后同一用例变红(正文变成 `看  一眼`),恢复即绿
2026-09-22 19:54:36 +08:00
k88936 9e50947d02 重构:引用输入区的选区兜底收敛成一个 helper
抽出 $selectionOrRootEnd():选区缺失或指向已被重建掉的节点时统一回落到草稿末尾
insertReferences / insertText / $insertContentAtSelection 三处重复的兜底判断改为调用它
行为不变:插件插入与粘贴插入仍走同一套「插到草稿末尾」的策略,只是不再各写一份
2026-09-22 19:53:42 +08:00
k88936 ec1f207021 重构:引用 provider 的 match / candidates 改名为 fuzzyLookup / lookup,删除 onPasteText
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m7s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
match(query) 改名 fuzzyLookup(query):名字写明它是包含匹配 + 截断的模糊菜单查询
candidates() 改名 lookup():名字写明它是精确查找用的就绪全量候选,不模糊不截断
删除 onPasteText 补读钩子:粘贴只用此刻就绪的候选,不等待、不补读,也不为解析提前读盘
输入区与资源 / Skill provider 同步改名,粘贴接管路径改读 lookup()
provider 用例与粘贴集成用例同步:Skill 冷启动改为「保持字面,敲过 `$` 后可解析」
补 jsdom 缺的 Range.getBoundingClientRect 与 ResizeObserver 桩(候选菜单测量用,真实浏览器都有)
同步 ADR 修订、实施计划、功能说明与决策记录
2026-09-22 19:50:42 +08:00
k88936 6950e08506 功能:引用输入区支持粘贴解析 @显示名 / $名称
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m55s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
新增 buildContentFromPastedText:粘贴文本按显示口径反解析,同名多候选与未命中 token 一律按文本保留
ReferenceProvider 增加可选 candidates 与 onPasteText:候选枚举保持纯函数,Skill 目录冷启动时补读一次
资源与 Skill provider 落地新缝,静默 provider(附件 / 运行画面区域)不参与粘贴解析
输入区在 CRITICAL 优先级接管 PASTE_COMMAND,仅解析出引用时插入并打 PASTE_TAG,一次 Ctrl+Z 整体回退
补充粘贴解析规则矩阵、provider 候选枚举与补读、输入区粘贴集成用例
同步 ADR 修订、实施计划、术语表、功能说明与决策记录
2026-09-22 19:06:19 +08:00
29 changed files with 862 additions and 735 deletions
+1 -3
View File
@@ -561,9 +561,7 @@ jobs:
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Validate CI cache maintenance behavior
run: |
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
node --test scripts/export-ci-npm-download-cache.test.mjs
run: python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
- name: Run repository checks
run: npm run check:repository-ci
+9
View File
@@ -210,6 +210,14 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用候选枚举**:
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
**引用粘贴解析**:
把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。
_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
@@ -240,6 +248,7 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
@@ -203,51 +203,12 @@ 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<PathBuf> {
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<PathBuf, String> {
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;
@@ -6,7 +6,6 @@ import {
Component,
createContext,
isValidElement,
memo,
useContext,
} from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
@@ -303,15 +302,7 @@ const streamingMarkdownComponents: Components = {
p: StreamingMarkdownParagraph,
};
/**
* 解析器的输入只有 `text` / `role` / `streaming` / `preserveBlankLines` 这几个标量,所以
* 内容没变的旧消息在父级重渲染时可以直接跳过:Markdown 解析与语法高亮是这个组件里最贵的
* 两件事(`react-markdown` 每次渲染都会重建 `unified()` 处理器并重跑全部插件),而旧消息
* 的文本永远不会再改。
*/
export const ChatMarkdownMessage = memo(ChatMarkdownMessageImpl);
function ChatMarkdownMessageImpl({
export function ChatMarkdownMessage({
text,
role,
streaming = false,
@@ -326,13 +317,8 @@ function ChatMarkdownMessageImpl({
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
// 流式中不高亮:高亮要跑一遍完整 AST + highlight.js,而流式增量会让同一段代码
// 每来一个 chunk 就重新高亮一次(文本还在长,结果立刻作废)。文本定稿时
// `streaming` 变回 false,这一笔高亮自然补上。
rehypePlugins={
streaming || text.length > MAX_HIGHLIGHT_CHARACTERS
? []
: [rehypeHighlight]
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
}
components={
streaming ? streamingMarkdownComponents : markdownComponents
@@ -16,10 +16,13 @@ import {
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type LexicalNode,
PASTE_COMMAND,
PASTE_TAG,
type TextNode,
} from 'lexical';
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
@@ -53,6 +56,7 @@ import {
ResourceReferenceNode,
} from './ResourceReferenceNode';
import {
buildContentFromPastedText,
buildContentFromTextTokens,
type ChatComposerDraft,
type ChatReference,
@@ -65,6 +69,12 @@ import {
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
/**
* Lexical 自己的剪贴板负载(导入优先级最高的一条):带着它复制粘贴时,真 chip 会被原样还原,
* 所以粘贴解析要让位给默认导入。
*/
const LEXICAL_EDITOR_CLIPBOARD_TYPE = 'application/x-lexical-editor';
type ResourceReferenceInputProps = {
onChange?: (draft: ChatComposerDraft) => void;
onEditorStateChange?: (editorState: EditorState) => void;
@@ -290,6 +300,59 @@ function applyContentToRoot(
});
}
/**
* 取当前可用的选区:选区缺失、或指向已被重建掉的节点时(跨会话恢复草稿后就是这种),
* 统一回落到草稿末尾,避免把内容插到一个已经不存在的位置。取不到时返回 `null`。
*/
function $selectionOrRootEnd() {
let selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
return $isRangeSelection(selection) ? selection : null;
}
/**
* 粘贴插入:在光标处就地插入 content 对应的节点,正文其余部分逐字不动。
*
* 与 `applyContentToRoot`(整根替换,供初始草稿与润色回写使用)的区别只在替换范围:
* 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成
* chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。
*
* 认不出的引用 partprovider 的 `toReference` 解析不出来)退回它的 token 文本,
* 绝不静默丢掉——粘贴进来的内容一个字符都不会凭空消失。
*/
function $insertContentAtSelection(
content: readonly DirectCodexUserContentPart[],
providers: readonly ReferenceProvider[],
) {
// 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。
if (!$selectionOrRootEnd()) return;
content.forEach((part) => {
if (part.type === 'input_text') {
part.text.split('\n').forEach((line, index) => {
if (index > 0) $selectionOrRootEnd()?.insertParagraph();
if (line) $selectionOrRootEnd()?.insertText(line);
});
return;
}
const reference = referenceFromPart(providers, part);
if (reference) {
$selectionOrRootEnd()?.insertNodes([
$createResourceReferenceNode(reference),
]);
return;
}
// 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
const token = mentionTokenFromPart(providers, part);
if (token) $selectionOrRootEnd()?.insertText(token);
});
}
/**
* 引用身份刷新(资源改名等):每个节点问第一个认得它的 provider 要新引用,
* 拿到不同实例就换成新节点。providers 原样返回表示无需改写。
@@ -361,24 +424,14 @@ function ResourceReferenceEditor({
(nextReferences: ChatReference[]) => {
if (nextReferences.length === 0) return;
editor.update(() => {
let selection = $getSelection();
// 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾,
// 避免把引用插到一个已经不存在的位置。
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
selection.insertNodes(
nextReferences.flatMap((reference) => [
$createResourceReferenceNode(reference),
$createTextNode(' '),
]),
);
}
const selection = $selectionOrRootEnd();
if (!selection) return;
selection.insertNodes(
nextReferences.flatMap((reference) => [
$createResourceReferenceNode(reference),
$createTextNode(' '),
]),
);
});
editor.focus();
},
@@ -390,19 +443,11 @@ function ResourceReferenceEditor({
const insert = text.replace(/\s+$/u, '');
if (!insert.trim()) return;
editor.update(() => {
let selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
if ($isRangeSelection(selection)) {
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
selection.insertText(insert);
}
const selection = $selectionOrRootEnd();
if (!selection) return;
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
selection.insertText(insert);
});
editor.focus();
},
@@ -522,6 +567,45 @@ function ResourceReferenceEditor({
);
}, [editor, multiline]);
// 粘贴解析:只有「粘贴文本里真的解析出了引用 token」时才接管,其余一律返回 false 放行
// Lexical 的默认导入——同 namespace 复制出来的 Lexical payload 本来就能还原真 chip
// 不含 token 的纯文本、图片文件粘贴也都保持原行为。
//
// 接管时整段文本在一次 update 内插入(与默认粘贴同样打 PASTE_TAG),所以一次 Ctrl+Z 就整体
// 回退;token 之外的每个字符逐字保留(换行落成段落分隔,与默认纯文本粘贴同形状)。
useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
const clipboardData = (event as ClipboardEvent | null)?.clipboardData;
if (!clipboardData || typeof clipboardData.getData !== 'function') {
return false;
}
// 同 namespace 的 Lexical payload 自带真 chip,不抢它的默认导入。
if (clipboardData.getData(LEXICAL_EDITOR_CLIPBOARD_TYPE)) return false;
const text = clipboardData.getData('text/plain');
if (!text.trim()) return false;
const providers = providersRef.current;
// 只认「此刻就绪」的候选:数据还没到的种类本次按文本保留,输入区不等待也不补读。
const references = providers.flatMap(
(provider) => provider.lookup?.() ?? [],
);
const content = buildContentFromPastedText(text, references);
if (!content) return false;
event.preventDefault();
editor.update(
() => {
$insertContentAtSelection(content, providersRef.current);
},
// 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。
{ tag: PASTE_TAG },
);
return true;
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor]);
// —— C8 AI 润色与发送前提醒 ——
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
@@ -796,16 +880,16 @@ function ProviderMentionMenu({
[onOpenChange, trigger],
);
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider`match` 始终保持纯函数,
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider`fuzzyLookup` 始终保持纯函数,
// 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。
useEffect(() => {
provider.onMenuQueryChange?.(query);
}, [provider, query]);
const options = useMemo(() => {
if (query === null || !provider.match) return [];
if (query === null || !provider.fuzzyLookup) return [];
return provider
.match(query)
.fuzzyLookup(query)
.map((reference) => new ReferenceMentionOption(reference));
}, [provider, query]);
@@ -49,12 +49,14 @@ export function createResourceReferenceProvider({
}): ReferenceProvider {
return {
trigger: '@',
match: (query) =>
fuzzyLookup: (query) =>
resourceProviderData(assets)
.references.filter((reference) =>
resourceReferenceMatchesQuery(reference, query),
)
.slice(0, MENTION_OPTION_LIMIT),
// 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。
lookup: () => resourceProviderData(assets).references,
toReference: (part: DirectCodexUserContentPart): ChatReference | null => {
if (part.type !== 'agc_resource_reference') return null;
const asset = resourceProviderData(assets).byId.get(part.resourceId);
@@ -49,7 +49,10 @@ function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
]).then(([builtin, client]) => [...builtin, ...client]);
}
function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
function matchesSkillQuery(
skill: { name: string; description?: string },
query: string,
) {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return (
@@ -58,6 +61,22 @@ function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
);
}
/** 目录项 → 引用:同名只留第一条(与 `match` 同一份去重口径)。 */
function skillReferences(skills: readonly SkillCatalogItem[]) {
const seen = new Set<string>();
const references: ChatReference[] = [];
for (const skill of skills) {
if (seen.has(skill.name)) continue;
seen.add(skill.name);
references.push({
type: 'skill',
name: skill.name,
description: skill.description,
});
}
return references;
}
/**
* Skill 引用的 provider(宿主 hook)。
*
@@ -96,22 +115,15 @@ export function useSkillReferenceProvider(): ReferenceProvider {
onMenuQueryChange: (query) => {
if (query !== null) ensureCatalog();
},
match: (query) => {
const seen = new Set<string>();
return skills
.filter((skill) => {
if (seen.has(skill.name)) return false;
seen.add(skill.name);
return matchesSkillQuery(skill, query);
})
.slice(0, MENTION_OPTION_LIMIT)
.map(
(skill): ChatReference => ({
type: 'skill',
name: skill.name,
description: skill.description,
}),
);
// 精确查找:目录还没就绪(用户还没敲过 `$`)时返回空数组,本次 `$名称` 逐字保留。
lookup: () => skillReferences(skills),
fuzzyLookup: (query) => {
return skillReferences(skills)
.filter(
(reference) =>
reference.type === 'skill' && matchesSkillQuery(reference, query),
)
.slice(0, MENTION_OPTION_LIMIT);
},
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
part.type === 'agc_skill_reference'
@@ -19,12 +19,25 @@ export type ReferenceProvider = {
*/
trigger: string | null;
/**
* 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。
* 模糊查询:候选菜单的过滤。名字写明它是模糊的——按 query 做包含匹配、大小写不敏感,
* 并在 provider 内部截断到候选上限。静默 provider 不实现。
*
* **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会
* 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。
* 精确查找用 `lookup`,不要拿它反查 token。
*/
match?: (query: string) => ChatReference[];
fuzzyLookup?: (query: string) => ChatReference[];
/**
* 精确查找用的全量候选:某一刻 provider 真正能解析出的所有引用,不做模糊过滤、不截断。
*
* 与 `fuzzyLookup` 的区别只有「模糊与截断」,两者共用同一份候选来源。没有触发符的静默
* provider 不实现。
*
* **同样是纯函数**:输入区在粘贴事件里同步调用它;只返回已就绪的快照,不发起任何读取。
* 数据还没到时返回空数组(或省略不实现),解析不出的 token 逐字保留——例如 Skill 目录的就绪
* 时机仍是用户第一次敲出 `$`,冷启动时粘贴 `$名称` 就是字面文本,不会被猜成别的引用。
*/
lookup?: () => ChatReference[];
/**
* 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在
* 带触发符的 provider 上调用**。
@@ -417,6 +417,69 @@ export function buildContentFromTextTokens(
return content;
}
/** token 在整段文本里出现的次数:与 `findMentionToken` 同一套边界规则,按行统计。 */
function countMentionTokenOccurrences(value: string, token: string) {
let count = 0;
for (const line of value.split(/\r?\n/u)) {
let from = 0;
for (;;) {
const index = findMentionToken(line, token, from);
if (index < 0) break;
count += 1;
from = index + token.length;
}
}
return count;
}
/**
* 粘贴文本 → canonical content(粘贴侧的唯一解析口径)。
*
* 候选来自调用方传入的引用清单(输入区给的是 `ReferenceProvider.lookup()` 此刻就绪的全量
* 候选),token 就是
* `chatReferenceMentionToken`——与显示口径逐字同一个字符串,所以「从气泡复制再粘贴」不需要
* 任何兼容别名:`@显示名` / `$名称` 认得出,`@hero.png`、resourceId、大小写变体一律不认。
*
* 两条保守规则:
*
* - 同名多候选(同一个 token 对应多条引用身份)一律按文本保留——宁可不成 chip,也不能认错引用。
* - 只把正文里真的出现过的 token 交给 `buildContentFromTextTokens`,不做「未命中候选补到末尾」
* (那是润色回包的语义,照搬会把整份清单追加到粘贴文本后面)。同一 token 出现几次就展开几条
* 候选,所以重复出现的引用会各自原位成 chip。
*
* 返回 `null` 表示这段文本里没有任何可解析的 token,调用方应放行编辑器的默认粘贴。
*/
export function buildContentFromPastedText(
value: string,
references: readonly ChatReference[],
): DirectCodexUserContentPart[] | null {
const referencesByToken = new Map<string, ChatReference[]>();
for (const reference of references) {
const token = chatReferenceMentionToken(reference);
const bucket = referencesByToken.get(token);
if (!bucket) {
referencesByToken.set(token, [reference]);
continue;
}
const key = chatReferenceKey(reference);
if (!bucket.some((item) => chatReferenceKey(item) === key)) {
bucket.push(reference);
}
}
const candidates: ContentTokenCandidate[] = [];
for (const [token, bucket] of referencesByToken) {
if (bucket.length !== 1) continue;
const reference = bucket[0]!;
const part = chatReferenceToContentPart(reference);
const occurrences = countMentionTokenOccurrences(value, token);
for (let index = 0; index < occurrences; index += 1) {
candidates.push({ token, part });
}
}
if (candidates.length === 0) return null;
return buildContentFromTextTokens(value, candidates);
}
/**
* legacy「text + references + attachments」DTO → canonical content:旧调用方继续吐双轨
* 形状,这里按 token 扫回真 part,不另立第二份事实源。
@@ -1,5 +1,5 @@
import { ChevronDown, Lightbulb } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { useState } from 'react';
import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent';
import { AgentProcessSummary } from '../../../../../../../../packages/shared/src/components/AgentProcessSummary';
@@ -12,9 +12,7 @@ import { agentProcessPreview } from '../../../../../features/project-workspace/a
* 折叠态是单行纯文本预览(Markdown 只取可见文字,符号不进预览);展开态复用助手正文的
* 安全 Markdown 链路。这里只有表现与展开态,思考内容本身由两条产品路径各自的事实源提供。
*/
export const AgentReasoning = memo(AgentReasoningImpl);
function AgentReasoningImpl({
export function AgentReasoning({
text,
label = '思考过程',
testId,
@@ -24,12 +22,7 @@ function AgentReasoningImpl({
testId?: string;
}) {
const [expanded, setExpanded] = useState(false);
/*
折叠态那一行预览是**完整 Markdown AST 解析**`remark-parse` + `unified`),和展开态
`react-markdown` 那次解析是两笔开销;上面那层 `memo` 让内容没变的思考块整个跳过,
这里的 `useMemo` 再兜一层,避免同一文本在真正重渲染时被重算。
*/
const preview = useMemo(() => agentProcessPreview(text), [text]);
const preview = agentProcessPreview(text);
return (
<AgentMessageContent
as="details"
@@ -82,7 +82,7 @@ describe('资源引用 provider', () => {
expect(provider.isReady?.()).toBe(true);
});
it('候选只含可提及素材,`match` 按显示名 / id / kind 过滤并截断到 8 条', () => {
it('候选只含可提及素材,`fuzzyLookup` 按显示名 / id / kind 模糊过滤并截断到 8 条', () => {
const assets = [
heroAsset,
asset('asset-icon', 'icon', 'image/png', 'assets/btn.png'),
@@ -91,21 +91,21 @@ describe('资源引用 provider', () => {
asset('asset-agent', 'document', 'text/markdown', '.agent/notes.md'),
];
const scoped = createResourceReferenceProvider({ assets });
expect(scoped.match?.('')).toHaveLength(2);
expect(scoped.fuzzyLookup?.('')).toHaveLength(2);
const many = Array.from({ length: 10 }, (_, index) =>
asset(`asset-${index}`, 'image', 'image/png', `assets/pic-${index}.png`),
);
const limited = createResourceReferenceProvider({ assets: many });
expect(limited.match?.('')).toHaveLength(8);
expect(limited.fuzzyLookup?.('')).toHaveLength(8);
expect(provider.match?.('hero')?.map((item) => item.type)).toEqual([
expect(provider.fuzzyLookup?.('hero')?.map((item) => item.type)).toEqual([
'resource',
]);
expect(provider.match?.('HERO-IDLE')).toHaveLength(1);
expect(provider.match?.('character')).toHaveLength(1);
expect(provider.match?.(' hero ')).toHaveLength(1);
expect(provider.match?.('missing')).toEqual([]);
expect(provider.fuzzyLookup?.('HERO-IDLE')).toHaveLength(1);
expect(provider.fuzzyLookup?.('character')).toHaveLength(1);
expect(provider.fuzzyLookup?.(' hero ')).toHaveLength(1);
expect(provider.fuzzyLookup?.('missing')).toEqual([]);
});
it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => {
@@ -116,7 +116,7 @@ describe('资源引用 provider', () => {
],
});
const candidates = sameName.match?.('') ?? [];
const candidates = sameName.fuzzyLookup?.('') ?? [];
// 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。
expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([
'@hero',
@@ -127,6 +127,45 @@ describe('资源引用 provider', () => {
);
});
it('`lookup` 给精确查找用的全量清单:与菜单同一份可提及素材,但不受模糊过滤与截断影响', () => {
const assets = [
heroAsset,
asset('asset-orphan', 'image', 'image/png', ''),
...Array.from({ length: 10 }, (_, index) =>
asset(
`asset-${index}`,
'image',
'image/png',
`assets/pic-${index}.png`,
),
),
];
const scoped = createResourceReferenceProvider({ assets });
// 不可提及素材(没有 localPath)不在候选里,条目数与展示名口径与菜单一致。
expect(scoped.fuzzyLookup?.('')).toHaveLength(8);
expect(scoped.lookup?.()).toHaveLength(11);
expect(scoped.lookup?.().map(chatReferenceMentionToken)).toEqual(
expect.arrayContaining(['@hero-idle', '@pic-0', '@pic-9']),
);
// 粘贴解析只认「显示名逐字一致」的 token,所以候选的 token 必须是显示名形态。
expect(
scoped
.lookup?.()
.some(
(item) => 'resourceId' in item && item.resourceId === 'asset-orphan',
),
).toBe(false);
// 菜单就是「同一份候选 + query 过滤 + 截断」,所以截断后的前缀逐字一致。
expect(scoped.lookup?.().slice(0, 8)).toEqual(scoped.fuzzyLookup?.(''));
});
it('清单为空时 `lookup` 是空数组:粘贴不会把任何 token 当成引用', () => {
expect(createResourceReferenceProvider({ assets: [] }).lookup?.()).toEqual(
[],
);
});
it('`toReference` 只认资源 part:资产已删除时不合成引用', () => {
expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe(
null,
@@ -147,7 +186,7 @@ describe('资源引用 provider', () => {
});
it('`refresh` 按 manifest 换显示名:改名换新引用、未变恒等、已删除原样返回', () => {
const reference = provider.match?.('hero')?.[0] as ResourceReference;
const reference = provider.fuzzyLookup?.('hero')?.[0] as ResourceReference;
expect(provider.refresh(reference)).toBe(reference);
const renamed = createResourceReferenceProvider({
@@ -305,7 +344,7 @@ describe('运行画面区域 provider', () => {
});
describe('Skill provider', () => {
it('触发符是 `$`:挂载与 `match` 都不发查询,菜单第一次打开时才读应用级目录', async () => {
it('触发符是 `$`:挂载与 `fuzzyLookup` 都不发查询,菜单第一次打开时才读应用级目录', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
@@ -319,9 +358,9 @@ describe('Skill provider', () => {
expect(result.current.trigger).toBe('$');
expect(invoke).not.toHaveBeenCalled();
// `match` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
// `fuzzyLookup` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
act(() => {
expect(result.current.match?.('')).toEqual([]);
expect(result.current.fuzzyLookup?.('')).toEqual([]);
});
expect(invoke).not.toHaveBeenCalled();
@@ -333,7 +372,7 @@ describe('Skill provider', () => {
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
});
await waitFor(() => {
expect(result.current.match?.('')).toHaveLength(1);
expect(result.current.fuzzyLookup?.('')).toHaveLength(1);
});
// 目录只读一次:后续每次敲 `$` 都复用同一份候选。
expect(invoke).toHaveBeenCalledTimes(2);
@@ -342,12 +381,42 @@ describe('Skill provider', () => {
result.current.onMenuQueryChange?.(null);
});
expect(invoke).toHaveBeenCalledTimes(2);
expect(result.current.match?.('测试')?.[0]).toMatchObject({
expect(result.current.fuzzyLookup?.('测试')?.[0]).toMatchObject({
type: 'skill',
name: 'agc-test-skill',
});
});
it('`lookup` 是纯函数:目录还没读就返回空数组、也不发起读取,菜单打开后才查得到', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
}
if (command === 'list_client_extensions') return [];
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke: invoke as never } };
const { result } = renderHook(() => useSkillReferenceProvider());
// 冷启动:精确查找只能看到「此刻就绪」的候选,目录没读过就是空数组——粘贴 `$名称` 因此按字面保留。
act(() => {
expect(result.current.lookup?.()).toEqual([]);
});
expect(invoke).not.toHaveBeenCalled();
// 菜单第一次打开才读目录,读回之后精确查找立刻可用。
act(() => {
result.current.onMenuQueryChange?.('');
});
await waitFor(() => {
expect(result.current.lookup?.()).toHaveLength(1);
});
expect(result.current.lookup?.().map(chatReferenceMentionToken)).toEqual([
'$agc-test-skill',
]);
});
it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
@@ -394,17 +463,17 @@ describe('Skill provider', () => {
result.current.onMenuQueryChange?.('');
});
await waitFor(() => {
expect(result.current.match?.('')).toHaveLength(8);
expect(result.current.fuzzyLookup?.('')).toHaveLength(8);
});
// 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。
expect(result.current.match?.('builtin-0')).toHaveLength(1);
expect(result.current.match?.('builtin-')).toHaveLength(8);
expect(result.current.match?.('client-skill')).toMatchObject([
expect(result.current.fuzzyLookup?.('builtin-0')).toHaveLength(1);
expect(result.current.fuzzyLookup?.('builtin-')).toHaveLength(8);
expect(result.current.fuzzyLookup?.('client-skill')).toMatchObject([
{ type: 'skill', name: 'client-skill' },
]);
// 未启用与非 Skill 扩展都不是候选。
expect(result.current.match?.('client-off')).toEqual([]);
expect(result.current.match?.('client-plugin')).toEqual([]);
expect(result.current.fuzzyLookup?.('client-off')).toEqual([]);
expect(result.current.fuzzyLookup?.('client-plugin')).toEqual([]);
});
it('一次瞬时失败不锁死候选:下一次 `match` 还会重读目录,并在控制台留痕', async () => {
@@ -432,7 +501,7 @@ describe('Skill provider', () => {
result.current.onMenuQueryChange?.('a');
});
await waitFor(() => {
expect(result.current.match?.('')).toHaveLength(1);
expect(result.current.fuzzyLookup?.('')).toHaveLength(1);
});
expect(invoke).toHaveBeenCalledTimes(4);
// 读取失败不能静默:控制台要留下可排障的一条。
@@ -33,6 +33,7 @@ import { attachmentReferenceProvider } from '../src/features/project-workspace/r
import { createResourceReferenceProvider } from '../src/features/project-workspace/reference-source/resourceReferenceProvider';
import { runtimeRegionReferenceProvider } from '../src/features/project-workspace/reference-source/runtimeRegionReferenceProvider';
import { useSkillReferenceProvider } from '../src/features/project-workspace/reference-source/skillReferenceProvider';
import type { ReferenceProvider } from '../src/features/project-workspace/reference-source/types';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
@@ -67,6 +68,65 @@ import {
type RuntimeRegionReference,
} from '../src/features/project-workspace/resourceReferences';
/**
* jsdom 没有 `DragEvent` / `ClipboardEvent` 构造器,而 Lexical 的默认粘贴路径按构造器名字判断
* 事件类型(`objectKlassEquals`);真实客户端(Tauri / Chromium)两者都在。这里只补齐测试环境
* 缺的那部分,名字必须与真实构造器逐字一致,否则 Lexical 会把粘贴数据源当成 `null`。
*/
function defineEventClassShim(name: 'DragEvent' | 'ClipboardEvent') {
if (typeof (globalThis as Record<string, unknown>)[name] === 'function')
return;
const shim = class extends Event {};
Object.defineProperty(shim, 'name', { value: name });
Object.defineProperty(globalThis, name, {
value: shim,
configurable: true,
writable: true,
});
}
defineEventClassShim('DragEvent');
defineEventClassShim('ClipboardEvent');
/**
* jsdom 的 `Range` 没有 `getBoundingClientRect`,而候选菜单打开时会用它测锚点位置
* `LexicalTypeaheadMenuPlugin` 的 `getRect`);真实浏览器两者都在,这里只补测试环境缺的那部分。
*/
function defineRangeRectShim() {
if (typeof Range === 'undefined') return;
if (typeof Range.prototype.getBoundingClientRect === 'function') return;
Range.prototype.getBoundingClientRect = () =>
typeof DOMRect === 'function'
? new DOMRect()
: ({
x: 0,
y: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
} as DOMRect);
}
defineRangeRectShim();
/** jsdom 没有 `ResizeObserver`,候选菜单打开时会构造它;真实浏览器都有。 */
function defineResizeObserverShim() {
if (typeof window === 'undefined') return;
if (typeof window.ResizeObserver === 'function') return;
window.ResizeObserver = class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
defineResizeObserverShim();
function asset(
id: string,
kind: string,
@@ -231,6 +291,48 @@ function composerEditor(): {
return editor;
}
/**
* jsdom 里合成一次原生粘贴:Lexical 的 DOM 监听器把 `paste` 事件转成 PASTE_COMMAND
* 所以这条链路和真实粘贴一致(含 `clipboardData` 读取与 `preventDefault`)。
*/
function pasteComposerText(
text: string,
payload: { html?: string; lexical?: string } = {},
) {
// 真实粘贴发生在有焦点的编辑器里;jsdom 不会自己给编辑器设选区,这里先落到草稿末尾,
// 否则 Lexical 默认粘贴处理器拿不到 selection,会直接放弃这次粘贴。
act(() => {
composerEditor().update(() => {
$getRoot().selectEnd();
});
});
const element = screen.getByLabelText('聊天');
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
getData: (type: string) => {
if (type === 'text/plain') return text;
if (type === 'text/html') return payload.html ?? '';
if (type === 'application/x-lexical-editor') {
return payload.lexical ?? '';
}
return '';
},
},
});
act(() => {
element.dispatchEvent(event);
});
return event;
}
/** 草稿里的 Skill 引用名,按 content 顺序。 */
function draftSkillNames(draft: ChatComposerDraft | undefined) {
return (draft?.content ?? []).flatMap((part) =>
part.type === 'agc_skill_reference' ? [part.name] : [],
);
}
/**
* jsdom 里键盘输入不会进入 Lexical,所以直接走编辑器 API 写文本;
* 它触发的是和真实输入同一条更新链路,typeahead 监听器同样会被唤醒。
@@ -332,6 +434,180 @@ describe('ResourceReferenceInput', () => {
}
});
test('粘贴含引用的显示文本:原位重建 chip,其余字符逐字保留', async () => {
const ref = createRef<ResourceReferenceInputHandle>();
render(
<ComposerHost
ref={ref}
ariaLabel="聊天"
assets={assets}
onChange={vi.fn()}
/>,
);
pasteComposerText('看 @hero 这一版');
await waitFor(() => {
expect(draftResourceIds(ref.current?.getDraft())).toEqual(['hero']);
});
expect(ref.current?.getDraft().content).toEqual([
{ type: 'input_text', text: '看 ' },
{ type: 'agc_resource_reference', resourceId: 'hero' },
{ type: 'input_text', text: ' 这一版' },
]);
// 一次粘贴是一条撤销记录、一次文本更新:chip 在文本模型里只占 1 个字符。
expect(editorTextSize()).toBe('看 '.length + 1 + ' 这一版'.length);
});
test('粘贴解析出的引用若已无法解析,退化成 token 文本而不是整条丢掉', async () => {
const ref = createRef<ResourceReferenceInputHandle>();
// provider 认得出这个 tokenlookup 有候选),但插入那一刻已经解析不出引用(例如资产刚被删)。
const ghostProvider: ReferenceProvider = {
trigger: '@',
fuzzy_lookup: () => [],
lookup: () => [resourceReferenceFromAsset(assets[0]!, 'asset-picker')],
toReference: () => null,
refresh: (reference) => reference,
mentionToken: (part) =>
part.type === 'agc_resource_reference' ? '@hero' : null,
};
render(
<ResourceReferenceInput
ref={ref}
providers={[ghostProvider]}
projectPath="C:/project"
ariaLabel="聊天"
onChange={vi.fn()}
/>,
);
pasteComposerText('看 @hero 一眼');
await waitFor(() => {
expect(draftText(ref.current?.getDraft())).toBe('看 @hero 一眼');
});
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
});
test('粘贴未命中的 token:整段按字面落进正文,不接管、不提示', async () => {
const ref = createRef<ResourceReferenceInputHandle>();
render(
<ComposerHost
ref={ref}
ariaLabel="聊天"
assets={assets}
onChange={vi.fn()}
/>,
);
// 项目里没有名为 `hero.png` 的显示名(`@hero` 才是当前清单的口径),所以这条不是引用。
pasteComposerText('@hero.png 换成夜景');
await waitFor(() => {
expect(draftText(ref.current?.getDraft())).toBe('@hero.png 换成夜景');
});
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
});
test('粘贴不含引用的纯文本仍走编辑器默认导入(换行成段,不经过解析)', async () => {
const ref = createRef<ResourceReferenceInputHandle>();
render(
<ComposerHost
ref={ref}
ariaLabel="聊天"
assets={assets}
onChange={vi.fn()}
/>,
);
pasteComposerText('第一行\n第二行');
await waitFor(() => {
expect(ref.current?.getDraft().content).toEqual([
{ type: 'input_text', text: '第一行' },
{ type: 'input_text', text: '\n' },
{ type: 'input_text', text: '第二行' },
]);
});
});
test('剪贴板里带 Lexical 负载时让位给默认导入:文本里的 @显示名 不被解析', async () => {
const ref = createRef<ResourceReferenceInputHandle>();
render(
<ComposerHost
ref={ref}
ariaLabel="聊天"
assets={assets}
onChange={vi.fn()}
/>,
);
pasteComposerText('看 @hero 这一版', { lexical: '{"namespace":"other"}' });
await waitFor(() => {
expect(draftText(ref.current?.getDraft())).toBe('看 @hero 这一版');
});
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
});
test('Skill 目录冷启动:粘贴 `$名称` 保持字面,敲过一次 `$` 之后粘贴才成 chip', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
}
if (command === 'list_client_extensions') return [];
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke: invoke as never } };
try {
const ref = createRef<ResourceReferenceInputHandle>();
render(
<ComposerHost
ref={ref}
ariaLabel="聊天"
assets={assets}
withSkillProvider
onChange={vi.fn()}
/>,
);
// 冷目录:精确查找没有候选,`$名称` 逐字保留,也不替用户去读目录。
pasteComposerText('用 $agc-test-skill 出图');
await waitFor(() => {
expect(draftText(ref.current?.getDraft())).toBe(
'用 $agc-test-skill 出图',
);
});
expect(invoke).not.toHaveBeenCalled();
// 用户敲出 `$`(菜单懒加载)之后目录才就绪,此后粘贴才重建 chip。
act(() => {
ref.current?.clear();
});
insertComposerText('$');
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
});
act(() => {
ref.current?.clear();
});
pasteComposerText('用 $agc-test-skill 出图');
await waitFor(() => {
expect(draftSkillNames(ref.current?.getDraft())).toEqual([
'agc-test-skill',
]);
});
expect(ref.current?.getDraft().content).toEqual([
{ type: 'input_text', text: '用 ' },
{ type: 'agc_skill_reference', name: 'agc-test-skill' },
{ type: 'input_text', text: ' 出图' },
]);
} finally {
delete window.__TAURI__;
}
});
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
const base: RuntimeRegionReference = {
type: 'runtime-region',
@@ -1,12 +1,17 @@
import { describe, expect, it } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
buildContentFromPastedText,
type ChatComposerDraft,
chatComposerDraftToDirectCodexUserItem,
type ChatReference,
directCodexContentToPromptText,
hasMeaningfulDirectCodexContent,
resourceLabelResolver,
resourceReferenceFromAsset,
} from '../src/features/project-workspace/resourceReferences';
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
describe('DirectProject user Response item', () => {
it('保留 Lexical content 的文本与引用交错顺序', () => {
@@ -108,3 +113,104 @@ describe('DirectProject user Response item', () => {
).toBe('用 @asset-hero 做主视觉');
});
});
describe('粘贴文本反解析', () => {
const assetEntry = (
id: string,
localPath: string,
): GameCreationAppAssetManifestEntry => ({
id,
kind: 'character',
mediaType: 'image/png',
localPath,
source: { kind: 'uploaded' },
});
const heroAsset = assetEntry('hero', 'assets/hero.png');
const enemyAsset = assetEntry('enemy', 'assets/enemy.png');
const hero = resourceReferenceFromAsset(heroAsset, 'asset-picker');
const enemy = resourceReferenceFromAsset(enemyAsset, 'asset-picker');
const skill: ChatReference = { type: 'skill', name: 'image-gen' };
it('命中显示名 token 时原位换成引用,其余字符逐字保留', () => {
expect(buildContentFromPastedText('看 @hero 这一版', [hero])).toEqual([
{ type: 'input_text', text: '看 ' },
{ type: 'agc_resource_reference', resourceId: 'hero' },
{ type: 'input_text', text: ' 这一版' },
]);
expect(buildContentFromPastedText('用 $image-gen 出图', [skill])).toEqual([
{ type: 'input_text', text: '用 ' },
{ type: 'agc_skill_reference', name: 'image-gen' },
{ type: 'input_text', text: ' 出图' },
]);
});
it('不做兼容别名:只认显示名,且 token 前后必须是行首 / 行尾或空白', () => {
// 带扩展名的文件名、紧贴中文、全角 `@`、大小写变体都不解析——宁可不成 chip,也不能认错。
expect(buildContentFromPastedText('@hero.png', [hero])).toBeNull();
expect(buildContentFromPastedText('看@hero这一版', [hero])).toBeNull();
expect(buildContentFromPastedText('hero', [hero])).toBeNull();
expect(buildContentFromPastedText('@HERO', [hero])).toBeNull();
// 行首 / 行尾同样是边界。
expect(buildContentFromPastedText('@hero', [hero])).toEqual([
{ type: 'agc_resource_reference', resourceId: 'hero' },
]);
});
it('未命中的 token 逐字保留,混在正文里也只换认出那几条', () => {
expect(
buildContentFromPastedText('@hero 与 @unknown 都在', [hero]),
).toEqual([
{ type: 'agc_resource_reference', resourceId: 'hero' },
{ type: 'input_text', text: ' 与 @unknown 都在' },
]);
// 一条都没命中时返回 null:调用方据此放行编辑器默认粘贴。
expect(buildContentFromPastedText('纯文本 @unknown', [hero])).toBeNull();
});
it('同名多候选一律按文本保留:宁可不成 chip,也不能认错引用', () => {
const sameName = [
resourceReferenceFromAsset(
assetEntry('asset-hero-a', 'characters/hero.png'),
'asset-picker',
),
resourceReferenceFromAsset(
assetEntry('asset-hero-b', 'enemies/hero.png'),
'asset-picker',
),
];
expect(buildContentFromPastedText('@hero', sameName)).toBeNull();
// 其余可判定的 token 照常解析。
expect(
buildContentFromPastedText('@hero @enemy', [...sameName, enemy]),
).toEqual([
{ type: 'input_text', text: '@hero ' },
{ type: 'agc_resource_reference', resourceId: 'enemy' },
]);
});
it('同一 token 出现几次就成几个 chip,换行保留为独立的文本 part', () => {
expect(buildContentFromPastedText('@hero\n@hero', [hero])).toEqual([
{ type: 'agc_resource_reference', resourceId: 'hero' },
{ type: 'input_text', text: '\n' },
{ type: 'agc_resource_reference', resourceId: 'hero' },
]);
});
it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => {
const content: DirectCodexUserContentPart[] = [
{ type: 'input_text', text: '看 ' },
{ type: 'agc_resource_reference', resourceId: 'hero' },
{ type: 'input_text', text: ' 这一版,再用 ' },
{ type: 'agc_skill_reference', name: 'image-gen' },
{ type: 'input_text', text: ' 出图' },
];
const displayed = directCodexContentToPromptText(
content,
resourceLabelResolver([heroAsset]),
);
expect(displayed).toBe('看 @hero 这一版,再用 $image-gen 出图');
expect(buildContentFromPastedText(displayed, [hero, skill])).toEqual(
content,
);
});
});
+1 -18
View File
@@ -79,22 +79,7 @@ bash scripts/gitea-ci-job-image.sh export /仓库外受控路径/genarrative-git
bash scripts/gitea-ci-job-image.sh load-runner
```
默认构建 tag 为 `genarrative/gitea-project-ci:20260920.2`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、构建配置与缓存导出脚本、checkout 脚本、根 workspace 的唯一 npm lock 与全部 workspace manifest,以及 server-rs、桌面壳和 AI 游戏创作壳的 Cargo manifests/lock。AGC 的 `vendor/*/Cargo.toml` 和三个编辑器 bridge crate 的 manifest 同样参与,避免漏掉本地 path 依赖;不发送业务源码、素材或本地私密文件。新镜像显式安装并精确校验 `npm 10.9.7`,不依赖 Node 发行包隐含的 npm 版本;除固定工具链外,还按一份 npm workspace lock 与三份 Cargo lock 预热下载缓存。npm 只执行一次忽略 lifecycle scripts 的 workspace `npm ci`(最多 5 次整命令级有界重试,处理 registry ECONNRESET),三个 `cargo fetch --locked` 最多执行 5 次整命令级有界重试,再分别以断网 `cargo fetch --locked` 验证缓存闭合,镜像不包含 `node_modules` 或 Cargo `target``build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。workspace lock 或 manifest 变化落地后必须按下述顺序重建并装载镜像;过渡期旧固定镜像缺少 `GENARRATIVE_GITEA_CI_NPM_VERSION` 时,校验只输出 `npm_version=partial` 和 Actions warning,继续由当前 job 的根 `npm ci` 验证唯一 lock,不能据此宣称 npm 版本或新依赖缓存已经闭合。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。
基础镜像构建需要 Docker Buildx 插件,固定使用独立的 `genarrative-ci-images` docker-container builderBuildKit `v0.23.2`),不改变默认 builder、Docker daemon 配置或其它构建。脚本按 `gitea-ci-buildkitd.toml` 首次创建 builder;配置的 24 GB 为 GC 空间目标、4 GB 为保留量、宿主保留 10 GB 空闲,均不是活动构建的硬磁盘配额。BuildKit 自动回收可释放的旧记录,正在使用的记录受保护;不运行全局 prune。已有 builder 的配置变更须另择空闲窗口应用,脚本不会为修改 GC 配置而重启它。
Cargo registry 的压缩包与索引、npm `_cacache` 使用稳定命名、`sharing=locked` 的持久 cache mount,不随 commit 或 lock 哈希更名。它们仅供受信任宿主的镜像构建使用,不挂给 PR job;未缓存的新版本仍按当前锁文件下载并校验。cache mount 本身不进入输出镜像,因此构建显式物化下载快照:Cargo 只导出本次实际解包的 crate 归档及索引;npm 按当前 lock 的 integrity 筛选已下载条目并校验内容,不把历史包版本、凭据或可写 target 一并复制。最终 CI 镜像仍提供独立的下载缓存目录,普通 job 在自身容器内使用。
首次启用前可从现有可信 CI 镜像导入下载缓存,避免从空缓存重新下载;后续正常构建不必重复导入。维护服务以 root 运行时,下述命令也以 root 执行,确保使用同一套 Buildx 配置。Ubuntu 发行版 Docker 的插件包名为 `docker-buildx`Docker 官方发行源则为 `docker-buildx-plugin`);只安装匹配当前 Docker 来源的插件包,无需重启 runner。
```bash
sudo apt-get install docker-buildx
# Buildx 0.30.1 的 inspect 不支持 --format;脚本读取普通输出的 Driver 字段。
# 替换为运维已验证的完整 Image ID;只提取 registry/cache、registry/index 和 npm/_cacache。
sudo bash scripts/gitea-ci-job-image.sh seed-downloads 'sha256:<可信镜像的64位摘要>'
```
seed 临时目录与容器在结束时删除,既有镜像只读提取、不运行其入口;新基础镜像继续从固定工具链与 runner base 构建,不继承旧对象快照层。未执行 seed 或下载缓存被 GC 回收只影响速度,不影响正确性。升级维护器需同步安装新版 `maintain-gitea-rust-cache.py` 才会获得 journal 阶段日志;基础镜像构建脚本与 Dockerfile 来自所选 master run 的提交,不把 PR 分支代码直接用于线上维护。
默认构建 tag 为 `genarrative/gitea-project-ci:20260920.2`。脚本通过 NUL 分隔白名单 tar 流只发送 Dockerfile、checkout 脚本、根 workspace 的唯一 npm lock 与全部 workspace manifest,以及 server-rs、桌面壳和 AI 游戏创作壳的 Cargo manifests/lock,外加 AI 游戏创作壳本地路径依赖的三个编辑器 bridge crate 源树;不会把业务源码、素材或本地私密文件发送给 Docker daemon。新镜像显式安装并精确校验 `npm 10.9.7`,不依赖 Node 发行包隐含的 npm 版本;除固定工具链外,还按一份 npm workspace lock 与三份 Cargo lock 预热下载缓存。npm 只执行一次忽略 lifecycle scripts 的 workspace `npm ci`(最多 5 次整命令级有界重试,处理 registry ECONNRESET),三个 `cargo fetch --locked` 最多执行 5 次整命令级有界重试,再分别以断网 `cargo fetch --locked` 验证缓存闭合,镜像不包含 `node_modules` 或 Cargo `target``build` 完成后会自动运行环境校验,`load-runner` 还会比对宿主和 runner 内层的完整 Image ID,并在内层执行 bwrap 与 Chrome headless canary。workspace lock 或 manifest 变化落地后必须按下述顺序重建并装载镜像;过渡期旧固定镜像缺少 `GENARRATIVE_GITEA_CI_NPM_VERSION` 时,校验只输出 `npm_version=partial` 和 Actions warning,继续由当前 job 的根 `npm ci` 验证唯一 lock,不能据此宣称 npm 版本或新依赖缓存已经闭合。执行这些命令不要求必须使用 root,但执行账号必须有权访问宿主 Docker API 并管理 runner 容器;没有该权限时交给 runner 运维人员执行。
runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到经 `build / verify / load-runner` 验证并写入配置的完整 Image ID。内层 Docker 数据必须持久化,`force_pull` 保持 `false`;该精确 Image ID 在内层不存在时 job 应直接失败,不回退到浮动 tag 或现场拉取。各个 job 使用镜像内 `genarrative-gitea-checkout` 直接从当前 Gitea 拉取事件 commit,带 5 次有界重试,不再运行时下载 GitHub checkout action;随后以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,同时校验工具链、一份 npm workspace 缓存锁、三份 Cargo 缓存锁、bwrap 和 Chrome headless。锁不匹配时校验会输出 `partial` 和醒目的 Actions warning,提示在可信分支落地后刷新镜像。各 job 仍各自运行一次干净的根 `npm ci`,以唯一 workspace lock 校验全部 App/package/tool 依赖并隔离 PR 依赖;统一通过 `scripts/ci-npm-ci-with-retry.sh` 最多执行 3 次整命令级有界重试,并使用镜像内 npm cache 和 `prefer-offline`。锁文件新增依赖时允许经受控网络补齐,本阶段不启用共享 Actions cache。
@@ -114,8 +99,6 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到
自动维护由宿主 systemd timer 调用 `scripts/maintain-gitea-rust-cache.py`,只管理 Gitea CI 测试镜像,不修改 Jenkins、生产发布、本地开发或客户端发行构建。六个 Rust job 仅在 master push 中导出本次 CI 新增的 sccache 对象;已命中的继承对象只上传新近使用时间,通过 Gitea 原生 V4 artifact 接口上传;PR 不发布。维护器选择已结束且六组产物完整的最新 master run,校验提交、任务尝试、工具链与来源镜像,与六组实际使用的同一镜像快照合并去重,并按新近使用时间限制快照总容量为 4 GiB,然后从无对象缓存基础镜像组装新镜像,**不重复执行 Cargo 预热编译,也不要求源 run 事先全绿**。缺组、取消或校验失败时保留现役版,不混合不同 run 的对象来假装完整快照。
维护 journal 分阶段记录来源 run、基础镜像重建或复用、artifact 下载、对象合并、镜像组装校验、导出、载入及空闲等待;长操作记录开始和结束耗时,失败输出对应私有 `artifacts/<source-sha>/build.log` 路径。构建的详细下载与 Docker 输出仍只写该日志,不回显 Token、命令环境或认证配置。
切换先通过专属入口阻断新的 FetchTask,确认已转发的领取请求全部收到完整上游响应,并检查入口持久化跟踪的已领取任务全部结束、内层 Docker 没有活动容器。任务终态必须依据 Runner 的执行结束及最终上报协议,不能由容器暂时为空、API 已取消或请求超时推断。有任务即恢复领取并延后,不停止任务;状态未知拒绝切换。维护器只需普通账号的 `write:repository` Token(包括查询、下载及定向删除 artifact),不访问全局 Runner 管理 API。切换后等待使用该 Image ID 的完整真实 master push CI 通过,才允许下一次升级及旧镜像清理;不会自动重跑失败用例或为了验收额外触发整轮 CI。首次接管的历史镜像默认不归自动清理管理。
维护状态、凭据、归档和配置备份保存在仓库外。当前版、回滚版、待验证候选、它们的基础镜像及容器引用的镜像均受保护。清理只针对维护器登记的专属 tag、完整 Image ID 和专用目录中的归档;禁止全局 prune。API、构建、验证或空闲检查失败时保留现役镜像与回滚资料,不以失败重跑制造全绿结果。
-18
View File
@@ -1,18 +0,0 @@
# 专用于 Gitea CI 基础镜像;不调整宿主 Docker 或其它 builder 的 GC。
[worker.oci]
gc = true
reservedSpace = "4GB"
maxUsedSpace = "24GB"
minFreeSpace = "10GB"
# 覆盖默认的 48 小时 / 488 MiB 临时缓存回收规则,周末后仍可命中下载包。
[[worker.oci.gcpolicy]]
filters = ["type==exec.cachemount"]
keepDuration = "168h"
maxUsedSpace = "8GB"
[[worker.oci.gcpolicy]]
all = true
reservedSpace = "4GB"
maxUsedSpace = "24GB"
minFreeSpace = "10GB"
+2 -34
View File
@@ -8,16 +8,6 @@ RUN rustup component add rustfmt \
&& cargo --version \
&& rustfmt --version
# 显式的一次性迁移入口:只导入下载缓存,不继承旧 CI 镜像层。
FROM rust-toolchain AS download-cache-seed
RUN --mount=type=bind,from=download-seed,target=/seed \
--mount=type=cache,id=genarrative-ci-cargo-cache-v1,target=/downloads/cargo-cache,sharing=locked \
--mount=type=cache,id=genarrative-ci-cargo-index-v1,target=/downloads/cargo-index,sharing=locked \
--mount=type=cache,id=genarrative-ci-npm-v1,target=/downloads/npm,sharing=locked \
cp -a /seed/cargo-cache/. /downloads/cargo-cache/ \
&& cp -a /seed/cargo-index/. /downloads/cargo-index/ \
&& cp -a /seed/npm/. /downloads/npm/
FROM rust-toolchain AS rust-dependency-cache
ENV CARGO_HTTP_MULTIPLEXING=false \
@@ -30,9 +20,7 @@ COPY plugins/agc-cocos-editor/native/cocos-editor-bridge /tmp/genarrative-cargo-
COPY plugins/agc-unity-editor/native/unity-editor-bridge /tmp/genarrative-cargo-cache/plugins/agc-unity-editor/native/unity-editor-bridge
COPY plugins/agc-godot-editor/native/godot-editor-bridge /tmp/genarrative-cargo-cache/plugins/agc-godot-editor/native/godot-editor-bridge
RUN --mount=type=cache,id=genarrative-ci-cargo-cache-v1,target=/usr/local/cargo/registry/cache,sharing=locked \
--mount=type=cache,id=genarrative-ci-cargo-index-v1,target=/usr/local/cargo/registry/index,sharing=locked \
find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \
RUN find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \
| while IFS= read -r crate_dir; do \
mkdir -p "${crate_dir}/src"; \
: > "${crate_dir}/src/lib.rs"; \
@@ -65,16 +53,6 @@ RUN --mount=type=cache,id=genarrative-ci-cargo-cache-v1,target=/usr/local/cargo/
&& CARGO_NET_OFFLINE=true cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri/Cargo.toml \
&& mkdir -p /opt/ci-downloads/registry/cache \
&& cp -a /usr/local/cargo/registry/index /opt/ci-downloads/registry/ \
&& for source in /usr/local/cargo/registry/src/*/*; do \
[ -d "${source}" ] || continue; \
registry="$(basename "$(dirname "${source}")")"; \
package="$(basename "${source}")"; \
mkdir -p "/opt/ci-downloads/registry/cache/${registry}"; \
cp "/usr/local/cargo/registry/cache/${registry}/${package}.crate" \
"/opt/ci-downloads/registry/cache/${registry}/" || exit 1; \
done \
&& rm -rf /tmp/genarrative-cargo-cache
FROM ${RUNNER_IMAGE}
@@ -149,7 +127,6 @@ RUN node_archive="node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& ln -sfn /usr/local/lib/genarrative-node/bin/corepack /usr/local/bin/corepack
COPY --from=rust-dependency-cache /usr/local/cargo /usr/local/cargo
COPY --from=rust-dependency-cache /opt/ci-downloads/registry /usr/local/cargo/registry
COPY --from=rust-dependency-cache /usr/local/rustup /usr/local/rustup
ARG NPM_LOCK_SHA256
@@ -171,12 +148,10 @@ COPY server-rs/Cargo.lock /usr/local/share/genarrative-ci/locks/server-rs.Cargo.
COPY apps/desktop-shell/src-tauri/Cargo.lock /usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock
COPY apps/ai-game-creator-shell/src-tauri/Cargo.lock /usr/local/share/genarrative-ci/locks/ai-game-creator-shell.Cargo.lock
COPY deploy/container/gitea-ci-checkout.sh /usr/local/bin/genarrative-gitea-checkout
COPY scripts/export-ci-npm-download-cache.mjs /usr/local/share/genarrative-ci/export-npm-cache.mjs
# npm registry 偶发 ECONNRESET,镜像预热也需要整命令级有界重试;
# 失败重试复用同一 npm cache,不会重复下载已完成的包。
RUN --mount=type=cache,id=genarrative-ci-npm-v1,target=/var/cache/genarrative-ci-npm,sharing=locked \
test -n "${NPM_LOCK_SHA256}" \
RUN test -n "${NPM_LOCK_SHA256}" \
&& test -n "${SERVER_RUST_LOCK_SHA256}" \
&& test -n "${DESKTOP_RUST_LOCK_SHA256}" \
&& test -n "${AGC_RUST_LOCK_SHA256}" \
@@ -200,7 +175,6 @@ RUN --mount=type=cache,id=genarrative-ci-npm-v1,target=/var/cache/genarrative-ci
&& npm_ci_with_retry() { \
for attempt in 1 2 3 4 5; do \
if npm ci \
--cache /var/cache/genarrative-ci-npm \
--ignore-scripts \
--no-audit \
--no-fund \
@@ -220,12 +194,6 @@ RUN --mount=type=cache,id=genarrative-ci-npm-v1,target=/var/cache/genarrative-ci
/usr/local/share/genarrative-ci/npm/apps/*/node_modules \
/usr/local/share/genarrative-ci/npm/packages/*/node_modules \
/usr/local/share/genarrative-ci/npm/tools/*/node_modules \
&& rm -rf /root/.npm/_cacache \
&& mkdir -p /root/.npm/_cacache \
&& node /usr/local/share/genarrative-ci/export-npm-cache.mjs \
/usr/local/lib/genarrative-node/lib/node_modules/npm \
/usr/local/share/genarrative-ci/npm/package-lock.json \
/var/cache/genarrative-ci-npm/_cacache /root/.npm/_cacache \
&& npm cache verify
# 依赖预热会在 workspace 内解析出 Node 发行包自带的 npm(例如 10.9.8),
@@ -3,9 +3,6 @@
!deploy/container/
!deploy/container/gitea-ci-job.Dockerfile
!deploy/container/gitea-ci-checkout.sh
!deploy/container/gitea-ci-buildkitd.toml
!scripts/
!scripts/export-ci-npm-download-cache.mjs
!package.json
!package-lock.json
!server-rs/
@@ -22,9 +19,6 @@
!apps/ai-game-creator-shell/src-tauri/
!apps/ai-game-creator-shell/src-tauri/Cargo.toml
!apps/ai-game-creator-shell/src-tauri/Cargo.lock
!apps/ai-game-creator-shell/src-tauri/vendor/
!apps/ai-game-creator-shell/src-tauri/vendor/*/
!apps/ai-game-creator-shell/src-tauri/vendor/*/Cargo.toml
!apps/desktop-shell/
!apps/desktop-shell/package.json
!apps/desktop-shell/src-tauri/
@@ -54,4 +48,4 @@
!plugins/agc-godot-editor/
!plugins/agc-godot-editor/native/
!plugins/agc-godot-editor/native/godot-editor-bridge/
!plugins/agc-*-editor/native/*-editor-bridge/Cargo.toml
!plugins/agc-*-editor/native/*-editor-bridge/**
@@ -16,6 +16,21 @@
- 输入区删除 `versions` / `activeVersionId` / `showTriggerButton` / `onReferencePickerOpen` / `skills``assets` 被注入的 provider 取代;`projectPath` 只保留给输入区自己的润色链路。
- 输入区与宿主之间只留三个通用接缝:`providers`(引用来源)、`inputActions`(操作排里的宿主控件,例如 `@` 触发钮)、`submitSuppressed`(宿主浮层打开时 Enter 让位)。引用种类一个都不进输入区。
- `provider.match` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。
- `provider.fuzzyLookup` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。
- 附件并入 `ChatReference`,编辑器收敛为单一引用节点类型,附件 chip 的 DOM 契约逐字保留;附件导入成功后以芯片进入正文,失败不插入;控制器不再持有附件数组,`MAX_CHAT_COMPOSER_ATTACHMENTS` 改为按草稿中的附件芯片数计算,导入进行中禁止发送。
- 用户可见行为保持不变,唯一例外是已裁决的缺陷修复:非 DirectProject 宿主不再出现 `$` Skill 候选。
## 修订(2026-09-22):粘贴解析需要第二道只读缝
粘贴进来的纯文本要按同一套引用文本语法反解析回正文芯片,因此 provider 的两个查询能力按「模糊 / 精确」分开命名,各自说清自己的语义:
- `fuzzyLookup(query)`:候选菜单那条路——按 query 做包含匹配、大小写不敏感,并在 provider 内部截断到候选上限。名字写明它是模糊的,避免被拿去反查 token。
- `lookup()`:精确查找那条路——某一刻 provider 真正能解析出的全部引用,不做模糊过滤、不截断,与菜单共用同一份候选来源;仍是纯函数,由输入区在粘贴事件里同步调用,数据没到就是空数组。
这不推翻本 ADR 的懒加载结论:`onMenuQueryChange` 仍是唯一的懒加载入口,粘贴解析**只用此刻就绪的候选**,不等待、不补读。Skill 目录因此还是「用户第一次敲出 `$` 才读」——冷启动时粘贴 `$名称` 就按字面文本保留(看得见、不是猜错),不为了粘贴去提前读盘。
附件与运行画面区域仍是静默 provider:它们没有候选,所以粘贴解析不认 `@附件名` / `@区域标签`,这两类 token 粘贴时逐字保留(附件与运行区域的身份来自文件与 run,纯文本重建不出来)。
歧义口径:同一个 token 对应多条引用身份(同名素材)时一律按文本保留;解析只认显示名逐字一致(不认扩展名、resourceId、大小写变体),未命中的 token 与其余文字逐字保留。
(本次把上一条同名决策里的 `match` / `candidates` 改名为 `fuzzyLookup` / `lookup`,语义不变;旧名不再保留。)
@@ -0,0 +1,43 @@
# 引用粘贴解析实施计划
对应:[引用候选由宿主注入](../../adr/【ADR】引用候选由宿主注入-2026-09-22.md)(含 2026-09-22 修订节);决策记录见 `docs/project-memory/shared-memory/decision-log.md` 的 2026-09-22 条目。上一步的重构计划见 [引用输入区重构与宿主注入](【实施计划】引用输入区重构与宿主注入-2026-09-22.md),那份明确不含本项。
## 一句话交付与验收判据
把从用户消息气泡(或任何同口径文本)复制出来的 `@显示名` / `$名称` 粘贴进引用输入区时,原位重建同顺序的引用芯片;其余文字逐字保留。
验收判据:
1. 粘贴文本 → 引用:`@显示名``$名称` 逐字命中当前宿主注入的 provider 候选时原位换成芯片,一次 Ctrl+Z 整体回退,token 之外的每个字符(含换行与空白)原样保留。
2. 逐字一致、不做兼容别名:不认 `@hero.png``resourceId`、大小写变体、全角 ``;token 前后必须是行首 / 行尾或空白。
3. 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、不猜路径或文件名。
4. 只有真的解析出引用时才接管:同 namespace 的 `application/x-lexical-editor` 负载、不含 token 的纯文本、图片文件粘贴一律放行编辑器默认导入,现有粘贴行为逐字不变。
5. 附件与运行画面区域不参与粘贴解析(静默 provider 没有候选),它们的 token 粘贴时按文本保留。
6. Skill 目录冷启动不阻塞粘贴:`lookup()` 是纯函数,目录没到就是空数组——冷启动时粘贴 `$名称` 保留为文本(不等待、不补读),用户敲过一次 `$` 后即可解析。
## 流程判定
本次是共享组件的一处行为增量 + provider 契约加两个可选能力,不动 schema、不动公开 API/DTO、不动后端;按轻量流程只建本实施计划,不新建主规范与里程碑规范(与上一步重构同一判定)。
## 提交切分
1. **反解析口径**`resourceReferences.ts` 新增 `buildContentFromPastedText(text, references)`(粘贴侧唯一反解析;与 `buildContentFromTextTokens` 同一套边界规则),配规则矩阵单测。
2. **provider 契约**`reference-source/types.ts` 把菜单查询改名为 `fuzzyLookup(query)`、新增精确查找 `lookup()`(两者共用同一份候选来源);`resourceReferenceProvider` 给全量可提及候选,`skillReferenceProvider` 给去重后的 Skill 候选。
3. **输入区接管**`ResourceReferenceInput` 注册 `COMMAND_PRIORITY_CRITICAL``PASTE_COMMAND`,只在解析出引用时 `preventDefault` 并在一次 `editor.update``PASTE_TAG`)内按选区插入;配集成用例。
4. **文档**`CONTEXT.md` 术语、本计划、ADR 修订节、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md` 与决策记录。
## 验证
定向:`npx vitest run apps/ai-game-creator-shell/tests/resourceReferences.test.ts apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`;全量:`npm run typecheck``npm run check:encoding``npm run check:doc-index``git diff --check`。真实客户端手感(粘贴时的候选菜单位置、大段文本粘贴观感、Tauri 剪贴板只带图片时的行为)留待真机验收。
## 风险与回滚
- 解析接管会绕过 Lexical 的 `text/html` 富文本导入:只在文本里真的解析出引用时接管,取舍已在上一条判据里限定;要完全避开富文本场景可以后续按 `clipboardData.types` 再收窄。
- 气泡里若出现与素材同名的 `@区域标签` / `@附件名`,粘贴会被认成素材引用——纯文本无法区分,属已知取舍。
- 回滚按提交粒度 revertcanonical content 形状、provider 的既有能力与出站文本口径都不变。
## 执行状态(2026-09-22
已完成:`buildContentFromPastedText` 与规则矩阵单测(命中 / 未命中 / 相邻中文 / 扩展名 / 大小写 / 全角 / 同名歧义 / 重复出现 / 换行 / 与显示口径互为逆运算);provider 的 `fuzzyLookup` / `lookup` 及用例;输入区 `PASTE_COMMAND` 接管与 5 条集成用例(粘贴重建芯片、未命中保持字面、纯文本走默认导入、Lexical 负载让位、Skill 冷启动保持字面且敲过 `$` 后可解析);文档同步。
未做(本次范围外):斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片的解析、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。
@@ -1,5 +1,17 @@
# 决策记录
## 2026-09-22 引用粘贴解析:只认显示口径的 token,宁可不成芯片也不能认错
- 背景:引用输入区的 `@` / `$` 只由 `LexicalTypeaheadMenuPlugin` 的逐字敲击触发,粘贴走 Lexical 默认路径(`text/plain` → 纯文本),所以从用户消息气泡复制回来的 `@显示名` / `$名称` 粘进来就是死文本;同时 chip 的 `text/plain` 是占位符,复制出去再粘回来必然丢引用(气泡显示文本才是完整 token 的形态)。
- 决策(口径):新增粘贴侧唯一反解析 `buildContentFromPastedText(text, references)``apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`),候选由宿主注入的 provider 枚举,token 就是 `chatReferenceMentionToken`——与出站显示逐字同一个字符串,所以**不做任何兼容别名**:不认 `@hero.png``resourceId`、大小写变体、全角 ``;边界仍是「行首 / 行尾或空白」(显示侧 token 前后补空白,两端自洽)。
- 决策(宁可不成芯片也不能认错):同一个 token 对应多条引用身份(同名素材)时一律按文本保留;未命中的 token 静默保留、不提示、不猜文件名或路径;附件与运行画面区域不参与(静默 provider 没有候选,`@附件名` / `@区域标签` 按文本保留)。
- 决策(provider 契约按「模糊 / 精确」分两个口):上一条里的 `match(query)` 改名 `fuzzyLookup(query)`(名字写明它是包含匹配 + 截断的模糊菜单查询),`candidates()` 改名 `lookup()`(精确查找用的、就绪的全量候选,不模糊不截断),两者共用同一份候选来源。粘贴解析只用 `lookup()` 此刻就绪的候选:不等待、不补读,也不为了解析去提前读盘;Skill 目录仍是「用户第一次敲出 `$` 才读」,冷启动时粘贴 `$名称` 保持字面文本,敲过一次 `$` 后即可重建芯片。曾一度加过的 `onPasteText` 补读钩子已删除——它既不改变本次粘贴的结果,又让输入区反过来关心 provider 的触发符。
- 决策(接管范围):输入区在 `COMMAND_PRIORITY_CRITICAL` 注册 `PASTE_COMMAND`,**只在真的解析出引用时**接管(同 namespace 的 `application/x-lexical-editor` 负载、无 token 纯文本、图片文件一律 `return false` 走默认导入);接管时一次 `editor.update(..., { tag: PASTE_TAG })` 内按选区插入,所以一次 Ctrl+Z 整体回退,token 之外逐字保留。
- 原因:粘贴是用户此刻的编辑,事后回头改写他的输入(例如清单到齐后再把文本改成芯片)等于前端替用户重写内容;而任何「多候选取其一」「按文件名猜资源」的启发式都会制造看不出错的错引用。
- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,ResourceReferenceInput.tsx,reference-source/{types.ts,resourceReferenceProvider.ts,skillReferenceProvider.ts}}``apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts,resourceReferenceInput.test.tsx}``CONTEXT.md``docs/adr/【ADR】引用候选由宿主注入-2026-09-22.md`(修订节)、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、本文件。
- 未纳入本次:斜杠命令 `/` 解析、拖拽文本(drop)、附件 / 运行画面区域 / 文件路径 / URL / 剪贴板图片、复制侧 `text/plain` 形态调整、扩展安装卸载后的目录即时失效。
- 验证方式:`buildContentFromPastedText` 规则矩阵单测(含「显示文本再粘贴回来得到同一份 content」这条逆运算)、provider 的 `fuzzyLookup` / `lookup` 用例、输入区集成用例(真 Lexical `paste` 事件 → 芯片、未命中等价于默认粘贴、Skill 冷启动保持字面且敲过 `$` 后可解析);另跑 `npm run typecheck``npm run check:encoding``npm run check:doc-index``git diff --check`
## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离
- 背景:`ResourceReferenceInput``apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`)同时承担「拿数据」与「编辑数据」:素材以未过滤 manifest 传入后由组件自己派生候选、显示名与「当前版本素材」scope,Skill 候选由组件自己 invoke `list_agc_skill_catalog``list_client_extensions`(只在用户敲出 `$` 时触发),素材选择面板与缩略图预览 invoke 也住在组件内部。后果是 5 个宿主(DirectProject 聊天、策划输入盒、画布生成面板、资源卡快速编辑、测试夹具)无差别获得 `$` Skill 候选,而只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 SkillRust `direct_codex_user_item_to_codex_turn_input``apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs`),其余宿主只把它退化成字面文本,形成误导入口。
@@ -96,10 +96,6 @@ 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`,合并代码不等于服务启用。
修改 Gitea workflow 的 job 显示名称、ID 或缓存导出组时,必须同步维护器的 `JOBS` / `RUST_JOB_IDS``test_gitea_cache_maintenance.py` 直接对照实际 workflow 检查全集和导出映射,避免自动刷新或镜像验收因名单漂移长期等待。维护器 `Api.request``method` 是必填关键字参数,GET 也必须显式指定,不根据 body 推断请求方法。
@@ -9,7 +9,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
`ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂):
- `createResourceReferenceProvider({ assets })``@` 素材候选;
- `useSkillReferenceProvider()``$` Skill 候选(读取应用级 Skill 目录,只在用户第一次敲出 `$` 时发生——输入区在 effect 里回调 provider 的 `onMenuQueryChange``match` 本身是纯函数;失败会放开重试);
- `useSkillReferenceProvider()``$` Skill 候选(读取应用级 Skill 目录,只在用户第一次敲出 `$` 时发生——输入区在 effect 里回调 provider 的 `onMenuQueryChange``fuzzyLookup` 本身是纯函数;失败会放开重试);
- 附件与运行画面区域是**静默 provider**(无触发符、无候选),只参与正文 part 的身份解析、改名刷新与文本形态。
输入区按 `providers` 数组顺序取第一个非空回答,不判断任何引用种类,也没有总装 builder。**没注入就没有这类引用**:只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 SkillRust `direct_codex_user_item_to_codex_turn_input`),所以只有它注入 Skill provider;策划输入盒、画布生成面板、资源卡快速编辑与画布生成浮层只注入资源 + 两个静默 provider,不再出现退化成正文文本的 `$` 误导入口(已裁决的缺陷修复)。
@@ -31,6 +31,17 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用),否则返回“聊天内容不能为空”。各类引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。
## 粘贴解析(2026-09-22
把含引用 token 的纯文本粘进输入区时,可以逐字命中的 token 会原位变回引用芯片:
- 只认与显示口径逐字一致的 token:`@显示名`(素材)与 `$名称`Skill)。`@hero.png`、资源 ID、大小写变体、全角 `` 都不解析;token 前后必须是行首 / 行尾或空白(与出站文本「token 前后各留一个空白」自洽),所以从用户消息气泡复制出来的那段文字粘回来会重建同一批芯片。
- 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。
- 附件与运行画面区域的 token`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。
- 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。
- 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。
- Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。
## 拖拽引用(2026-09-21
除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env node
// 持久下载缓存可以保留旧版本;交付给 CI 镜像的快照只带当前 lock 已下载的包。
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
const [npmRoot, lockPath, source, destination] = process.argv.slice(2);
if (!npmRoot || !lockPath || !source || !destination) {
throw new Error(
'usage: export-ci-npm-download-cache.mjs <npm-root> <lock> <source> <destination>',
);
}
const require = createRequire(path.resolve(npmRoot, 'package.json'));
const cacache = require('cacache');
const lock = JSON.parse(await fs.readFile(lockPath, 'utf8'));
const integrities = new Set(
Object.values(lock.packages).flatMap((entry) =>
typeof entry.integrity === 'string' ? [entry.integrity] : [],
),
);
let count = 0;
for await (const entry of cacache.ls.stream(source)) {
if (!integrities.has(entry.integrity)) continue;
await pipeline(
cacache.get.stream(source, entry.key, { integrity: entry.integrity }),
cacache.put.stream(destination, entry.key, {
integrity: entry.integrity,
metadata: entry.metadata,
}),
);
count += 1;
}
console.log(
`[ci-image] exported ${count} npm cache entries for the current lock`,
);
@@ -1,83 +0,0 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const npmRoot = [
path.resolve(path.dirname(process.execPath), 'node_modules/npm'),
path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm'),
...(process.env.npm_execpath
? [path.resolve(path.dirname(process.env.npm_execpath), '..')]
: []),
].find((root) => existsSync(path.join(root, 'node_modules/cacache')));
assert.ok(npmRoot, 'tests require the cacache bundled with npm');
const cacache = createRequire(path.join(npmRoot, 'package.json'))('cacache');
const script = fileURLToPath(
new URL('./export-ci-npm-download-cache.mjs', import.meta.url),
);
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ci-npm-snapshot-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source');
const destination = path.join(root, 'destination');
const lock = path.join(root, 'package-lock.json');
const key =
'make-fetch-happen:request-cache:https://registry.npmjs.org/example/-/example-1.0.0.tgz';
const metadata = {
url: key.slice('make-fetch-happen:request-cache:'.length),
};
const integrity = String(
await cacache.put(source, key, 'current-package', { metadata }),
);
await cacache.put(source, 'old-package', 'unused-old-version');
await fs.writeFile(
lock,
JSON.stringify({ packages: { 'node_modules/example': { integrity } } }),
);
const run = () =>
spawnSync(process.execPath, [script, npmRoot, lock, source, destination], {
encoding: 'utf8',
});
return { source, destination, key, metadata, integrity, run };
}
test('exports only current lock content, preserving npm request metadata for offline use', async (t) => {
const f = await fixture(t);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(Object.keys(await cacache.ls(f.destination)), [f.key]);
const output = await cacache.get(f.destination, f.key);
assert.equal(output.data.toString(), 'current-package');
assert.deepEqual(output.metadata, f.metadata);
// 输出是独立快照;移走持久缓存仍可使用,不依赖挂载、链接或旧 builder。
await fs.rm(f.source, { recursive: true });
assert.equal(
(await cacache.get(f.destination, f.key)).data.toString(),
'current-package',
);
});
test('rejects a corrupted cached package instead of publishing it', async (t) => {
const f = await fixture(t);
const digest = Buffer.from(f.integrity.split('-')[1], 'base64').toString(
'hex',
);
const contentPath = path.join(
f.source,
'content-v2',
'sha512',
digest.slice(0, 2),
digest.slice(2, 4),
digest.slice(4),
);
await fs.writeFile(contentPath, 'corrupted-package');
const result = f.run();
assert.notEqual(result.status, 0);
assert.match(result.stderr, /EINTEGRITY|EBADSIZE/);
});
+3 -47
View File
@@ -6,31 +6,12 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
dockerfile_context_path="deploy/container/gitea-ci-job.Dockerfile"
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260920.2}"
runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}"
builder_name="genarrative-ci-images"
prepare_builder() {
if ! docker buildx version >/dev/null 2>&1; then
echo 'Gitea CI image builds require the Docker Buildx plugin; see deploy/container/README.md.' >&2
return 1
fi
if ! docker buildx inspect "${builder_name}" >/dev/null 2>&1; then
docker buildx create --name "${builder_name}" --driver docker-container \
--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}" | awk '$1 == "Driver:" { print $2 }')" != docker-container ]]; then
echo "${builder_name} must use the isolated docker-container driver" >&2
return 1
fi
}
write_build_context_file_list() {
printf '%s\0' \
deploy/container/gitea-ci-job.Dockerfile \
deploy/container/gitea-ci-job.Dockerfile.dockerignore \
deploy/container/gitea-ci-buildkitd.toml \
deploy/container/gitea-ci-checkout.sh \
scripts/export-ci-npm-download-cache.mjs \
package.json \
package-lock.json \
apps/admin-web/package.json \
@@ -48,9 +29,8 @@ write_build_context_file_list() {
server-rs/Cargo.lock \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/desktop-shell/src-tauri/Cargo.lock
find server-rs/crates apps/ai-game-creator-shell/src-tauri/vendor \
plugins/agc-*-editor/native/*-editor-bridge \
-name Cargo.toml \
find server-rs/crates plugins/agc-*-editor/native/*-editor-bridge \
\( -name Cargo.toml -o -path 'plugins/agc-*-editor/native/*-editor-bridge/*' \) \
-type f -print0 \
| sort -z
}
@@ -59,7 +39,6 @@ usage() {
cat <<'EOF'
用法:
bash scripts/gitea-ci-job-image.sh build
bash scripts/gitea-ci-job-image.sh seed-downloads <可信 CI 镜像完整 Image ID>
bash scripts/gitea-ci-job-image.sh revision
bash scripts/gitea-ci-job-image.sh verify [镜像引用]
bash scripts/gitea-ci-job-image.sh load-runner [镜像引用]
@@ -87,28 +66,6 @@ verify_image() {
command_name="${1:-}"
case "${command_name}" in
seed-downloads)
# 运维显式指定的可信镜像只贡献下载包,不作为新基础镜像的父层。
seed_image="${2:-}"
[[ "${seed_image}" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo 'seed requires a full trusted Image ID' >&2; exit 2; }
prepare_builder
seed_dir="$(mktemp -d)"
seed_container=""
cleanup_seed() {
if [[ -n "${seed_container}" ]]; then docker rm --volumes "${seed_container}" >/dev/null; fi
rm -rf -- "${seed_dir}"
}
trap cleanup_seed EXIT
seed_container="$(docker create "${seed_image}")"
mkdir -p "${seed_dir}/cargo-cache" "${seed_dir}/cargo-index" "${seed_dir}/npm"
docker cp "${seed_container}:/usr/local/cargo/registry/cache/." "${seed_dir}/cargo-cache/"
docker cp "${seed_container}:/usr/local/cargo/registry/index/." "${seed_dir}/cargo-index/"
docker cp "${seed_container}:/root/.npm/_cacache/." "${seed_dir}/npm/"
docker buildx build --builder "${builder_name}" --progress plain \
--target download-cache-seed --no-cache-filter download-cache-seed \
--build-context "download-seed=${seed_dir}" \
--file "${repo_root}/${dockerfile_context_path}" "${seed_dir}"
;;
revision)
# 与 build 的 IMAGE_REVISION 使用同一份输入顺序,用于维护器判断基础镜像是否过期。
(
@@ -117,7 +74,6 @@ case "${command_name}" in
)
;;
build)
prepare_builder
image_revision="$(bash "${BASH_SOURCE[0]}" revision)"
npm_lock_sha256="$(sha256sum "${repo_root}/package-lock.json")"
npm_lock_sha256="${npm_lock_sha256%% *}"
@@ -131,7 +87,7 @@ case "${command_name}" in
cd "${repo_root}"
write_build_context_file_list \
| tar --null --create --file - --files-from=- \
| docker buildx build --builder "${builder_name}" --load --progress plain \
| docker build \
--pull=false \
--build-arg "IMAGE_REVISION=${image_revision}" \
--build-arg "NPM_LOCK_SHA256=${npm_lock_sha256}" \

Some files were not shown because too many files have changed in this diff Show More