合并资源工作台分支:素材类型可由用户变更与角标显示资源类型并入
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Native shell tests (pull_request) Successful in 17m12s

- 把 v3 顶端 ace31a5f1(快速编辑 @ 引用素材 + @ 面板标签筛选)合入本分支,与本分支 1079f6c91(用户可变更素材类型、角标改显示资源类型)并存
- 冲突零:index.tsx 两侧改动落点不同(快速编辑 promptInputSlot 接线 vs 角标取值点),projectResourceLiveIntegration 与验收文档自动合并
- 验证:npm run typecheck exit 0;resourceClassificationPanel + projectResourceLiveIntegration 共 45 tests 全绿;check:encoding 4409 文件通过;git diff --check 干净
This commit is contained in:
2026-09-12 16:57:28 +08:00
11 changed files with 598 additions and 125 deletions
@@ -34,7 +34,6 @@ import {
Loader2,
Music2,
RotateCcw,
Search,
Sparkles,
Video,
} from 'lucide-react';
@@ -51,6 +50,7 @@ import {
} from 'react';
import { createPortal, flushSync } from 'react-dom';
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import {
type GameCreationAppAssetManifestEntry,
@@ -90,7 +90,9 @@ import {
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
resourceReferenceMatchesTagSelection,
type ResourceReferenceScope,
resourceReferenceTagLibrary,
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
@@ -114,6 +116,14 @@ type ResourceReferenceInputProps = {
multiline?: boolean;
rows?: number;
showTriggerButton?: boolean;
/**
* 内置「AI 润色 / 恢复原文」动作是否渲染,默认 `true`。
*
* 聊天输入区用内置那一份(不带资源编辑场景上下文);资源侧快速编辑由宿主自己的
* `ResourcePromptPolishSlot` 承担润色(带场景上下文与长度上限),传 `false`
* 避免同一个面板里出现两个润色入口。
*/
showPolishAction?: boolean;
inputRef?: RefObject<HTMLDivElement | null>;
};
@@ -121,10 +131,17 @@ type ResourcePickerScopeState = {
query: string;
filter: ResourceReferenceFilter;
selectedResourceIds: string[];
/** 已选标签(多选),与搜索词、功能分类三者叠加生效。 */
selectedTags: string[];
};
function createResourcePickerScopeState(): ResourcePickerScopeState {
return { query: '', filter: 'all', selectedResourceIds: [] };
return {
query: '',
filter: 'all',
selectedResourceIds: [],
selectedTags: [],
};
}
function createResourcePickerScopeStates(): Record<
@@ -291,6 +308,7 @@ function ResourceReferenceEditor({
multiline,
rows,
showTriggerButton = true,
showPolishAction = true,
inputRef,
composerRef,
}: ResourceReferenceInputProps & {
@@ -336,12 +354,19 @@ function ResourceReferenceEditor({
const pickerQuery = pickerScopeState.query;
const pickerFilter = pickerScopeState.filter;
const selectedResourceIds = pickerScopeState.selectedResourceIds;
const selectedTags = pickerScopeState.selectedTags;
const scopeReferences =
pickerScope === 'current-version'
? currentVersionAssetReferences
: assetReferences;
// 标签库按当前页签的候选派生:两个页签各自一份,标签与计数都不跨页签串。
const scopeTagItems = useMemo(
() => resourceReferenceTagLibrary(scopeReferences),
[scopeReferences],
);
const updatePickerScopeState = useCallback(
(patch: Partial<ResourcePickerScopeState>) => {
setPickerScopeStates((current) => ({
@@ -479,24 +504,23 @@ function ResourceReferenceEditor({
});
}, [editor, refreshResourceReferenceLabels]);
useEffect(
() =>
editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) {
return false;
}
event.preventDefault();
(event.target as HTMLElement | null)
?.closest('form')
?.requestSubmit();
return true;
},
COMMAND_PRIORITY_HIGH,
),
[editor],
);
// Enter 提交只属于单行输入区(聊天 / 项目总控)。多行输入区(资源卡快速编辑提示词)
// 的 Enter 必须留给换行,否则用户没法在提示词里分行。
useEffect(() => {
if (multiline) return undefined;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) {
return false;
}
event.preventDefault();
(event.target as HTMLElement | null)?.closest('form')?.requestSubmit();
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, multiline]);
useEffect(
() =>
@@ -727,9 +751,12 @@ function ResourceReferenceEditor({
const pickerReferences = useMemo(() => {
return scopeReferences.filter((reference) => {
if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false;
return resourceReferenceMatchesCategoryFilter(reference, pickerFilter);
if (!resourceReferenceMatchesCategoryFilter(reference, pickerFilter)) {
return false;
}
return resourceReferenceMatchesTagSelection(reference, selectedTags);
});
}, [pickerFilter, pickerQuery, scopeReferences]);
}, [pickerFilter, pickerQuery, scopeReferences, selectedTags]);
const visiblePickerReferences = pickerReferences.slice(0, 120);
@@ -805,38 +832,46 @@ function ResourceReferenceEditor({
<AtSign size={15} aria-hidden="true" />
</button>
) : null}
<button
type="button"
className="resource-reference-input-polish"
aria-label="AI 润色"
title="AI 润色"
aria-busy={polishing}
disabled={disabled || polishing || value.trim() === ''}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void polishPrompt()}
>
{polishing ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<Sparkles size={15} aria-hidden="true" />
)}
</button>
{polishedOriginalText !== null ? (
<button
type="button"
className="resource-reference-input-restore"
aria-label="恢复原文"
title="恢复原文"
disabled={disabled || polishing}
onMouseDown={(event) => event.preventDefault()}
onClick={restoreOriginalPrompt}
>
<RotateCcw size={15} aria-hidden="true" />
</button>
{showPolishAction ? (
<>
<button
type="button"
className="resource-reference-input-polish"
aria-label="AI 润色"
title="AI 润色"
aria-busy={polishing}
disabled={disabled || polishing || value.trim() === ''}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void polishPrompt()}
>
{polishing ? (
<Loader2
size={15}
className="animate-spin"
aria-hidden="true"
/>
) : (
<Sparkles size={15} aria-hidden="true" />
)}
</button>
{polishedOriginalText !== null ? (
<button
type="button"
className="resource-reference-input-restore"
aria-label="恢复原文"
title="恢复原文"
disabled={disabled || polishing}
onMouseDown={(event) => event.preventDefault()}
onClick={restoreOriginalPrompt}
>
<RotateCcw size={15} aria-hidden="true" />
</button>
) : null}
</>
) : null}
</div>
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{!reminderOpen && (polishing || polishError) ? (
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
<span
className="resource-reference-input-status"
role="status"
@@ -894,34 +929,33 @@ function ResourceReferenceEditor({
ariaLabel="素材范围"
className="platform-theme platform-theme--light resource-reference-picker-scopes"
/>
<div className="resource-reference-picker-search">
<Search size={14} aria-hidden="true" />
<input
aria-label={
{/* 搜索 + 功能分类 + 标签用共享筛选条:与资源画布、参考图弹窗同一套筛选 UI,
标签口径也来自共享标签库;两个页签各自持有一份独立状态。 */}
<PlatformResourceFilterBar
ariaLabel="素材筛选"
search={{
value: pickerQuery,
label:
pickerScope === 'current-version'
? '搜索当前版本素材'
: '搜索全部画布素材'
}
value={pickerQuery}
placeholder="搜索名称或资源 ID"
onChange={(event) =>
updatePickerScopeState({
query: event.currentTarget.value,
})
}
/>
</div>
<PlatformSegmentedTabs
items={RESOURCE_REFERENCE_FILTERS}
activeId={pickerFilter}
onChange={(nextFilter) =>
: '搜索全部画布素材',
placeholder: '搜索名称或资源 ID',
onChange: (value) => updatePickerScopeState({ query: value }),
}}
categoryItems={RESOURCE_REFERENCE_FILTERS}
activeCategoryId={pickerFilter}
onCategoryChange={(nextFilter) =>
updatePickerScopeState({ filter: nextFilter })
}
layout="scroll"
gap="sm"
frame="bare"
surface="transparent"
size="sm"
tagItems={scopeTagItems}
activeTags={selectedTags}
onToggleTag={(tag) =>
updatePickerScopeState({
selectedTags: selectedTags.includes(tag)
? selectedTags.filter((item) => item !== tag)
: [...selectedTags, tag],
})
}
className="platform-theme platform-theme--light resource-reference-picker-filters"
/>
<div className="resource-reference-picker-list" role="listbox">
@@ -5,6 +5,11 @@ import {
gameCreationAppAssetTags,
type GameIterationVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
assetTagsMatchSelection,
buildGameCreationAppAssetTagLibrary,
type GameCreationAppAssetTagLibraryEntry,
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
export type ResourceReferenceSource =
| 'asset-picker'
@@ -60,6 +65,22 @@ export function dispatchResourceReferenceInsert(reference: ChatReference) {
);
}
/**
* `@` 输入区 portal 到 `document.body` 的两块浮层:资源选择器与输入区候选菜单。
*
* 它们不在宿主的浮层边界里(DOM 上挂在 body),所以宿主的「点外部关闭」判定需要
* 拿它当白名单,否则点候选项会被判成点外部、把正在编辑的面板一起收掉。
*/
export const RESOURCE_REFERENCE_OVERLAY_SELECTOR =
'.resource-reference-picker, .resource-reference-menu';
export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest(RESOURCE_REFERENCE_OVERLAY_SELECTOR) !== null
);
}
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
text: '',
references: [],
@@ -153,6 +174,36 @@ export function resourceReferenceMatchesCategoryFilter(
return filter === 'all' || resourceReferenceCategory(reference) === filter;
}
/**
* `@` 面板的标签判据:直接用引用自带的 manifest `tags`,口径落在共享标签库
* (归一化去空白、同素材去重、已选标签全部命中)。这里不另建第二套标签匹配,
* 因此 `@` 面板、资源画布与参考图筛选条对「标签算不算命中」永远同一答案。
*/
export function resourceReferenceMatchesTagSelection(
reference: ResourceReference,
selectedTags: readonly string[],
): boolean {
return assetTagsMatchSelection(reference.tags, selectedTags);
}
/**
* `@` 面板的标签库:由当前页签的候选引用派生,复用共享标签库的排序与计数口径
* (使用次数降序 → 标签 zh-CN 升序)。
*
* 计数只覆盖传进来的候选,所以两个页签各自的计数与各自的候选范围一致,
* 不会拿另一页签或整个画布的素材凑数。
*/
export function resourceReferenceTagLibrary(
references: readonly ResourceReference[],
): GameCreationAppAssetTagLibraryEntry[] {
return buildGameCreationAppAssetTagLibrary(
references.map((reference) => ({
id: reference.resourceId,
tags: reference.tags,
})),
);
}
/**
* `@` 面板的两个页签。两个页签各自持有独立的搜索与筛选状态,
* 也不与资源画布的筛选联动。
@@ -618,6 +618,24 @@
--auto-grow-editor-min-height: 5.6rem;
}
/* 快速编辑提示词输入区(AGC 注入位 `promptInputSlot`):跑 `@` 引用生成器的
* `ResourceReferenceInput` 替掉共享 composer 的默认 textarea,落同一格——
* 与 `.image-canvas-editor__generation-prompt` 一样铺满 composer 的一整行。
* 这一族类名只属于 AGC 侧,网页端不传注入位,不会渲染到。 */
.resource-canvas-quick-edit-prompt-input {
grid-column: 1 / -1;
min-width: 0;
}
.resource-canvas-quick-edit-prompt-input .resource-reference-input {
padding: 6px 6px 6px 10px;
border-radius: 1.05rem;
}
.resource-canvas-quick-edit-prompt-input .resource-reference-input-editor {
max-height: 8.4rem;
}
.image-canvas-editor__quick-edit-submit {
justify-self: end;
min-width: 6.8rem;
+5 -24
View File
@@ -4665,7 +4665,7 @@ h2 {
right: 0;
bottom: calc(100% + 8px);
display: grid;
grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
width: min(440px, 88vw);
max-height: min(480px, 70vh);
overflow: hidden;
@@ -4699,30 +4699,11 @@ h2 {
cursor: pointer;
}
.resource-reference-picker-search {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
margin: 10px 12px 8px;
padding: 0 10px;
border: 1px solid var(--platform-surface-border, #dbe3ef);
border-radius: 9px;
background: var(--platform-input-fill, #fff);
}
.resource-reference-picker-search input {
height: 34px;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: inherit;
font: inherit;
}
/* 搜索 + 功能分类 + 标签由共享筛选条(`PlatformResourceFilterBar`)渲染,
这里只给它留出面板内的边距;两个页签各自持有自己那一份筛选状态。 */
.resource-reference-picker-filters {
padding: 0 12px 8px;
min-width: 0;
padding: 10px 12px 8px;
}
.resource-reference-picker-list {
@@ -94,8 +94,12 @@ import {
type LocalGamePreviewInspectSelection,
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
dispatchResourceReferenceInsert,
isResourceReferenceOverlayTarget,
resolveActiveIterationVersion,
resourceReferenceCategoryLabel,
} from '../../features/project-workspace/resourceReferences';
@@ -1402,6 +1406,17 @@ export default function ProjectDevelopmentView({
useState<CanvasLayer | null>(null);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
/**
* `@`
*
* `ChatReference` / `resourceReferenceFromAsset`
* `ResourceReferenceInput` `@显示名` payload
* `@`
*
*/
const [quickEditReferences, setQuickEditReferences] = useState<
ChatReference[]
>([]);
/**
*
*
@@ -1635,6 +1650,10 @@ export default function ProjectDevelopmentView({
*
*
*
*
* `@` portal body
* DOM
*
*/
useImageCanvasFloatingOptionDismiss({
isOpen: resolveResourceCanvasFloatingPanelDismissOpen({
@@ -1650,6 +1669,7 @@ export default function ProjectDevelopmentView({
}),
boundaryRefs: [resourceBookManagerRef],
onDismiss: clearResourceCanvasFocus,
isInsideExtraOverlay: isResourceReferenceOverlayTarget,
});
/**
@@ -5762,6 +5782,7 @@ export default function ProjectDevelopmentView({
setQuickEditSourceLayer(layer);
const panelDraft = createResourceQuickEditPanelDraft(layer);
setQuickEditPanel(panelDraft);
setQuickEditReferences([]);
resourceQuickEditRequestRef.current = {
...createResourceEditRequestIdentity(panelDraft.prompt),
sourceLayerId: layer.id,
@@ -5793,6 +5814,20 @@ export default function ProjectDevelopmentView({
);
}, []);
/**
* 稿 + `@`
*
* `applyResourceQuickEditPrompt`
* chip payload `@`
*/
const applyResourceQuickEditDraft = useCallback(
(draft: ChatComposerDraft) => {
setQuickEditReferences(draft.references);
applyResourceQuickEditPrompt(draft.text);
},
[applyResourceQuickEditPrompt],
);
/**
*
* manifest
@@ -6729,6 +6764,33 @@ export default function ProjectDevelopmentView({
panel={quickEditPanel}
style={quickEditPanelStyle}
setQuickEditPanel={setQuickEditPanel}
promptInputSlot={
// 与聊天输入区同一个 `@` 引用输入区:候选、插入动作与
// 引用模型都复用那一份,提示词文本里的 `@显示名` 也
// 由它拼装,因此出站 payload 与聊天 `@` 一致。
<div className="resource-canvas-quick-edit-prompt-input">
<ResourceReferenceInput
ariaLabel="快速编辑提示词"
value={quickEditPanel.prompt}
references={quickEditReferences}
onChange={applyResourceQuickEditDraft}
assets={manifest.assets}
projectPath={projectPath}
versions={projectVersions}
activeVersionId={activeVersionId}
multiline
rows={3}
placeholder="你希望素材如何修改?"
disabled={
quickEditPanel.status === 'generating'
}
// 润色由下面的 ResourcePromptPolishSlot 承担
// (带资源编辑场景上下文与长度上限),
// 不在这里再渲染第二个润色入口。
showPolishAction={false}
/>
</div>
}
promptActionSlot={
<ResourcePromptPolishSlot
subject="图片素材的快速编辑提示词"
@@ -13,6 +13,7 @@ import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_MODEL,
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
@@ -840,6 +841,58 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
});
it('快速编辑提示词里能 @ 出资源选择器,插入的引用与聊天同字面量进入派生请求', async () => {
const { deriveCalls } = installTauri();
render(<DerivedWorkbench includeArt />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
await setComposerText(
within(panel).getByLabelText('快速编辑提示词'),
'把夜色改成星空',
);
// 提示词输入区自带 `@` 入口:面板里的「插入素材引用」开出的是资源选择器本身,
// 不是第二个面板或另一套引用格式。
fireEvent.click(
within(panel).getByRole('button', { name: '插入素材引用' }),
);
const picker = await screen.findByRole('dialog', { name: '选择素材' });
fireEvent.click(within(picker).getByRole('option', { name: /^rules/u }));
fireEvent.click(within(picker).getByRole('button', { name: '插入引用' }));
// 回填:chip 与文本都落在快速编辑提示词里。
await waitFor(() => {
expect(
document.querySelector('[data-resource-reference-id="source-rules"]'),
).not.toBeNull();
});
expect(
within(panel).getByLabelText('快速编辑提示词').textContent,
).toContain('把夜色改成星空@rules');
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(1));
// 出站负载:派生请求里的提示词就是聊天 `@` 拼出来的那一串(`@显示名`),
// 期望值直接由共享引用模型算,钉住"快速编辑没有第二种引用格式"。
const mentionedAsset = canvasFixture.manifest?.assets.find(
(entry) => entry.id === 'source-rules',
);
if (!mentionedAsset) throw new Error('missing source-rules fixture');
const expectedText = `把夜色改成星空@${
resourceReferenceFromAsset(mentionedAsset, 'asset-picker').label
}`;
expect(deriveCalls[0]?.prompt).toBe(expectedText);
});
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
@@ -1,5 +1,11 @@
// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import {
cleanup,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { StrictMode } from 'react';
@@ -27,6 +33,8 @@ import {
resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery,
resourceReferenceMatchesTagSelection,
resourceReferenceTagLibrary,
} from '../src/features/project-workspace/resourceReferences';
function asset(
@@ -97,6 +105,46 @@ const assets = [
asset('theme', 'background-music', 'audio/mpeg', 'assets/theme.mp3'),
];
/** 带 manifest 标签的同一批素材:标签筛选用例只换 `tags`,其余口径与 `assets` 一致。 */
const taggedAssets: GameCreationAppAssetManifestEntry[] = [
{ ...assets[0]!, tags: ['角色', '主角'] },
{ ...assets[1]!, tags: ['角色'] },
{ ...assets[2]!, tags: ['背景乐'] },
];
/**
* 面板里的候选筛选条由共享组件渲染,标签行是它内部带 `素材筛选标签` 名字的分组。
* 用这一层 scope 取标签 chip,避免和同名的功能分类按钮(例如「角色与对象」)串。
*/
function pickerTagGroup() {
return within(screen.getByRole('group', { name: '素材筛选标签' }));
}
/** 取某个 aria-label 的引用输入区,把它内部(含 `@` 按钮)的操作局限定在自己身上。 */
function referenceInputByLabel(ariaLabel: string) {
const editor = screen.getByLabelText(ariaLabel);
const root = editor.closest('.resource-reference-input');
if (!root)
throw new Error(`resource reference input not found: ${ariaLabel}`);
return within(root as HTMLElement);
}
async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
await userEvent.click(
referenceInputByLabel(ariaLabel).getByRole('button', {
name: '插入素材引用',
}),
);
await userEvent.click(
within(screen.getByRole('dialog', { name: '选择素材' })).getByRole(
'option',
{ name: optionName },
),
);
await userEvent.click(screen.getByRole('button', { name: '插入引用' }));
await settleComposer();
}
afterEach(cleanup);
describe('ResourceReferenceInput', () => {
@@ -602,4 +650,201 @@ describe('ResourceReferenceInput', () => {
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero');
});
test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => {
const references = taggedAssets.map((entry) =>
resourceReferenceFromAsset(entry, 'asset-picker'),
);
expect(resourceReferenceTagLibrary(references)).toEqual([
{ tag: '角色', assetCount: 2, assetIds: ['enemy', 'hero'] },
{ tag: '背景乐', assetCount: 1, assetIds: ['theme'] },
{ tag: '主角', assetCount: 1, assetIds: ['hero'] },
]);
const hero = references[0]!;
const enemy = references[1]!;
const theme = references[2]!;
// 空选择不过滤;单标签按「素材带这个标签」判。
expect(resourceReferenceMatchesTagSelection(hero, [])).toBe(true);
expect(resourceReferenceMatchesTagSelection(theme, ['角色'])).toBe(false);
// 多标签是 AND:hero 同时有「角色」「主角」才算命中,enemy 只有「角色」。
expect(resourceReferenceMatchesTagSelection(hero, ['角色', '主角'])).toBe(
true,
);
expect(resourceReferenceMatchesTagSelection(enemy, ['角色', '主角'])).toBe(
false,
);
});
test('标签筛选把不符合的候选筛掉,多标签叠加,再与搜索关键字、功能分类叠加', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 没传 versions 时默认落在「全部画布素材」,标签行按这一页签的候选派生。
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色2',
);
await user.click(pickerTagGroup().getByRole('button', { name: /^/u }));
// 只勾一个标签:没带这个标签的素材必须消失,不是"排前面"。
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
await user.click(pickerTagGroup().getByRole('button', { name: /^/u }));
// 第二个标签再收一层:两个标签都命中的只剩 hero。
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull();
expect(
pickerTagGroup()
.getByRole('button', { name: /^/u })
.getAttribute('aria-pressed'),
).toBe('true');
// 关键字叠在标签之上:hero 不含 'enemy',两个标签 + 关键字一起就空了。
await user.type(screen.getByLabelText('搜索全部画布素材'), 'enemy');
expect(screen.queryByRole('option')).toBeNull();
expect(screen.getByText('没有匹配的素材')).not.toBeNull();
// 功能分类也参与叠加:hero 是 character,切到「音频」后同样为空。
await user.clear(screen.getByLabelText('搜索全部画布素材'));
await user.click(screen.getByRole('button', { name: '音频' }));
expect(screen.queryByRole('option')).toBeNull();
// 取消两个标签,分类仍在生效:只剩音频素材 theme。
await user.click(pickerTagGroup().getByRole('button', { name: /^/u }));
await user.click(pickerTagGroup().getByRole('button', { name: /^/u }));
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
});
test('两个页签各自持有一份独立的标签库与已选标签', async () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
versions={[iterationVersion('v1', ['hero', 'theme'])]}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
// 当前版本 v1 只绑定 hero / theme,标签计数也只看这两个。
expect(
screen
.getByRole('tab', { name: '当前版本素材' })
.getAttribute('aria-selected'),
).toBe('true');
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色1',
);
expect(pickerTagGroup().queryByText('主角')).not.toBeNull();
await user.click(pickerTagGroup().getByRole('button', { name: /^/u }));
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
// 另一个页签有自己的已选标签(此时为空)与自己的标签计数。
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(
pickerTagGroup()
.getByRole('button', { name: /^/u })
.getAttribute('aria-pressed'),
).toBe('false');
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
'角色2',
);
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
// 切回来仍是自己那份选择与筛后结果。
expect(
pickerTagGroup()
.getByRole('button', { name: /^/u })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
});
test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => {
const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<>
<ResourceReferenceInput
value=""
references={[]}
onChange={chatOnChange}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>
<ResourceReferenceInput
value=""
references={[]}
onChange={quickEditOnChange}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="快速编辑提示词"
multiline
rows={3}
showPolishAction={false}
/>
</>,
);
await insertAssetThroughPicker('聊天', /hero/u);
await insertAssetThroughPicker('快速编辑提示词', /hero/u);
const chatDraft = chatOnChange.mock.calls.at(-1)?.[0];
const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0];
// 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同:
// 「快速编辑」不允许出现第二种引用格式。
expect(chatDraft?.text).toBe('@hero');
expect(quickEditDraft).toEqual(chatDraft);
expect(quickEditDraft?.references).toEqual([
resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'),
]);
expect(
document.querySelectorAll('[data-resource-reference-id="hero"]'),
).toHaveLength(2);
});
test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => {
render(
<ResourceReferenceInput
value="把夜色改成星空"
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="快速编辑提示词"
multiline
rows={3}
showPolishAction={false}
/>,
);
expect(screen.queryByRole('button', { name: 'AI 润色' })).toBeNull();
// `@` 入口照旧在:润色收起来不影响引用。
expect(
referenceInputByLabel('快速编辑提示词').getByRole('button', {
name: '插入素材引用',
}),
).not.toBeNull();
});
});
@@ -68,7 +68,8 @@
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S19** @ 引用 | 输入区「@」→ 两个页签 + 筛选 + 搜索 + 多选 → 「添加到对话」 | 两页签**各自独立**的搜索与筛选状态,**不影响**资源画布筛选;已选素材不因筛选或搜索隐藏而被取消;chip 整体插入 | L187、L420426、L453461#309 C4 | `role="tablist"``aria-label="素材范围"`)内两个 `role="tab"``当前版本素材` / `全部画布素材`;输入框 aria-label 分别为 `搜索当前版本素材` / `搜索全部画布素材`chip = `span.resource-reference-chip[data-resource-reference-id]` | **chip 原子性有正式定义**chip 是 Lexical `DecoratorNode`,纯文本表示固定占 **1 个字符** `U+FFFC` ⇒ 一次 Backspace 只能整体删掉一个 chip,不可能删半截;引用身份是稳定 `resourceId`,改名后按 id 刷新显示名 |
| **S19** @ 引用 | 输入区「@」→ 两个页签 + 筛选(功能分类 + 多标签)+ 搜索 + 多选 → 「添加到对话」 | 两页签**各自独立**的搜索与筛选状态(含各自一份标签库),**不影响**资源画布筛选;已选素材不因筛选或搜索隐藏而被取消;chip 整体插入 | L187、L420426、L453461#309 C4 | `role="tablist"``aria-label="素材范围"`)内两个 `role="tab"``当前版本素材` / `全部画布素材`;输入框 aria-label 分别为 `搜索当前版本素材` / `搜索全部画布素材`标签行是共享筛选条里 `aria-label="素材筛选标签"` 的分组,chip 用 `aria-pressed` 表达选中;chip = `span.resource-reference-chip[data-resource-reference-id]` | **chip 原子性有正式定义**chip 是 Lexical `DecoratorNode`,纯文本表示固定占 **1 个字符** `U+FFFC` ⇒ 一次 Backspace 只能整体删掉一个 chip,不可能删半截;引用身份是稳定 `resourceId`,改名后按 id 刷新显示名。**多标签是 AND**(口径来自 `packages/shared``assetTagsMatchSelection`,标签取自 manifest `assets[].tags`),并与搜索词、功能分类三者叠加 |
| **S19a** 快速编辑内 @ 引用 | 资源卡「快速编辑」→ 提示词输入区「插入素材引用」或直接输入 `@` → 选素材 → 「修改」 | 快速编辑提示词输入区与聊天输入区**同一套**引用输入区:候选、插入动作、引用模型一致;提示词文本里的引用与聊天同字面量(`@显示名`),提交后该文本原样进入 `derive_local_project_resource``prompt` | #309 C4 / C10 | 快速编辑面板内存在 `aria-label="插入素材引用"``aria-label="快速编辑提示词"`;插入后编辑区出现 `[data-resource-reference-id]` chip;派生请求 `prompt` 等于 `文本@显示名` | 该输入区不渲染内置「AI 润色」(润色仍由面板的「AI 润色」承担,带资源编辑场景上下文与长度上限);多行输入区的 Enter 只换行、不提交;点 `@` 选择器里的候选不会被判成「点外部」而收起面板 |
| **S20** 润色与发送前提醒 | ① 点「AI 润色」→ 看回填与「恢复原文」;② 直接发送(≥40 字)→ 处理提醒弹窗三条动作与「不再提醒」 | 润色只优化文本、不发送不提交;第一次成功润色落下原文快照、可重复润色、失败保留原文;未润色直接发送时弹「发送前提醒」 | L120(气泡对比度)、飞书 §12.1/12.2;#309 C8 | 润色按钮 `aria-label="AI 润色"` + `aria-busy`;出现「恢复原文」按钮;提醒弹窗为 portal `role="dialog"`、标题「发送前提醒」、动作 `关闭` / `使用原文提交` / `AI 润色` + checkbox `不再提醒`;偏好存 `localStorage['agc.chat.prompt-polish-reminder.disabled']` | 触发门禁:trim 后 ≥40 字、不以 `/` 开头、本轮未润色未确认、未勾「不再提醒」。计费不自建,走 server-rs `/api/llm/chat/completions``/api/llm/responses`;「1 泥点」这个数字源码中未硬编码 |
### F 阶段 · 台账核对
@@ -194,6 +195,8 @@ api-server 是否本次重启:□ 是 □ 否
| S17 播放 → 运行视图 + 本地预览 | ☐ 过 ☐ 不过 | url______________ |
| S18 停止预览 / 退出收尾对齐 stopped | ☐ 过 ☐ 不过 | |
| S19 @ 引用两页签 + 独立筛选 + chip 原子性 | ☐ 过 ☐ 不过 | |
| S19a @ 面板标签筛选(多标签 AND,叠加关键字与功能分类) | ☐ 过 ☐ 不过 | |
| S19b 快速编辑提示词内 @ 引用(同聊天引用模型与字面量) | ☐ 过 ☐ 不过 | |
| S20 润色 + 发送前提醒 + 不再提醒 | ☐ 过 ☐ 不过 | |
| S21 台账(即时同步 / 双布局 / 无溢出) | ☐ 过 ☐ 不过 | 1280×800 ☐ 1280×720 ☐ |
| 门禁:typecheck / AGC tests / check:encoding / git diff --check | ☐ 过 ☐ 不过 | |
@@ -257,5 +260,5 @@ api-server 是否本次重启:□ 是 □ 否
## 8. 验收边界
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览)**、聊天 @ 引用与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。资源替换的后端矩阵(版本绑定改写放行的六条不变式与两组互斥、绑定改写两条路径、硬门禁与提示、CAS、四条拒绝路径、读时自愈口径、审计留痕)见 `apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs``apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs`,前端矩阵见 `apps/ai-game-creator-shell/tests/resourceVersionReplacement*.test.ts(x)`
@@ -98,6 +98,14 @@ type ImageCanvasBasicGenerationComposerViewProps = {
submitAriaLabel?: string;
submittingStatusLabel?: string;
styleControl?: ReactNode;
/**
* 提示词输入控件的主机注入位(AGC 用它在快速编辑提示词里跑 `@` 引用生成器)。
*
* 传了它就用宿主控件替换默认的 `AutoGrowTextArea`(宿主自己负责等价的可访问性:
* 同一个 `aria-label`、同一份提示词文本);不传时 DOM 与行为逐字不变。
* 场景错误提示的 `aria-describedby` 只挂在默认 textarea 上,需要它的调用方不要传本插槽。
*/
promptInputSlot?: ReactNode;
/**
* 提示词下方的主机注入位(AGC 用它放「AI 润色」)。
*
@@ -162,6 +170,7 @@ export function ImageCanvasBasicGenerationComposerView({
submitAriaLabel = '生成',
submittingStatusLabel = '生成中',
styleControl,
promptInputSlot,
promptActionSlot,
}: ImageCanvasBasicGenerationComposerViewProps) {
const references = dialog.generationReferences ?? [];
@@ -284,27 +293,29 @@ export function ImageCanvasBasicGenerationComposerView({
) : null}
</div>
) : null}
<AutoGrowTextArea
aria-label={resolvedPromptLabel}
aria-invalid={sceneContentError ? true : undefined}
aria-describedby={
sceneContentError ? EDITOR_SCENE_CONTENT_ERROR_ID : undefined
}
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder={resolvedPromptPlaceholder}
className={promptClassName}
onValueChange={(value) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
prompt: value,
}
: currentDialog,
)
}
/>
{promptInputSlot ?? (
<AutoGrowTextArea
aria-label={resolvedPromptLabel}
aria-invalid={sceneContentError ? true : undefined}
aria-describedby={
sceneContentError ? EDITOR_SCENE_CONTENT_ERROR_ID : undefined
}
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder={resolvedPromptPlaceholder}
className={promptClassName}
onValueChange={(value) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
prompt: value,
}
: currentDialog,
)
}
/>
)}
{promptActionSlot}
{sceneContentError ? (
<p
@@ -33,6 +33,8 @@ export type ImageCanvasQuickEditPanelViewProps = {
onRequestUpload?: (target: UploadTarget) => void;
onRememberImageModel?: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
/** 提示词输入控件的主机注入位(AGC 用它把提示词换成能 `@` 引用素材的输入区)。 */
promptInputSlot?: ReactNode;
/** 提示词下方的主机注入位(AGC 用它放「AI 润色」);不传时 DOM 不变。 */
promptActionSlot?: ReactNode;
onSubmit: () => void;
@@ -103,6 +105,7 @@ export function ImageCanvasQuickEditPanelView({
onRequestUpload,
onRememberImageModel = () => {},
hasPendingImageReferenceUploads = false,
promptInputSlot,
promptActionSlot,
onSubmit,
}: ImageCanvasQuickEditPanelViewProps) {
@@ -166,6 +169,7 @@ export function ImageCanvasQuickEditPanelView({
referenceAriaLabelFormatter={(_, index) => `${index + 1}`}
referenceRemoveLabelFormatter={(_, index) => `删除图${index + 1}`}
panelClassName="image-canvas-editor__quick-edit-panel image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--quick-edit"
promptInputSlot={promptInputSlot}
promptActionSlot={promptActionSlot}
footerClassName="image-canvas-editor__generation-composer-footer image-canvas-editor__quick-edit-footer"
promptClassName="image-canvas-editor__generation-prompt image-canvas-editor__quick-edit-prompt"
@@ -10,6 +10,15 @@ type UseImageCanvasFloatingOptionDismissOptions = {
onDismiss: () => void;
/** Escape 关闭后把焦点还给的触发按钮;不传则只关闭不移焦。 */
restoreFocusRef?: FloatingOptionBoundaryRef | null;
/**
* 宿主自己的「点这里不算外部」补充判据。
*
* 共享 hook 只认识 `image-canvas-editor__portal-menu` 这一类共享弹出层;宿主自己
* portal 到 body 的浮层(例如 `@` 资源选择器与它的候选菜单)用它在同一处登记,
* 否则点浮层里的候选项会被判成点外部、把宿主面板一起收掉。
* 不传时(网页端画布)判定与既有行为逐字一致。
*/
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
};
function isEventInsideBoundary(
@@ -38,6 +47,7 @@ export function useImageCanvasFloatingOptionDismiss({
boundaryRefs,
onDismiss,
restoreFocusRef,
isInsideExtraOverlay,
}: UseImageCanvasFloatingOptionDismissOptions) {
useEffect(() => {
if (!isOpen || typeof document === 'undefined') {
@@ -48,7 +58,8 @@ export function useImageCanvasFloatingOptionDismiss({
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
if (
isEventInsideBoundary(event.target, boundaryRefs) ||
isEventInsideFloatingMenu(event.target)
isEventInsideFloatingMenu(event.target) ||
isInsideExtraOverlay?.(event.target) === true
) {
return;
}
@@ -80,5 +91,5 @@ export function useImageCanvasFloatingOptionDismiss({
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [boundaryRefs, isOpen, onDismiss, restoreFocusRef]);
}, [boundaryRefs, isInsideExtraOverlay, isOpen, onDismiss, restoreFocusRef]);
}