合并最新 master 到 CI 下载缓存优化分支
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
合入 origin/master 的浏览器进程清理与引用输入框重构。 保留 CI 下载缓存、最小构建上下文和维护日志优化。 通过 17 项工作流测试、2 项 npm 缓存导出测试及编码检查。
This commit was merged in pull request #476.
This commit is contained in:
+32
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -207,7 +206,6 @@ export function App({
|
||||
initialProjectManifest,
|
||||
initialProjectKind = 'web',
|
||||
planningStartMode = false,
|
||||
activeVersionId = null,
|
||||
initialPlanningPrompt = '',
|
||||
initialPlanningPromptClaimScope = '',
|
||||
initialCreationType = null,
|
||||
@@ -2199,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,
|
||||
@@ -2332,7 +2325,6 @@ export function App({
|
||||
<>
|
||||
<PlanningChatView
|
||||
initialPlanningPrompt={initialPlanningPrompt}
|
||||
activeVersionId={chatActiveVersionId}
|
||||
composerRef={chatComposerRef}
|
||||
chatProjectAssets={chatProjectAssets}
|
||||
hiddenConversationCount={hiddenConversationCount}
|
||||
@@ -2462,7 +2454,6 @@ export function App({
|
||||
attachmentNotice={chatFileImportNotice}
|
||||
importingFiles={chatFilesImporting}
|
||||
onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)}
|
||||
versions={chatProjectVersions}
|
||||
/>
|
||||
{runtimeConfigOpen ? (
|
||||
<RuntimeConfigDialog
|
||||
|
||||
@@ -869,7 +869,6 @@ export function WorkspaceLauncherShell({
|
||||
initialPlanningPromptClaimScope={
|
||||
switchedToGameRuntime ? 'approved-design-build' : ''
|
||||
}
|
||||
activeVersionId={activeVersionId}
|
||||
planningStartMode={planningStartMode}
|
||||
playRequest={playRequest}
|
||||
onPlayRequestHandled={handlePlayRequestHandled}
|
||||
|
||||
@@ -59,11 +59,6 @@ export type ProjectChatComponentProps = {
|
||||
importing: boolean,
|
||||
) => void;
|
||||
planningStartMode?: boolean;
|
||||
/**
|
||||
* C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。
|
||||
* `null` 表示回退到 manifest 中最新的版本。
|
||||
*/
|
||||
activeVersionId?: string | null;
|
||||
playRequest?: {
|
||||
projectPath: string;
|
||||
requestId: number;
|
||||
|
||||
+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');
|
||||
|
||||
+47
-3
@@ -5,6 +5,7 @@ import {
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -21,11 +22,20 @@ import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/i
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
|
||||
import { attachmentReferenceProvider } from '../project-workspace/reference-source/attachmentReferenceProvider';
|
||||
import { createResourceReferenceProvider } from '../project-workspace/reference-source/resourceReferenceProvider';
|
||||
import { runtimeRegionReferenceProvider } from '../project-workspace/reference-source/runtimeRegionReferenceProvider';
|
||||
import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
} from '../project-workspace/ResourceReferenceInput';
|
||||
import {
|
||||
ResourceReferencePicker,
|
||||
ResourceReferencePickerAction,
|
||||
type ResourceReferencePickerHandle,
|
||||
} from '../project-workspace/ResourceReferencePicker';
|
||||
import {
|
||||
assetsSignature,
|
||||
type ChatComposerDraft,
|
||||
directCodexContentKey,
|
||||
directCodexContentToLegacyContentDto,
|
||||
@@ -253,6 +263,24 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
const referenceAssets = resourceCanvasAssetGenerationReferenceAssets(
|
||||
assets ?? [],
|
||||
);
|
||||
const referenceAssetsSignature = assetsSignature(referenceAssets);
|
||||
/*
|
||||
引用来源:画布生成面板只注入资源与两个静默 provider——它把 `@` 引用当参考图用,
|
||||
而 `$` Skill 候选只有 DirectProject 回合那条路径会被解析成真 Skill,这里不注入。
|
||||
*/
|
||||
const referenceProviders = useMemo(
|
||||
() => [
|
||||
createResourceReferenceProvider({ assets: referenceAssets }),
|
||||
attachmentReferenceProvider,
|
||||
runtimeRegionReferenceProvider,
|
||||
],
|
||||
// `referenceAssets` 每次渲染都是新数组(`.filter()` 派生),依赖它等于从不命中缓存:
|
||||
// provider 身份一变,输入区就会重新注册 Lexical 更新监听。按内容签名取依赖才真的能跳过重建。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[referenceAssetsSignature],
|
||||
);
|
||||
const promptInputRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const referencePickerRef = useRef<ResourceReferencePickerHandle | null>(null);
|
||||
/*
|
||||
content 是唯一事实源:提示词与引用在这里单向派生,面板不各存一份镜像。
|
||||
|
||||
@@ -473,14 +501,30 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
ariaLabel="生成提示词"
|
||||
initialContent={content}
|
||||
onChange={applyDraft}
|
||||
assets={referenceAssets}
|
||||
providers={referenceProviders}
|
||||
projectPath={projectPath ?? ''}
|
||||
versions={versions}
|
||||
activeVersionId={activeVersionId}
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
|
||||
showPolishAction={false}
|
||||
containerRef={promptInputRootRef}
|
||||
inputActions={
|
||||
<ResourceReferencePickerAction
|
||||
onOpen={() => referencePickerRef.current?.open()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 素材选择器由宿主渲染、自己拿数据;确认后只把选中的引用交回输入区句柄。 */}
|
||||
<ResourceReferencePicker
|
||||
ref={referencePickerRef}
|
||||
anchorRef={promptInputRootRef}
|
||||
assets={referenceAssets}
|
||||
versions={versions}
|
||||
activeVersionId={activeVersionId}
|
||||
projectPath={projectPath ?? ''}
|
||||
onInsert={(references) =>
|
||||
promptInputRef.current?.insertReferences(references)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -11328,11 +11328,7 @@ button.design-workspace-tree__entry:hover,
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 待发附件 / 队列 chip:压在输入区上方,横向排布、超出换行。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-attachments,
|
||||
/* 队列 chip:压在输入区上方,横向排布、超出换行。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
@@ -11346,11 +11342,6 @@ button.design-workspace-tree__entry:hover,
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-attachments
|
||||
li,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
@@ -11367,12 +11358,6 @@ button.design-workspace-tree__entry:hover,
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-attachments
|
||||
li
|
||||
span,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
@@ -11383,11 +11368,6 @@ button.design-workspace-tree__entry:hover,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
.project-chat-composer-attachments
|
||||
button,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-composer.is-direct-codex
|
||||
@@ -11622,9 +11602,9 @@ button.design-workspace-tree__entry:hover,
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`,
|
||||
即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的
|
||||
`.project-chat-reference-trigger`)。这里原本有一条针对它的
|
||||
/* direct-codex 的 `@` 触发钮是下面控制排里的 `.project-chat-reference-trigger`,
|
||||
不是输入区操作排里的 `.resource-reference-input-at`(宿主不注入
|
||||
`ResourceReferencePickerAction`,改用控制排那个按钮)。这里原本有一条针对它的
|
||||
`position: static !important` / 30px 主色方块规则,永远匹配不到,已删除;
|
||||
要改 direct-codex 的 `@` 触发钮外观请改 `.project-chat-reference-trigger`。 */
|
||||
|
||||
|
||||
+3
-5
@@ -122,8 +122,8 @@ export function DirectProjectChatView({
|
||||
});
|
||||
const {
|
||||
appendLocalMessage,
|
||||
attachments,
|
||||
attachmentNotice,
|
||||
attachmentsImporting,
|
||||
cancelQueuedTurn,
|
||||
cancelTurn,
|
||||
composerNotice,
|
||||
@@ -134,7 +134,6 @@ export function DirectProjectChatView({
|
||||
localMessages,
|
||||
pendingUserItemId,
|
||||
queuedTurns,
|
||||
removeAttachment,
|
||||
startInitialTurn,
|
||||
statusNotice,
|
||||
submit,
|
||||
@@ -247,8 +246,8 @@ export function DirectProjectChatView({
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath}
|
||||
attachments={attachments}
|
||||
attachmentNotice={attachmentNotice}
|
||||
attachmentsImporting={attachmentsImporting}
|
||||
queuedTurns={queuedTurns}
|
||||
composerNotice={composerNotice}
|
||||
busy={turnStatus.displayBusy}
|
||||
@@ -260,9 +259,8 @@ export function DirectProjectChatView({
|
||||
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
|
||||
if (projectPath) void refresh(projectPath);
|
||||
}}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onSubmit={submit}
|
||||
onUploadFiles={(files) => void uploadFiles(files)}
|
||||
onUploadFiles={uploadFiles}
|
||||
/>
|
||||
</div>
|
||||
{settingsOpen ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user