Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2
This commit is contained in:
@@ -648,6 +648,32 @@ function readHeadCommit() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git 的 %s 会把「标题后紧接说明行、没有空行」的整个首段拼成一行;
|
||||
* 更新摘要只允许展示原始提交消息的第一行,避免把说明暴露给用户。
|
||||
*/
|
||||
function commitMessageTitle(message) {
|
||||
return String(message ?? '')
|
||||
.split(/\r?\n/u, 1)[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 解析 `git log -z --format=%h%x09%B`,只保留每个 commit 的消息首行。 */
|
||||
function parseReleaseCommitLog(output) {
|
||||
return output
|
||||
.split('\0')
|
||||
.map((record) => record.trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((record) => {
|
||||
const separator = record.indexOf('\t');
|
||||
if (separator < 0) return null;
|
||||
const sha = record.slice(0, separator);
|
||||
const subject = commitMessageTitle(record.slice(separator + 1));
|
||||
return sha && subject ? { sha, subject } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一次发布到本次之间的客户端相关提交。
|
||||
*
|
||||
@@ -675,8 +701,9 @@ export function collectReleaseCommits(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'-z',
|
||||
'--no-merges',
|
||||
'--format=%h%x09%s',
|
||||
'--format=%h%x09%B',
|
||||
`${previousCommit}..${headCommit}`,
|
||||
'--',
|
||||
...paths,
|
||||
@@ -686,28 +713,22 @@ export function collectReleaseCommits(
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
return parseReleaseCommitLog(output);
|
||||
}
|
||||
|
||||
/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
|
||||
/** 自动更新摘要:逐条列客户端相关改动标题,超过上限时折叠并整体截断。 */
|
||||
export function formatReleaseNotes(
|
||||
commits,
|
||||
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
|
||||
) {
|
||||
if (!commits || commits.length === 0) return '';
|
||||
const lines = commits.slice(0, limit).map(({ sha, subject }) => {
|
||||
const lines = commits.slice(0, limit).map(({ subject }) => {
|
||||
const title = commitMessageTitle(subject);
|
||||
const trimmed =
|
||||
subject.length > subjectLength
|
||||
? `${subject.slice(0, subjectLength - 1)}…`
|
||||
: subject;
|
||||
return `- ${trimmed}(${sha})`;
|
||||
title.length > subjectLength
|
||||
? `${title.slice(0, subjectLength - 1)}…`
|
||||
: title;
|
||||
return `- ${trimmed}`;
|
||||
});
|
||||
if (commits.length > limit) {
|
||||
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
|
||||
@@ -726,20 +747,21 @@ export function collectRecentReleaseCommits({
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
|
||||
[
|
||||
'log',
|
||||
'-z',
|
||||
'--no-merges',
|
||||
`-n${limit}`,
|
||||
'--format=%h%x09%B',
|
||||
'--',
|
||||
...paths,
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const commits = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
const commits = parseReleaseCommitLog(output);
|
||||
return commits.length > 0 ? commits : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -645,17 +645,19 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
|
||||
test('no-bundle smoke skips version writes and manifest generation', async () => {
|
||||
const steps = [];
|
||||
await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
build: (_args, context) => {
|
||||
steps.push(context.channel);
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
await withEnv({ AGC_UPDATE_CHANNEL: undefined }, () =>
|
||||
buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
build: (_args, context) => {
|
||||
steps.push(context.channel);
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
@@ -1075,20 +1077,17 @@ test('release entry forwards the built artifacts and dry-run mode to the uploade
|
||||
assert.ok(source.includes('\n dryRun,\n'));
|
||||
});
|
||||
|
||||
test('release notes list client commits with short sha and bound their size', () => {
|
||||
test('release notes list client commit subjects only and bound their size', () => {
|
||||
const notes = formatReleaseNotes([
|
||||
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
|
||||
{ sha: '55af6014', subject: '修'.repeat(120) },
|
||||
]);
|
||||
const lines = notes.split('\n');
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
|
||||
const truncatedSubject = lines[1]
|
||||
.replace(/^- /u, '')
|
||||
.replace(/(55af6014)$/u, '');
|
||||
assert.equal(lines[0], '- 客户端更新切换到官方更新插件');
|
||||
const truncatedSubject = lines[1].replace(/^- /u, '');
|
||||
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
|
||||
assert.match(truncatedSubject, /…$/u);
|
||||
assert.match(lines[1], /(55af6014)$/u);
|
||||
|
||||
const many = formatReleaseNotes(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
@@ -1097,6 +1096,15 @@ test('release notes list client commits with short sha and bound their size', ()
|
||||
})),
|
||||
);
|
||||
assert.match(many, /- 其余 8 项客户端改动省略$/u);
|
||||
assert.equal(
|
||||
formatReleaseNotes([
|
||||
{
|
||||
sha: 'ignored',
|
||||
subject: '提交标题\n不应展示的说明一\n不应展示的说明二',
|
||||
},
|
||||
]),
|
||||
'- 提交标题',
|
||||
);
|
||||
assert.equal(formatReleaseNotes([]), '');
|
||||
assert.equal(formatReleaseNotes(null), '');
|
||||
});
|
||||
@@ -1120,8 +1128,17 @@ test('release commits cover only client paths and skip merge commits', () => {
|
||||
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
|
||||
'fn main() {}\n',
|
||||
);
|
||||
const commitMessagePath = path.join(
|
||||
directory,
|
||||
'.git',
|
||||
'commit-message.txt',
|
||||
);
|
||||
writeFileSync(
|
||||
commitMessagePath,
|
||||
'客户端:新增更新插件接入\n补充更新插件接入的详细说明\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
|
||||
git('commit', '--quiet', '-F', commitMessagePath);
|
||||
|
||||
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
|
||||
git('add', '.');
|
||||
@@ -1147,6 +1164,7 @@ test('release commits cover only client paths and skip merge commits', () => {
|
||||
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
|
||||
assert.ok(commits, '应能在临时仓库里收集提交');
|
||||
const subjects = commits.map((entry) => entry.subject);
|
||||
// 标题后没有空行时,git %s 会把说明行拼进标题;摘要必须取原始首行。
|
||||
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
|
||||
assert.deepEqual(subjects, [
|
||||
'客户端:侧分支改动',
|
||||
|
||||
@@ -1941,6 +1941,7 @@ for (const snippet of [
|
||||
'官方账号服务(固定)',
|
||||
'runtime_config.save',
|
||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||
'已载入客户端运行视图',
|
||||
'async function executeRunLocal',
|
||||
'function needsInitializedChatProject',
|
||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||
|
||||
@@ -6,6 +6,7 @@ mod model;
|
||||
mod network_policy;
|
||||
mod playtest;
|
||||
mod process;
|
||||
mod sweep;
|
||||
|
||||
pub use discovery::discover_chrome_or_edge;
|
||||
#[allow(unused_imports)]
|
||||
@@ -22,6 +23,7 @@ pub(crate) use process::validate_local_preview_in_browser_with_cancellation;
|
||||
pub use process::{
|
||||
validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction,
|
||||
};
|
||||
pub(crate) use sweep::sweep_stale_browser_processes;
|
||||
|
||||
pub(crate) use model::required_viewport_playtests_passed;
|
||||
pub(crate) use playtest::browser_playtest_scenario_fingerprint;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -304,6 +304,8 @@ async fn host_npm(
|
||||
|
||||
pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let started = Instant::now();
|
||||
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
|
||||
let _ = tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes).await;
|
||||
let run = async {
|
||||
let fixture = tempfile::tempdir().map_err(|_| "web-preflight-temp-unavailable")?;
|
||||
let root = fixture.path();
|
||||
|
||||
@@ -2467,6 +2467,17 @@ fn main() {
|
||||
if let Some(directory) = game_creator_runtime_config_dir() {
|
||||
setup_log.set(directory.join("diagnostics/startup.log"));
|
||||
}
|
||||
// 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。
|
||||
// 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let notes =
|
||||
tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if !notes.is_empty() {
|
||||
app_log!("startup.browser-sweep: {}", notes.join("; "));
|
||||
}
|
||||
});
|
||||
setup_log.append("startup.appdata.configure.complete");
|
||||
let config_dir = game_creator_runtime_config_dir().ok_or_else(|| {
|
||||
let error = std::io::Error::new(
|
||||
|
||||
@@ -1810,3 +1810,112 @@ setInterval(() => {}, 1000);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_process_job_terminate_reaps_process_tree() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
// 系统进程快照(pid, ppid),用于证明 ping 子进程真实存在并被收割。
|
||||
fn windows_process_snapshot_for_test() -> Vec<(u32, u32)> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
TH32CS_SNAPPROCESS,
|
||||
};
|
||||
// SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut entry = PROCESSENTRY32W::default();
|
||||
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||
let mut processes = Vec::new();
|
||||
// SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。
|
||||
let mut available = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||
while available != 0 {
|
||||
processes.push((entry.th32ProcessID, entry.th32ParentProcessID));
|
||||
// SAFETY: 同上。
|
||||
available = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||
}
|
||||
// SAFETY: snapshot 是本函数持有的合法句柄。
|
||||
unsafe { CloseHandle(snapshot) };
|
||||
processes
|
||||
}
|
||||
|
||||
// cmd 启动第一个 ping 子进程后整树存活;terminate 必须连子进程一起收割。
|
||||
// timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。
|
||||
let mut child = Command::new("cmd.exe")
|
||||
.args([
|
||||
"/c",
|
||||
"ping",
|
||||
"127.0.0.1",
|
||||
"-n",
|
||||
"60",
|
||||
"&",
|
||||
"ping",
|
||||
"127.0.0.1",
|
||||
"-n",
|
||||
"60",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn cmd fixture");
|
||||
let job = WindowsProcessJob::assign_std(&child).expect("assign job");
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
// 前置检查不能只凭 Job 非空:快照里必须真的看到 cmd 拉起了 ping 子进程。
|
||||
let tree_before: Vec<u32> = {
|
||||
let snapshot = windows_process_snapshot_for_test();
|
||||
let mut tree = vec![child.id()];
|
||||
let mut index = 0;
|
||||
while index < tree.len() {
|
||||
let parent = tree[index];
|
||||
for (pid, ppid) in &snapshot {
|
||||
if *ppid == parent && !tree.contains(pid) {
|
||||
tree.push(*pid);
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
tree
|
||||
};
|
||||
assert!(
|
||||
tree_before.len() >= 2,
|
||||
"fixture 必须包含 ping 子进程,否则收割断言是空转: {tree_before:?}"
|
||||
);
|
||||
assert!(
|
||||
!job.is_empty().expect("query job"),
|
||||
"fixture 进程树必须先存活,否则收割断言是空转"
|
||||
);
|
||||
job.terminate().expect("terminate job");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !job.is_empty().expect("query job") {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Windows Job 进程树未被收割"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// Job 为空之外,快照里的整棵树(含 ping 子进程)也必须真实消失。
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let snapshot = windows_process_snapshot_for_test();
|
||||
let survivors: Vec<u32> = tree_before
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|pid| snapshot.iter().any(|(live, _)| live == pid))
|
||||
.collect();
|
||||
if survivors.is_empty() {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Job terminate 后 fixture 子进程仍存活: {survivors:?}"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
@@ -170,7 +170,6 @@ type AppProps = {
|
||||
initialProjectManifest?: GameCreationAppManifest;
|
||||
initialProjectKind?: LocalProjectKind;
|
||||
planningStartMode?: boolean;
|
||||
activeVersionId?: ProjectChatComponentProps['activeVersionId'];
|
||||
initialPlanningPrompt?: string;
|
||||
initialPlanningPromptClaimScope?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
@@ -184,6 +183,7 @@ type AppProps = {
|
||||
metadata?: ProjectManifestSnapshotMetadata,
|
||||
) => void;
|
||||
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
|
||||
onRunNotice?: ProjectChatComponentProps['onRunNotice'];
|
||||
onAgentRuntimeSummariesChange?: (
|
||||
summaries: ProjectAgentRuntimeSummary[],
|
||||
) => void;
|
||||
@@ -206,7 +206,6 @@ export function App({
|
||||
initialProjectManifest,
|
||||
initialProjectKind = 'web',
|
||||
planningStartMode = false,
|
||||
activeVersionId = null,
|
||||
initialPlanningPrompt = '',
|
||||
initialPlanningPromptClaimScope = '',
|
||||
initialCreationType = null,
|
||||
@@ -216,6 +215,7 @@ export function App({
|
||||
onPlayRequestHandled,
|
||||
onManifestChange,
|
||||
onPreviewChange,
|
||||
onRunNotice,
|
||||
onAgentRuntimeSummariesChange,
|
||||
onAgentResultsChange,
|
||||
}: AppProps = {}) {
|
||||
@@ -812,9 +812,7 @@ export function App({
|
||||
const agentRuntimeResumeProjectPathRef = useRef<string | null>(null);
|
||||
const initialProjectOpenedRef = useRef(false);
|
||||
const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null);
|
||||
const executeRunLocalRef = useRef<(announceToChat: boolean) => void>(
|
||||
() => undefined,
|
||||
);
|
||||
const executeRunLocalRef = useRef<() => void>(() => undefined);
|
||||
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
|
||||
|
||||
function requestRuntimeConfigOpen() {
|
||||
@@ -1388,7 +1386,7 @@ export function App({
|
||||
}
|
||||
handledPlayRequestRef.current = requestKey;
|
||||
onPlayRequestHandled?.(playRequest.requestId);
|
||||
void executeRunLocalRef.current(true);
|
||||
void executeRunLocalRef.current();
|
||||
}, [localProject?.projectPath, onPlayRequestHandled, playRequest]);
|
||||
|
||||
/**
|
||||
@@ -1720,20 +1718,6 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作台壳要把一句结果说给用户在项目对话里听。
|
||||
*
|
||||
* DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流;
|
||||
* 立项策划路径仍写壳自己的 `messages`。
|
||||
*/
|
||||
function announceProjectChatMessage(text: string) {
|
||||
if (directProjectMode) {
|
||||
directProjectChatRef.current?.announce(text);
|
||||
return;
|
||||
}
|
||||
setMessages((current) => [...current, { role: 'assistant', text }]);
|
||||
}
|
||||
|
||||
async function executeChatAgentReply({
|
||||
prompt,
|
||||
clientTurnId: directConversationTurnId,
|
||||
@@ -1839,19 +1823,18 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRunLocal(announceToChat: boolean) {
|
||||
async function executeRunLocal() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
if (announceToChat) {
|
||||
announceProjectChatMessage('需要在 Tauri App 内运行。');
|
||||
}
|
||||
onRunNotice?.({ tone: 'error', message: '需要在 Tauri App 内运行。' });
|
||||
return;
|
||||
}
|
||||
const nextProjectPath = resolveChatProjectPath(localProject);
|
||||
if (!nextProjectPath) {
|
||||
if (announceToChat) {
|
||||
announceProjectChatMessage('请先用 /project 设置本地项目。');
|
||||
}
|
||||
onRunNotice?.({
|
||||
tone: 'error',
|
||||
message: '请先用 /project 设置本地项目。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1862,11 +1845,9 @@ export function App({
|
||||
);
|
||||
if (activePreview) {
|
||||
updateClientPreview(activePreview);
|
||||
if (announceToChat) {
|
||||
announceProjectChatMessage(
|
||||
`已切换到客户端运行视图:${activePreview.url}`,
|
||||
);
|
||||
}
|
||||
// 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里
|
||||
// 写一条「已切换到客户端运行视图:URL」。
|
||||
onRunNotice?.({ message: '已载入客户端运行视图' });
|
||||
return;
|
||||
}
|
||||
const previewResult = await invoke<LocalPreviewResult>(
|
||||
@@ -1878,16 +1859,19 @@ export function App({
|
||||
if (!directProjectMode) {
|
||||
void refreshAgentRunTrace(nextProjectPath);
|
||||
}
|
||||
if (announceToChat) {
|
||||
announceProjectChatMessage(
|
||||
`运行通过,已载入客户端运行视图:${previewResult.url}`,
|
||||
);
|
||||
}
|
||||
/*
|
||||
* 成功与失败都走工作台壳的 toast,对话区不再承载这条过程反馈,所以这两处不受
|
||||
* `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、
|
||||
* 恒为 true(见 `executeRunLocalRef.current(true)`)。
|
||||
*/
|
||||
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (announceToChat) {
|
||||
announceProjectChatMessage(message);
|
||||
}
|
||||
onRunNotice?.({
|
||||
tone: 'error',
|
||||
message: `运行游戏失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2213,11 +2197,6 @@ export function App({
|
||||
const chatProjectAssets = manifest.assets.filter(
|
||||
(asset) => asset.localPath && !asset.localPath.startsWith('.agent/'),
|
||||
);
|
||||
// `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest,
|
||||
// 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出,
|
||||
// 传 `null` 表示回退到 manifest 中最新的版本。
|
||||
const chatProjectVersions = manifest.versions ?? [];
|
||||
const chatActiveVersionId = activeVersionId;
|
||||
|
||||
const visibleMessages = latestVisibleItems(
|
||||
messages,
|
||||
@@ -2346,7 +2325,6 @@ export function App({
|
||||
<>
|
||||
<PlanningChatView
|
||||
initialPlanningPrompt={initialPlanningPrompt}
|
||||
activeVersionId={chatActiveVersionId}
|
||||
composerRef={chatComposerRef}
|
||||
chatProjectAssets={chatProjectAssets}
|
||||
hiddenConversationCount={hiddenConversationCount}
|
||||
@@ -2476,7 +2454,6 @@ export function App({
|
||||
attachmentNotice={chatFileImportNotice}
|
||||
importingFiles={chatFilesImporting}
|
||||
onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)}
|
||||
versions={chatProjectVersions}
|
||||
/>
|
||||
{runtimeConfigOpen ? (
|
||||
<RuntimeConfigDialog
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ProjectRunNotice } from './model';
|
||||
|
||||
/**
|
||||
* 成功提示一闪而过就够;失败提示要留得够久,用户得看清是什么没跑起来。
|
||||
*
|
||||
* 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它
|
||||
* 唯一的出口——2.6 秒对错误太短。
|
||||
*/
|
||||
const RUN_NOTICE_MILLIS: Record<'success' | 'error', number> = {
|
||||
success: 2600,
|
||||
error: 6000,
|
||||
};
|
||||
|
||||
export type RunNotice = ProjectRunNotice & {
|
||||
/** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */
|
||||
id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行 / 预览类动作的浮层提示。
|
||||
*
|
||||
* 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面;
|
||||
* 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。
|
||||
*/
|
||||
export function RunNoticeToast({
|
||||
notice,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: RunNotice | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
onDismiss,
|
||||
RUN_NOTICE_MILLIS[notice.tone ?? 'success'],
|
||||
);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [notice, onDismiss]);
|
||||
|
||||
if (!notice) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
key={notice.id}
|
||||
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] -translate-x-1/2"
|
||||
data-project-run-notice-toast="true"
|
||||
>
|
||||
<PlatformRuntimeStatusToast
|
||||
tone={notice.tone ?? 'success'}
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -42,8 +42,9 @@ import { projectPathsMatchForInvalidation } from '../project-summary/projectPath
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import type { WorkspaceLauncherShellProps } from './model';
|
||||
import type { ProjectRunNotice, WorkspaceLauncherShellProps } from './model';
|
||||
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
|
||||
import { type RunNotice, RunNoticeToast } from './RunNoticeToast';
|
||||
import { useAccountWallet } from './useAccountWallet';
|
||||
import {
|
||||
DESIGN_ARTIFACTS_BUILD_PROMPT,
|
||||
@@ -79,6 +80,13 @@ export function WorkspaceLauncherShell({
|
||||
title: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
/**
|
||||
* 运行 / 预览类动作的浮层提示。
|
||||
*
|
||||
* 这类过程反馈不进对话区(见 `ProjectChatComponentProps.onRunNotice`),
|
||||
* `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。
|
||||
*/
|
||||
const [runNotice, setRunNotice] = useState<RunNotice | null>(null);
|
||||
// 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随
|
||||
// currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先
|
||||
// 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。
|
||||
@@ -600,6 +608,10 @@ export function WorkspaceLauncherShell({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRunNotice = useCallback((notice: ProjectRunNotice) => {
|
||||
setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice }));
|
||||
}, []);
|
||||
|
||||
function showLauncherNotice(title: string) {
|
||||
setLauncherNotice({
|
||||
title,
|
||||
@@ -825,6 +837,7 @@ export function WorkspaceLauncherShell({
|
||||
currentProjectContext.projectPath,
|
||||
)
|
||||
}
|
||||
onNotice={handleRunNotice}
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onHomeOpen={() => setLauncherView('home')}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
@@ -856,12 +869,12 @@ export function WorkspaceLauncherShell({
|
||||
initialPlanningPromptClaimScope={
|
||||
switchedToGameRuntime ? 'approved-design-build' : ''
|
||||
}
|
||||
activeVersionId={activeVersionId}
|
||||
planningStartMode={planningStartMode}
|
||||
playRequest={playRequest}
|
||||
onPlayRequestHandled={handlePlayRequestHandled}
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onPreviewChange={setActiveProjectPreview}
|
||||
onRunNotice={handleRunNotice}
|
||||
onAgentRuntimeSummariesChange={
|
||||
setActiveProjectAgentRuntimeSummaries
|
||||
}
|
||||
@@ -905,6 +918,7 @@ export function WorkspaceLauncherShell({
|
||||
) : isWindowChrome ? null : (
|
||||
<AccountWalletBar controller={accountWallet} />
|
||||
)}
|
||||
<RunNoticeToast notice={runNotice} onDismiss={() => setRunNotice(null)} />
|
||||
{launcherNotice ? (
|
||||
<div
|
||||
className="launcher-dialog-backdrop"
|
||||
|
||||
@@ -34,6 +34,17 @@ export type WorkspaceLauncherProps = {
|
||||
initialView?: LauncherView;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行 / 预览类动作的一次性浮层提示。
|
||||
*
|
||||
* `tone` 只区分观感(成功绿 / 失败红),文案由发出方给出:运行成功、切到运行视图、
|
||||
* 在浏览器打开失败都走这一条通道。
|
||||
*/
|
||||
export type ProjectRunNotice = {
|
||||
message: string;
|
||||
tone?: 'success' | 'error';
|
||||
};
|
||||
|
||||
export type ProjectChatComponentProps = {
|
||||
initialProjectPath?: string;
|
||||
initialProjectManifest?: GameCreationAppManifest;
|
||||
@@ -48,11 +59,6 @@ export type ProjectChatComponentProps = {
|
||||
importing: boolean,
|
||||
) => void;
|
||||
planningStartMode?: boolean;
|
||||
/**
|
||||
* C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。
|
||||
* `null` 表示回退到 manifest 中最新的版本。
|
||||
*/
|
||||
activeVersionId?: string | null;
|
||||
playRequest?: {
|
||||
projectPath: string;
|
||||
requestId: number;
|
||||
@@ -64,6 +70,13 @@ export type ProjectChatComponentProps = {
|
||||
metadata?: ProjectManifestSnapshotMetadata,
|
||||
) => void;
|
||||
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
|
||||
/**
|
||||
* 运行 / 预览类动作的一次性浮层提示。
|
||||
*
|
||||
* 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区——
|
||||
* 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。
|
||||
*/
|
||||
onRunNotice?: (notice: ProjectRunNotice) => void;
|
||||
onAgentRuntimeSummariesChange?: (
|
||||
summaries: ProjectAgentRuntimeSummary[],
|
||||
) => void;
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { $getNodeByKey, type NodeKey } from 'lexical';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
|
||||
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
|
||||
import type { AttachmentReference } from './resourceReferences';
|
||||
|
||||
export function AttachmentReferenceChip({
|
||||
attachment,
|
||||
nodeKey,
|
||||
}: {
|
||||
attachment: DirectCodexUserAttachmentReferencePart;
|
||||
attachment: AttachmentReference;
|
||||
nodeKey: NodeKey;
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
DecoratorNode,
|
||||
type EditorConfig,
|
||||
type LexicalNode,
|
||||
type NodeKey,
|
||||
type SerializedLexicalNode,
|
||||
type Spread,
|
||||
} from 'lexical';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
|
||||
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
|
||||
|
||||
export type SerializedAttachmentReferenceNode = Spread<
|
||||
{
|
||||
attachment: DirectCodexUserAttachmentReferencePart;
|
||||
type: 'attachment-reference';
|
||||
version: 1;
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
/** 附件在纯文本里的唯一占位字符:一个附件 chip 就当一个字符。 */
|
||||
const OBJECT_REPLACEMENT = '\uFFFC';
|
||||
|
||||
/**
|
||||
* Inline canonical attachment part rendered inside the Lexical composer.
|
||||
* The node stores the complete part so read-back preserves the project path and status.
|
||||
*/
|
||||
export class AttachmentReferenceNode extends DecoratorNode<ReactNode> {
|
||||
__attachment: DirectCodexUserAttachmentReferencePart;
|
||||
|
||||
static getType() {
|
||||
return 'attachment-reference';
|
||||
}
|
||||
|
||||
static clone(node: AttachmentReferenceNode) {
|
||||
return new AttachmentReferenceNode(node.__attachment, node.__key);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedAttachmentReferenceNode) {
|
||||
return $createAttachmentReferenceNode(serializedNode.attachment);
|
||||
}
|
||||
|
||||
constructor(
|
||||
attachment: DirectCodexUserAttachmentReferencePart,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key);
|
||||
this.__attachment = attachment;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedAttachmentReferenceNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
attachment: this.__attachment,
|
||||
type: 'attachment-reference',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
createDOM(_config: EditorConfig) {
|
||||
return document.createElement('span');
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getTextContent() {
|
||||
return OBJECT_REPLACEMENT;
|
||||
}
|
||||
|
||||
getTextContentSize() {
|
||||
return OBJECT_REPLACEMENT.length;
|
||||
}
|
||||
|
||||
isInline() {
|
||||
return true;
|
||||
}
|
||||
|
||||
isKeyboardSelectable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
decorate() {
|
||||
return (
|
||||
<AttachmentReferenceChip
|
||||
attachment={this.__attachment}
|
||||
nodeKey={this.__key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createAttachmentReferenceNode(
|
||||
attachment: DirectCodexUserAttachmentReferencePart,
|
||||
) {
|
||||
return $applyNodeReplacement(new AttachmentReferenceNode(attachment));
|
||||
}
|
||||
|
||||
export function $isAttachmentReferenceNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is AttachmentReferenceNode {
|
||||
return node instanceof AttachmentReferenceNode;
|
||||
}
|
||||
+13
-2
@@ -11,9 +11,20 @@ function chipTitle(reference: ChatReference) {
|
||||
if (reference.type === 'skill') {
|
||||
return `${reference.name} · Skill`;
|
||||
}
|
||||
if (reference.type === 'attachment') {
|
||||
return `${reference.name} · 附件`;
|
||||
}
|
||||
return `${reference.label} · 运行区域`;
|
||||
}
|
||||
|
||||
/** chip 显示的引用名:附件有自己的芯片(`AttachmentReferenceChip`),这里只是把联合收窄。 */
|
||||
function chipLabel(reference: ChatReference) {
|
||||
if (reference.type === 'skill' || reference.type === 'attachment') {
|
||||
return reference.name;
|
||||
}
|
||||
return reference.label;
|
||||
}
|
||||
|
||||
export function ResourceReferenceChip({
|
||||
reference,
|
||||
nodeKey,
|
||||
@@ -39,11 +50,11 @@ export function ResourceReferenceChip({
|
||||
>
|
||||
<span aria-hidden="true">{reference.type === 'skill' ? '$' : '@'}</span>
|
||||
<span className="resource-reference-chip-label">
|
||||
{reference.type === 'skill' ? reference.name : reference.label}
|
||||
{chipLabel(reference)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除引用 ${reference.type === 'skill' ? reference.name : reference.label}`}
|
||||
aria-label={`移除引用 ${chipLabel(reference)}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
editor.update(() => {
|
||||
|
||||
+336
-916
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -9,6 +9,7 @@ import {
|
||||
} from 'lexical';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
|
||||
import { ResourceReferenceChip } from './ResourceReferenceChip';
|
||||
import type { ChatReference } from './resourceReferences';
|
||||
|
||||
@@ -89,7 +90,15 @@ export class ResourceReferenceNode extends DecoratorNode<ReactNode> {
|
||||
}
|
||||
|
||||
decorate() {
|
||||
return (
|
||||
// 引用节点只有一个类型(`ChatReference` 联合),chip 的外观按引用成员选:
|
||||
// 附件芯片的 DOM 契约(`data-attachment-reference` / `data-attachment-status` / title)
|
||||
// 由它自己那段组件保证,节点层不复制一份。
|
||||
return this.__reference.type === 'attachment' ? (
|
||||
<AttachmentReferenceChip
|
||||
attachment={this.__reference}
|
||||
nodeKey={this.__key}
|
||||
/>
|
||||
) : (
|
||||
<ResourceReferenceChip
|
||||
reference={this.__reference}
|
||||
nodeKey={this.__key}
|
||||
|
||||
+573
File diff suppressed because it is too large
Load Diff
+43
@@ -0,0 +1,43 @@
|
||||
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import type { ChatReference } from '../resourceReferences';
|
||||
import type { ReferenceProvider } from './types';
|
||||
|
||||
/** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */
|
||||
export function attachmentReferenceFromPart(
|
||||
part: DirectCodexUserContentPart & { type: 'agc_attachment_reference' },
|
||||
): ChatReference {
|
||||
return {
|
||||
type: 'attachment',
|
||||
name: part.name,
|
||||
mediaType: part.mediaType,
|
||||
size: part.size,
|
||||
localPath: part.localPath,
|
||||
status: part.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件的 provider:**静默**(没有触发符、没有候选)。
|
||||
*
|
||||
* 附件只由宿主的导入动作或跨会话草稿回填进入正文,所以它只需要回答「这个 part 是不是附件」
|
||||
* 「它在正文里长什么样」。附件本身不进候选菜单,也不需要改名刷新。
|
||||
*/
|
||||
export function createAttachmentReferenceProvider(): ReferenceProvider {
|
||||
return {
|
||||
trigger: null,
|
||||
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
|
||||
part.type === 'agc_attachment_reference'
|
||||
? attachmentReferenceFromPart(part)
|
||||
: null,
|
||||
refresh: (reference: ChatReference): ChatReference | null =>
|
||||
reference.type === 'attachment' ? reference : null,
|
||||
mentionToken: (part) =>
|
||||
part.type === 'agc_attachment_reference' ? `@${part.name}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件 provider 无状态,宿主可以共用同一个实例。
|
||||
* 它没有触发符,所以「注入它」不会给输入区加出任何候选入口。
|
||||
*/
|
||||
export const attachmentReferenceProvider = createAttachmentReferenceProvider();
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import {
|
||||
assetsSignature,
|
||||
type ChatReference,
|
||||
mentionableAssetReferences,
|
||||
refreshResourceReference,
|
||||
resourceDisplayName,
|
||||
type ResourceReference,
|
||||
resourceReferenceFromAsset,
|
||||
resourceReferenceMatchesQuery,
|
||||
} from '../resourceReferences';
|
||||
import type { ReferenceProvider } from './types';
|
||||
|
||||
/** 候选菜单最多展示多少条:两条触发链(`@` / `$`)共用同一个上限。 */
|
||||
const MENTION_OPTION_LIMIT = 8;
|
||||
|
||||
type ResourceProviderData = {
|
||||
byId: ReadonlyMap<string, GameCreationAppAssetManifestEntry>;
|
||||
references: ResourceReference[];
|
||||
};
|
||||
|
||||
let cachedSignature = '\u0000';
|
||||
let cachedData: ResourceProviderData | null = null;
|
||||
|
||||
function resourceProviderData(
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
): ResourceProviderData {
|
||||
const signature = assetsSignature(assets);
|
||||
if (cachedData && signature === cachedSignature) {
|
||||
return cachedData;
|
||||
}
|
||||
cachedSignature = signature;
|
||||
cachedData = {
|
||||
byId: new Map(assets.map((asset) => [asset.id, asset])),
|
||||
references: mentionableAssetReferences(assets, 'asset-picker'),
|
||||
};
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源引用的 provider:候选、身份解析、显示名刷新与 `@显示名` 文本形态都按宿主传进来的
|
||||
* 这份 manifest 展开。输入区自己不再读清单。
|
||||
*/
|
||||
export function createResourceReferenceProvider({
|
||||
assets,
|
||||
}: {
|
||||
assets: readonly GameCreationAppAssetManifestEntry[];
|
||||
}): ReferenceProvider {
|
||||
return {
|
||||
trigger: '@',
|
||||
match: (query) =>
|
||||
resourceProviderData(assets)
|
||||
.references.filter((reference) =>
|
||||
resourceReferenceMatchesQuery(reference, query),
|
||||
)
|
||||
.slice(0, MENTION_OPTION_LIMIT),
|
||||
toReference: (part: DirectCodexUserContentPart): ChatReference | null => {
|
||||
if (part.type !== 'agc_resource_reference') return null;
|
||||
const asset = resourceProviderData(assets).byId.get(part.resourceId);
|
||||
// 资产已不在 manifest(已删除):这里不合成引用,由调用方按同一份清单显式报缺口,
|
||||
// 而不是静默把一条「带参考」变成「无参考」。
|
||||
return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null;
|
||||
},
|
||||
refresh: (reference) =>
|
||||
reference.type === 'resource'
|
||||
? refreshResourceReference(reference, resourceProviderData(assets).byId)
|
||||
: null,
|
||||
mentionToken: (part) => {
|
||||
if (part.type !== 'agc_resource_reference') return null;
|
||||
const asset = resourceProviderData(assets).byId.get(part.resourceId);
|
||||
if (!asset) return `@${part.resourceId}`;
|
||||
return `@${resourceDisplayName(asset)}`;
|
||||
},
|
||||
// manifest 还空着时先别把草稿落进编辑器:这时候落下去,引用会被当成「已删除」丢掉。
|
||||
isReady: () => assets.length > 0,
|
||||
};
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import type {
|
||||
ChatReference,
|
||||
RuntimeRegionReference,
|
||||
} from '../resourceReferences';
|
||||
import type { ReferenceProvider } from './types';
|
||||
|
||||
/**
|
||||
* 运行画面区域引用的 provider:**静默**(没有触发符、没有候选)。
|
||||
*
|
||||
* 这类引用来自本地游戏预览里的选点动作,走 `RESOURCE_REFERENCE_INSERT_EVENT` 直接进正文,
|
||||
* 从来没有过候选菜单;它在这里负责的是跨会话草稿回填与 `@标签` 文本形态。
|
||||
*/
|
||||
export function createRuntimeRegionReferenceProvider(): ReferenceProvider {
|
||||
return {
|
||||
trigger: null,
|
||||
toReference: (part: DirectCodexUserContentPart): ChatReference | null => {
|
||||
if (part.type !== 'agc_runtime_region_reference') return null;
|
||||
return {
|
||||
type: 'runtime-region',
|
||||
label: part.label,
|
||||
runId: part.runId ?? undefined,
|
||||
versionId: part.versionId ?? undefined,
|
||||
elementTag: part.elementTag ?? undefined,
|
||||
elementRole: part.elementRole ?? undefined,
|
||||
text: part.text ?? undefined,
|
||||
width: part.width ?? undefined,
|
||||
height: part.height ?? undefined,
|
||||
resourceIds: part.resourceIds,
|
||||
source: 'runtime-picker',
|
||||
} satisfies RuntimeRegionReference;
|
||||
},
|
||||
refresh: (reference: ChatReference): ChatReference | null =>
|
||||
reference.type === 'runtime-region' ? reference : null,
|
||||
mentionToken: (part) =>
|
||||
part.type === 'agc_runtime_region_reference' ? `@${part.label}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行画面区域 provider 无状态,宿主可以共用同一个实例。
|
||||
* 它没有触发符,所以「注入它」不会给输入区加出任何候选入口。
|
||||
*/
|
||||
export const runtimeRegionReferenceProvider =
|
||||
createRuntimeRegionReferenceProvider();
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../../app/tauri';
|
||||
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import type { ChatReference } from '../resourceReferences';
|
||||
import type { ReferenceProvider } from './types';
|
||||
|
||||
/** 候选菜单最多展示多少条:与资源候选同一上限。 */
|
||||
const MENTION_OPTION_LIMIT = 8;
|
||||
|
||||
type SkillCatalogItem = { name: string; description?: string };
|
||||
|
||||
/**
|
||||
* 读应用级 Skill 目录:内置 Skill 与已启用的客户端 Skill 合起来是同一份候选。
|
||||
*
|
||||
* 这是一次**应用级**读取(不是项目清单),所以只在用户第一次敲出 `$` 时发生:
|
||||
* 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。
|
||||
* 查询失败时放开已请求标记:一次瞬时失败不能让这个输入区此后永远拿不到技能候选。
|
||||
*/
|
||||
function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) return Promise.resolve([]);
|
||||
return Promise.all([
|
||||
invoke<Array<{ name: string; description: string }>>(
|
||||
'list_agc_skill_catalog',
|
||||
).then((items) =>
|
||||
items.map((item) => ({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})),
|
||||
),
|
||||
invoke<
|
||||
Array<{
|
||||
name: string;
|
||||
extensionType: string;
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
}>
|
||||
>('list_client_extensions').then((items) =>
|
||||
items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.extensionType === 'skill' &&
|
||||
item.enabled &&
|
||||
item.status === 'enabled',
|
||||
)
|
||||
.map((item) => ({ name: item.name })),
|
||||
),
|
||||
]).then(([builtin, client]) => [...builtin, ...client]);
|
||||
}
|
||||
|
||||
function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return (
|
||||
skill.name.toLowerCase().includes(normalized) ||
|
||||
(skill.description?.toLowerCase().includes(normalized) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 引用的 provider(宿主 hook)。
|
||||
*
|
||||
* 与资源 provider 不同,Skill 候选是**异步**的应用级读取,所以它必须是一份 React 状态:
|
||||
* 用户敲出 `$` 打开候选菜单时(`onMenuQueryChange` 收到非 `null`,由输入区在 effect 里回调)
|
||||
* 发起读取,结果到了之后宿主重渲染,输入区随之拿到新的候选。
|
||||
* `match` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
|
||||
* 读取本身不进输入区,只有宿主才知道这条路该不该存在——
|
||||
* 目前只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill。
|
||||
*/
|
||||
export function useSkillReferenceProvider(): ReferenceProvider {
|
||||
const [skills, setSkills] = useState<SkillCatalogItem[]>([]);
|
||||
const requestedRef = useRef(false);
|
||||
|
||||
const ensureCatalog = useCallback(() => {
|
||||
if (requestedRef.current) return;
|
||||
requestedRef.current = true;
|
||||
void loadSkillCatalog()
|
||||
.then((items) => setSkills(items))
|
||||
.catch((error) => {
|
||||
// 失败不静默:一次瞬时失败会在下一次菜单查询变化(继续敲字或重开菜单)时重试,
|
||||
// 但持续失败至少要在控制台留痕,否则用户看到的是「敲 `$` 什么都没有」,排障时没有任何线索。
|
||||
console.warn(
|
||||
'[skill-reference] Skill 目录读取失败,下次触发重试',
|
||||
error,
|
||||
);
|
||||
requestedRef.current = false;
|
||||
setSkills([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
trigger: '$',
|
||||
// 懒加载走菜单回调:只有用户真的敲出 `$`(query 非 null)时才值得读这份应用级目录。
|
||||
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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
|
||||
part.type === 'agc_skill_reference'
|
||||
? { type: 'skill', name: part.name }
|
||||
: null,
|
||||
refresh: (reference: ChatReference): ChatReference | null =>
|
||||
reference.type === 'skill' ? reference : null,
|
||||
mentionToken: (part) =>
|
||||
part.type === 'agc_skill_reference' ? `$${part.name}` : null,
|
||||
}),
|
||||
[ensureCatalog, skills],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import type { ChatReference } from '../resourceReferences';
|
||||
|
||||
/**
|
||||
* 宿主注入给引用输入区的一种引用来源。
|
||||
*
|
||||
* 一个 provider 只认一种引用:候选、身份解析、改名刷新与「它在正文里的文本形态」都归它。
|
||||
* 输入区按 `providers` 数组顺序取**第一个非空回答**,不判断引用种类,也不认识任何具体字段;
|
||||
* 「没注入就没有这类引用」是默认,可见性不需要额外的开关。
|
||||
*
|
||||
* 每种引用一个独立工厂,宿主按需选择性注入——只注入资源 provider 就只有 `@`,
|
||||
* 再注入 Skill provider 才出现 `$`。**不做总装 builder**。
|
||||
*/
|
||||
export type ReferenceProvider = {
|
||||
/**
|
||||
* 触发候选菜单的字符(`@` / `$`);`null` 表示静默 provider:
|
||||
* 它仍参与 part 身份解析、改名刷新与文本形态,但不会在输入区里开出候选菜单
|
||||
* (附件、运行画面区域就是这样进来的)。
|
||||
*/
|
||||
trigger: string | null;
|
||||
/**
|
||||
* 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。
|
||||
*
|
||||
* **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会
|
||||
* 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。
|
||||
*/
|
||||
match?: (query: string) => ChatReference[];
|
||||
/**
|
||||
* 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在
|
||||
* 带触发符的 provider 上调用**。
|
||||
*
|
||||
* 这是懒加载的唯一入口:例如 Skill 目录是应用级异步读取,第一次收到非 `null` 时再发起,
|
||||
* 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。
|
||||
*/
|
||||
onMenuQueryChange?: (query: string | null) => void;
|
||||
/** canonical part → 引用;不属于本 provider 或暂时无法解析时返回 `null`。 */
|
||||
toReference: (part: DirectCodexUserContentPart) => ChatReference | null;
|
||||
/** 引用身份刷新(资源改名等);不属于本 provider 时返回 `null`,原样返回表示无需改写。 */
|
||||
refresh: (reference: ChatReference) => ChatReference | null;
|
||||
/** part 在正文文本里的 token(`@显示名` / `$名称`);不属于本 provider 时返回 `null`。 */
|
||||
mentionToken: (part: DirectCodexUserContentPart) => string | null;
|
||||
/**
|
||||
* 数据是否已经到齐(例如 manifest 还没加载完时为 `false`);省略表示始终就绪。
|
||||
*
|
||||
* 输入区用它决定「要不要现在就把初始草稿落进编辑器」:草稿里有本 provider 认不出、
|
||||
* 但它迟早能认出来的 part 时先不落,否则那条引用会被静默丢掉。
|
||||
*/
|
||||
isReady?: () => boolean;
|
||||
};
|
||||
@@ -53,10 +53,21 @@ export type SkillReference = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 聊天附件在正文里的引用形态。
|
||||
*
|
||||
* 与 canonical part `agc_attachment_reference` 逐字同字段(名称、类型、大小、项目相对路径、状态),
|
||||
* 因为「导入成功即入正文」之后,附件芯片就是本轮附件的唯一事实源,没有第二份状态可以回退。
|
||||
*/
|
||||
export type AttachmentReference = {
|
||||
type: 'attachment';
|
||||
} & DirectCodexUserAttachmentReferencePart;
|
||||
|
||||
export type ChatReference =
|
||||
| ResourceReference
|
||||
| RuntimeRegionReference
|
||||
| SkillReference;
|
||||
| SkillReference
|
||||
| AttachmentReference;
|
||||
|
||||
export type ChatComposerDraft = {
|
||||
/** Lexical 节点按顺序投影出的 canonical user content,唯一事实源。 */
|
||||
@@ -67,7 +78,6 @@ export type ChatComposerDraft = {
|
||||
export type DirectCodexLegacyContentDto = {
|
||||
text: string;
|
||||
references: ChatReference[];
|
||||
attachments: DirectCodexUserAttachmentReferencePart[];
|
||||
};
|
||||
|
||||
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
|
||||
@@ -128,36 +138,82 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* canonical content → 可读文本;资源引用按当前 manifest 展开为显示名。
|
||||
* 资源显示名解析:由调用方注入,函数自己不去读 manifest。
|
||||
*
|
||||
* **逐字投影,不裁剪空白**:前端不替用户改写输入,只有整条 content 的全空白判定
|
||||
* (`hasMeaningfulDirectCodexContent`)算「空」。需要首尾裁剪的调用方在自己的边界做。
|
||||
* 返回 `undefined` 时按引用自带的 `resourceId` 兜底显示,保持「引用不因清单缺失而消失」的口径。
|
||||
*/
|
||||
export type ResourceLabelResolver = (resourceId: string) => string | undefined;
|
||||
|
||||
/** 按 manifest 取资源显示名;引用已不在清单时返回 `undefined`。 */
|
||||
export function resourceLabelResolver(
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
): ResourceLabelResolver {
|
||||
return (resourceId) => {
|
||||
const asset = assets.find((item) => item.id === resourceId);
|
||||
return asset ? resourceDisplayName(asset) : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* canonical content → 可读文本;每个引用 part 经 `tokenOf` 展开,文本 part 逐字保留。
|
||||
*
|
||||
* **每个 token 前后各留一个空白**(相邻已是空白或相邻就是另一个 token 时不重复补):这样
|
||||
* 出站文本与「前后是空白 / 行首行尾」的反解析口径自洽,`@显示名` 紧贴中文的写法不会出现。
|
||||
* 逐字保留文本 part 自己的空白,只有整条 content 的全空白判定
|
||||
* (`hasMeaningfulDirectCodexContent`)算「空」;需要首尾裁剪的调用方在自己的边界做。
|
||||
*/
|
||||
export function joinMentionText(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
tokenOf: (part: DirectCodexUserContentPart) => string | null,
|
||||
): string {
|
||||
let text = '';
|
||||
// 上一个 token 的后置空白尚未落地:下一个 part 决定它是被吃掉(本来就以空白开头)
|
||||
// 还是真的补出来,避免出现双空白。
|
||||
let pendingBlank = false;
|
||||
for (const part of content) {
|
||||
const token = tokenOf(part);
|
||||
if (token === null) {
|
||||
if (part.type !== 'input_text' || part.text === '') continue;
|
||||
if (pendingBlank) {
|
||||
pendingBlank = false;
|
||||
if (!/^\s/u.test(part.text)) text += ' ';
|
||||
}
|
||||
text += part.text;
|
||||
continue;
|
||||
}
|
||||
if (pendingBlank) {
|
||||
text += ' ';
|
||||
pendingBlank = false;
|
||||
} else if (!/\s$/u.test(text)) {
|
||||
text += ' ';
|
||||
}
|
||||
text += token;
|
||||
pendingBlank = true;
|
||||
}
|
||||
return pendingBlank ? `${text} ` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* canonical content → 可读文本;资源引用按调用方注入的解析器展开为显示名。
|
||||
*
|
||||
* 显示名解析不进这个函数:调用方(聊天控制器、队列 chip、legacy 投影)各自持有清单,
|
||||
* 由它们把 `resourceLabelResolver(assets)` 传进来。
|
||||
*
|
||||
* TODO we will rewrite this with ref as component later.
|
||||
* (引用最终要作为组件参与渲染,那时这层「content → 文本」的展开就由组件自己承担,
|
||||
* 这里的 token 拼接只是当前口径的落点。)
|
||||
*/
|
||||
export function directCodexContentToPromptText(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
resolveResourceLabel: ResourceLabelResolver,
|
||||
) {
|
||||
const labels = new Map(
|
||||
assets.map((asset) => [asset.id, resourceDisplayName(asset)]),
|
||||
);
|
||||
return content
|
||||
.map((part) => {
|
||||
if (part.type === 'input_text') return part.text;
|
||||
if (part.type === 'agc_resource_reference') {
|
||||
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
|
||||
}
|
||||
if (part.type === 'agc_skill_reference') {
|
||||
return `$${part.name}`;
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') {
|
||||
return `@${part.label}`;
|
||||
}
|
||||
if (part.type === 'agc_attachment_reference') {
|
||||
return `@${part.name}`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
return joinMentionText(content, (part) => {
|
||||
if (part.type === 'input_text') return null;
|
||||
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
|
||||
if (part.type === 'agc_skill_reference') return `$${part.name}`;
|
||||
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
|
||||
return `@${resolveResourceLabel(part.resourceId) ?? part.resourceId}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */
|
||||
@@ -205,6 +261,16 @@ export function chatReferenceToContentPart(
|
||||
if (reference.type === 'skill') {
|
||||
return { type: 'agc_skill_reference', name: reference.name };
|
||||
}
|
||||
if (reference.type === 'attachment') {
|
||||
return {
|
||||
type: 'agc_attachment_reference',
|
||||
name: reference.name,
|
||||
mediaType: reference.mediaType,
|
||||
size: reference.size,
|
||||
localPath: reference.localPath,
|
||||
status: reference.status,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'agc_runtime_region_reference',
|
||||
label: reference.label,
|
||||
@@ -220,26 +286,25 @@ export function chatReferenceToContentPart(
|
||||
}
|
||||
|
||||
/**
|
||||
* part 在「文本 → content」反解析里的可扫描 token:与 `directCodexContentToPromptText`
|
||||
* 出站口径逐字一致——附件是 `@附件名`,资源与 runtime 引用是 `@显示名`,Skill 是 `$名称`。
|
||||
* `input_text` 没有 token,返回 `null`。
|
||||
* 单条引用在文本里的可扫描 token:与出站口径逐字一致——附件 `@附件名`、Skill `$名称`、
|
||||
* 资源与运行画面区域 `@显示名`。资源引用自带显示名,所以这里不必再查 manifest。
|
||||
*/
|
||||
export function contentPartMentionToken(
|
||||
part: DirectCodexUserContentPart,
|
||||
resourceLabels: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
if (part.type === 'input_text') return null;
|
||||
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
|
||||
if (part.type === 'agc_skill_reference') return `$${part.name}`;
|
||||
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
|
||||
return `@${resourceLabels.get(part.resourceId) ?? part.resourceId}`;
|
||||
export function chatReferenceMentionToken(reference: ChatReference): string {
|
||||
if (reference.type === 'skill') return `$${reference.name}`;
|
||||
if (reference.type === 'attachment') return `@${reference.name}`;
|
||||
return `@${reference.label}`;
|
||||
}
|
||||
|
||||
/** 单条引用在文本里的可扫描 token(引用自带显示名,不必再查 manifest)。 */
|
||||
export function chatReferenceMentionToken(reference: ChatReference): string {
|
||||
return reference.type === 'skill'
|
||||
? `$${reference.name}`
|
||||
: `@${reference.label}`;
|
||||
/**
|
||||
* 候选菜单的副标题:引用自带的补充说明。
|
||||
*
|
||||
* 只在这里按引用种类分支——输入区与选择器都按这一份口径渲染,不各自再判一次类型。
|
||||
*/
|
||||
export function chatReferenceDisplayHint(reference: ChatReference): string {
|
||||
if (reference.type === 'resource') return reference.kind;
|
||||
if (reference.type === 'skill') return reference.description ?? 'Skill';
|
||||
if (reference.type === 'attachment') return reference.mediaType || '附件';
|
||||
return reference.elementTag ?? '运行区域';
|
||||
}
|
||||
|
||||
/** 一条待恢复 part 与它在文本里的 token。 */
|
||||
@@ -358,24 +423,18 @@ export function buildContentFromTextTokens(
|
||||
*
|
||||
* 面板重开草稿、润色回包等 legacy 输入都走这一条翻译;文本里保留着 `@显示名` / `$名称` /
|
||||
* `@附件名` 就能原位恢复 chip,整体被改写的 part 补在末尾(见 `buildContentFromTextTokens`)。
|
||||
* 附件就是 `references` 里的 `attachment` 成员:本轮附件只有「正文芯片」一份事实源,
|
||||
* 这里不再有第二个 `attachments` 数组可以叠加。
|
||||
*/
|
||||
export function legacyContentDtoToContent(dto: {
|
||||
text: string;
|
||||
references: readonly ChatReference[];
|
||||
attachments?: readonly DirectCodexUserAttachmentReferencePart[];
|
||||
}): DirectCodexUserContentPart[] {
|
||||
return buildContentFromTextTokens(dto.text, [
|
||||
...dto.references.map((reference) => ({
|
||||
token: chatReferenceMentionToken(reference),
|
||||
part: chatReferenceToContentPart(reference),
|
||||
})),
|
||||
...(dto.attachments ?? []).map((attachment) => ({
|
||||
token: `@${attachment.name}`,
|
||||
part: {
|
||||
type: 'agc_attachment_reference' as const,
|
||||
...attachment,
|
||||
},
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -419,7 +478,13 @@ function unresolvedResourceReference(resourceId: string): ResourceReference {
|
||||
};
|
||||
}
|
||||
|
||||
/** canonical content → legacy text + reference + attachment DTO。只允许单向翻译。 */
|
||||
/**
|
||||
* canonical content → legacy text + reference DTO。只允许单向翻译。
|
||||
*
|
||||
* 附件也在 `references` 里(`attachment` 成员),所以这一层不需要第二个附件数组;
|
||||
* 资源引用要带回 manifest 的完整身份(kind / mediaType / 分类 / 标签),所以这一层
|
||||
* 仍然收 `assets`,只是把显示名展开交给 `resourceLabelResolver` 这一份注入的口径。
|
||||
*/
|
||||
export function directCodexContentToLegacyContentDto(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
@@ -453,14 +518,25 @@ export function directCodexContentToLegacyContentDto(
|
||||
}
|
||||
if (part.type === 'agc_skill_reference') {
|
||||
references.push({ type: 'skill', name: part.name });
|
||||
return;
|
||||
}
|
||||
if (part.type === 'agc_attachment_reference') {
|
||||
references.push({
|
||||
type: 'attachment',
|
||||
name: part.name,
|
||||
mediaType: part.mediaType,
|
||||
size: part.size,
|
||||
localPath: part.localPath,
|
||||
status: part.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
text: directCodexContentToPromptText(content, assets),
|
||||
references,
|
||||
attachments: content.flatMap((part) =>
|
||||
part.type === 'agc_attachment_reference' ? [part] : [],
|
||||
text: directCodexContentToPromptText(
|
||||
content,
|
||||
resourceLabelResolver(assets),
|
||||
),
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -485,6 +561,45 @@ export function resourceReferenceFromAsset(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `@` 候选只收「有本地文件、且不是 `.agent/` 内部产物」的已登记素材。
|
||||
* 判据只影响候选与引用身份解析,不影响 manifest 本身。
|
||||
*/
|
||||
export function isMentionableAsset(asset: GameCreationAppAssetManifestEntry) {
|
||||
return Boolean(asset.localPath) && !asset.localPath.startsWith('.agent/');
|
||||
}
|
||||
|
||||
/** 可提及素材 → 候选引用(`@` 面板与 `@` 候选共用同一份投影)。 */
|
||||
export function mentionableAssetReferences(
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
source: ResourceReferenceSource,
|
||||
) {
|
||||
return assets
|
||||
.filter(isMentionableAsset)
|
||||
.map((asset) => resourceReferenceFromAsset(asset, source));
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用显示名只由 manifest 资产内容决定,而调用方每次渲染都会重建 assets 数组,
|
||||
* 这里用内容签名做缓存键,避免与改名无关的渲染反复重算。
|
||||
*/
|
||||
export function assetsSignature(
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
) {
|
||||
return assets
|
||||
.map((asset) =>
|
||||
[
|
||||
asset.id,
|
||||
asset.kind,
|
||||
asset.mediaType,
|
||||
asset.localPath,
|
||||
asset.category ?? '',
|
||||
gameCreationAppAssetTags(asset).join(','),
|
||||
].join('\u0000'),
|
||||
)
|
||||
.join('\u0001');
|
||||
}
|
||||
|
||||
export function sameResourceReference(
|
||||
left: ResourceReference,
|
||||
right: ResourceReference,
|
||||
@@ -673,13 +788,23 @@ function runtimeRegionReferenceDiscriminators(
|
||||
}:${reference.width ?? ''}:${reference.height ?? ''}:${resourceIds}`;
|
||||
}
|
||||
|
||||
function chatReferenceKey(reference: ChatReference) {
|
||||
/**
|
||||
* 引用的身份键:只认稳定身份,**不含显示名**。
|
||||
*
|
||||
* 同一个显示名可以来自两条不同引用(`characters/hero.png` 与 `enemies/hero.png` 都展开成
|
||||
* `@hero`),所以去重与候选菜单的 React key 都必须用它,而不是显示 token。
|
||||
*/
|
||||
export function chatReferenceKey(reference: ChatReference) {
|
||||
if (reference.type === 'resource') {
|
||||
return `resource:${reference.resourceId}:${reference.source}`;
|
||||
}
|
||||
if (reference.type === 'skill') {
|
||||
return `skill:${reference.name}`;
|
||||
}
|
||||
if (reference.type === 'attachment') {
|
||||
// 附件身份落在导入产物上:同名文件可以来自不同目录,项目相对路径才是它的唯一身份。
|
||||
return `attachment:${reference.localPath}:${reference.name}`;
|
||||
}
|
||||
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
|
||||
}
|
||||
|
||||
@@ -696,6 +821,9 @@ export function chatReferenceListKey(references: ChatReference[]) {
|
||||
if (reference.type === 'skill') {
|
||||
return `skill:${reference.name}`;
|
||||
}
|
||||
if (reference.type === 'attachment') {
|
||||
return `attachment:${reference.localPath}:${reference.name}`;
|
||||
}
|
||||
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
|
||||
})
|
||||
.join('\u0001');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user