Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2

This commit is contained in:
2026-09-22 19:42:44 +08:00
73 changed files with 5119 additions and 1616 deletions
+32
View File
@@ -194,6 +194,34 @@ _Avoid_: 进度通知、快照轮询、第二套历史
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
**引用候选**:
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
**引用 provider**:
一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。
_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方
**静默 provider**:
不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。
_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支
**引用文本语法**:
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
**引用选择器**:
宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。
_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道
**附件芯片**:
聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。
_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -209,6 +237,10 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
- **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。
- **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。
- **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
@@ -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 指向可写的 PROCESSENTRY32WdwSize 已初始化。
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();
}
+25 -48
View File
@@ -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,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();
@@ -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;
}
@@ -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(() => {
File diff suppressed because it is too large Load Diff
@@ -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}
@@ -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();
@@ -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,
};
}
@@ -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();
@@ -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;
};

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