Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2f85c089 | |||
| 4380d453d7 | |||
| f502829fde | |||
| a56790eaa6 | |||
| 6e1fbac5d2 | |||
| b93d15e46e | |||
| 57a72f652c | |||
| 55801deb38 | |||
| a46302846a | |||
| 1eaa51b72b | |||
| 185649323e | |||
| 05a5ea4d85 | |||
| 944ae9a6eb | |||
| 7bc411119c | |||
| 3103a6e632 | |||
| b417144baf | |||
| fd0c1007ad | |||
| 079466b29b | |||
| 1e6b0e5684 | |||
| a0d72bbef2 |
@@ -561,7 +561,9 @@ 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'
|
||||
run: |
|
||||
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
|
||||
node --test scripts/export-ci-npm-download-cache.test.mjs
|
||||
|
||||
- name: Run repository checks
|
||||
run: npm run check:repository-ci
|
||||
|
||||
@@ -210,14 +210,6 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内
|
||||
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
|
||||
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
|
||||
|
||||
**引用候选枚举**:
|
||||
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
|
||||
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
|
||||
|
||||
**引用粘贴解析**:
|
||||
把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。
|
||||
_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字
|
||||
|
||||
**引用输入区**:
|
||||
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
|
||||
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
|
||||
@@ -248,7 +240,6 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
|
||||
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
|
||||
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
|
||||
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
|
||||
- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token(`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。
|
||||
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
|
||||
|
||||
## Example dialogue
|
||||
|
||||
@@ -2641,48 +2641,6 @@ const databaseTableLabelMap: Record<string, string> = {
|
||||
profile_recharge_order: '充值订单',
|
||||
profile_feedback_submission: '反馈提交',
|
||||
profile_save_archive: '存档记录',
|
||||
story_session: '剧情会话',
|
||||
story_event: '剧情事件',
|
||||
npc_state: 'NPC 状态',
|
||||
inventory_slot: '背包槽位',
|
||||
battle_state: '战斗状态',
|
||||
treasure_record: '宝藏记录',
|
||||
quest_record: '任务记录',
|
||||
quest_log: '任务日志',
|
||||
player_progression: '玩家进度',
|
||||
chapter_progression: '章节进度',
|
||||
custom_world_profile: '自定义世界档案',
|
||||
custom_world_session: '自定义世界会话',
|
||||
custom_world_agent_session: '自定义世界 Agent 会话',
|
||||
custom_world_agent_message: '自定义世界 Agent 消息',
|
||||
custom_world_agent_operation: '自定义世界 Agent 操作',
|
||||
custom_world_draft_card: '自定义世界草稿卡片',
|
||||
custom_world_gallery_entry: '自定义世界画廊条目',
|
||||
puzzle_agent_session: '拼图 Agent 会话',
|
||||
puzzle_agent_message: '拼图 Agent 消息',
|
||||
puzzle_work_profile: '拼图作品档案',
|
||||
puzzle_event: '拼图事件',
|
||||
puzzle_runtime_run: '拼图运行记录',
|
||||
puzzle_leaderboard_entry: '拼图排行榜条目',
|
||||
match3d_agent_session: '抓大鹅 Agent 会话',
|
||||
match3d_agent_message: '抓大鹅 Agent 消息',
|
||||
match3d_work_profile: '抓大鹅作品档案',
|
||||
match3d_runtime_run: '抓大鹅运行记录',
|
||||
square_hole_agent_session: '方洞挑战 Agent 会话',
|
||||
square_hole_agent_message: '方洞挑战 Agent 消息',
|
||||
square_hole_work_profile: '方洞挑战作品档案',
|
||||
square_hole_runtime_run: '方洞挑战运行记录',
|
||||
visual_novel_agent_session: '视觉小说 Agent 会话',
|
||||
visual_novel_agent_message: '视觉小说 Agent 消息',
|
||||
visual_novel_work_profile: '视觉小说作品档案',
|
||||
visual_novel_runtime_run: '视觉小说运行记录',
|
||||
visual_novel_runtime_history_entry: '视觉小说历史条目',
|
||||
visual_novel_runtime_event: '视觉小说运行事件',
|
||||
big_fish_creation_session: '大鱼吃小鱼创建会话',
|
||||
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
|
||||
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
|
||||
big_fish_event: '大鱼吃小鱼事件',
|
||||
big_fish_runtime_run: '大鱼吃小鱼运行记录',
|
||||
asset_object: '资产对象',
|
||||
asset_entity_binding: '资产实体绑定',
|
||||
asset_event: '资产事件',
|
||||
@@ -2724,48 +2682,6 @@ const databaseTableDescriptionMap: Record<string, string> = {
|
||||
profile_recharge_order: '充值订单表',
|
||||
profile_feedback_submission: '反馈提交记录表',
|
||||
profile_save_archive: '用户存档记录表',
|
||||
story_session: '剧情会话表',
|
||||
story_event: '剧情事件表',
|
||||
npc_state: 'NPC 状态表',
|
||||
inventory_slot: '背包槽位表',
|
||||
battle_state: '战斗状态表',
|
||||
treasure_record: '宝藏记录表',
|
||||
quest_record: '任务记录表',
|
||||
quest_log: '任务日志表',
|
||||
player_progression: '玩家进度表',
|
||||
chapter_progression: '章节进度表',
|
||||
custom_world_profile: '自定义世界档案表',
|
||||
custom_world_session: '自定义世界会话表',
|
||||
custom_world_agent_session: '自定义世界 Agent 会话表',
|
||||
custom_world_agent_message: '自定义世界 Agent 消息表',
|
||||
custom_world_agent_operation: '自定义世界 Agent 操作表',
|
||||
custom_world_draft_card: '自定义世界草稿卡片表',
|
||||
custom_world_gallery_entry: '自定义世界画廊条目表',
|
||||
puzzle_agent_session: '拼图 Agent 会话表',
|
||||
puzzle_agent_message: '拼图 Agent 消息表',
|
||||
puzzle_work_profile: '拼图作品档案表',
|
||||
puzzle_event: '拼图事件表',
|
||||
puzzle_runtime_run: '拼图运行记录表',
|
||||
puzzle_leaderboard_entry: '拼图排行榜条目表',
|
||||
match3d_agent_session: '抓大鹅 Agent 会话表',
|
||||
match3d_agent_message: '抓大鹅 Agent 消息表',
|
||||
match3d_work_profile: '抓大鹅作品档案表',
|
||||
match3d_runtime_run: '抓大鹅运行记录表',
|
||||
square_hole_agent_session: '方洞挑战 Agent 会话表',
|
||||
square_hole_agent_message: '方洞挑战 Agent 消息表',
|
||||
square_hole_work_profile: '方洞挑战作品档案表',
|
||||
square_hole_runtime_run: '方洞挑战运行记录表',
|
||||
visual_novel_agent_session: '视觉小说 Agent 会话表',
|
||||
visual_novel_agent_message: '视觉小说 Agent 消息表',
|
||||
visual_novel_work_profile: '视觉小说作品档案表',
|
||||
visual_novel_runtime_run: '视觉小说运行记录表',
|
||||
visual_novel_runtime_history_entry: '视觉小说历史条目表',
|
||||
visual_novel_runtime_event: '视觉小说运行事件表',
|
||||
big_fish_creation_session: '大鱼吃小鱼创建会话表',
|
||||
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
|
||||
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
|
||||
big_fish_event: '大鱼吃小鱼事件表',
|
||||
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
|
||||
asset_object: '资产对象表',
|
||||
asset_entity_binding: '资产实体绑定表',
|
||||
asset_event: '资产事件表',
|
||||
|
||||
@@ -175,6 +175,107 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'game-distribution',
|
||||
]);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'game-distribution:publish',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||
).toBe('publish');
|
||||
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'0',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
|
||||
).toContain('游戏发布入口灰度');
|
||||
});
|
||||
|
||||
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
gates: [
|
||||
...configResponse.gates,
|
||||
{
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 20,
|
||||
allowUserIds: ['user-internal'],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: '游戏发布入口灰度',
|
||||
updatedAt: '2026-09-22T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'game-distribution',
|
||||
]);
|
||||
fireEvent.click(screen.getByLabelText('启用'));
|
||||
fireEvent.change(screen.getByLabelText('灰度比例'), {
|
||||
target: { value: '20' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
|
||||
target: { value: 'user-internal' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('描述'), {
|
||||
target: { value: '游戏发布入口灰度' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 20,
|
||||
allowUserIds: ['user-internal'],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: '游戏发布入口灰度',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('未创建的预设开关在后台可见并可一键配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = await screen.findByText('game-distribution:publish');
|
||||
expect(row).not.toBeNull();
|
||||
// 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。
|
||||
const configureButton = row.closest('tr')?.querySelector('button');
|
||||
expect(configureButton).not.toBeNull();
|
||||
await user.click(configureButton!);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'game-distribution:publish',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
|
||||
@@ -28,6 +28,7 @@ interface GateTargetOption {
|
||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||
'image-editor': '画布',
|
||||
agc: '客户端',
|
||||
'game-distribution': '游戏分发',
|
||||
};
|
||||
|
||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
@@ -45,6 +46,14 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
label: 'Agent 侧边栏',
|
||||
description: '画布 Agent 入口灰度',
|
||||
},
|
||||
{
|
||||
prefix: 'game-distribution',
|
||||
suffix: 'publish',
|
||||
key: 'game-distribution:publish',
|
||||
label: '游戏发布',
|
||||
description:
|
||||
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminGrayReleaseConfigPage({
|
||||
@@ -197,6 +206,11 @@ export function AdminGrayReleaseConfigPage({
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
// 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。
|
||||
const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter(
|
||||
(option) => !gates.some((gate) => gate.gateKey === option.key),
|
||||
);
|
||||
|
||||
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
|
||||
return {
|
||||
gateKey: gateKey.trim(),
|
||||
@@ -443,6 +457,53 @@ export function AdminGrayReleaseConfigPage({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>可配置开关</h3>
|
||||
<span>{unconfiguredGateTargets.length}</span>
|
||||
</div>
|
||||
{unconfiguredGateTargets.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gate</th>
|
||||
<th>说明</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unconfiguredGateTargets.map((option) => (
|
||||
<tr key={option.key}>
|
||||
<td>
|
||||
{option.key}
|
||||
<small>
|
||||
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
|
||||
{option.label}
|
||||
</small>
|
||||
</td>
|
||||
<td>{option.description}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => applyGateTarget(option)}
|
||||
>
|
||||
配置
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '预设开关都已创建'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{confirmDialog}
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -5974,15 +5974,19 @@ pub(crate) fn create_local_project_checkpoint(
|
||||
create_local_project_checkpoint_at(root)
|
||||
}
|
||||
|
||||
/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。
|
||||
///
|
||||
/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时
|
||||
/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。
|
||||
#[tauri::command]
|
||||
pub(crate) fn export_local_project_package(
|
||||
pub(crate) async fn export_local_project_package(
|
||||
project_path: String,
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.export_package")?;
|
||||
let _lock = acquire_project_write_lock(root, "project.export_package")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
export_local_project_package_at(root)
|
||||
export_local_project_package_for_publish_at(root).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -136,6 +136,159 @@ pub(crate) fn export_local_project_package_at(
|
||||
///
|
||||
/// The caller receives the package bytes and a deterministic file manifest, but
|
||||
/// never receives a filesystem path that it could accidentally send to the API.
|
||||
/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤,
|
||||
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
|
||||
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
|
||||
|
||||
/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。
|
||||
///
|
||||
/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控
|
||||
/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。
|
||||
pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result<Option<&'static str>, String> {
|
||||
for cwd in [".", "game"] {
|
||||
let package_root = if cwd == "." {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
resolve_local_project_path(root, cwd)?
|
||||
};
|
||||
let package_path = package_root.join("package.json");
|
||||
let metadata = match fs::symlink_metadata(&package_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = fs::read_to_string(&package_path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(package) = serde_json::from_str::<serde_json::Value>(&content) else {
|
||||
continue;
|
||||
};
|
||||
let declared = package
|
||||
.get("scripts")
|
||||
.and_then(|scripts| scripts.get("build"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if declared.is_some() {
|
||||
return Ok(Some(cwd));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。
|
||||
pub(crate) fn read_publish_build_command(
|
||||
root: &Path,
|
||||
cwd_relative: &str,
|
||||
) -> Result<String, String> {
|
||||
let package_root = if cwd_relative == "." {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
resolve_local_project_path(root, cwd_relative)?
|
||||
};
|
||||
let package_path = package_root.join("package.json");
|
||||
let content = fs::read_to_string(&package_path).map_err(|error| {
|
||||
format!(
|
||||
"读取 package.json 失败:{}: {error}",
|
||||
package_path.display()
|
||||
)
|
||||
})?;
|
||||
let package: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|error| format!("解析 package.json 失败:{error}"))?;
|
||||
package
|
||||
.get("scripts")
|
||||
.and_then(|scripts| scripts.get("build"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "package.json 未定义 build 脚本".to_string())
|
||||
}
|
||||
|
||||
/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。
|
||||
fn publish_build_failure_tail(output: &str) -> String {
|
||||
const MAX_CHARS: usize = 2_000;
|
||||
let trimmed = output.trim();
|
||||
let chars = trimmed.chars().count();
|
||||
if chars <= MAX_CHARS {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::<String>();
|
||||
format!("…{tail}")
|
||||
}
|
||||
|
||||
/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct PublishBuildPlan {
|
||||
pub(crate) cwd_relative: &'static str,
|
||||
pub(crate) command: String,
|
||||
/// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。
|
||||
pub(crate) needs_dependency_install: bool,
|
||||
}
|
||||
|
||||
/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。
|
||||
pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result<PublishBuildPlan, String> {
|
||||
let Some(cwd_relative) = resolve_publish_build_cwd(root)? else {
|
||||
return Err(
|
||||
"项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let command = read_publish_build_command(root, cwd_relative)?;
|
||||
let needs_dependency_install =
|
||||
cwd_relative == "game" && !root.join("game").join("node_modules").is_dir();
|
||||
Ok(PublishBuildPlan {
|
||||
cwd_relative,
|
||||
command,
|
||||
needs_dependency_install,
|
||||
})
|
||||
}
|
||||
|
||||
/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。
|
||||
///
|
||||
/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包;
|
||||
/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败
|
||||
/// 返回带日志尾部的可操作错误,不回传本地路径。
|
||||
pub(crate) async fn export_local_project_package_for_publish_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
if validate_project_game_entry(root).is_ok() {
|
||||
return export_local_project_package_at(root);
|
||||
}
|
||||
let plan = resolve_publish_build_plan(root)?;
|
||||
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
|
||||
// 否则作者要点两次(先 bootstrap 再发布)。
|
||||
if plan.needs_dependency_install {
|
||||
let bootstrap =
|
||||
crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?;
|
||||
if bootstrap.status != "completed" {
|
||||
return Err(format!(
|
||||
"安装 game 依赖失败(npm install 未通过):\n{}",
|
||||
publish_build_failure_tail(&bootstrap.output)
|
||||
));
|
||||
}
|
||||
}
|
||||
let built = crate::project::verification::run_project_verification_with_commit_at(
|
||||
root,
|
||||
"build",
|
||||
&plan.command,
|
||||
PUBLISH_BUILD_TIMEOUT_SECONDS,
|
||||
plan.cwd_relative,
|
||||
|| Ok(()),
|
||||
)
|
||||
.await?;
|
||||
if built.status != "completed" {
|
||||
return Err(format!(
|
||||
"构建可玩版本失败(npm run build 未通过):\n{}",
|
||||
publish_build_failure_tail(&built.output)
|
||||
));
|
||||
}
|
||||
validate_project_game_entry(root)
|
||||
.map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?;
|
||||
export_local_project_package_at(root)
|
||||
}
|
||||
|
||||
pub(crate) fn read_local_project_export_package_at(
|
||||
root: &Path,
|
||||
package_relative_path: &str,
|
||||
|
||||
@@ -3935,6 +3935,165 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-plan-game", "Phaser 工程").expect("project init");
|
||||
|
||||
// 脚手架是 game/ + vite build(Phaser 4 工程):缺依赖时必须先 install。
|
||||
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
|
||||
assert_eq!(plan.cwd_relative, "game");
|
||||
assert_eq!(plan.command, "vite build");
|
||||
assert!(
|
||||
plan.needs_dependency_install,
|
||||
"缺少 game/node_modules 时应先装依赖"
|
||||
);
|
||||
|
||||
fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules");
|
||||
let installed = resolve_publish_build_plan(&root).expect("解析构建计划");
|
||||
assert!(
|
||||
!installed.needs_dependency_install,
|
||||
"已有依赖时不应重复 install"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_build_plan_falls_back_to_root_npm_build() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-plan-root", "根工程构建").expect("project init");
|
||||
// 去掉 game 子工程的 build,改用项目根 npm 工程构建。
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "plan-root-game",
|
||||
"private": true,
|
||||
"scripts": { "check": "node -e \"process.exit(0)\"" }
|
||||
}))
|
||||
.expect("serialize game package json"),
|
||||
)
|
||||
.expect("write game package json");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "plan-root-fixture",
|
||||
"private": true,
|
||||
"scripts": { "build": "node build-root.mjs" }
|
||||
}))
|
||||
.expect("serialize root package json"),
|
||||
)
|
||||
.expect("write root package json");
|
||||
|
||||
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
|
||||
assert_eq!(plan.cwd_relative, ".");
|
||||
assert_eq!(plan.command, "node build-root.mjs");
|
||||
assert!(!plan.needs_dependency_install);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_runs_project_build_before_packaging() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-auto-build", "自动构建发布项目")
|
||||
.expect("project init");
|
||||
// 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "publish-auto-build-fixture",
|
||||
"private": true,
|
||||
"scripts": { "build": "node build-publish.mjs" }
|
||||
}))
|
||||
.expect("serialize package json"),
|
||||
)
|
||||
.expect("write package json");
|
||||
fs::write(
|
||||
root.join("build-publish.mjs"),
|
||||
r#"import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
mkdirSync('dist/assets', { recursive: true });
|
||||
writeFileSync('dist/index.html', '<!doctype html><html><head><meta charset="utf-8"><title>Auto Build</title><script src="assets/app.js"></script></head><body><h1>AUTO-BUILD</h1></body></html>');
|
||||
writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";');
|
||||
"#,
|
||||
)
|
||||
.expect("write build script");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect("发布前构建并导出");
|
||||
|
||||
assert!(root.join("dist/index.html").is_file());
|
||||
assert!(root.join("dist/assets/app.js").is_file());
|
||||
assert!(result
|
||||
.package_relative_path
|
||||
.starts_with("exports/playtest-package-"));
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
log.contains("project.verify build"),
|
||||
"发布前应记录一次 project.verify build:{log}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_skips_build_when_playable_entry_exists() {
|
||||
let root = unique_project_path();
|
||||
init_existing_html_project_at(&root, "project-publish-skip", "已构建发布项目")
|
||||
.expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect("已有可玩入口时直接导出");
|
||||
|
||||
assert!(result.package_relative_path.ends_with(".zip"));
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
!log.contains("project.verify build"),
|
||||
"已有可玩入口时不应触发构建:{log}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_reports_actionable_error_without_entry_or_build_script() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目")
|
||||
.expect("project init");
|
||||
// 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "publish-no-entry-fixture",
|
||||
"private": true,
|
||||
"scripts": { "check": "node -e \"process.exit(0)\"" }
|
||||
}))
|
||||
.expect("serialize package json"),
|
||||
)
|
||||
.expect("write package json");
|
||||
|
||||
let error = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect_err("缺少入口且没有 build 脚本时必须失败关闭");
|
||||
|
||||
assert!(
|
||||
error.contains("还没有可玩入口"),
|
||||
"错误应说明缺少可玩入口:{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("build 脚本"),
|
||||
"错误应指向 build 脚本:{error}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -1206,16 +1206,27 @@ export function App({
|
||||
* 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出;
|
||||
* 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。
|
||||
*/
|
||||
/**
|
||||
* 发布相关提示同时写工作台状态与 DirectProject 对话。
|
||||
*
|
||||
* 普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,只写 workspaceStatus
|
||||
* 会让「点了发布没反应」;这里统一通过聊天容器的 announce 出口回话。
|
||||
*/
|
||||
function announcePublishMessage(message: string) {
|
||||
setWorkspaceStatus(message);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
}
|
||||
|
||||
async function requestGamePublish() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setWorkspaceStatus('需要在 Tauri App 内发布');
|
||||
announcePublishMessage('需要在 Tauri App 内发布');
|
||||
return;
|
||||
}
|
||||
const nextProjectPath =
|
||||
resolveChatProjectPath(localProject) ?? projectPath.trim();
|
||||
if (!nextProjectPath) {
|
||||
setWorkspaceStatus('先打开一个项目再发布');
|
||||
announcePublishMessage('先打开一个项目再发布');
|
||||
return;
|
||||
}
|
||||
const runExport = async () => {
|
||||
@@ -1224,7 +1235,9 @@ export function App({
|
||||
'export_local_project_package',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`);
|
||||
announcePublishMessage(
|
||||
`已构建并打包试玩包:${result.packageRelativePath}`,
|
||||
);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
appendLocalPermissionLog(
|
||||
@@ -1233,7 +1246,7 @@ export function App({
|
||||
'project.export_package',
|
||||
);
|
||||
} catch (error) {
|
||||
setWorkspaceStatus(
|
||||
announcePublishMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<ReactMarkdown
|
||||
skipHtml
|
||||
remarkPlugins={[remarkGfm]}
|
||||
// 流式中不高亮:高亮要跑一遍完整 AST + highlight.js,而流式增量会让同一段代码
|
||||
// 每来一个 chunk 就重新高亮一次(文本还在长,结果立刻作废)。文本定稿时
|
||||
// `streaming` 变回 false,这一笔高亮自然补上。
|
||||
rehypePlugins={
|
||||
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
|
||||
streaming || text.length > MAX_HIGHLIGHT_CHARACTERS
|
||||
? []
|
||||
: [rehypeHighlight]
|
||||
}
|
||||
components={
|
||||
streaming ? streamingMarkdownComponents : markdownComponents
|
||||
|
||||
+34
-118
@@ -16,13 +16,10 @@ 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';
|
||||
@@ -56,7 +53,6 @@ import {
|
||||
ResourceReferenceNode,
|
||||
} from './ResourceReferenceNode';
|
||||
import {
|
||||
buildContentFromPastedText,
|
||||
buildContentFromTextTokens,
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
@@ -69,12 +65,6 @@ 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;
|
||||
@@ -300,59 +290,6 @@ 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 之外的每个字符照原样保留。
|
||||
*
|
||||
* 认不出的引用 part(provider 的 `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 原样返回表示无需改写。
|
||||
@@ -424,14 +361,24 @@ function ResourceReferenceEditor({
|
||||
(nextReferences: ChatReference[]) => {
|
||||
if (nextReferences.length === 0) return;
|
||||
editor.update(() => {
|
||||
const selection = $selectionOrRootEnd();
|
||||
if (!selection) return;
|
||||
selection.insertNodes(
|
||||
nextReferences.flatMap((reference) => [
|
||||
$createResourceReferenceNode(reference),
|
||||
$createTextNode(' '),
|
||||
]),
|
||||
);
|
||||
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(' '),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
editor.focus();
|
||||
},
|
||||
@@ -443,11 +390,19 @@ function ResourceReferenceEditor({
|
||||
const insert = text.replace(/\s+$/u, '');
|
||||
if (!insert.trim()) return;
|
||||
editor.update(() => {
|
||||
const selection = $selectionOrRootEnd();
|
||||
if (!selection) return;
|
||||
const rootText = $getRoot().getTextContent();
|
||||
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
|
||||
selection.insertText(insert);
|
||||
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);
|
||||
}
|
||||
});
|
||||
editor.focus();
|
||||
},
|
||||
@@ -567,45 +522,6 @@ 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`(资源侧两处入口共用同一份);这里只剩下
|
||||
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
|
||||
@@ -880,16 +796,16 @@ function ProviderMentionMenu({
|
||||
[onOpenChange, trigger],
|
||||
);
|
||||
|
||||
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`fuzzyLookup` 始终保持纯函数,
|
||||
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`match` 始终保持纯函数,
|
||||
// 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。
|
||||
useEffect(() => {
|
||||
provider.onMenuQueryChange?.(query);
|
||||
}, [provider, query]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (query === null || !provider.fuzzyLookup) return [];
|
||||
if (query === null || !provider.match) return [];
|
||||
return provider
|
||||
.fuzzyLookup(query)
|
||||
.match(query)
|
||||
.map((reference) => new ReferenceMentionOption(reference));
|
||||
}, [provider, query]);
|
||||
|
||||
|
||||
+1
-3
@@ -49,14 +49,12 @@ export function createResourceReferenceProvider({
|
||||
}): ReferenceProvider {
|
||||
return {
|
||||
trigger: '@',
|
||||
fuzzyLookup: (query) =>
|
||||
match: (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);
|
||||
|
||||
+17
-29
@@ -49,10 +49,7 @@ function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
|
||||
]).then(([builtin, client]) => [...builtin, ...client]);
|
||||
}
|
||||
|
||||
function matchesSkillQuery(
|
||||
skill: { name: string; description?: string },
|
||||
query: string,
|
||||
) {
|
||||
function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return (
|
||||
@@ -61,22 +58,6 @@ function matchesSkillQuery(
|
||||
);
|
||||
}
|
||||
|
||||
/** 目录项 → 引用:同名只留第一条(与 `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)。
|
||||
*
|
||||
@@ -115,15 +96,22 @@ export function useSkillReferenceProvider(): ReferenceProvider {
|
||||
onMenuQueryChange: (query) => {
|
||||
if (query !== null) ensureCatalog();
|
||||
},
|
||||
// 精确查找:目录还没就绪(用户还没敲过 `$`)时返回空数组,本次 `$名称` 逐字保留。
|
||||
lookup: () => skillReferences(skills),
|
||||
fuzzyLookup: (query) => {
|
||||
return skillReferences(skills)
|
||||
.filter(
|
||||
(reference) =>
|
||||
reference.type === 'skill' && matchesSkillQuery(reference, query),
|
||||
)
|
||||
.slice(0, MENTION_OPTION_LIMIT);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
|
||||
part.type === 'agc_skill_reference'
|
||||
|
||||
+2
-15
@@ -19,25 +19,12 @@ export type ReferenceProvider = {
|
||||
*/
|
||||
trigger: string | null;
|
||||
/**
|
||||
* 模糊查询:候选菜单的过滤。名字写明它是模糊的——按 query 做包含匹配、大小写不敏感,
|
||||
* 并在 provider 内部截断到候选上限。静默 provider 不实现。
|
||||
* 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。
|
||||
*
|
||||
* **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会
|
||||
* 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。
|
||||
* 精确查找用 `lookup`,不要拿它反查 token。
|
||||
*/
|
||||
fuzzyLookup?: (query: string) => ChatReference[];
|
||||
/**
|
||||
* 精确查找用的全量候选:某一刻 provider 真正能解析出的所有引用,不做模糊过滤、不截断。
|
||||
*
|
||||
* 与 `fuzzyLookup` 的区别只有「模糊与截断」,两者共用同一份候选来源。没有触发符的静默
|
||||
* provider 不实现。
|
||||
*
|
||||
* **同样是纯函数**:输入区在粘贴事件里同步调用它;只返回已就绪的快照,不发起任何读取。
|
||||
* 数据还没到时返回空数组(或省略不实现),解析不出的 token 逐字保留——例如 Skill 目录的就绪
|
||||
* 时机仍是用户第一次敲出 `$`,冷启动时粘贴 `$名称` 就是字面文本,不会被猜成别的引用。
|
||||
*/
|
||||
lookup?: () => ChatReference[];
|
||||
match?: (query: string) => ChatReference[];
|
||||
/**
|
||||
* 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在
|
||||
* 带触发符的 provider 上调用**。
|
||||
|
||||
@@ -417,69 +417,6 @@ 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,不另立第二份事实源。
|
||||
|
||||
+10
-3
@@ -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 (
|
||||
<AgentMessageContent
|
||||
as="details"
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('资源引用 provider', () => {
|
||||
expect(provider.isReady?.()).toBe(true);
|
||||
});
|
||||
|
||||
it('候选只含可提及素材,`fuzzyLookup` 按显示名 / id / kind 模糊过滤并截断到 8 条', () => {
|
||||
it('候选只含可提及素材,`match` 按显示名 / 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.fuzzyLookup?.('')).toHaveLength(2);
|
||||
expect(scoped.match?.('')).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.fuzzyLookup?.('')).toHaveLength(8);
|
||||
expect(limited.match?.('')).toHaveLength(8);
|
||||
|
||||
expect(provider.fuzzyLookup?.('hero')?.map((item) => item.type)).toEqual([
|
||||
expect(provider.match?.('hero')?.map((item) => item.type)).toEqual([
|
||||
'resource',
|
||||
]);
|
||||
expect(provider.fuzzyLookup?.('HERO-IDLE')).toHaveLength(1);
|
||||
expect(provider.fuzzyLookup?.('character')).toHaveLength(1);
|
||||
expect(provider.fuzzyLookup?.(' hero ')).toHaveLength(1);
|
||||
expect(provider.fuzzyLookup?.('missing')).toEqual([]);
|
||||
expect(provider.match?.('HERO-IDLE')).toHaveLength(1);
|
||||
expect(provider.match?.('character')).toHaveLength(1);
|
||||
expect(provider.match?.(' hero ')).toHaveLength(1);
|
||||
expect(provider.match?.('missing')).toEqual([]);
|
||||
});
|
||||
|
||||
it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => {
|
||||
@@ -116,7 +116,7 @@ describe('资源引用 provider', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const candidates = sameName.fuzzyLookup?.('') ?? [];
|
||||
const candidates = sameName.match?.('') ?? [];
|
||||
// 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。
|
||||
expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([
|
||||
'@hero',
|
||||
@@ -127,45 +127,6 @@ 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,
|
||||
@@ -186,7 +147,7 @@ describe('资源引用 provider', () => {
|
||||
});
|
||||
|
||||
it('`refresh` 按 manifest 换显示名:改名换新引用、未变恒等、已删除原样返回', () => {
|
||||
const reference = provider.fuzzyLookup?.('hero')?.[0] as ResourceReference;
|
||||
const reference = provider.match?.('hero')?.[0] as ResourceReference;
|
||||
expect(provider.refresh(reference)).toBe(reference);
|
||||
|
||||
const renamed = createResourceReferenceProvider({
|
||||
@@ -344,7 +305,7 @@ describe('运行画面区域 provider', () => {
|
||||
});
|
||||
|
||||
describe('Skill provider', () => {
|
||||
it('触发符是 `$`:挂载与 `fuzzyLookup` 都不发查询,菜单第一次打开时才读应用级目录', async () => {
|
||||
it('触发符是 `$`:挂载与 `match` 都不发查询,菜单第一次打开时才读应用级目录', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_agc_skill_catalog') {
|
||||
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
|
||||
@@ -358,9 +319,9 @@ describe('Skill provider', () => {
|
||||
expect(result.current.trigger).toBe('$');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
// `fuzzyLookup` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
|
||||
// `match` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
|
||||
act(() => {
|
||||
expect(result.current.fuzzyLookup?.('')).toEqual([]);
|
||||
expect(result.current.match?.('')).toEqual([]);
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
@@ -372,7 +333,7 @@ describe('Skill provider', () => {
|
||||
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.fuzzyLookup?.('')).toHaveLength(1);
|
||||
expect(result.current.match?.('')).toHaveLength(1);
|
||||
});
|
||||
// 目录只读一次:后续每次敲 `$` 都复用同一份候选。
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
@@ -381,42 +342,12 @@ describe('Skill provider', () => {
|
||||
result.current.onMenuQueryChange?.(null);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.fuzzyLookup?.('测试')?.[0]).toMatchObject({
|
||||
expect(result.current.match?.('测试')?.[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') {
|
||||
@@ -463,17 +394,17 @@ describe('Skill provider', () => {
|
||||
result.current.onMenuQueryChange?.('');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.fuzzyLookup?.('')).toHaveLength(8);
|
||||
expect(result.current.match?.('')).toHaveLength(8);
|
||||
});
|
||||
// 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。
|
||||
expect(result.current.fuzzyLookup?.('builtin-0')).toHaveLength(1);
|
||||
expect(result.current.fuzzyLookup?.('builtin-')).toHaveLength(8);
|
||||
expect(result.current.fuzzyLookup?.('client-skill')).toMatchObject([
|
||||
expect(result.current.match?.('builtin-0')).toHaveLength(1);
|
||||
expect(result.current.match?.('builtin-')).toHaveLength(8);
|
||||
expect(result.current.match?.('client-skill')).toMatchObject([
|
||||
{ type: 'skill', name: 'client-skill' },
|
||||
]);
|
||||
// 未启用与非 Skill 扩展都不是候选。
|
||||
expect(result.current.fuzzyLookup?.('client-off')).toEqual([]);
|
||||
expect(result.current.fuzzyLookup?.('client-plugin')).toEqual([]);
|
||||
expect(result.current.match?.('client-off')).toEqual([]);
|
||||
expect(result.current.match?.('client-plugin')).toEqual([]);
|
||||
});
|
||||
|
||||
it('一次瞬时失败不锁死候选:下一次 `match` 还会重读目录,并在控制台留痕', async () => {
|
||||
@@ -501,7 +432,7 @@ describe('Skill provider', () => {
|
||||
result.current.onMenuQueryChange?.('a');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.fuzzyLookup?.('')).toHaveLength(1);
|
||||
expect(result.current.match?.('')).toHaveLength(1);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledTimes(4);
|
||||
// 读取失败不能静默:控制台要留下可排障的一条。
|
||||
|
||||
@@ -33,7 +33,6 @@ 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,
|
||||
@@ -68,65 +67,6 @@ 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,
|
||||
@@ -291,48 +231,6 @@ 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 监听器同样会被唤醒。
|
||||
@@ -434,180 +332,6 @@ 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 认得出这个 token(lookup 有候选),但插入那一刻已经解析不出引用(例如资产刚被删)。
|
||||
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,17 +1,12 @@
|
||||
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 的文本与引用交错顺序', () => {
|
||||
@@ -113,104 +108,3 @@ 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +79,22 @@ 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,外加 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 运维人员执行。
|
||||
默认构建 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 builder(BuildKit `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 分支代码直接用于线上维护。
|
||||
|
||||
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。
|
||||
|
||||
@@ -97,8 +112,16 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到
|
||||
|
||||
### Rust 测试组编译对象快照
|
||||
|
||||
宿主下载每份 artifact 时核对 Gitea `size_in_bytes`、响应 `Content-Length`(若提供)和实际字节数,并在下载完成后立即检查 ZIP 格式与 CRC。截断、损坏归档或临时传输故障最多尝试 3 次,每次重新获取签名下载地址;不向签名地址转发 API Token。重试仍失败则删除临时下载并保留现役镜像,不能把 EOF 当作完整下载成功。
|
||||
|
||||
同一 sccache key 的完整对象 SHA 不同时,不直接视为编译结果不同:sccache 对象 ZIP 的成员写入顺序可能不同。仅对不同 job 新增对象之间的冲突,按成员名核对内容 SHA-256、权限及 ZIP 元数据,全部一致才保留一份原始对象并更新使用时间;真实内容差异、异常 ZIP 和继承对象冲突仍拒绝。此比较不改写缓存 key 或对象,不依赖 CRC 代替内容校验。
|
||||
|
||||
自动维护由宿主 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、命令环境或认证配置。
|
||||
|
||||
快照组装使用宿主 `default` Docker builder,读取已载入宿主的无对象缓存基础镜像;不能使用无法直接读取宿主镜像的独立 docker-container builder。BuildKit 的 `FROM` 不接受裸 `sha256:<Image ID>`,因此维护器在持锁期间将确定的基础 Image ID 绑定到专用临时 `assembly-base` tag,并在组装成功或失败后去除该 tag;来源元数据及 runner 配置仍使用完整 Image ID。临时别名不登记为受管基础镜像,不改变历史镜像的清理归属;进程被强杀时最多残留这一个别名,下次组装会重新绑定并清除。
|
||||
|
||||
切换先通过专属入口阻断新的 FetchTask,确认已转发的领取请求全部收到完整上游响应,并检查入口持久化跟踪的已领取任务全部结束、内层 Docker 没有活动容器。任务终态必须依据 Runner 的执行结束及最终上报协议,不能由容器暂时为空、API 已取消或请求超时推断。有任务即恢复领取并延后,不停止任务;状态未知拒绝切换。维护器只需普通账号的 `write:repository` Token(包括查询、下载及定向删除 artifact),不访问全局 Runner 管理 API。切换后等待使用该 Image ID 的完整真实 master push CI 通过,才允许下一次升级及旧镜像清理;不会自动重跑失败用例或为了验收额外触发整轮 CI。首次接管的历史镜像默认不归自动清理管理。
|
||||
|
||||
维护状态、凭据、归档和配置备份保存在仓库外。当前版、回滚版、待验证候选、它们的基础镜像及容器引用的镜像均受保护。清理只针对维护器登记的专属 tag、完整 Image ID 和专用目录中的归档;禁止全局 prune。API、构建、验证或空闲检查失败时保留现役镜像与回滚资料,不以失败重跑制造全绿结果。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 专用于 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"
|
||||
@@ -8,6 +8,16 @@ 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 \
|
||||
@@ -20,7 +30,9 @@ 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 find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \
|
||||
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 {} \; \
|
||||
| while IFS= read -r crate_dir; do \
|
||||
mkdir -p "${crate_dir}/src"; \
|
||||
: > "${crate_dir}/src/lib.rs"; \
|
||||
@@ -53,6 +65,16 @@ RUN find /tmp/genarrative-cargo-cache -name Cargo.toml -exec dirname {} \; \
|
||||
&& 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}
|
||||
@@ -127,6 +149,7 @@ 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
|
||||
@@ -148,10 +171,12 @@ 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 test -n "${NPM_LOCK_SHA256}" \
|
||||
RUN --mount=type=cache,id=genarrative-ci-npm-v1,target=/var/cache/genarrative-ci-npm,sharing=locked \
|
||||
test -n "${NPM_LOCK_SHA256}" \
|
||||
&& test -n "${SERVER_RUST_LOCK_SHA256}" \
|
||||
&& test -n "${DESKTOP_RUST_LOCK_SHA256}" \
|
||||
&& test -n "${AGC_RUST_LOCK_SHA256}" \
|
||||
@@ -175,6 +200,7 @@ RUN test -n "${NPM_LOCK_SHA256}" \
|
||||
&& 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 \
|
||||
@@ -194,6 +220,12 @@ RUN test -n "${NPM_LOCK_SHA256}" \
|
||||
/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,6 +3,9 @@
|
||||
!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/
|
||||
@@ -19,6 +22,9 @@
|
||||
!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/
|
||||
@@ -48,4 +54,4 @@
|
||||
!plugins/agc-godot-editor/
|
||||
!plugins/agc-godot-editor/native/
|
||||
!plugins/agc-godot-editor/native/godot-editor-bridge/
|
||||
!plugins/agc-*-editor/native/*-editor-bridge/**
|
||||
!plugins/agc-*-editor/native/*-editor-bridge/Cargo.toml
|
||||
|
||||
@@ -16,21 +16,6 @@
|
||||
|
||||
- 输入区删除 `versions` / `activeVersionId` / `showTriggerButton` / `onReferencePickerOpen` / `skills`,`assets` 被注入的 provider 取代;`projectPath` 只保留给输入区自己的润色链路。
|
||||
- 输入区与宿主之间只留三个通用接缝:`providers`(引用来源)、`inputActions`(操作排里的宿主控件,例如 `@` 触发钮)、`submitSuppressed`(宿主浮层打开时 Enter 让位)。引用种类一个都不进输入区。
|
||||
- `provider.fuzzyLookup` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 provider 的可选 `onMenuQueryChange(query)`,由输入区在 `useEffect` 里回调,菜单关闭时收到 `null`。Skill 目录因此第一次敲出 `$` 时才读,渲染期不再有 invokes 或 ref 写入。
|
||||
- `provider.match` 必须是纯函数(输入区在渲染阶段调它取候选);懒加载走 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`,语义不变;旧名不再保留。)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user