Merge branch 'master' into fix/design-agent-fe
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 57s
Project CI / Backend tests (pull_request) Successful in 5m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m33s
Project CI / Native shell tests (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m7s
Project CI / Frontend tests (pull_request) Successful in 3m1s
Project CI / Repository checks (pull_request) Successful in 3m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m37s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 57s
Project CI / Backend tests (pull_request) Successful in 5m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m33s
Project CI / Native shell tests (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m7s
Project CI / Frontend tests (pull_request) Successful in 3m1s
Project CI / Repository checks (pull_request) Successful in 3m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m37s
This commit is contained in:
@@ -386,6 +386,9 @@ export function createChannelConfig(
|
||||
return {
|
||||
productName,
|
||||
identifier,
|
||||
app: {
|
||||
windows: [{ title: productName }],
|
||||
},
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel, target)],
|
||||
@@ -452,6 +455,8 @@ export function runTauriBuild(
|
||||
// Vite embeds the platform API origin in the packaged renderer. The
|
||||
// release channel and updater channel therefore cannot drift apart.
|
||||
VITE_AGC_PLATFORM_CHANNEL: channel,
|
||||
VITE_AGC_PRODUCT_NAME:
|
||||
resolveChannelInstallIdentity(channel).productName,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -177,8 +177,11 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
productName: `${AGC_PRODUCT_NAME}开发版`,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
app: {
|
||||
windows: [{ title: `${AGC_PRODUCT_NAME}开发版` }],
|
||||
},
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
@@ -202,7 +205,7 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
|
||||
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
|
||||
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
productName: `${AGC_PRODUCT_NAME}开发版`,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('release'), {
|
||||
@@ -277,6 +280,10 @@ test('packaged renderer receives the same channel as the updater manifest', () =
|
||||
},
|
||||
});
|
||||
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
|
||||
assert.equal(
|
||||
spawnOptions?.env?.VITE_AGC_PRODUCT_NAME,
|
||||
`${AGC_PRODUCT_NAME} Release`,
|
||||
);
|
||||
});
|
||||
|
||||
test('macOS manifests advertise exactly the architectures actually built', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
|
||||
* 本地项目与运行锁。
|
||||
*
|
||||
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
|
||||
* 默认渠道 `dev` 保持已发布客户端标识不变:升级链路与既有安装不能断;展示名显式标记为开发版。
|
||||
*/
|
||||
|
||||
export const AGC_DEFAULT_CHANNEL = 'dev';
|
||||
@@ -46,8 +46,9 @@ export function resolveReleaseChannel(env = process.env) {
|
||||
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
|
||||
}
|
||||
|
||||
/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */
|
||||
/** 安装身份里的展示后缀:`dev` → `开发版`,`release` → `Release`,`beta-2` → `Beta-2`。 */
|
||||
export function channelDisplaySuffix(channel) {
|
||||
if (channel === AGC_DEFAULT_CHANNEL) return '开发版';
|
||||
return validateReleaseChannel(channel)
|
||||
.split('-')
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
@@ -55,19 +56,19 @@ export function channelDisplaySuffix(channel) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
|
||||
* 保证同一台设备上不同渠道互不覆盖。
|
||||
* 渠道对应的安装身份。默认渠道保持既有 identifier 以兼容已安装客户端,
|
||||
* 但展示名明确标记为开发版;其它渠道派生独立 identifier,保证同一台设备上并存。
|
||||
*/
|
||||
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
|
||||
validateReleaseChannel(channel);
|
||||
if (channel === AGC_DEFAULT_CHANNEL) {
|
||||
return Object.freeze({
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
|
||||
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
|
||||
productName:
|
||||
channel === AGC_DEFAULT_CHANNEL
|
||||
? `${AGC_PRODUCT_NAME}开发版`
|
||||
: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
|
||||
identifier:
|
||||
channel === AGC_DEFAULT_CHANNEL
|
||||
? AGC_APP_IDENTIFIER
|
||||
: `${AGC_APP_IDENTIFIER}.${channel}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ execFileSync(
|
||||
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
import {
|
||||
@@ -1313,7 +1312,7 @@ if (
|
||||
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
|
||||
// 的升级链路与既有安装目录都会断开。
|
||||
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
|
||||
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
|
||||
if (tauriConfig.productName !== defaultChannelIdentity.productName) {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
@@ -1486,7 +1485,7 @@ if (
|
||||
!Array.isArray(tauriConfig.app?.windows) ||
|
||||
tauriConfig.app.windows.length !== 1 ||
|
||||
tauriConfig.app.windows[0]?.label !== 'client' ||
|
||||
tauriConfig.app.windows[0]?.title !== '陶泥儿' ||
|
||||
tauriConfig.app.windows[0]?.title !== defaultChannelIdentity.productName ||
|
||||
tauriConfig.app.windows[0]?.url !== 'index.html'
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "陶泥儿",
|
||||
"productName": "陶泥儿开发版",
|
||||
"version": "0.1.67",
|
||||
"identifier": "world.genarrative.ai-game-creator",
|
||||
"build": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "client",
|
||||
"title": "陶泥儿",
|
||||
"title": "陶泥儿开发版",
|
||||
"url": "index.html",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import appPackage from '../../package.json';
|
||||
|
||||
const DEFAULT_APP_NAME = '陶泥儿开发版';
|
||||
|
||||
/**
|
||||
* 产品名由构建期注入;本地 Vite 开发没有注入时沿用 dev 渠道产品名。
|
||||
*
|
||||
* 发布构建可通过 `VITE_AGC_PRODUCT_NAME` 注入渠道产品名,避免 UI 自己
|
||||
* 根据渠道推导名称,保证标题栏、关于页等前端展示与安装身份保持一致。
|
||||
*/
|
||||
const injectedAppName = import.meta.env.VITE_AGC_PRODUCT_NAME?.trim();
|
||||
|
||||
/** Product metadata shared by the client UI and release bundle. */
|
||||
export const APP_NAME = 'Genarrative AI Game Creator';
|
||||
export const APP_NAME = injectedAppName || DEFAULT_APP_NAME;
|
||||
export const APP_VERSION = appPackage.version;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { APP_NAME } from '../app/appMetadata';
|
||||
import { appUpdateCheckEnabled } from '../app/featureFlags';
|
||||
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
|
||||
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
|
||||
@@ -150,12 +151,15 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
|
||||
<header className="window-chrome__bar" aria-label="窗口标题栏">
|
||||
<div className="window-chrome__leading">
|
||||
<div className="window-chrome__brand" aria-label="陶泥儿 GameAgent">
|
||||
<div
|
||||
className="window-chrome__brand"
|
||||
aria-label={`${APP_NAME} GameAgent`}
|
||||
>
|
||||
<span className="window-chrome__brand-mark">
|
||||
<img src={brandIcon} alt="" />
|
||||
</span>
|
||||
<span className="window-chrome__brand-copy">
|
||||
<strong>陶泥儿</strong>
|
||||
<strong>{APP_NAME}</strong>
|
||||
<span>GameAgent</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+164
-35
@@ -16,10 +16,13 @@ import {
|
||||
$isLineBreakNode,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
type EditorState,
|
||||
KEY_ENTER_COMMAND,
|
||||
type LexicalNode,
|
||||
PASTE_COMMAND,
|
||||
PASTE_TAG,
|
||||
type TextNode,
|
||||
} from 'lexical';
|
||||
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
ResourceReferenceNode,
|
||||
} from './ResourceReferenceNode';
|
||||
import {
|
||||
buildContentFromPastedText,
|
||||
buildContentFromTextTokens,
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
@@ -60,11 +64,18 @@ import {
|
||||
chatReferenceKey,
|
||||
chatReferenceMentionToken,
|
||||
chatReferenceToContentPart,
|
||||
contentPartText,
|
||||
dedupeChatReferences,
|
||||
joinMentionText,
|
||||
} from './resourceReferences';
|
||||
import { usePromptPolish } from './usePromptPolish';
|
||||
|
||||
/**
|
||||
* Lexical 自己的剪贴板负载(导入优先级最高的一条):带着它复制粘贴时,真 chip 会被原样还原,
|
||||
* 所以粘贴解析要让位给默认导入。
|
||||
*/
|
||||
const LEXICAL_EDITOR_CLIPBOARD_TYPE = 'application/x-lexical-editor';
|
||||
|
||||
type ResourceReferenceInputProps = {
|
||||
onChange?: (draft: ChatComposerDraft) => void;
|
||||
onEditorStateChange?: (editorState: EditorState) => void;
|
||||
@@ -226,6 +237,20 @@ function mentionTokenFromPart(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* part 落进正文时用的文本:provider 的 `mentionToken`,它答不出来(provider 契约被破坏)时退到
|
||||
* `contentPartText` 的通用文本形态。文本 part 自己就是文本,返回 `null`。
|
||||
*
|
||||
* 粘贴插入、润色回写的候选扫描与落点都走这一条:只要 part 不是文本,就一定有一段正常文本可落,
|
||||
* 「认不出的引用走文本、绝不静默丢」在几个入口是同一份实现,不是各写一遍兜底。
|
||||
*/
|
||||
function mentionTokenOrText(
|
||||
providers: readonly ReferenceProvider[],
|
||||
part: DirectCodexUserContentPart,
|
||||
): string | null {
|
||||
return mentionTokenFromPart(providers, part) ?? contentPartText(part);
|
||||
}
|
||||
|
||||
/**
|
||||
* 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。
|
||||
*
|
||||
@@ -256,8 +281,10 @@ function applyPolishedTextToRoot(
|
||||
) {
|
||||
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
|
||||
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
|
||||
// provider 答不出 token 的 part 也照样进候选(token 退到 `mentionTokenOrText` 的通用文本形态):
|
||||
// 它在回包里没被提到时会作为末尾孤儿补回来,而不是从这门翻译里直接消失。
|
||||
const candidates = current.content.flatMap((part) => {
|
||||
const token = mentionTokenFromPart(providers, part);
|
||||
const token = mentionTokenOrText(providers, part);
|
||||
return token ? [{ token, part }] : [];
|
||||
});
|
||||
applyContentToRoot(buildContentFromTextTokens(value, candidates), providers);
|
||||
@@ -286,8 +313,81 @@ function applyContentToRoot(
|
||||
const reference = referenceFromPart(providers, part);
|
||||
if (reference) {
|
||||
paragraph.append($createResourceReferenceNode(reference));
|
||||
return;
|
||||
}
|
||||
// 解析不出引用的 part 落它的文本(与粘贴同一条兜底链),整根替换同样不许把内容吃掉。
|
||||
const token = mentionTokenOrText(providers, part);
|
||||
if (token) paragraph.append($createTextNode(token));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前可用的选区:选区缺失、或指向已被重建掉的节点时(跨会话恢复草稿后就是这种),
|
||||
* 统一回落到草稿末尾,避免把内容插到一个已经不存在的位置。取不到时返回 `null`。
|
||||
*/
|
||||
function $selectionOrRootEnd() {
|
||||
let selection = $getSelection();
|
||||
if (
|
||||
!$isRangeSelection(selection) ||
|
||||
!selection.anchor.getNode().isAttached()
|
||||
) {
|
||||
$getRoot().selectEnd();
|
||||
selection = $getSelection();
|
||||
}
|
||||
return $isRangeSelection(selection) ? selection : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粘贴插入:在光标处就地插入 content 对应的节点,正文其余部分逐字不动。
|
||||
*
|
||||
* 与 `applyContentToRoot`(整根替换,供初始草稿与润色回写使用)的区别只在替换范围:
|
||||
* 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成
|
||||
* chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。
|
||||
*
|
||||
* 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本;连 provider 的
|
||||
* `mentionToken` 都答不出来时退到 `contentPartText` 的通用文本形态。两条兜底合起来保证
|
||||
* 「粘贴进来的内容一个字符都不会凭空消失」,这个分支不存在什么都不插的出路。
|
||||
*
|
||||
* 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行
|
||||
* 编辑器的默认粘贴,否则这段文字两边都不管。
|
||||
*/
|
||||
function $insertContentAtSelection(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
providers: readonly ReferenceProvider[],
|
||||
): boolean {
|
||||
// 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。
|
||||
if (!$selectionOrRootEnd()) return false;
|
||||
let inserted = false;
|
||||
content.forEach((part) => {
|
||||
if (part.type === 'input_text') {
|
||||
part.text.split('\n').forEach((line, index) => {
|
||||
if (index > 0) {
|
||||
$selectionOrRootEnd()?.insertParagraph();
|
||||
inserted = true;
|
||||
}
|
||||
if (line) {
|
||||
$selectionOrRootEnd()?.insertText(line);
|
||||
inserted = true;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const reference = referenceFromPart(providers, part);
|
||||
if (reference) {
|
||||
$selectionOrRootEnd()?.insertNodes([
|
||||
$createResourceReferenceNode(reference),
|
||||
]);
|
||||
inserted = true;
|
||||
return;
|
||||
}
|
||||
// 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
|
||||
const token = mentionTokenOrText(providers, part);
|
||||
if (token) {
|
||||
$selectionOrRootEnd()?.insertText(token);
|
||||
inserted = true;
|
||||
}
|
||||
});
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,24 +461,14 @@ function ResourceReferenceEditor({
|
||||
(nextReferences: ChatReference[]) => {
|
||||
if (nextReferences.length === 0) return;
|
||||
editor.update(() => {
|
||||
let selection = $getSelection();
|
||||
// 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾,
|
||||
// 避免把引用插到一个已经不存在的位置。
|
||||
if (
|
||||
!$isRangeSelection(selection) ||
|
||||
!selection.anchor.getNode().isAttached()
|
||||
) {
|
||||
$getRoot().selectEnd();
|
||||
selection = $getSelection();
|
||||
}
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertNodes(
|
||||
nextReferences.flatMap((reference) => [
|
||||
$createResourceReferenceNode(reference),
|
||||
$createTextNode(' '),
|
||||
]),
|
||||
);
|
||||
}
|
||||
const selection = $selectionOrRootEnd();
|
||||
if (!selection) return;
|
||||
selection.insertNodes(
|
||||
nextReferences.flatMap((reference) => [
|
||||
$createResourceReferenceNode(reference),
|
||||
$createTextNode(' '),
|
||||
]),
|
||||
);
|
||||
});
|
||||
editor.focus();
|
||||
},
|
||||
@@ -390,19 +480,11 @@ function ResourceReferenceEditor({
|
||||
const insert = text.replace(/\s+$/u, '');
|
||||
if (!insert.trim()) return;
|
||||
editor.update(() => {
|
||||
let selection = $getSelection();
|
||||
if (
|
||||
!$isRangeSelection(selection) ||
|
||||
!selection.anchor.getNode().isAttached()
|
||||
) {
|
||||
$getRoot().selectEnd();
|
||||
selection = $getSelection();
|
||||
}
|
||||
if ($isRangeSelection(selection)) {
|
||||
const rootText = $getRoot().getTextContent();
|
||||
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
|
||||
selection.insertText(insert);
|
||||
}
|
||||
const selection = $selectionOrRootEnd();
|
||||
if (!selection) return;
|
||||
const rootText = $getRoot().getTextContent();
|
||||
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
|
||||
selection.insertText(insert);
|
||||
});
|
||||
editor.focus();
|
||||
},
|
||||
@@ -522,6 +604,53 @@ function ResourceReferenceEditor({
|
||||
);
|
||||
}, [editor, multiline]);
|
||||
|
||||
// 粘贴解析:只有「粘贴文本里真的解析出了引用 token」且「这一整段真的插进了正文」时才接管,
|
||||
// 其余一律返回 false 放行 Lexical 的默认导入——同 namespace 复制出来的 Lexical payload
|
||||
// 本来就能还原真 chip,不含 token 的纯文本、图片文件粘贴也都保持原行为。
|
||||
//
|
||||
// 接管时整段文本在一次 update 内插入(与默认粘贴同样打 PASTE_TAG),所以一次 Ctrl+Z 就整体
|
||||
// 回退;token 之外的每个字符逐字保留(换行落成段落分隔,与默认纯文本粘贴同形状)。
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
(event) => {
|
||||
const clipboardData = (event as ClipboardEvent | null)?.clipboardData;
|
||||
if (!clipboardData || typeof clipboardData.getData !== 'function') {
|
||||
return false;
|
||||
}
|
||||
// 同 namespace 的 Lexical payload 自带真 chip,不抢它的默认导入。
|
||||
if (clipboardData.getData(LEXICAL_EDITOR_CLIPBOARD_TYPE)) return false;
|
||||
const text = clipboardData.getData('text/plain');
|
||||
if (!text.trim()) return false;
|
||||
const providers = providersRef.current;
|
||||
// 只认「此刻就绪」的候选:数据还没到的种类本次按文本保留,输入区不等待也不补读。
|
||||
const references = providers.flatMap(
|
||||
(provider) => provider.lookup?.() ?? [],
|
||||
);
|
||||
const content = buildContentFromPastedText(text, references);
|
||||
if (!content) return false;
|
||||
// 先真的插进去,再决定接管这次粘贴:插入为空(取不到选区)时必须放行默认粘贴,
|
||||
// 否则这段文字既没进我们的插入、又被 preventDefault 挡掉了默认导入,静默消失。
|
||||
// 编辑器已经在一次更新里时 `editor.update` 会把回调排队,这时 `ranSync` 仍是 false,
|
||||
// 按原口径先接管,等队列里的那次插入落地。
|
||||
let ranSync = false;
|
||||
let inserted = false;
|
||||
editor.update(
|
||||
() => {
|
||||
ranSync = true;
|
||||
inserted = $insertContentAtSelection(content, providersRef.current);
|
||||
},
|
||||
// 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。
|
||||
{ tag: PASTE_TAG },
|
||||
);
|
||||
if (ranSync && !inserted) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
// —— C8 AI 润色与发送前提醒 ——
|
||||
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
|
||||
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
|
||||
@@ -796,16 +925,16 @@ function ProviderMentionMenu({
|
||||
[onOpenChange, trigger],
|
||||
);
|
||||
|
||||
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`match` 始终保持纯函数,
|
||||
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`fuzzyLookup` 始终保持纯函数,
|
||||
// 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。
|
||||
useEffect(() => {
|
||||
provider.onMenuQueryChange?.(query);
|
||||
}, [provider, query]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (query === null || !provider.match) return [];
|
||||
if (query === null || !provider.fuzzyLookup) return [];
|
||||
return provider
|
||||
.match(query)
|
||||
.fuzzyLookup(query)
|
||||
.map((reference) => new ReferenceMentionOption(reference));
|
||||
}, [provider, query]);
|
||||
|
||||
|
||||
+8
-3
@@ -1,5 +1,8 @@
|
||||
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import type { ChatReference } from '../resourceReferences';
|
||||
import {
|
||||
type ChatReference,
|
||||
normalizeMentionName,
|
||||
} from '../resourceReferences';
|
||||
import type { ReferenceProvider } from './types';
|
||||
|
||||
/** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */
|
||||
@@ -8,7 +11,7 @@ export function attachmentReferenceFromPart(
|
||||
): ChatReference {
|
||||
return {
|
||||
type: 'attachment',
|
||||
name: part.name,
|
||||
name: normalizeMentionName(part.name),
|
||||
mediaType: part.mediaType,
|
||||
size: part.size,
|
||||
localPath: part.localPath,
|
||||
@@ -32,7 +35,9 @@ export function createAttachmentReferenceProvider(): ReferenceProvider {
|
||||
refresh: (reference: ChatReference): ChatReference | null =>
|
||||
reference.type === 'attachment' ? reference : null,
|
||||
mentionToken: (part) =>
|
||||
part.type === 'agc_attachment_reference' ? `@${part.name}` : null,
|
||||
part.type === 'agc_attachment_reference'
|
||||
? `@${normalizeMentionName(part.name)}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -49,12 +49,14 @@ export function createResourceReferenceProvider({
|
||||
}): ReferenceProvider {
|
||||
return {
|
||||
trigger: '@',
|
||||
match: (query) =>
|
||||
fuzzyLookup: (query) =>
|
||||
resourceProviderData(assets)
|
||||
.references.filter((reference) =>
|
||||
resourceReferenceMatchesQuery(reference, query),
|
||||
)
|
||||
.slice(0, MENTION_OPTION_LIMIT),
|
||||
// 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。
|
||||
lookup: () => resourceProviderData(assets).references,
|
||||
toReference: (part: DirectCodexUserContentPart): ChatReference | null => {
|
||||
if (part.type !== 'agc_resource_reference') return null;
|
||||
const asset = resourceProviderData(assets).byId.get(part.resourceId);
|
||||
|
||||
+40
-23
@@ -2,7 +2,10 @@ 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 ChatReference,
|
||||
normalizeMentionName,
|
||||
} from '../resourceReferences';
|
||||
import type { ReferenceProvider } from './types';
|
||||
|
||||
/** 候选菜单最多展示多少条:与资源候选同一上限。 */
|
||||
@@ -25,7 +28,7 @@ function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
|
||||
'list_agc_skill_catalog',
|
||||
).then((items) =>
|
||||
items.map((item) => ({
|
||||
name: item.name,
|
||||
name: normalizeMentionName(item.name),
|
||||
description: item.description,
|
||||
})),
|
||||
),
|
||||
@@ -44,12 +47,15 @@ function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
|
||||
item.enabled &&
|
||||
item.status === 'enabled',
|
||||
)
|
||||
.map((item) => ({ name: item.name })),
|
||||
.map((item) => ({ name: normalizeMentionName(item.name) })),
|
||||
),
|
||||
]).then(([builtin, client]) => [...builtin, ...client]);
|
||||
}
|
||||
|
||||
function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
|
||||
function matchesSkillQuery(
|
||||
skill: { name: string; description?: string },
|
||||
query: string,
|
||||
) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return (
|
||||
@@ -58,13 +64,29 @@ function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 目录项 → 引用:同名只留第一条(与 `fuzzyLookup` 同一份去重口径)。 */
|
||||
function skillReferences(skills: readonly SkillCatalogItem[]) {
|
||||
const seen = new Set<string>();
|
||||
const references: ChatReference[] = [];
|
||||
for (const skill of skills) {
|
||||
if (seen.has(skill.name)) continue;
|
||||
seen.add(skill.name);
|
||||
references.push({
|
||||
type: 'skill',
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
});
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 引用的 provider(宿主 hook)。
|
||||
*
|
||||
* 与资源 provider 不同,Skill 候选是**异步**的应用级读取,所以它必须是一份 React 状态:
|
||||
* 用户敲出 `$` 打开候选菜单时(`onMenuQueryChange` 收到非 `null`,由输入区在 effect 里回调)
|
||||
* 发起读取,结果到了之后宿主重渲染,输入区随之拿到新的候选。
|
||||
* `match` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
|
||||
* `fuzzyLookup` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
|
||||
* 读取本身不进输入区,只有宿主才知道这条路该不该存在——
|
||||
* 目前只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill。
|
||||
*/
|
||||
@@ -96,31 +118,26 @@ export function useSkillReferenceProvider(): ReferenceProvider {
|
||||
onMenuQueryChange: (query) => {
|
||||
if (query !== null) ensureCatalog();
|
||||
},
|
||||
match: (query) => {
|
||||
const seen = new Set<string>();
|
||||
return skills
|
||||
.filter((skill) => {
|
||||
if (seen.has(skill.name)) return false;
|
||||
seen.add(skill.name);
|
||||
return matchesSkillQuery(skill, query);
|
||||
})
|
||||
.slice(0, MENTION_OPTION_LIMIT)
|
||||
.map(
|
||||
(skill): ChatReference => ({
|
||||
type: 'skill',
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
}),
|
||||
);
|
||||
// 精确查找:目录还没就绪(用户还没敲过 `$`)时返回空数组,本次 `$名称` 逐字保留。
|
||||
lookup: () => skillReferences(skills),
|
||||
fuzzyLookup: (query) => {
|
||||
return skillReferences(skills)
|
||||
.filter(
|
||||
(reference) =>
|
||||
reference.type === 'skill' && matchesSkillQuery(reference, query),
|
||||
)
|
||||
.slice(0, MENTION_OPTION_LIMIT);
|
||||
},
|
||||
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
|
||||
part.type === 'agc_skill_reference'
|
||||
? { type: 'skill', name: part.name }
|
||||
? { type: 'skill', name: normalizeMentionName(part.name) }
|
||||
: null,
|
||||
refresh: (reference: ChatReference): ChatReference | null =>
|
||||
reference.type === 'skill' ? reference : null,
|
||||
mentionToken: (part) =>
|
||||
part.type === 'agc_skill_reference' ? `$${part.name}` : null,
|
||||
part.type === 'agc_skill_reference'
|
||||
? `$${normalizeMentionName(part.name)}`
|
||||
: null,
|
||||
}),
|
||||
[ensureCatalog, skills],
|
||||
);
|
||||
|
||||
+15
-2
@@ -19,12 +19,25 @@ export type ReferenceProvider = {
|
||||
*/
|
||||
trigger: string | null;
|
||||
/**
|
||||
* 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。
|
||||
* 模糊查询:候选菜单的过滤。名字写明它是模糊的——按 query 做包含匹配、大小写不敏感,
|
||||
* 并在 provider 内部截断到候选上限。静默 provider 不实现。
|
||||
*
|
||||
* **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会
|
||||
* 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。
|
||||
* 精确查找用 `lookup`,不要拿它反查 token。
|
||||
*/
|
||||
match?: (query: string) => ChatReference[];
|
||||
fuzzyLookup?: (query: string) => ChatReference[];
|
||||
/**
|
||||
* 精确查找用的全量候选:某一刻 provider 真正能解析出的所有引用,不做模糊过滤、不截断。
|
||||
*
|
||||
* 与 `fuzzyLookup` 的区别只有「模糊与截断」,两者共用同一份候选来源。没有触发符的静默
|
||||
* provider 不实现。
|
||||
*
|
||||
* **同样是纯函数**:输入区在粘贴事件里同步调用它;只返回已就绪的快照,不发起任何读取。
|
||||
* 数据还没到时返回空数组(或省略不实现),解析不出的 token 逐字保留——例如 Skill 目录的就绪
|
||||
* 时机仍是用户第一次敲出 `$`,冷启动时粘贴 `$名称` 就是字面文本,不会被猜成别的引用。
|
||||
*/
|
||||
lookup?: () => ChatReference[];
|
||||
/**
|
||||
* 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在
|
||||
* 带触发符的 provider 上调用**。
|
||||
|
||||
@@ -154,6 +154,26 @@ export function resourceLabelResolver(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用名(资源显示名 / Skill 名 / 附件名)的空白不变量:`@显示名`、`$名称`、`@附件名` 里
|
||||
* 不允许出现空白,内部空白统一折成 `-`。
|
||||
*
|
||||
* 引用的名字同时就是它在正文里的 token,而 token 的边界规则是「前后为空白或行首行尾」
|
||||
* (`isMentionTokenBoundary`)。名字里一旦有空白,`@hero v2` 在反解析时会被切成 `@hero` +
|
||||
* 文本 `v2`:短名字抢先命中,真正的引用反而变成补在末尾的孤儿。空白折成 `-` 之后 token 自带
|
||||
* 边界,`@hero` 不会再命中 `@hero-v2`(后一个字符是 `-`,不是空白)。
|
||||
*
|
||||
* 这里只做归一化、不做 `resourceId` 之类的兜底:兜底属于名字的来源侧(例如
|
||||
* `resourceDisplayName` 在文件名词干为空时回退 `asset.id`),归一化本身保持是个纯函数。
|
||||
*
|
||||
* 归一化不保证名字唯一:`hero v2` 与 `hero-v2` 会折成同一个 token,两条引用因此在候选菜单里
|
||||
* 显示同一个标签。归一化后的碰撞由 `buildContentFromPastedText` 的「同名多候选一律按文本保留」
|
||||
* 兜住(不认错,但两者都成不了 chip);候选菜单侧不做冲突检测,标签重复是这条取舍的可见残留。
|
||||
*/
|
||||
export function normalizeMentionName(value: string) {
|
||||
return value.trim().replace(/\s+/gu, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* canonical content → 可读文本;每个引用 part 经 `tokenOf` 展开,文本 part 逐字保留。
|
||||
*
|
||||
@@ -207,13 +227,44 @@ export function directCodexContentToPromptText(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
resolveResourceLabel: ResourceLabelResolver,
|
||||
) {
|
||||
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}`;
|
||||
});
|
||||
return joinMentionText(content, (part) =>
|
||||
contentPartToken(part, resolveResourceLabel),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 part 在正文文本里的 token(`@显示名` / `$名称` / `@附件名` / `@区域标签`);文本 part
|
||||
* 没有 token,返回 `null`。
|
||||
*
|
||||
* 逐字返回 token 本身:不带补白、不裁剪——补白是 `joinMentionText` 在拼整段文本时加的,
|
||||
* 什么时候需要留白由那里的上下文决定,token 的投影不替它决定。
|
||||
*/
|
||||
function contentPartToken(
|
||||
part: DirectCodexUserContentPart,
|
||||
resolveResourceLabel: ResourceLabelResolver,
|
||||
): string | null {
|
||||
if (part.type === 'input_text') return null;
|
||||
if (part.type === 'agc_attachment_reference')
|
||||
return `@${normalizeMentionName(part.name)}`;
|
||||
if (part.type === 'agc_skill_reference')
|
||||
return `$${normalizeMentionName(part.name)}`;
|
||||
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
|
||||
return `@${normalizeMentionName(
|
||||
resolveResourceLabel(part.resourceId) ?? part.resourceId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个非文本 part 的文本形态,就是它的 token(见 `contentPartToken`):资源拿不到显示名时
|
||||
* 退回 `resourceId`。文本 part 没有文本形态,返回 `null`。
|
||||
*
|
||||
* 只给「插入那一刻解析不出引用、也拿不到 provider 的 token」兜底:粘贴过来的字必须落下去,
|
||||
* 哪怕落成一段文本。它不参与解析,也不是解析依据——扫描一律用 `chatReferenceMentionToken`。
|
||||
*/
|
||||
export function contentPartText(
|
||||
part: DirectCodexUserContentPart,
|
||||
): string | null {
|
||||
return contentPartToken(part, () => undefined);
|
||||
}
|
||||
|
||||
/** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */
|
||||
@@ -290,8 +341,12 @@ export function chatReferenceToContentPart(
|
||||
* 资源与运行画面区域 `@显示名`。资源引用自带显示名,所以这里不必再查 manifest。
|
||||
*/
|
||||
export function chatReferenceMentionToken(reference: ChatReference): string {
|
||||
if (reference.type === 'skill') return `$${reference.name}`;
|
||||
if (reference.type === 'attachment') return `@${reference.name}`;
|
||||
if (reference.type === 'skill')
|
||||
return `$${normalizeMentionName(reference.name)}`;
|
||||
if (reference.type === 'attachment')
|
||||
return `@${normalizeMentionName(reference.name)}`;
|
||||
if (reference.type === 'resource')
|
||||
return `@${normalizeMentionName(reference.label)}`;
|
||||
return `@${reference.label}`;
|
||||
}
|
||||
|
||||
@@ -417,6 +472,74 @@ export function buildContentFromTextTokens(
|
||||
return content;
|
||||
}
|
||||
|
||||
/** token 在整段文本里出现的次数:与 `findMentionToken` 同一套边界规则,按行统计。 */
|
||||
function countMentionTokenOccurrences(value: string, token: string) {
|
||||
let count = 0;
|
||||
for (const line of value.split(/\r?\n/u)) {
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const index = findMentionToken(line, token, from);
|
||||
if (index < 0) break;
|
||||
count += 1;
|
||||
from = index + token.length;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粘贴文本 → canonical content(粘贴侧的唯一解析口径)。
|
||||
*
|
||||
* 候选来自调用方传入的引用清单(输入区给的是 `ReferenceProvider.lookup()` 此刻就绪的全量
|
||||
* 候选),token 就是
|
||||
* `chatReferenceMentionToken`——与显示口径逐字同一个字符串,所以「从气泡复制再粘贴」不需要
|
||||
* 任何兼容别名:`@显示名` / `$名称` 认得出,`@hero.png`、resourceId、大小写变体一律不认。
|
||||
*
|
||||
* 三条保守规则:
|
||||
*
|
||||
* - 同名多候选(同一个 token 对应多条引用身份)一律按文本保留——宁可不成 chip,也不能认错引用。
|
||||
* - 退化 token(只有触发符的裸 `@` / `$`,即引用名为空)不参与解析:边界规则会把正文里
|
||||
* 任何一处裸 `@` 当成它,等于把无关文字错认成一条引用。
|
||||
* - 只把正文里真的出现过的 token 交给 `buildContentFromTextTokens`,不做「未命中候选补到末尾」
|
||||
* (那是润色回包的语义,照搬会把整份清单追加到粘贴文本后面)。同一 token 出现几次就展开几条
|
||||
* 候选,所以重复出现的引用会各自原位成 chip。
|
||||
*
|
||||
* 返回 `null` 表示这段文本里没有任何可解析的 token,调用方应放行编辑器的默认粘贴。
|
||||
*/
|
||||
export function buildContentFromPastedText(
|
||||
value: string,
|
||||
references: readonly ChatReference[],
|
||||
): DirectCodexUserContentPart[] | null {
|
||||
const referencesByToken = new Map<string, ChatReference[]>();
|
||||
for (const reference of references) {
|
||||
const token = chatReferenceMentionToken(reference);
|
||||
const bucket = referencesByToken.get(token);
|
||||
if (!bucket) {
|
||||
referencesByToken.set(token, [reference]);
|
||||
continue;
|
||||
}
|
||||
const key = chatReferenceKey(reference);
|
||||
if (!bucket.some((item) => chatReferenceKey(item) === key)) {
|
||||
bucket.push(reference);
|
||||
}
|
||||
}
|
||||
const candidates: ContentTokenCandidate[] = [];
|
||||
for (const [token, bucket] of referencesByToken) {
|
||||
// 名字为空的引用只会给出一个裸触发符:它能在正文里匹配到任何一处 `@` / `$`,
|
||||
// 认下来就是错认。空名字是上游数据问题,这里按「宁可不成 chip」的同一口径跳过。
|
||||
if (token.length <= 1) continue;
|
||||
if (bucket.length !== 1) continue;
|
||||
const reference = bucket[0]!;
|
||||
const part = chatReferenceToContentPart(reference);
|
||||
const occurrences = countMentionTokenOccurrences(value, token);
|
||||
for (let index = 0; index < occurrences; index += 1) {
|
||||
candidates.push({ token, part });
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
return buildContentFromTextTokens(value, candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* legacy「text + references + attachments」DTO → canonical content:旧调用方继续吐双轨
|
||||
* 形状,这里按 token 扫回真 part,不另立第二份事实源。
|
||||
@@ -540,9 +663,18 @@ export function directCodexContentToLegacyContentDto(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材显示名:文件名去掉扩展名,再过一遍引用名口径(见 `normalizeMentionName`)。
|
||||
*
|
||||
* 整名就是扩展名时(`.env`、`.gitignore`)去扩展名会得到空串,这里回退 `asset.id`:
|
||||
* 显示名同时是正文里的 token,空名字会退化成只有触发符的裸 `@`——候选菜单里是一枚空芯片,
|
||||
* 粘贴解析还会拿它认领正文里任何一处裸 `@`(见 `buildContentFromPastedText`)。
|
||||
* 与渲染侧「显示名解析不到就用 `resourceId`」是同一口径。
|
||||
*/
|
||||
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
|
||||
const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id;
|
||||
return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id;
|
||||
const stem = fileName.replace(/\.[^.]+$/u, '');
|
||||
return normalizeMentionName(stem || asset.id);
|
||||
}
|
||||
|
||||
export function resourceReferenceFromAsset(
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
import type { LauncherImportedAttachment } from '../../../../app/types';
|
||||
import { normalizeMentionName } from '../../../../features/project-workspace/resourceReferences';
|
||||
|
||||
export type DirectCodexTurnAttachment = {
|
||||
name: string;
|
||||
@@ -16,7 +17,8 @@ export function toDirectCodexTurnAttachments(
|
||||
}
|
||||
return imported.map((item) => {
|
||||
const attachment: DirectCodexTurnAttachment = {
|
||||
name: item.fileName,
|
||||
// 附件名同时是正文里的 `@附件名` token,所以和其它引用名一样不允许空白(见 normalizeMentionName)。
|
||||
name: normalizeMentionName(item.fileName),
|
||||
mediaType: item.mediaType,
|
||||
};
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { APP_VERSION } from '../../src/app/appMetadata';
|
||||
import { APP_NAME, APP_VERSION } from '../../src/app/appMetadata';
|
||||
import { repoPath } from '../repoPath';
|
||||
import {
|
||||
act,
|
||||
@@ -348,7 +348,7 @@ export function registerRuntimeSettingsTests() {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /关于/ }));
|
||||
|
||||
expect(screen.getByText('Genarrative AI Game Creator')).not.toBeNull();
|
||||
expect(screen.getByText(APP_NAME)).not.toBeNull();
|
||||
expect(
|
||||
screen.getByTestId('runtime-settings-app-logo').getAttribute('src'),
|
||||
).toContain('taonier-product-ip.png');
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('资源引用 provider', () => {
|
||||
expect(provider.isReady?.()).toBe(true);
|
||||
});
|
||||
|
||||
it('候选只含可提及素材,`match` 按显示名 / id / kind 过滤并截断到 8 条', () => {
|
||||
it('候选只含可提及素材,`fuzzyLookup` 按显示名 / id / kind 模糊过滤并截断到 8 条', () => {
|
||||
const assets = [
|
||||
heroAsset,
|
||||
asset('asset-icon', 'icon', 'image/png', 'assets/btn.png'),
|
||||
@@ -91,21 +91,21 @@ describe('资源引用 provider', () => {
|
||||
asset('asset-agent', 'document', 'text/markdown', '.agent/notes.md'),
|
||||
];
|
||||
const scoped = createResourceReferenceProvider({ assets });
|
||||
expect(scoped.match?.('')).toHaveLength(2);
|
||||
expect(scoped.fuzzyLookup?.('')).toHaveLength(2);
|
||||
|
||||
const many = Array.from({ length: 10 }, (_, index) =>
|
||||
asset(`asset-${index}`, 'image', 'image/png', `assets/pic-${index}.png`),
|
||||
);
|
||||
const limited = createResourceReferenceProvider({ assets: many });
|
||||
expect(limited.match?.('')).toHaveLength(8);
|
||||
expect(limited.fuzzyLookup?.('')).toHaveLength(8);
|
||||
|
||||
expect(provider.match?.('hero')?.map((item) => item.type)).toEqual([
|
||||
expect(provider.fuzzyLookup?.('hero')?.map((item) => item.type)).toEqual([
|
||||
'resource',
|
||||
]);
|
||||
expect(provider.match?.('HERO-IDLE')).toHaveLength(1);
|
||||
expect(provider.match?.('character')).toHaveLength(1);
|
||||
expect(provider.match?.(' hero ')).toHaveLength(1);
|
||||
expect(provider.match?.('missing')).toEqual([]);
|
||||
expect(provider.fuzzyLookup?.('HERO-IDLE')).toHaveLength(1);
|
||||
expect(provider.fuzzyLookup?.('character')).toHaveLength(1);
|
||||
expect(provider.fuzzyLookup?.(' hero ')).toHaveLength(1);
|
||||
expect(provider.fuzzyLookup?.('missing')).toEqual([]);
|
||||
});
|
||||
|
||||
it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => {
|
||||
@@ -116,7 +116,7 @@ describe('资源引用 provider', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const candidates = sameName.match?.('') ?? [];
|
||||
const candidates = sameName.fuzzyLookup?.('') ?? [];
|
||||
// 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。
|
||||
expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([
|
||||
'@hero',
|
||||
@@ -127,6 +127,45 @@ describe('资源引用 provider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('`lookup` 给精确查找用的全量清单:与菜单同一份可提及素材,但不受模糊过滤与截断影响', () => {
|
||||
const assets = [
|
||||
heroAsset,
|
||||
asset('asset-orphan', 'image', 'image/png', ''),
|
||||
...Array.from({ length: 10 }, (_, index) =>
|
||||
asset(
|
||||
`asset-${index}`,
|
||||
'image',
|
||||
'image/png',
|
||||
`assets/pic-${index}.png`,
|
||||
),
|
||||
),
|
||||
];
|
||||
const scoped = createResourceReferenceProvider({ assets });
|
||||
|
||||
// 不可提及素材(没有 localPath)不在候选里,条目数与展示名口径与菜单一致。
|
||||
expect(scoped.fuzzyLookup?.('')).toHaveLength(8);
|
||||
expect(scoped.lookup?.()).toHaveLength(11);
|
||||
expect(scoped.lookup?.().map(chatReferenceMentionToken)).toEqual(
|
||||
expect.arrayContaining(['@hero-idle', '@pic-0', '@pic-9']),
|
||||
);
|
||||
// 粘贴解析只认「显示名逐字一致」的 token,所以候选的 token 必须是显示名形态。
|
||||
expect(
|
||||
scoped
|
||||
.lookup?.()
|
||||
.some(
|
||||
(item) => 'resourceId' in item && item.resourceId === 'asset-orphan',
|
||||
),
|
||||
).toBe(false);
|
||||
// 菜单就是「同一份候选 + query 过滤 + 截断」,所以截断后的前缀逐字一致。
|
||||
expect(scoped.lookup?.().slice(0, 8)).toEqual(scoped.fuzzyLookup?.(''));
|
||||
});
|
||||
|
||||
it('清单为空时 `lookup` 是空数组:粘贴不会把任何 token 当成引用', () => {
|
||||
expect(createResourceReferenceProvider({ assets: [] }).lookup?.()).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('`toReference` 只认资源 part:资产已删除时不合成引用', () => {
|
||||
expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe(
|
||||
null,
|
||||
@@ -147,7 +186,7 @@ describe('资源引用 provider', () => {
|
||||
});
|
||||
|
||||
it('`refresh` 按 manifest 换显示名:改名换新引用、未变恒等、已删除原样返回', () => {
|
||||
const reference = provider.match?.('hero')?.[0] as ResourceReference;
|
||||
const reference = provider.fuzzyLookup?.('hero')?.[0] as ResourceReference;
|
||||
expect(provider.refresh(reference)).toBe(reference);
|
||||
|
||||
const renamed = createResourceReferenceProvider({
|
||||
@@ -191,7 +230,23 @@ describe('资源引用 provider', () => {
|
||||
describe('附件 provider', () => {
|
||||
it('静默:没有触发符也没有候选,注入它不会加出任何入口', () => {
|
||||
expect(attachmentReferenceProvider.trigger).toBe(null);
|
||||
expect(attachmentReferenceProvider.match).toBeUndefined();
|
||||
expect(attachmentReferenceProvider.fuzzyLookup).toBeUndefined();
|
||||
});
|
||||
|
||||
it('附件名过同一份空白口径:`toReference` 与 `mentionToken` 都是 `@brief-v2.md`', () => {
|
||||
const part = {
|
||||
type: 'agc_attachment_reference' as const,
|
||||
name: 'brief v2.md',
|
||||
mediaType: 'text/markdown',
|
||||
size: 128,
|
||||
localPath: 'notes/brief v2.md',
|
||||
status: 'imported',
|
||||
};
|
||||
expect(attachmentReferenceProvider.toReference(part)).toMatchObject({
|
||||
type: 'attachment',
|
||||
name: 'brief-v2.md',
|
||||
});
|
||||
expect(attachmentReferenceProvider.mentionToken(part)).toBe('@brief-v2.md');
|
||||
});
|
||||
|
||||
it('`toReference` 逐字搬运附件字段,并只认附件 part', () => {
|
||||
@@ -305,7 +360,7 @@ describe('运行画面区域 provider', () => {
|
||||
});
|
||||
|
||||
describe('Skill provider', () => {
|
||||
it('触发符是 `$`:挂载与 `match` 都不发查询,菜单第一次打开时才读应用级目录', async () => {
|
||||
it('触发符是 `$`:挂载与 `fuzzyLookup` 都不发查询,菜单第一次打开时才读应用级目录', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_agc_skill_catalog') {
|
||||
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
|
||||
@@ -319,9 +374,9 @@ describe('Skill provider', () => {
|
||||
expect(result.current.trigger).toBe('$');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
// `match` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
|
||||
// `fuzzyLookup` 是纯函数:输入区在渲染阶段调它,这里不能替 provider 发起任何读取。
|
||||
act(() => {
|
||||
expect(result.current.match?.('')).toEqual([]);
|
||||
expect(result.current.fuzzyLookup?.('')).toEqual([]);
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
@@ -333,7 +388,7 @@ describe('Skill provider', () => {
|
||||
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.match?.('')).toHaveLength(1);
|
||||
expect(result.current.fuzzyLookup?.('')).toHaveLength(1);
|
||||
});
|
||||
// 目录只读一次:后续每次敲 `$` 都复用同一份候选。
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
@@ -342,12 +397,81 @@ describe('Skill provider', () => {
|
||||
result.current.onMenuQueryChange?.(null);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.match?.('测试')?.[0]).toMatchObject({
|
||||
expect(result.current.fuzzyLookup?.('测试')?.[0]).toMatchObject({
|
||||
type: 'skill',
|
||||
name: 'agc-test-skill',
|
||||
});
|
||||
});
|
||||
|
||||
it('目录里的名字带空白时折成 `-`:候选与 token 是同一份口径', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_agc_skill_catalog') {
|
||||
return [{ name: 'agc test skill', description: '测试 Skill' }];
|
||||
}
|
||||
if (command === 'list_client_extensions') {
|
||||
return [
|
||||
{
|
||||
name: 'client skill',
|
||||
extensionType: 'skill',
|
||||
enabled: true,
|
||||
status: 'enabled',
|
||||
},
|
||||
];
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke: invoke as never } };
|
||||
|
||||
const { result } = renderHook(() => useSkillReferenceProvider());
|
||||
act(() => {
|
||||
result.current.onMenuQueryChange?.('');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.lookup?.()).toHaveLength(2);
|
||||
});
|
||||
expect(
|
||||
result.current
|
||||
.lookup?.()
|
||||
.map((item) => (item.type === 'skill' ? item.name : null)),
|
||||
).toEqual(['agc-test-skill', 'client-skill']);
|
||||
expect(
|
||||
result.current.mentionToken?.({
|
||||
type: 'agc_skill_reference',
|
||||
name: 'agc test skill',
|
||||
}),
|
||||
).toBe('$agc-test-skill');
|
||||
});
|
||||
|
||||
it('`lookup` 是纯函数:目录还没读就返回空数组、也不发起读取,菜单打开后才查得到', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_agc_skill_catalog') {
|
||||
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
|
||||
}
|
||||
if (command === 'list_client_extensions') return [];
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke: invoke as never } };
|
||||
|
||||
const { result } = renderHook(() => useSkillReferenceProvider());
|
||||
|
||||
// 冷启动:精确查找只能看到「此刻就绪」的候选,目录没读过就是空数组——粘贴 `$名称` 因此按字面保留。
|
||||
act(() => {
|
||||
expect(result.current.lookup?.()).toEqual([]);
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
// 菜单第一次打开才读目录,读回之后精确查找立刻可用。
|
||||
act(() => {
|
||||
result.current.onMenuQueryChange?.('');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.lookup?.()).toHaveLength(1);
|
||||
});
|
||||
expect(result.current.lookup?.().map(chatReferenceMentionToken)).toEqual([
|
||||
'$agc-test-skill',
|
||||
]);
|
||||
});
|
||||
|
||||
it('内置目录与已启用客户端 Skill 合并后按名字去重,并截断到 8 条', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_agc_skill_catalog') {
|
||||
@@ -394,17 +518,17 @@ describe('Skill provider', () => {
|
||||
result.current.onMenuQueryChange?.('');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.match?.('')).toHaveLength(8);
|
||||
expect(result.current.fuzzyLookup?.('')).toHaveLength(8);
|
||||
});
|
||||
// 同名客户端项被内置项挡掉,上限只作用于当前查询的命中集合。
|
||||
expect(result.current.match?.('builtin-0')).toHaveLength(1);
|
||||
expect(result.current.match?.('builtin-')).toHaveLength(8);
|
||||
expect(result.current.match?.('client-skill')).toMatchObject([
|
||||
expect(result.current.fuzzyLookup?.('builtin-0')).toHaveLength(1);
|
||||
expect(result.current.fuzzyLookup?.('builtin-')).toHaveLength(8);
|
||||
expect(result.current.fuzzyLookup?.('client-skill')).toMatchObject([
|
||||
{ type: 'skill', name: 'client-skill' },
|
||||
]);
|
||||
// 未启用与非 Skill 扩展都不是候选。
|
||||
expect(result.current.match?.('client-off')).toEqual([]);
|
||||
expect(result.current.match?.('client-plugin')).toEqual([]);
|
||||
expect(result.current.fuzzyLookup?.('client-off')).toEqual([]);
|
||||
expect(result.current.fuzzyLookup?.('client-plugin')).toEqual([]);
|
||||
});
|
||||
|
||||
it('一次瞬时失败不锁死候选:下一次 `match` 还会重读目录,并在控制台留痕', async () => {
|
||||
@@ -432,7 +556,7 @@ describe('Skill provider', () => {
|
||||
result.current.onMenuQueryChange?.('a');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.match?.('')).toHaveLength(1);
|
||||
expect(result.current.fuzzyLookup?.('')).toHaveLength(1);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledTimes(4);
|
||||
// 读取失败不能静默:控制台要留下可排障的一条。
|
||||
|
||||
@@ -33,6 +33,7 @@ import { attachmentReferenceProvider } from '../src/features/project-workspace/r
|
||||
import { createResourceReferenceProvider } from '../src/features/project-workspace/reference-source/resourceReferenceProvider';
|
||||
import { runtimeRegionReferenceProvider } from '../src/features/project-workspace/reference-source/runtimeRegionReferenceProvider';
|
||||
import { useSkillReferenceProvider } from '../src/features/project-workspace/reference-source/skillReferenceProvider';
|
||||
import type { ReferenceProvider } from '../src/features/project-workspace/reference-source/types';
|
||||
import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
@@ -67,6 +68,65 @@ import {
|
||||
type RuntimeRegionReference,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
|
||||
/**
|
||||
* jsdom 没有 `DragEvent` / `ClipboardEvent` 构造器,而 Lexical 的默认粘贴路径按构造器名字判断
|
||||
* 事件类型(`objectKlassEquals`);真实客户端(Tauri / Chromium)两者都在。这里只补齐测试环境
|
||||
* 缺的那部分,名字必须与真实构造器逐字一致,否则 Lexical 会把粘贴数据源当成 `null`。
|
||||
*/
|
||||
function defineEventClassShim(name: 'DragEvent' | 'ClipboardEvent') {
|
||||
if (typeof (globalThis as Record<string, unknown>)[name] === 'function')
|
||||
return;
|
||||
const shim = class extends Event {};
|
||||
Object.defineProperty(shim, 'name', { value: name });
|
||||
Object.defineProperty(globalThis, name, {
|
||||
value: shim,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
defineEventClassShim('DragEvent');
|
||||
defineEventClassShim('ClipboardEvent');
|
||||
|
||||
/**
|
||||
* jsdom 的 `Range` 没有 `getBoundingClientRect`,而候选菜单打开时会用它测锚点位置
|
||||
* (`LexicalTypeaheadMenuPlugin` 的 `getRect`);真实浏览器两者都在,这里只补测试环境缺的那部分。
|
||||
*/
|
||||
function defineRangeRectShim() {
|
||||
if (typeof Range === 'undefined') return;
|
||||
if (typeof Range.prototype.getBoundingClientRect === 'function') return;
|
||||
Range.prototype.getBoundingClientRect = () =>
|
||||
typeof DOMRect === 'function'
|
||||
? new DOMRect()
|
||||
: ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
} as DOMRect);
|
||||
}
|
||||
|
||||
defineRangeRectShim();
|
||||
|
||||
/** jsdom 没有 `ResizeObserver`,候选菜单打开时会构造它;真实浏览器都有。 */
|
||||
function defineResizeObserverShim() {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window.ResizeObserver === 'function') return;
|
||||
window.ResizeObserver = class ResizeObserverStub {
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
defineResizeObserverShim();
|
||||
|
||||
function asset(
|
||||
id: string,
|
||||
kind: string,
|
||||
@@ -231,6 +291,48 @@ function composerEditor(): {
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom 里合成一次原生粘贴:Lexical 的 DOM 监听器把 `paste` 事件转成 PASTE_COMMAND,
|
||||
* 所以这条链路和真实粘贴一致(含 `clipboardData` 读取与 `preventDefault`)。
|
||||
*/
|
||||
function pasteComposerText(
|
||||
text: string,
|
||||
payload: { html?: string; lexical?: string } = {},
|
||||
) {
|
||||
// 真实粘贴发生在有焦点的编辑器里;jsdom 不会自己给编辑器设选区,这里先落到草稿末尾,
|
||||
// 否则 Lexical 默认粘贴处理器拿不到 selection,会直接放弃这次粘贴。
|
||||
act(() => {
|
||||
composerEditor().update(() => {
|
||||
$getRoot().selectEnd();
|
||||
});
|
||||
});
|
||||
const element = screen.getByLabelText('聊天');
|
||||
const event = new Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', {
|
||||
value: {
|
||||
getData: (type: string) => {
|
||||
if (type === 'text/plain') return text;
|
||||
if (type === 'text/html') return payload.html ?? '';
|
||||
if (type === 'application/x-lexical-editor') {
|
||||
return payload.lexical ?? '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
element.dispatchEvent(event);
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
/** 草稿里的 Skill 引用名,按 content 顺序。 */
|
||||
function draftSkillNames(draft: ChatComposerDraft | undefined) {
|
||||
return (draft?.content ?? []).flatMap((part) =>
|
||||
part.type === 'agc_skill_reference' ? [part.name] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom 里键盘输入不会进入 Lexical,所以直接走编辑器 API 写文本;
|
||||
* 它触发的是和真实输入同一条更新链路,typeahead 监听器同样会被唤醒。
|
||||
@@ -332,6 +434,213 @@ describe('ResourceReferenceInput', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('粘贴含引用的显示文本:原位重建 chip,其余字符逐字保留', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<ComposerHost
|
||||
ref={ref}
|
||||
ariaLabel="聊天"
|
||||
assets={assets}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
pasteComposerText('看 @hero 这一版');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draftResourceIds(ref.current?.getDraft())).toEqual(['hero']);
|
||||
});
|
||||
expect(ref.current?.getDraft().content).toEqual([
|
||||
{ type: 'input_text', text: '看 ' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
{ type: 'input_text', text: ' 这一版' },
|
||||
]);
|
||||
// 一次粘贴是一条撤销记录、一次文本更新:chip 在文本模型里只占 1 个字符。
|
||||
expect(editorTextSize()).toBe('看 '.length + 1 + ' 这一版'.length);
|
||||
});
|
||||
|
||||
test('粘贴解析出的引用若已无法解析,退化成 token 文本而不是整条丢掉', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
// provider 认得出这个 token(lookup 有候选),但插入那一刻已经解析不出引用(例如资产刚被删)。
|
||||
const ghostProvider: ReferenceProvider = {
|
||||
trigger: '@',
|
||||
lookup: () => [resourceReferenceFromAsset(assets[0]!, 'asset-picker')],
|
||||
toReference: () => null,
|
||||
refresh: (reference) => reference,
|
||||
mentionToken: (part) =>
|
||||
part.type === 'agc_resource_reference' ? '@hero' : null,
|
||||
};
|
||||
render(
|
||||
<ResourceReferenceInput
|
||||
ref={ref}
|
||||
providers={[ghostProvider]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
pasteComposerText('看 @hero 一眼');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draftText(ref.current?.getDraft())).toBe('看 @hero 一眼');
|
||||
});
|
||||
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
|
||||
});
|
||||
|
||||
test('粘贴命中的引用连 token 都解析不出来时,落下它的文本形态而不是什么都不插', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
// provider 契约被破坏:lookup 有候选,但 toReference 与 mentionToken 都答不出来。
|
||||
// 这时必须落一段正常文本(资源退回 resourceId),粘贴进来的字不许凭空消失。
|
||||
const brokenProvider: ReferenceProvider = {
|
||||
trigger: '@',
|
||||
lookup: () => [
|
||||
resourceReferenceFromAsset(
|
||||
asset('ghost-asset', 'character', 'image/png', 'assets/幽灵.png'),
|
||||
'asset-picker',
|
||||
),
|
||||
],
|
||||
toReference: () => null,
|
||||
refresh: (reference) => reference,
|
||||
mentionToken: () => null,
|
||||
};
|
||||
render(
|
||||
<ResourceReferenceInput
|
||||
ref={ref}
|
||||
providers={[brokenProvider]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
pasteComposerText('看 @幽灵 一眼');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draftText(ref.current?.getDraft())).toBe('看 @ghost-asset 一眼');
|
||||
});
|
||||
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
|
||||
});
|
||||
|
||||
test('粘贴未命中的 token:整段按字面落进正文,不接管、不提示', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<ComposerHost
|
||||
ref={ref}
|
||||
ariaLabel="聊天"
|
||||
assets={assets}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 项目里没有名为 `hero.png` 的显示名(`@hero` 才是当前清单的口径),所以这条不是引用。
|
||||
pasteComposerText('@hero.png 换成夜景');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draftText(ref.current?.getDraft())).toBe('@hero.png 换成夜景');
|
||||
});
|
||||
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
|
||||
});
|
||||
|
||||
test('粘贴不含引用的纯文本仍走编辑器默认导入(换行成段,不经过解析)', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<ComposerHost
|
||||
ref={ref}
|
||||
ariaLabel="聊天"
|
||||
assets={assets}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
pasteComposerText('第一行\n第二行');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ref.current?.getDraft().content).toEqual([
|
||||
{ type: 'input_text', text: '第一行' },
|
||||
{ type: 'input_text', text: '\n' },
|
||||
{ type: 'input_text', text: '第二行' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('剪贴板里带 Lexical 负载时让位给默认导入:文本里的 @显示名 不被解析', async () => {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<ComposerHost
|
||||
ref={ref}
|
||||
ariaLabel="聊天"
|
||||
assets={assets}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
pasteComposerText('看 @hero 这一版', { lexical: '{"namespace":"other"}' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draftText(ref.current?.getDraft())).toBe('看 @hero 这一版');
|
||||
});
|
||||
expect(draftResourceIds(ref.current?.getDraft())).toEqual([]);
|
||||
});
|
||||
|
||||
test('Skill 目录冷启动:粘贴 `$名称` 保持字面,敲过一次 `$` 之后粘贴才成 chip', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_agc_skill_catalog') {
|
||||
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
|
||||
}
|
||||
if (command === 'list_client_extensions') return [];
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke: invoke as never } };
|
||||
|
||||
try {
|
||||
const ref = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<ComposerHost
|
||||
ref={ref}
|
||||
ariaLabel="聊天"
|
||||
assets={assets}
|
||||
withSkillProvider
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 冷目录:精确查找没有候选,`$名称` 逐字保留,也不替用户去读目录。
|
||||
pasteComposerText('用 $agc-test-skill 出图');
|
||||
await waitFor(() => {
|
||||
expect(draftText(ref.current?.getDraft())).toBe(
|
||||
'用 $agc-test-skill 出图',
|
||||
);
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
// 用户敲出 `$`(菜单懒加载)之后目录才就绪,此后粘贴才重建 chip。
|
||||
act(() => {
|
||||
ref.current?.clear();
|
||||
});
|
||||
insertComposerText('$');
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
|
||||
});
|
||||
act(() => {
|
||||
ref.current?.clear();
|
||||
});
|
||||
pasteComposerText('用 $agc-test-skill 出图');
|
||||
await waitFor(() => {
|
||||
expect(draftSkillNames(ref.current?.getDraft())).toEqual([
|
||||
'agc-test-skill',
|
||||
]);
|
||||
});
|
||||
expect(ref.current?.getDraft().content).toEqual([
|
||||
{ type: 'input_text', text: '用 ' },
|
||||
{ type: 'agc_skill_reference', name: 'agc-test-skill' },
|
||||
{ type: 'input_text', text: ' 出图' },
|
||||
]);
|
||||
} finally {
|
||||
delete window.__TAURI__;
|
||||
}
|
||||
});
|
||||
|
||||
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
|
||||
const base: RuntimeRegionReference = {
|
||||
type: 'runtime-region',
|
||||
@@ -553,6 +862,67 @@ describe('ResourceReferenceInput', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('润色回写时 provider 答不出 token 的 part 仍留在草稿里,不在这门翻译里消失', async () => {
|
||||
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
|
||||
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
|
||||
// provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。
|
||||
const tokenlessProvider: ReferenceProvider = {
|
||||
trigger: '@',
|
||||
lookup: () => [reference],
|
||||
toReference: (part) =>
|
||||
part.type === 'agc_resource_reference' ? reference : null,
|
||||
refresh: (part) => part,
|
||||
mentionToken: () => null,
|
||||
};
|
||||
const composerRef = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => composerRef.current?.replaceText('润色后的需求')}
|
||||
>
|
||||
模拟润色
|
||||
</button>
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
providers={[tokenlessProvider]}
|
||||
initialContent={[chatReferenceToContentPart(reference)]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
await settleComposer();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
|
||||
await settleComposer();
|
||||
expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']);
|
||||
expect(draftText(composerRef.current?.getDraft())).toBe(
|
||||
'润色后的需求 @hero ',
|
||||
);
|
||||
});
|
||||
|
||||
test('初始草稿里解析不出的引用落文本形态,不从草稿里消失', async () => {
|
||||
const composerRef = createRef<ResourceReferenceInputHandle>();
|
||||
render(
|
||||
<ComposerHost
|
||||
ref={composerRef}
|
||||
initialContent={[
|
||||
{ type: 'agc_resource_reference', resourceId: 'missing-asset' },
|
||||
]}
|
||||
assets={assets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await settleComposer();
|
||||
expect(composerRef.current?.getDraft().content).toEqual([
|
||||
{ type: 'input_text', text: '@missing-asset' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
buildContentFromPastedText,
|
||||
type ChatComposerDraft,
|
||||
chatComposerDraftToDirectCodexUserItem,
|
||||
type ChatReference,
|
||||
chatReferenceMentionToken,
|
||||
contentPartText,
|
||||
directCodexContentToPromptText,
|
||||
hasMeaningfulDirectCodexContent,
|
||||
normalizeMentionName,
|
||||
resourceDisplayName,
|
||||
resourceLabelResolver,
|
||||
resourceReferenceFromAsset,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
|
||||
describe('DirectProject user Response item', () => {
|
||||
it('保留 Lexical content 的文本与引用交错顺序', () => {
|
||||
@@ -108,3 +117,215 @@ describe('DirectProject user Response item', () => {
|
||||
).toBe('用 @asset-hero 做主视觉');
|
||||
});
|
||||
});
|
||||
|
||||
describe('粘贴文本反解析', () => {
|
||||
const assetEntry = (
|
||||
id: string,
|
||||
localPath: string,
|
||||
): GameCreationAppAssetManifestEntry => ({
|
||||
id,
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath,
|
||||
source: { kind: 'uploaded' },
|
||||
});
|
||||
const heroAsset = assetEntry('hero', 'assets/hero.png');
|
||||
const enemyAsset = assetEntry('enemy', 'assets/enemy.png');
|
||||
const hero = resourceReferenceFromAsset(heroAsset, 'asset-picker');
|
||||
const enemy = resourceReferenceFromAsset(enemyAsset, 'asset-picker');
|
||||
const skill: ChatReference = { type: 'skill', name: 'image-gen' };
|
||||
|
||||
it('命中显示名 token 时原位换成引用,其余字符逐字保留', () => {
|
||||
expect(buildContentFromPastedText('看 @hero 这一版', [hero])).toEqual([
|
||||
{ type: 'input_text', text: '看 ' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
{ type: 'input_text', text: ' 这一版' },
|
||||
]);
|
||||
expect(buildContentFromPastedText('用 $image-gen 出图', [skill])).toEqual([
|
||||
{ type: 'input_text', text: '用 ' },
|
||||
{ type: 'agc_skill_reference', name: 'image-gen' },
|
||||
{ type: 'input_text', text: ' 出图' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('不做兼容别名:只认显示名,且 token 前后必须是行首 / 行尾或空白', () => {
|
||||
// 带扩展名的文件名、紧贴中文、全角 `@`、大小写变体都不解析——宁可不成 chip,也不能认错。
|
||||
expect(buildContentFromPastedText('@hero.png', [hero])).toBeNull();
|
||||
expect(buildContentFromPastedText('看@hero这一版', [hero])).toBeNull();
|
||||
expect(buildContentFromPastedText('@hero', [hero])).toBeNull();
|
||||
expect(buildContentFromPastedText('@HERO', [hero])).toBeNull();
|
||||
// 行首 / 行尾同样是边界。
|
||||
expect(buildContentFromPastedText('@hero', [hero])).toEqual([
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('未命中的 token 逐字保留,混在正文里也只换认出那几条', () => {
|
||||
expect(
|
||||
buildContentFromPastedText('@hero 与 @unknown 都在', [hero]),
|
||||
).toEqual([
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
{ type: 'input_text', text: ' 与 @unknown 都在' },
|
||||
]);
|
||||
// 一条都没命中时返回 null:调用方据此放行编辑器默认粘贴。
|
||||
expect(buildContentFromPastedText('纯文本 @unknown', [hero])).toBeNull();
|
||||
});
|
||||
|
||||
it('同名多候选一律按文本保留:宁可不成 chip,也不能认错引用', () => {
|
||||
const sameName = [
|
||||
resourceReferenceFromAsset(
|
||||
assetEntry('asset-hero-a', 'characters/hero.png'),
|
||||
'asset-picker',
|
||||
),
|
||||
resourceReferenceFromAsset(
|
||||
assetEntry('asset-hero-b', 'enemies/hero.png'),
|
||||
'asset-picker',
|
||||
),
|
||||
];
|
||||
expect(buildContentFromPastedText('@hero', sameName)).toBeNull();
|
||||
// 其余可判定的 token 照常解析。
|
||||
expect(
|
||||
buildContentFromPastedText('@hero @enemy', [...sameName, enemy]),
|
||||
).toEqual([
|
||||
{ type: 'input_text', text: '@hero ' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'enemy' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('同一 token 出现几次就成几个 chip,换行保留为独立的文本 part', () => {
|
||||
expect(buildContentFromPastedText('@hero\n@hero', [hero])).toEqual([
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
{ type: 'input_text', text: '\n' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('引用名内部空白折成 `-`:资源显示名 / Skill 名 / 附件名同一条口径', () => {
|
||||
expect(normalizeMentionName('hero v2')).toBe('hero-v2');
|
||||
expect(normalizeMentionName(' 英雄\u3000参考\t')).toBe('英雄-参考');
|
||||
expect(
|
||||
resourceDisplayName(assetEntry('hero-v2', 'assets/hero v2.png')),
|
||||
).toBe('hero-v2');
|
||||
expect(
|
||||
chatReferenceMentionToken({ type: 'skill', name: 'image gen' }),
|
||||
).toBe('$image-gen');
|
||||
expect(
|
||||
chatReferenceMentionToken({
|
||||
type: 'attachment',
|
||||
name: 'brief v2.md',
|
||||
mediaType: 'text/markdown',
|
||||
size: 1,
|
||||
localPath: 'notes/brief v2.md',
|
||||
status: 'imported',
|
||||
}),
|
||||
).toBe('@brief-v2.md');
|
||||
});
|
||||
|
||||
it('空白折成 `-` 后 token 自带边界:前缀重叠不再多插一个 chip', () => {
|
||||
const heroV2Asset = assetEntry('hero-v2', 'assets/hero v2.png');
|
||||
const heroV2 = resourceReferenceFromAsset(heroV2Asset, 'asset-picker');
|
||||
const displayed = directCodexContentToPromptText(
|
||||
[
|
||||
{ type: 'input_text', text: '看' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero-v2' },
|
||||
{ type: 'input_text', text: '这一版' },
|
||||
],
|
||||
resourceLabelResolver([heroAsset, heroV2Asset]),
|
||||
);
|
||||
expect(displayed).toBe('看 @hero-v2 这一版');
|
||||
expect(buildContentFromPastedText(displayed, [hero, heroV2])).toEqual([
|
||||
{ type: 'input_text', text: '看 ' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero-v2' },
|
||||
{ type: 'input_text', text: ' 这一版' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('整名就是扩展名(`.env` / `.gitignore`)时显示名回退 asset.id,不留空名', () => {
|
||||
// 空名字会退化成只有触发符的裸 `@` token:菜单里是一枚空芯片,粘贴解析还会认领正文里
|
||||
// 任何一处裸 `@`(见「退化 token 不参与解析」用例)。
|
||||
expect(resourceDisplayName(assetEntry('dotenv', '.env'))).toBe('dotenv');
|
||||
expect(
|
||||
resourceDisplayName(assetEntry('gitignore', 'config/.gitignore')),
|
||||
).toBe('gitignore');
|
||||
expect(
|
||||
resourceReferenceFromAsset(assetEntry('dotenv', '.env'), 'asset-picker')
|
||||
.label,
|
||||
).toBe('dotenv');
|
||||
});
|
||||
|
||||
it('退化 token(名字为空只剩触发符)不参与解析,正文里的裸 `@` 逐字保留', () => {
|
||||
// 上游仍然可能送来空名字的引用(显示名兜底只是把常见路径堵上),裸 `@` token 会在正文里
|
||||
// 匹配到任何一处 `@`,认下来就是错认:这里必须按「宁可不成 chip」跳过。
|
||||
const nameless: ChatReference = {
|
||||
...resourceReferenceFromAsset(
|
||||
assetEntry('dotenv', '.env'),
|
||||
'asset-picker',
|
||||
),
|
||||
label: '',
|
||||
};
|
||||
expect(chatReferenceMentionToken(nameless)).toBe('@');
|
||||
expect(
|
||||
buildContentFromPastedText('@ 单独一个 @ 符号,看 @hero', [
|
||||
nameless,
|
||||
hero,
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: 'input_text', text: '@ 单独一个 @ 符号,看 ' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
]);
|
||||
expect(buildContentFromPastedText('@ 只有裸符号', [nameless])).toBeNull();
|
||||
});
|
||||
|
||||
it('单 part 文本形态:解析不出引用时也有一段正常文本可落,不留空', () => {
|
||||
// 插入那一刻 provider 认不出这个 part 时,`$insertContentAtSelection` 落的就是这一份。
|
||||
const runtimeRegionPart = (label: string): DirectCodexUserContentPart => ({
|
||||
type: 'agc_runtime_region_reference',
|
||||
label,
|
||||
runId: null,
|
||||
versionId: null,
|
||||
elementTag: null,
|
||||
elementRole: null,
|
||||
text: null,
|
||||
width: null,
|
||||
height: null,
|
||||
resourceIds: [],
|
||||
});
|
||||
expect(
|
||||
contentPartText({ type: 'agc_resource_reference', resourceId: 'hero' }),
|
||||
).toBe('@hero');
|
||||
expect(
|
||||
contentPartText({ type: 'agc_skill_reference', name: 'image gen' }),
|
||||
).toBe('$image-gen');
|
||||
expect(
|
||||
contentPartText({
|
||||
type: 'agc_attachment_reference',
|
||||
name: 'brief v2.md',
|
||||
mediaType: 'text/markdown',
|
||||
size: 1,
|
||||
localPath: 'notes/brief v2.md',
|
||||
status: 'imported',
|
||||
}),
|
||||
).toBe('@brief-v2.md');
|
||||
expect(contentPartText(runtimeRegionPart('主画面'))).toBe('@主画面');
|
||||
// 文本 part 没有「文本形态」,它自己就是文本。
|
||||
expect(contentPartText({ type: 'input_text', text: '先看' })).toBeNull();
|
||||
});
|
||||
|
||||
it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => {
|
||||
const content: DirectCodexUserContentPart[] = [
|
||||
{ type: 'input_text', text: '看 ' },
|
||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||
{ type: 'input_text', text: ' 这一版,再用 ' },
|
||||
{ type: 'agc_skill_reference', name: 'image-gen' },
|
||||
{ type: 'input_text', text: ' 出图' },
|
||||
];
|
||||
const displayed = directCodexContentToPromptText(
|
||||
content,
|
||||
resourceLabelResolver([heroAsset]),
|
||||
);
|
||||
expect(displayed).toBe('看 @hero 这一版,再用 $image-gen 出图');
|
||||
expect(buildContentFromPastedText(displayed, [hero, skill])).toEqual(
|
||||
content,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user