画布验收遗留问题与后续需求 #419

Merged
suzmii merged 16 commits from fix/agc-acceptance-followup into master 2026-09-21 02:02:20 +08:00
48 changed files with 5095 additions and 373 deletions
@@ -759,8 +759,15 @@ function ResourceReferenceEditor({
const reminderDisabledRef = useRef(reminderDisabled);
reminderDisabledRef.current = reminderDisabled;
/**
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
* 否则刚显示出来的提示会被自己的回填立刻清掉。
*/
const appliedPromptRef = useRef<string | null>(null);
const applyPromptText = useCallback(
(text: string) => {
appliedPromptRef.current = text;
flushSync(() => {
onChange({ text, references: liveDraftRef.current.references });
});
@@ -782,13 +789,25 @@ function ResourceReferenceEditor({
const {
polishing,
error: polishError,
notice: polishNotice,
originalText: polishedOriginalText,
polish: runPolish,
restoreOriginal: restoreOriginalPrompt,
clearError: clearPolishError,
clearNotice: clearPolishNotice,
reset: resetPromptPolish,
} = promptPolishState;
useEffect(() => {
if (
appliedPromptRef.current !== null &&
value === appliedPromptRef.current
) {
return;
}
clearPolishNotice();
}, [clearPolishNotice, value]);
const polishPrompt = useCallback(async () => {
await runPolish();
}, [runPolish]);
@@ -1083,13 +1102,16 @@ function ResourceReferenceEditor({
) : null}
</div>
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
{showPolishAction &&
!reminderOpen &&
(polishing || polishError || polishNotice) ? (
<span
className="resource-reference-input-status"
role="status"
aria-live="polite"
>
{polishing ? '润色中…' : polishError}
{polishing ? '润色中…' : (polishError ?? polishNotice)}
</span>
) : null}
<LexicalTypeaheadMenuPlugin<ResourceMentionOption>
@@ -2,6 +2,15 @@ import { useCallback, useRef, useState } from 'react';
import { requestChatPromptPolish } from './chatPromptPolish';
/**
* 回包与原文一字不差时的提示语。
*
* 平台侧有时会把同一句话原样还回来(AGC-004:快速编辑里点「AI 润色」后文案毫无变化,
* 用户以为按钮没反应或已经改过)。这种情况必须给一条说得清的提示,而且**不能**落下
* 原文快照——没有可回退的变化,就不该冒出「恢复原文」这种假入口。
*/
export const PROMPT_POLISH_UNCHANGED_NOTICE = 'AI 润色结果与原文相同,未做修改';
/**
* 润色回填前的规范化结果。
*
@@ -41,14 +50,19 @@ export type UsePromptPolishOptions = {
export type UsePromptPolishResult = {
polishing: boolean;
error: string | null;
/** 最近一次成功回填的截断提示;没有截断时为 null。 */
/** 最近一次成功回包的提示:截断说明,或「与原文相同,未做修改」;两者都没有时为 null。 */
notice: string | null;
/** 首次成功润色时落下的原文快照;非空时宿主渲染「恢复原文」。 */
/**
* 首次**真的改动了文本**的润色时落下的原文快照;非空时宿主渲染「恢复原文」。
* 回包与原文相同的那些次不落快照(没什么可恢复的)。
*/
originalText: string | null;
/** 润色并在成功时回填,返回回填后的文本;失败返回 null 并保留原文。 */
polish: (options?: PromptPolishRunOptions) => Promise<string | null>;
restoreOriginal: () => void;
clearError: () => void;
/** 用户自己改了提示词:把上一轮往返留下的提示(截断 / 与原文相同)收掉。 */
clearNotice: () => void;
/** 清掉往返状态(草稿清空、面板换资源时用)。 */
reset: () => void;
};
@@ -56,6 +70,10 @@ export type UsePromptPolishResult = {
/**
* 提示词润色的状态机:失败保留原文、首次成功落原文快照、反复润色只覆盖结果。
*
* 成功但回包与原文逐字相同(规范化前后都没变)时不算「润色出了新东西」:不落原文快照、
* 不回填宿主状态,只给 {@link PROMPT_POLISH_UNCHANGED_NOTICE} 这条明确反馈,
* 免得用户把「按钮点了没反应」误当成功能坏了、或者以为文案已经被改过。
*
* 聊天输入区与资源侧(生成素材 / 快速编辑)共用这一份;它与界面无关,也不碰共享组件,
* 宿主自己决定文本存在哪里、怎么回填。Tauri 调用固定在 AGC 侧
* `requestChatPromptPolish`),共享 composer 只接一个可选的注入位。
@@ -108,6 +126,18 @@ export function usePromptPolish({
const normalized = normalizeResult
? normalizeResult(polished)
: { text: polished };
if (normalized.text === prompt) {
// 最终要写回宿主的文本与当前文本逐字相同:既没有新内容可回填,也没有可回退的
// 变化,因此不落原文快照、不写宿主状态——`applyPrompt` 在宿主侧还有「提示词变了
// 就重铸请求身份」这类副作用,回填一份没变的文本会平白作废一次请求身份。
//
// 判据必须是**规范化之后**的文本,不能只看回包:润色结果被按长度上限截回原文时
// `truncateResourceEditPrompt` 是切片不是 trim),回包与原文不同、写回去却一字
// 未变,那会渲染出一枚点了等于没点的「恢复原文」,正是要消灭的那种假入口。
// 截断提示优先——它解释的是「这次为什么没变」。
setNotice(normalized.notice ?? PROMPT_POLISH_UNCHANGED_NOTICE);
return normalized.text;
}
// 原文快照只在第一次成功润色时落下,因此「恢复原文」永远回到最初原文。
setOriginalText((current) => current ?? prompt);
setNotice(normalized.notice ?? null);
@@ -150,6 +180,10 @@ export function usePromptPolish({
setError(null);
}, []);
const clearNotice = useCallback(() => {
setNotice(null);
}, []);
const reset = useCallback(() => {
// 作废在飞请求:宿主清空草稿 / 换资源后,迟到的润色结果不许再回填。
requestIdRef.current += 1;
@@ -166,6 +200,7 @@ export function usePromptPolish({
polish,
restoreOriginal,
clearError,
clearNotice,
reset,
};
}
@@ -414,7 +414,13 @@ export function ResourceCanvasAssetGenerationPanelView({
onChange={(event) => setAssetName(event.currentTarget.value)}
/>
</label>
<label>
{/*
这一格**不能**用 `<label>` 包:`<label>` 会把点击转发给内部第一个可标注控件,而这一格里
第一个可标注控件是引用输入区的「插入素材引用」按钮——于是点输入框任意位置都会弹出素材
选择框(客户端验收现场那条)。两边的可访问名都由控件自身的 `aria-label` 提供(引用输入区
的 `ariaLabel` 与 `PlatformTextField` 的 `aria-label`),不依赖 label 关联。
*/}
<div className="resource-canvas-asset-generation-prompt-field">
<span></span>
{referenceEnabled ? (
/*
@@ -433,7 +439,7 @@ export function ResourceCanvasAssetGenerationPanelView({
versions={versions}
activeVersionId={activeVersionId}
multiline
rows={6}
rows={3}
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
showPolishAction={false}
/>
@@ -442,7 +448,7 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
rows={6}
rows={3}
autoFocus
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
@@ -450,7 +456,7 @@ export function ResourceCanvasAssetGenerationPanelView({
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
)}
</label>
</div>
{action.adjustableDimensions ? (
<div className="resource-canvas-asset-generation-dimensions">
<PlatformSegmentedTabs
@@ -488,22 +494,6 @@ export function ResourceCanvasAssetGenerationPanelView({
{resolveEditorImageSizeLabel({ aspectRatio, imageSize })}
</span>
)}
<ResourcePromptPolishSlot
subject={`素材生成提示词(${action.label}`}
editKind="image-reference"
prompt={prompt}
applyPrompt={setPrompt}
/>
{referenceEnabled && referenceLimit > 0 ? (
<p
className="resource-canvas-asset-generation-reference-hint"
data-resource-canvas-generation-reference-count={
referenceAssetIds.length
}
>
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
</p>
) : null}
{referenceProblemNotice ? (
<p
className="game-resource-generation-error"
@@ -525,18 +515,43 @@ export function ResourceCanvasAssetGenerationPanelView({
{shownError}
</p>
) : null}
{/*
动作行收口:润色入口、参考图计数与两个按钮挤在同一行。
原先「润色一行、参考图计数一行、按钮一行」把面板顶到必须滚动(验收现场截图里那条
滚动条),而这三块内容都没有独占一行的必要:润色作用在提示词上、计数是提示词的从属
信息、按钮是收尾动作。放一行之后面板高度回落到几何上界以内,滚动条物理上不再出现。
*/}
<div className="game-resource-generation-actions">
<PlatformActionButton
type="button"
tone="secondary"
onClick={closeWithDraft}
>
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{action.label}
</PlatformActionButton>
<ResourcePromptPolishSlot
subject={`素材生成提示词(${action.label}`}
editKind="image-reference"
prompt={prompt}
applyPrompt={setPrompt}
/>
{referenceEnabled && referenceLimit > 0 ? (
<p
className="resource-canvas-asset-generation-reference-hint"
data-resource-canvas-generation-reference-count={
referenceAssetIds.length
}
>
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
</p>
) : null}
<div className="game-resource-generation-actions-buttons">
<PlatformActionButton
type="button"
tone="secondary"
onClick={closeWithDraft}
>
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{action.label}
</PlatformActionButton>
</div>
</div>
</form>
</>
@@ -12,6 +12,12 @@ import {
resourceCanvasAssetGenerationTaskTone,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
import {
resourceCanvasResourceEditElapsedLabel,
type ResourceCanvasResourceEditTask,
resourceCanvasResourceEditTaskElapsedMillis,
resourceCanvasResourceEditTaskIsTerminal,
} from './resourceCanvasResourceEditTaskModel';
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
@@ -24,6 +30,14 @@ export const RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS = 160;
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
/**
* 派生/修改类任务(快速编辑、生成动画、抠图…)。
*
* 它们不在图片类生成任务的账本里(原生资源编辑账本按 `operationId` 记),但用户眼里都是
* 「我交出去、等着出结果的那件事」,所以进同一个侧栏、同一套分栏;状态与阶段文案各按自己的
* 账本渲染。缺省为空数组:老调用方不传就没有这一段。
*/
resourceEditTasks?: readonly ResourceCanvasResourceEditTask[];
/** 侧栏是否展开;折叠时只留贴边把手。 */
open: boolean;
onToggleOpen: () => void;
@@ -31,7 +45,53 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
function taskRow(
/**
* 侧栏的一行。
*
* 两种来源合并成同一条列表:图片类生成任务按后端账本推进,派生/修改任务按原生资源编辑账本
* 推进;排序与分栏只认「提交时间」和「是否终态」,用户不需要知道它们来自两套账本。
*/
type ResourceCanvasGenerationTaskRow =
| {
readonly source: 'asset-generation';
readonly createdAtMillis: number;
readonly task: ResourceCanvasAssetGenerationTask;
}
| {
readonly source: 'resource-edit';
readonly createdAtMillis: number;
readonly task: ResourceCanvasResourceEditTask;
};
function resourceCanvasGenerationTaskRowKey(
row: ResourceCanvasGenerationTaskRow,
): string {
return row.source === 'asset-generation'
? row.task.taskId
: row.task.operationId;
}
function resourceCanvasGenerationTaskRowIsTerminal(
row: ResourceCanvasGenerationTaskRow,
): boolean {
return row.source === 'asset-generation'
? resourceCanvasAssetGenerationTaskIsTerminal(row.task)
: resourceCanvasResourceEditTaskIsTerminal(row.task);
}
function sortResourceCanvasGenerationTaskRows(
rows: readonly ResourceCanvasGenerationTaskRow[],
): ResourceCanvasGenerationTaskRow[] {
return [...rows].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
resourceCanvasGenerationTaskRowKey(left).localeCompare(
resourceCanvasGenerationTaskRowKey(right),
),
);
}
function assetGenerationTaskRow(
task: ResourceCanvasAssetGenerationTask,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
@@ -86,6 +146,79 @@ function taskRow(
);
}
/**
* 派生/修改任务的一行。
*
* 与图片类生成的卡片同一套 class、同一套 tone,只有两点不同:① 没有「定位到素材」——原生账本
* 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上
* 这行是哪一次修改。
*/
function resourceEditTaskRow(
task: ResourceCanvasResourceEditTask,
nowMillis: number,
) {
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(
task,
nowMillis,
);
return (
<li
key={task.operationId}
className="game-resource-generation-task-card"
data-task-status={task.status}
data-task-source="resource-edit"
>
<div className="game-resource-generation-task-card-title-row">
<strong className="game-resource-generation-task-card-name">
{task.assetName}
</strong>
<span className="game-resource-generation-task-card-action">
{task.actionLabel}
</span>
</div>
<div className="game-resource-generation-task-card-meta">
<span
className="game-resource-generation-task-badge"
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
>
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
{elapsedMillis === null ? null : (
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
</span>
)}
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
{task.error}
</p>
) : null}
{task.prompt ? (
<p
className="game-resource-generation-task-card-prompt"
title={task.prompt}
>
{task.prompt}
</p>
) : null}
</li>
);
}
function resourceCanvasGenerationTaskRowNode(
row: ResourceCanvasGenerationTaskRow,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
return row.source === 'asset-generation'
? assetGenerationTaskRow(row.task, nowMillis, onFocusTask)
: resourceEditTaskRow(row.task, nowMillis);
}
/**
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
*
@@ -103,6 +236,7 @@ function taskRow(
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
resourceEditTasks = [],
open,
onToggleOpen,
onFocusTask,
@@ -115,20 +249,33 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
const [phase, setPhase] = useState<'idle' | 'entering' | 'leaving'>(
open ? 'entering' : 'idle',
);
const inFlightCount = tasks.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
).length;
const hasLiveTask = inFlightCount > 0;
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
/**
* 两套账本合成一条列表:图片类生成任务(后端生成账本)在前端本地队列里已按提交时间排序,
* 派生/修改任务(原生资源编辑账本)自带提交时间,这里统一按时间倒序,用户看到的就是
* 「我最近交出去的那几件事」。
*/
const ordered = useMemo<ResourceCanvasGenerationTaskRow[]>(
() =>
sortResourceCanvasGenerationTaskRows([
...sortResourceCanvasAssetGenerationTasks(tasks).map((task) => ({
source: 'asset-generation' as const,
createdAtMillis: task.createdAtMillis,
task,
})),
...resourceEditTasks.map((task) => ({
source: 'resource-edit' as const,
createdAtMillis: task.createdAtMillis,
task,
})),
]),
[resourceEditTasks, tasks],
);
const active = ordered.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const done = ordered.filter((task) =>
resourceCanvasAssetGenerationTaskIsTerminal(task),
(row) => !resourceCanvasGenerationTaskRowIsTerminal(row),
);
const done = ordered.filter(resourceCanvasGenerationTaskRowIsTerminal);
const inFlightCount = active.length;
const hasLiveTask = inFlightCount > 0;
const visibleDone = done.slice(
0,
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
@@ -248,8 +395,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
</p>
) : (
<ul className="game-resource-generation-tasks-list">
{rendered.active.map((task) =>
taskRow(task, nowMillis, onFocusTask),
{rendered.active.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
)}
@@ -271,8 +422,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
) : (
<>
<ul className="game-resource-generation-tasks-list">
{rendered.visibleDone.map((task) =>
taskRow(task, nowMillis, onFocusTask),
{rendered.visibleDone.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
{rendered.done.length > rendered.visibleDone.length ? (
@@ -1,4 +1,5 @@
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import {
type LocalProjectResourceEditKind,
@@ -30,14 +31,38 @@ export function ResourcePromptPolishSlot({
disabled = false,
applyPrompt,
}: ResourcePromptPolishSlotProps) {
/**
* 我们自己写回去的那一份文本。用于区分「润色回填导致的 prop 变化」与「用户手改」:
* 只有后者该把上一轮往返留下的提示收掉,否则刚显示出来的提示会被自己的回填清掉。
*/
const appliedPromptRef = useRef<string | null>(null);
// 宿主回调存 ref:包一层只为记账,不该让这份包装每次渲染都换身份、
// 进而把 `usePromptPolish` 的 `polish` 也跟着重造。
const applyPromptRef = useRef(applyPrompt);
applyPromptRef.current = applyPrompt;
const applyPromptAndTrack = useCallback((text: string) => {
appliedPromptRef.current = text;
applyPromptRef.current(text);
}, []);
const polish = usePromptPolish({
readPrompt: () => prompt,
applyPrompt,
applyPrompt: applyPromptAndTrack,
canPolish: () => !disabled,
resolveContext: () => resourceAssetPromptPolishContext(subject),
normalizeResult: (polished) =>
truncateResourceEditPrompt(polished, editKind),
});
const { clearNotice } = polish;
useEffect(() => {
if (
appliedPromptRef.current !== null &&
prompt === appliedPromptRef.current
) {
return;
}
// 手改过提示词:上一轮「与原文相同 / 已截断」的结论不再描述当前这段文本。
clearNotice();
}, [clearNotice, prompt]);
const statusText = polish.polishing
? '润色中…'
: (polish.error ?? polish.notice);
@@ -326,6 +326,17 @@
white-space: normal;
}
/* 派生/修改任务多一行提示词:用户要能认出这行是哪一次修改(图片类生成任务没有这一行)。 */
.game-resource-generation-task-card-prompt {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.68rem;
font-style: italic;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-locate {
justify-self: start;
border: 0;
@@ -1408,3 +1408,437 @@
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/*
* 角色动画生成面板(共享 `ImageCanvasCharacterAnimationPanelView`)与它的素材缩略图。
*
* 同一处样式缺口:`image-canvas-editor__character-animation-*`、`__generation-close` 与
* `__reference-strip/__reference-slot/__reference-chip*` 在网页端由整站表 `src/index.css` 提供,
* 而 AGC 不引那张表、宿主 chrome 表又只照抄了 composer 的通用规则。结果是这个面板只剩通用
* composer 外观:素材缩略图、预设胶囊、提交按钮与右上角关闭键全部落回默认流布局,面板里出现
* 一大块空白、预设竖排、`生成80泥点` 悬在中间(客户端验收现场截图)。
*
* 下面逐条照抄 `src/index.css` 的同名规则(注释里给行号),不手写近似值;AGC 不会渲染的兄弟
* 选择器不搬(例如 `.image-canvas-editor__character-animation-head` 在这版组件里没有节点)。
*/
/* src/index.css:6776 */
.image-canvas-editor__generation-composer--character-animation {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto auto auto;
width: min(42rem, calc(100% - 1.5rem));
}
/* src/index.css:6874 */
.image-canvas-editor__generation-close {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: #374151;
font-size: 0.82rem;
font-weight: 800;
}
/* src/index.css:6906 */
.image-canvas-editor__reference-strip {
display: flex;
grid-column: 1 / -1;
min-width: 0;
align-items: center;
gap: 0.36rem;
overflow-x: auto;
padding: 0 2.2rem 0.02rem 0;
scrollbar-width: thin;
}
/* src/index.css:6917 */
.image-canvas-editor__reference-slot {
position: relative;
display: inline-flex;
flex: 0 0 auto;
width: 3.95rem;
height: 3.95rem;
}
/* src/index.css:6925 */
.image-canvas-editor__reference-chip {
position: relative;
display: inline-grid;
width: 3.95rem;
min-width: 3.95rem;
max-width: 3.95rem;
height: 3.95rem;
min-height: 3.95rem;
grid-template-rows: 1.42rem auto;
align-items: center;
justify-items: center;
gap: 0.28rem;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 1rem;
background: #f2f3f5;
padding: 0.62rem 0.36rem 0.5rem;
color: #a3a8ae;
font-size: 0.68rem;
font-weight: 850;
line-height: 1.04;
text-align: center;
box-shadow: none;
}
/* src/index.css:6950 */
button.image-canvas-editor__reference-chip {
cursor: pointer;
}
button.image-canvas-editor__reference-chip:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(148, 163, 184, 0.22);
background: #eef0f2;
}
button.image-canvas-editor__reference-chip:disabled {
cursor: not-allowed;
opacity: 0.62;
transform: none;
}
/* src/index.css:6966 */
.image-canvas-editor__reference-chip-icon {
display: grid;
width: 1.42rem;
height: 1.42rem;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 0.36rem;
background: transparent;
color: currentColor;
}
.image-canvas-editor__reference-chip-icon img {
width: 100%;
height: 100%;
border-radius: 0.36rem;
object-fit: cover;
}
/* src/index.css:6985 */
.image-canvas-editor__reference-chip-label {
display: -webkit-box;
min-width: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
white-space: normal;
word-break: keep-all;
}
/* src/index.css:6996 */
.image-canvas-editor__reference-chip-source-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
clip-path: inset(50%);
}
/* src/index.css:7006 */
.image-canvas-editor__reference-chip-remove {
position: absolute;
top: 0.16rem;
right: 0.16rem;
z-index: 2;
display: grid;
width: 1.12rem;
height: 1.12rem;
place-items: center;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 999px;
background: rgba(15, 23, 42, 0.86);
color: #ffffff;
opacity: 0;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
pointer-events: none;
transform: scale(0.88);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.image-canvas-editor__reference-chip-remove--always-visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.image-canvas-editor__reference-slot:hover
.image-canvas-editor__reference-chip-remove,
.image-canvas-editor__reference-slot:focus-within
.image-canvas-editor__reference-chip-remove {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
@media (hover: none) {
.image-canvas-editor__reference-chip-remove {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
.image-canvas-editor__reference-chip-remove:disabled {
cursor: not-allowed;
opacity: 0;
pointer-events: none;
}
.image-canvas-editor__reference-chip-remove:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.95);
}
/* src/index.css:7061:带图缩略图的图标尺寸(角色动画的源图就是这一档) */
.image-canvas-editor__reference-chip--has-image
.image-canvas-editor__reference-chip-icon {
width: 1.74rem;
height: 1.74rem;
border-radius: 0.48rem;
}
.image-canvas-editor__reference-chip--has-image
.image-canvas-editor__reference-chip-icon
img {
border-radius: 0.48rem;
}
/* src/index.css:70747112tone 取色(AGC 目前渲染 quick-edit 与 character,其余一并有备) */
.image-canvas-editor__reference-chip--spec
.image-canvas-editor__reference-chip-icon {
color: #8b5cf6;
}
.image-canvas-editor__reference-chip--icon
.image-canvas-editor__reference-chip-icon {
color: #059669;
}
.image-canvas-editor__reference-chip--ui
.image-canvas-editor__reference-chip-icon {
color: #d97706;
}
.image-canvas-editor__reference-chip--character
.image-canvas-editor__reference-chip-icon {
color: #0f172a;
}
.image-canvas-editor__reference-chip--quick-edit {
background: #f3f4f6;
color: #9ca3af;
}
.image-canvas-editor__reference-chip--quick-edit
.image-canvas-editor__reference-chip-icon {
color: #9ca3af;
}
.image-canvas-editor__reference-chip--video
.image-canvas-editor__reference-chip-icon {
color: #2563eb;
}
.image-canvas-editor__reference-chip--audio
.image-canvas-editor__reference-chip-icon {
color: #7c3aed;
}
/* src/index.css:8728 */
.image-canvas-editor__character-animation-panel {
position: absolute;
z-index: 14;
display: grid;
width: min(42rem, calc(100vw - 1.5rem));
max-height: min(34rem, calc(100% - 1.5rem));
gap: 0.58rem;
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.36);
border-radius: 1.1rem;
background: rgba(255, 255, 255, 0.96);
padding: 0.76rem;
font-size: 0.82rem;
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.22);
}
/* src/index.css:8753 */
.image-canvas-editor__character-animation-textarea {
grid-column: 1 / -1;
--auto-grow-editor-min-height: 8rem;
--auto-grow-editor-max-height: 16rem;
}
/* src/index.css:8759 */
.image-canvas-editor__character-animation-presets {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: 0.38rem;
}
.image-canvas-editor__character-animation-preset {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 999px;
background: #f8fafc;
padding: 0.34rem 0.58rem;
color: #334155;
font-size: 0.78rem;
}
/* src/index.css:8775 */
.image-canvas-editor__character-animation-footer {
display: grid;
grid-column: 1 / -1;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.46rem;
}
.image-canvas-editor__character-animation-submit {
grid-column: 3;
min-width: 5.45rem;
height: 2.25rem;
gap: 0.4rem;
border: 0;
background: var(--image-canvas-brand-fill);
color: var(--image-canvas-brand-text-on-fill);
}
/* src/index.css:6811:提示词输入区的聚焦态(快速编辑 / 动画面板共用这一档)。 */
.image-canvas-editor__generation-prompt:focus-within,
.image-canvas-editor__video-prompt:focus {
border-color: var(--image-canvas-brand-border-strong);
background: #ffffff;
box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring);
}
/* src/index.css:6822 */
.image-canvas-editor__generation-prompt:has(
.auto-grow-text-area__content[aria-invalid='true']
) {
border-color: #e11d48;
background: #fff7f8;
}
/* src/index.css:6829 */
.image-canvas-editor__generation-prompt:has(
.auto-grow-text-area__content[aria-invalid='true']
):focus-within {
border-color: #e11d48;
box-shadow: 0 0 0 3px rgba(225, 29, 72, 0.14);
}
/*
* src/index.css:7630 —— 关闭键在浮层里的**落位**。
*
* 这一族在网页端有两条同名规则:6874 给基础外观,这条给「贴在浮层右上角」的绝对定位。
* 只搬前一条时按钮会掉进网格流、独自占一行并居中(客户端验收现场那张图里的 ×)。
*/
.image-canvas-editor__generation-close {
position: absolute;
right: 0.62rem;
top: 0.58rem;
width: 2rem;
height: 2rem;
color: #94a3b8;
}
/* src/index.css:16988composer 里的参数簇去掉自身底色,hover / 展开时才浮起。 */
.image-canvas-editor__generation-composer .image-canvas-editor__option-cluster {
min-height: 2.25rem;
background: transparent;
border-color: transparent;
}
/* src/index.css:16994 */
.image-canvas-editor__generation-composer
.image-canvas-editor__option-cluster:hover:not(:disabled),
.image-canvas-editor__generation-composer
.image-canvas-editor__option-cluster[aria-expanded='true'] {
border-color: rgba(15, 23, 42, 0.08);
background: #f8fafc;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
/*
* 窄屏整档照抄(src/index.css:8981 的 `@media (max-width: 760px)`):浮层落到底部整宽、
* 动作竖排、参数与提交整宽。选择器与断点都按网页端原样搬,避免"我拆一半、你拆一半"之后
* 两边再各自漂移。
*/
@media (max-width: 760px) {
/*
* src/index.css:9020 —— **只搬动画面板那一支**。
*
* 网页端这一条把快速编辑浮层与动画面板一起变成 `position: fixed` 的整宽底栏;AGC 的快速编辑
* 浮层是「居中贴资源卡」的绝对定位(`transform: translateX(-50%)` + 内联 left/top,见本文件
* 上面那一族),窄屏宽度已经由 `calc(100vw - 1.5rem)` 自己收好——跟网页端改成底栏会压住栏目
* 画布左下角工具栏与左侧「生成任务」侧栏。这条宿主差异在样式守卫里显式放行,不静默漂移。
*/
.image-canvas-editor__character-animation-panel {
position: fixed;
left: 0.75rem !important;
right: 0.75rem;
top: auto !important;
bottom: 0.75rem;
width: auto;
max-height: min(72vh, 34rem);
transform: none;
}
/* src/index.css:9037 */
.image-canvas-editor__generation-composer {
position: fixed;
left: 0.75rem !important;
right: 0.75rem;
top: auto !important;
bottom: 3.85rem;
width: auto;
max-height: min(72vh, 31rem);
grid-template-columns: 3.55rem minmax(0, 1fr);
overflow: auto;
transform: none;
}
/* src/index.css:9050 */
.image-canvas-editor__generation-composer-footer,
.image-canvas-editor__character-animation-footer {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
/* src/index.css:9056 */
.image-canvas-editor__option-popover-anchor--dimensions,
.image-canvas-editor__option-popover-anchor--model {
grid-column: auto;
width: 100%;
justify-self: stretch;
}
/* src/index.css:9063 */
.image-canvas-editor__option-cluster--dimensions,
.image-canvas-editor__option-cluster--model,
.image-canvas-editor__image-style-toggle,
.image-canvas-editor__readonly-generation-option,
.image-canvas-editor__generation-submit,
.image-canvas-editor__character-animation-submit {
grid-column: auto;
width: 100%;
max-width: none;
justify-self: stretch;
}
}
@@ -48,9 +48,9 @@ export function isResourceCanvasPanTarget(
if (!isResourceCanvasInteractionTarget(target)) return true;
return Boolean(
target.closest('.game-resource-card') &&
!target.closest(
'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video',
),
!target.closest(
'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video',
),
);
}
@@ -85,18 +85,6 @@ export function isResourceCanvasWheelOverlayTarget(
);
}
/**
* 画布浮层是否可以被清焦点顺手关掉。
*
* 对齐美术画布 `closeTransientEditorPanels()`:生成中的面板不关,否则用户看不到
* 「修改中」状态,也拿不到失败后的重试入口。信息浮层是只读的,没有生成态。
*/
export function canDismissResourceCanvasQuickEdit(
panel: { readonly status: string } | null | undefined,
): boolean {
return panel?.status !== 'generating';
}
/** 资源画布上的 AGC 自有弹层:它们开着时画布浮层不参与「点外部关闭」。 */
export type ResourceCanvasHostOverlayState = {
readonly isResourcePanelOpen: boolean;
@@ -14,8 +14,9 @@
align-content: center;
gap: 4px;
padding: 8px;
/* 差异只留「虚线 + 更深的描边」(表示还没落地);圆角与浮层卡共用基类 token。 */
border: 1px dashed #c9a493;
border-radius: 14px;
border-radius: var(--game-canvas-card-radius-inner, 14px);
background: rgb(255 250 247 / 88%);
color: #8a6a5c;
text-align: center;
@@ -80,14 +81,70 @@
/* 锚点给的是占位卡中心:不居中就会整体右偏半个面板宽(真实浏览器 x287 vs 卡中心 286 复现过)。 */
transform: translateX(-50%);
width: min(560px, calc(100% - 24px));
/* 外形走基类 token(= 网页端画布面板那一套),三块浮层因此长得一样。 */
border: 1px solid var(--game-canvas-card-border, #e4c8ba);
border-radius: var(--game-canvas-card-radius, 18px);
background: var(--game-canvas-card-fill, #fffaf7);
box-shadow: var(--game-canvas-card-shadow, 0 24px 64px rgb(62 37 27 / 24%));
padding: var(--game-canvas-card-padding, 18px);
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
pointer-events: auto;
/* 内联 `maxHeight` 按真实画布底边算;这条只是拿不到几何时的兜底上界。 */
/*
* 内联 `maxHeight` 按真实画布底边算;这条只是拿不到几何时的兜底上界。
*
* 面板内容已经压到「一行提示词 + 一行参数 + 一行动作」的紧凑版(见下面那组规则),正常几何下
* 根本到不了这里;`overflow` 与这条上界保留为极端窗口下的兜底,而不是日常出现的滚动条。
*/
max-height: min(560px, calc(100dvh - 160px));
}
/*
* 紧凑版:字段行两列(标签在左、控件在右)、比例与尺寸并排、动作用一行收口。
*
* 验收现场那张图里面板要滚动,是因为「标签各占一行 + 提示词 6 行 + 比例一行 + 尺寸一行 + 润色
* 一行 + 参考计数一行」把内容顶到几何上界之外。这里按信息层级重新排:从属信息(标签、计数、
* 润色)让位给主体(提示词、参数、动作),面板高度回落到上界以内,滚动条物理上不再出现。
*/
.game-resource-generation-form {
gap: 10px;
}
.game-resource-generation-form > label,
.game-resource-generation-form
> .resource-canvas-asset-generation-prompt-field {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 4px 10px;
}
.resource-canvas-asset-generation-prompt-field > span,
.game-resource-generation-form > label > span {
white-space: nowrap;
}
/* 比例(5 项)与尺寸(3 项)并排:两组都很短,各占半行足够点。 */
.resource-canvas-asset-generation-dimensions {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
/* 动作行:润色与参考计数贴左,两个按钮贴右,同一行收口。 */
.game-approval-dialog.resource-canvas-generation-floating-panel
.game-resource-generation-actions {
align-items: center;
gap: 10px;
padding-top: 2px;
}
.game-resource-generation-actions-buttons {
display: flex;
margin-left: auto;
gap: 8px;
}
/*
* 提交行固定在浮层底部。
*
@@ -113,4 +170,5 @@
margin: 0;
color: #9a7d70;
font-size: 11px;
white-space: nowrap;
}
@@ -1,6 +1,7 @@
import type { CanvasLayer } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
import type { QuickEditPanelState } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
import { createQuickEditPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel';
import type { ChatReference } from '../project-workspace/resourceReferences';
/**
* AGC 资源卡快速编辑面板的初始草稿。
@@ -13,3 +14,174 @@ export function createResourceQuickEditPanelDraft(
): QuickEditPanelState {
return createQuickEditPanelDraft(sourceLayer, {});
}
/**
* 一份还没提交的快速编辑草稿。
*
* 这是**宿主会话内存**里的一笔记录,不是正式业务状态:不落盘、不进资源编辑账本、
* 不参与对账。它存在的唯一理由是面板被点外部 / Esc 收起、或用户切到另一张素材卡之后,
* 用户刚写的提示词与 `@` 引用还能原样回到面板里;正式的可恢复事实仍然只由原生
* `list_pending_local_project_resource_edits` / `resume_local_project_resource_edit`
* 那条既有账本链路负责。
*/
export type ResourceQuickEditDraft = {
/** 提示词原文(含 `@显示名` 字面量,与聊天同源)。 */
readonly prompt: string;
/** 与提示词配套的 `@` 引用,回填输入区 chip。 */
readonly references: readonly ChatReference[];
/** 最后一次改动时间(恢复列表按新→旧排序)。 */
readonly updatedAt: number;
};
/**
* 资源**路径** → 未提交草稿。
*
* 键必须是投影的稳定身份,也就是 `ProjectResource.path`:资源投影本来就按 `path` 去重
* `resourceProjectionModel` 的 `uniqueByPath`),而资源 ID 会变——任务产物被正规化登记成
* 正式素材后,同一张卡会从 `task:<任务>:<路径>` 变成 `asset:<id>`。拿 ID 当键时,只要重投影
* 不是「打开中这一笔快速编辑」自己触发(换素材卡收起面板、Agent 或外部编辑器把同一路径登记成
* 资产),草稿就会挂在一个再也点不到的键上:恢复入口按 id 找不到而把它静默过滤,重开面板也按
* 新卡 id 查不到,用户刚写的提示词凭空消失。
*/
export type ResourceQuickEditDraftStore = ReadonlyMap<
string,
ResourceQuickEditDraft
>;
/**
* 空草稿表:只在项目身份变化时用它整体清空。
*
* 走工厂而不是导出一个共享常量:这个表会直接交给 `useState`,一旦哪天有人改成原地
* `set` / `delete`,模块级单例就会跨面板、跨项目串味。每次调用都拿到一张新表。
*/
export function createEmptyResourceQuickEditDraftStore(): ResourceQuickEditDraftStore {
return new Map();
}
/** 提示词与引用都空才算「没有草稿」,不留一条空记录去占用恢复入口。 */
export function isResourceQuickEditDraftEmpty(draft: {
readonly prompt: string;
readonly references: readonly unknown[];
}): boolean {
return draft.prompt.trim().length === 0 && draft.references.length === 0;
}
export function readResourceQuickEditDraft(
store: ResourceQuickEditDraftStore,
key: string | null | undefined,
): ResourceQuickEditDraft | undefined {
return key ? store.get(key) : undefined;
}
/**
* 写入一笔草稿补丁。
*
* - 只给 `prompt` 或只给 `references` 时,另一头沿用该键下既有记录(输入区两条
* 状态各自上报,谁都不该把对方清掉);
* - 合并结果为空就不留记录,避免恢复入口里堆一条点开空面板的条目。
*
* 没有「换键」这一步:键是资源路径,正规化把卡片换成 `asset:…` 也不改路径,草稿不需要
* 跟着投影搬家(换键逻辑本身正是「搬家搬丢了」的来源)。
*/
export function writeResourceQuickEditDraft(
store: ResourceQuickEditDraftStore,
input: {
readonly key: string;
readonly prompt?: string;
readonly references?: readonly ChatReference[];
readonly updatedAt: number;
},
): ResourceQuickEditDraftStore {
const current = store.get(input.key);
const next: ResourceQuickEditDraft = {
prompt: input.prompt ?? current?.prompt ?? '',
references: input.references ?? current?.references ?? [],
updatedAt: input.updatedAt,
};
const result = new Map(store);
if (isResourceQuickEditDraftEmpty(next)) {
result.delete(input.key);
return result;
}
result.set(input.key, next);
return result;
}
/** 恢复入口里的一行未提交草稿。 */
export type ResourceQuickEditDraftEntry = {
/** 资源路径——`writeResourceQuickEditDraft` 的键,也是回填 / 丢弃时要用的键。 */
readonly key: string;
readonly label: string;
readonly prompt: string;
readonly updatedAt: number;
};
/**
* 恢复入口里的未提交草稿:只列**此刻还在当前投影里**的素材(按路径匹配,与卡片 id 无关),
* 按最后改动时间从新到旧。
*
* 资源已经不在项目里的草稿没有可继续的落点,不该继续占着入口;但表里那份记录不在这里删,
* 删了就分不清「资源暂时不可见」和「用户已经不要这笔草稿」——由「继续编辑」与用户显式
* 丢弃两处负责清理。
*/
export function listResourceQuickEditDraftEntries(
store: ResourceQuickEditDraftStore,
resources: readonly {
readonly path: string;
readonly label: string;
}[],
): ResourceQuickEditDraftEntry[] {
const labelByPath = new Map(
resources.map((resource) => [resource.path, resource.label] as const),
);
const entries: ResourceQuickEditDraftEntry[] = [];
for (const [key, draft] of store) {
const label = labelByPath.get(key);
if (label === undefined) {
continue;
}
entries.push({
key,
label,
prompt: draft.prompt,
updatedAt: draft.updatedAt,
});
}
return entries.sort((left, right) => right.updatedAt - left.updatedAt);
}
/** 成功提交 / 用户显式丢弃后抹掉这笔草稿。 */
export function dropResourceQuickEditDraft(
store: ResourceQuickEditDraftStore,
key: string | null | undefined,
): ResourceQuickEditDraftStore {
if (!key || !store.has(key)) {
return store;
}
const result = new Map(store);
result.delete(key);
return result;
}
/**
* 把记录下来的草稿盖回刚造出来的面板草稿:模型 / 比例 / 尺寸等仍取当下的默认值,
* 只有用户真正改过的提示词与引用回填,避免用一份过期面板把当前默认档位也一起还原。
*/
export function restoreResourceQuickEditPanelDraft(
panelDraft: QuickEditPanelState,
draft: ResourceQuickEditDraft | undefined,
): QuickEditPanelState {
return draft ? { ...panelDraft, prompt: draft.prompt } : panelDraft;
}
/** 恢复入口里的提示词摘要:只做一行展示,不改变原草稿内容。 */
export function resourceQuickEditDraftPromptExcerpt(
prompt: string,
limit = 48,
): string {
const text = prompt.trim().replace(/\s+/gu, ' ');
if (!text) {
return '(尚未填写提示词)';
}
return text.length > limit ? `${text.slice(0, limit)}` : text;
}
@@ -0,0 +1,371 @@
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import type { ResourceCanvasAssetGenerationTaskStatus } from './resourceCanvasAssetGenerationTaskModel';
/**
* 原生资源编辑账本里的一条**未收口**操作(Rust `list_pending_local_project_resource_edits`)。
*
* `phase` 由 Rust 的 `ResourceEditLedgerPhase` 拥有:前端只做「阶段 → 面板状态」的映射与文案渲染,
* 不自己发明阶段,也不从别的字段反推进度。
*/
export type PendingLocalProjectResourceEdit = {
operationId: string;
editKind: string;
sourceResourceId: string;
assetName: string;
backgroundMode?: string | null;
screenColor?: string | null;
phase: string;
createdAt: number;
};
/**
* 本会话内**由本进程发起**的一次派生/修改提交。
*
* 原生账本要等 Rust 建好 ledger 才读得到,而「点了生成」到那一刻之间界面上一片空白;这份
* 本地记录就是补那一段的:提交当帧就进「生成任务」侧栏,收口时按终态改写。它与原生待办
* 列表按 `operationId` 合并,同一条不会出现两遍。
*/
export type ResourceCanvasResourceEditSubmission = {
operationId: string;
/** 输入区里那句需求,收进侧栏当副标题(与聊天 `@` 同源的原文)。 */
prompt: string;
assetName: string;
/** 入口文案:快速编辑 / 生成动画 / 图片抠图…,由 `pendingResourceEditKindLabel` 出。 */
actionLabel: string;
/**
* 这笔提交属于哪张资源(资源路径,见 `ProjectResource.path`);无源入口(视频 / 音效 /
* 背景音乐)为 `null`。
*
* 两个用途:① 快速编辑提交期间,这张资源的未提交草稿不再算「未完成编辑」——用户已经交出去了,
* 恢复入口里不该再多一条点开就重复提交的条目(见 `resourceCanvasQuickEditModel`);② 同一张
* 资源还有在途提交时,快速编辑与生成动画都不允许再铸一次身份(见
* `resourceCanvasResourceEditSubmissionBlocksResource`)。
*/
resourcePath: string | null;
createdAtMillis: number;
status: 'running' | 'completed' | 'failed';
error: string | null;
assetId: string | null;
finishedAtMillis: number | null;
};
/** 「生成任务」侧栏里的一条派生/修改任务:原生阶段 + 本地提交记录合并后的呈现单元。 */
export type ResourceCanvasResourceEditTask = {
operationId: string;
assetName: string;
actionLabel: string;
prompt: string;
status: ResourceCanvasAssetGenerationTaskStatus;
phaseDetail: string;
createdAtMillis: number;
finishedAtMillis: number | null;
error: string | null;
assetId: string | null;
/** 从原生账本恢复、本地没有对应提交记录的那一条:耗时不可知,面板不编时间。 */
restored: boolean;
};
/**
* 原生阶段 → 面板文案。
*
* 键逐字对应 Rust `ResourceEditLedgerPhase::as_str()`;措辞沿用恢复入口(「管理未完成编辑」)里
* 已经在用的那一套——同一条操作在两处必须叫同一个名字,否则用户会以为是两件事。未知阶段原样
* 透出阶段名,排障时能一眼看出前端还没跟上的新档,而不是显示一句编造的进度。
*/
export const RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS: Record<
string,
string
> = {
prepared: '原请求已冻结',
accepted: '远程已受理',
'remote-completed': '远程已完成',
'media-downloaded': '结果已下载',
'remote-failed': '远程明确失败',
'reconciliation-required': '需要人工对账',
committed: '已写入项目',
archived: '已归档',
};
/** 本会话内刚提交、原生账本还没读回来的那一段自述文案。 */
export const RESOURCE_CANVAS_RESOURCE_EDIT_SUBMITTED_PHASE =
'已提交,等待远端受理';
/** 提交成功、本地已经拿到结果的收口文案。 */
export const RESOURCE_CANVAS_RESOURCE_EDIT_COMPLETED_PHASE = '已产出新素材';
/**
* `editKind` → 入口文案。
*
* 与恢复入口(`list_pending_local_project_resource_edits` 那张列表)共用同一个映射:同一条操作
* 在两处显示的入口名必须一致,否则用户会以为它们是两件事。
*/
export function pendingResourceEditKindLabel({
editKind,
backgroundMode,
screenColor,
}: Pick<
PendingLocalProjectResourceEdit,
'editKind' | 'backgroundMode' | 'screenColor'
>) {
if (editKind === 'image') return '图片编辑';
if (editKind === 'background-removal') {
if (backgroundMode === 'complex') return '图片抠图 · 复杂背景';
if (backgroundMode === 'flat') {
const color = screenColor === 'auto' ? '自动背景色' : screenColor;
return `图片抠图 · 平面背景${color ? ` · ${color}` : ''}`;
}
return '图片抠图';
}
if (editKind === 'text') return '文本编辑';
if (editKind === 'agent-result') return '智能体结果编辑';
if (editKind === 'image-reference') return '快速编辑';
if (editKind === 'character-animation') return '生成动画';
if (editKind === 'sound-effect') return '生成音效';
if (editKind === 'background-music') return '生成背景音乐';
if (editKind === 'video') return '生成视频';
return '资源编辑';
}
/**
* 原生阶段 → 面板状态。
*
* `prepared` 是「账本已建、远端请求还没发出」的本地排队档,映射成 `queued` 让面板把它和
* `running` 一起放进「排队/生成中」分栏;两个真失败档(远端失败、需要人工对账)映射成
* `failed`,用户才有可点的定位与可见的失败原因。
*/
export function resourceCanvasResourceEditTaskStatusFromPhase(
phase: string,
): ResourceCanvasAssetGenerationTaskStatus {
switch (phase) {
case 'prepared':
return 'queued';
case 'remote-failed':
case 'reconciliation-required':
return 'failed';
case 'committed':
case 'archived':
return 'completed';
default:
return 'running';
}
}
/** 未知阶段不编文案:原样显示阶段名,排障时能一眼看出前端还没跟上的档。 */
export function resourceCanvasResourceEditPhaseLabel(phase: string): string {
return RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS[phase] ?? phase;
}
export function resourceCanvasResourceEditTaskIsTerminal(
task: Pick<ResourceCanvasResourceEditTask, 'status'>,
): boolean {
return task.status === 'completed' || task.status === 'failed';
}
export function resourceCanvasResourceEditSubmissionIsLive(
submission: Pick<ResourceCanvasResourceEditSubmission, 'status'>,
): boolean {
return submission.status === 'running';
}
/**
* 「这张资源上还有一笔没结束的提交」。
*
* 快速编辑(顺带把该资源的草稿从「未完成编辑」里让位)与生成动画共用同一条判据:提交即关面板
* 之后,「重开面板再提交」是唯一的重试路径,不拦就会出现两条并发提交——提示词没变时第二次会卡在
* 原生 per-operation 锁上、最后以阶段不可恢复报错;改了提示词则直接多出一笔付费生成。
*/
export function resourceCanvasResourceEditSubmissionBlocksResource(
submissions: readonly ResourceCanvasResourceEditSubmission[],
resourcePath: string,
): boolean {
return submissions.some(
(submission) =>
submission.resourcePath === resourcePath &&
resourceCanvasResourceEditSubmissionIsLive(submission),
);
}
/**
* 记一笔刚发生的提交。
*
* 按 `operationId` 覆盖写入:同一笔重试(身份复用)不该在侧栏里堆出第二条;换了身份则是新的一笔,
* 两条并存本来就是用户看到的真相。
*/
export function beginResourceCanvasResourceEditSubmission(
submissions: readonly ResourceCanvasResourceEditSubmission[],
input: Omit<
ResourceCanvasResourceEditSubmission,
'status' | 'error' | 'assetId' | 'finishedAtMillis'
>,
): ResourceCanvasResourceEditSubmission[] {
const next: ResourceCanvasResourceEditSubmission = {
...input,
status: 'running',
error: null,
assetId: null,
finishedAtMillis: null,
};
return [
...submissions.filter(
(submission) => submission.operationId !== input.operationId,
),
next,
];
}
/** 收口一笔提交:成功给产物 id,失败给原因;两者都写结束时间,侧栏据此停表。 */
export function settleResourceCanvasResourceEditSubmission(
submissions: readonly ResourceCanvasResourceEditSubmission[],
operationId: string,
result:
| { status: 'completed'; assetId: string | null; finishedAtMillis: number }
| { status: 'failed'; error: string; finishedAtMillis: number },
): ResourceCanvasResourceEditSubmission[] {
return submissions.map((submission) =>
submission.operationId === operationId
? result.status === 'completed'
? {
...submission,
status: 'completed',
assetId: result.assetId,
error: null,
finishedAtMillis: result.finishedAtMillis,
}
: {
...submission,
status: 'failed',
assetId: null,
error: result.error,
finishedAtMillis: result.finishedAtMillis,
}
: submission,
);
}
function resourceCanvasResourceEditTaskFromPendingEdit(
edit: PendingLocalProjectResourceEdit,
): ResourceCanvasResourceEditTask {
const status = resourceCanvasResourceEditTaskStatusFromPhase(edit.phase);
return {
operationId: edit.operationId,
assetName: edit.assetName,
actionLabel: pendingResourceEditKindLabel(edit),
prompt: '',
status,
phaseDetail: resourceCanvasResourceEditPhaseLabel(edit.phase),
createdAtMillis: edit.createdAt,
finishedAtMillis: null,
error:
status === 'failed'
? `原生账本停留在「${resourceCanvasResourceEditPhaseLabel(edit.phase)}」阶段`
: null,
assetId: null,
restored: true,
};
}
function resourceCanvasResourceEditTaskFromSubmission(
submission: ResourceCanvasResourceEditSubmission,
pending: PendingLocalProjectResourceEdit | undefined,
): ResourceCanvasResourceEditTask {
const live = resourceCanvasResourceEditSubmissionIsLive(submission);
return {
operationId: submission.operationId,
assetName: submission.assetName,
actionLabel: submission.actionLabel,
prompt: submission.prompt,
status: live
? pending
? resourceCanvasResourceEditTaskStatusFromPhase(pending.phase)
: 'running'
: submission.status,
// 在途期间以原生阶段为准(它才知道远端走到了哪一步);收口后由本地说结果。
phaseDetail: live
? pending
? resourceCanvasResourceEditPhaseLabel(pending.phase)
: RESOURCE_CANVAS_RESOURCE_EDIT_SUBMITTED_PHASE
: submission.status === 'failed'
? submission.error || '资源修改失败'
: RESOURCE_CANVAS_RESOURCE_EDIT_COMPLETED_PHASE,
createdAtMillis: submission.createdAtMillis,
finishedAtMillis: submission.finishedAtMillis,
error: live ? null : submission.error,
assetId: submission.assetId,
restored: false,
};
}
/**
* 原生待办账本 + 本地提交记录 → 侧栏里的派生/修改任务。
*
* 两个方向都要覆盖,与图片类生成任务的合并口径一致:本地提交按 `operationId` 用原生阶段刷新,
* 原生有、本地没有的记录(重开项目、别处提交、本会话已剪掉的历史)恢复成一条;本地已收口、
* 原生已经查不到的那几笔仍留在列表里——用户刚看到的结果不该因为账本收口而从面板上消失。
*/
export function resourceCanvasResourceEditTasks({
submissions,
pendingEdits,
}: {
submissions: readonly ResourceCanvasResourceEditSubmission[];
pendingEdits: readonly PendingLocalProjectResourceEdit[] | null | undefined;
}): ResourceCanvasResourceEditTask[] {
const safePending = Array.isArray(pendingEdits) ? pendingEdits : [];
const pendingByOperationId = new Map(
safePending.map((edit) => [edit.operationId, edit]),
);
const local = submissions.map((submission) =>
resourceCanvasResourceEditTaskFromSubmission(
submission,
pendingByOperationId.get(submission.operationId),
),
);
const knownOperationIds = new Set(
submissions.map((submission) => submission.operationId),
);
const restored = safePending
.filter((edit) => !knownOperationIds.has(edit.operationId))
.map(resourceCanvasResourceEditTaskFromPendingEdit);
return sortResourceCanvasResourceEditTasks([...local, ...restored]);
}
/** 面板展示顺序:新提交的在上。 */
export function sortResourceCanvasResourceEditTasks(
tasks: readonly ResourceCanvasResourceEditTask[],
): ResourceCanvasResourceEditTask[] {
return [...tasks].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
left.operationId.localeCompare(right.operationId),
);
}
/**
* 派生/修改任务的已耗时。
*
* 从原生账本恢复的收口记录没有结束时间(Rust 只给 `createdAt`),这里返回 `null`,由面板整格
* 不渲染——宁可不显示,也不拿创建时间冒充耗时。
*/
export function resourceCanvasResourceEditTaskElapsedMillis(
task: Pick<
ResourceCanvasResourceEditTask,
'createdAtMillis' | 'finishedAtMillis' | 'restored'
>,
nowMillis: number,
): number | null {
if (task.restored && task.finishedAtMillis === null) {
return null;
}
return Math.max(
0,
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
);
}
/** 与图片类生成任务共用一套耗时文案(`formatElapsedDuration` 的唯一入口)。 */
export function resourceCanvasResourceEditElapsedLabel(
elapsedMillis: number | null,
): string {
if (elapsedMillis === null) {
return '—';
}
return formatElapsedDuration(elapsedMillis) ?? '—';
}
+38 -1
View File
@@ -9546,6 +9546,26 @@ iframe.preview-frame {
box-shadow: 0 24px 64px rgb(62 37 27 / 24%);
}
/*
* 画布卡片基类 token
*
* 取值**照抄网页端美术画布的面板**`src/index.css` `.image-canvas-editor__generation-composer`
* 一族占位卡生成浮层 / 弹窗资源信息浮层从此共用同一组圆角 / 描边 / 底色 / 投影各卡
* 自己的规则只表达差异占位卡虚线描边浮层的定位与宽度信息浮层的窄宽
*
* 两块从网页端整站表逐条照抄下来的浮层快速编辑 / 生成动画本身不在这里改它们的取值由
* `tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx` 逐条比对改了会直接把守卫打红
* 它们本来就是这套取值的来源
*/
.game-project-workbench {
--game-canvas-card-radius: 1.1rem;
--game-canvas-card-radius-inner: 0.85rem;
--game-canvas-card-border: rgba(148, 163, 184, 0.36);
--game-canvas-card-fill: rgba(255, 255, 255, 0.96);
--game-canvas-card-shadow: 0 22px 48px rgba(15, 23, 42, 0.22);
--game-canvas-card-padding: 0.76rem;
}
.game-approval-dialog > header {
display: flex;
align-items: flex-start;
@@ -9590,7 +9610,7 @@ iframe.preview-frame {
background: #fff;
}
.game-resource-recovery-item > div {
.game-resource-recovery-item > div:first-child {
display: grid;
min-width: 0;
gap: 4px;
@@ -9606,7 +9626,18 @@ iframe.preview-frame {
color: #9a7d70;
}
/*
* 一行里的多枚动作收在一个容器里条目是 `space-between` flex动作不收拢就会被
* 摊到中间和原生条目右对齐的单枚动作对不齐移动端尤其明显
*/
.game-resource-recovery-actions {
display: flex;
flex: 0 0 auto;
gap: 8px;
}
.game-resource-recovery-item > button,
.game-resource-recovery-actions > button,
.game-resource-recovery-error > button {
min-height: 34px;
padding: 0 13px;
@@ -9618,6 +9649,7 @@ iframe.preview-frame {
}
.game-resource-recovery-item > button:disabled,
.game-resource-recovery-actions > button:disabled,
.game-resource-recovery-error > button:disabled {
cursor: wait;
opacity: 0.56;
@@ -9652,9 +9684,14 @@ iframe.preview-frame {
}
.game-resource-recovery-item > button,
.game-resource-recovery-actions,
.game-resource-recovery-error > button {
width: 100%;
}
.game-resource-recovery-actions {
flex-direction: column;
}
}
.game-approval-dialog > header button {
File diff suppressed because it is too large Load Diff
@@ -208,8 +208,122 @@ export function resourceBaseName(
return resource.label.replace(/\.[^.]+$/u, '').trim();
}
/**
* 派生资源名上限:与 Rust `normalize_resource_edit_name`
* `src-tauri/src/project/resource_editor.rs:802-808`)逐值同口径。
*/
export const RESOURCE_EDIT_NAME_MAX_LENGTH = 120;
/**
* 名称不合法时给用户看的那一句:与 Rust 的返回文案逐字一致(同文件 :805)。
*
* 前端只做镜像、不另造一套中文语义:提交前拦住和提交后失败看到的是同一句话,
* 用户不必靠比对两边措辞去猜这是不是同一个问题。
*/
export const RESOURCE_EDIT_NAME_INVALID_NOTICE =
'派生资源名称必须在 1..=120 字符内且不能包含控制字符';
/**
* 名称检查结果:只有 `error === null` 才允许进入生成请求。
*
* 不合法时 `name` 原样保留(已 trim),**绝不返回截断名**:超长名截到 120 会让两个不同的
* 长源名塌成同一个名字(同前缀的两张图就是这种情况),那是把一次可见失败换成一次静默重名。
*/
export type ResourceEditNameCheck = {
name: string;
error: string | null;
};
/**
* 按 Unicode 码点算长度:Rust 侧是 `value.chars().count()`(同文件 :804),
* 不是 JS 的 UTF-16 `.length`——`'😀'.repeat(120)` 的 `.length` 是 240,码点数才是 120。
* 用 `.length` 会把 Rust 明确接受的名字在前端误判成非法。
*/
function resourceNameLength(value: string) {
return [...value].length;
}
/**
* Rust `char::is_control()` 的口径:Unicode 通用类别 `Cc`,即 C0U+0000U+001F)、
* DELU+007F)与 C1U+0080U+009F)。
*
* 用码点比较而不是控制字符正则:`Cc` 之外(例如 `Cf` 格式符)Rust 是放行的,前端也不额外
* 收窄——这份实现的职责是镜像,不是借机加一条客户端自有规则。
*/
function isResourceNameControlCodePoint(codePoint: number) {
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
}
function resourceNameHasControlCharacter(value: string) {
for (const character of value) {
if (isResourceNameControlCodePoint(character.codePointAt(0) ?? 0)) {
return true;
}
}
return false;
}
/**
* 提交前按 Rust 的同一条规则检查派生资源名:先 `trim`,再要求非空、码点数 1..=120,
* 且不含控制字符。首尾的换行 / 制表符会被 `trim` 掉,因此不算违规——与 Rust 逐条一致。
*
* 调用方拿到 `error` 非空时必须在发请求**之前**停下并把它显示给用户(不合法时 `name`
* 只是 trim 后的原样名,不能拿它去提交)。
*/
export function resolveResourceEditNameCheck(
name: string,
): ResourceEditNameCheck {
const trimmed = name.trim();
if (
trimmed.length === 0 ||
resourceNameLength(trimmed) > RESOURCE_EDIT_NAME_MAX_LENGTH ||
resourceNameHasControlCharacter(trimmed)
) {
return { name: trimmed, error: RESOURCE_EDIT_NAME_INVALID_NOTICE };
}
return { name: trimmed, error: null };
}
/**
* 现役两种派生资源名后缀:快速编辑产出「-编辑版」,角色动画产出「-角色动画」。
*
* 后缀表是唯一来源,默认名与提交前检查共用它,避免「-编辑版」与「-角色动画」两条路径
* 再次各自演化出不同的拼接口径。
*/
export const DERIVED_RESOURCE_NAME_SUFFIXES = {
edit: '-编辑版',
'character-animation': '-角色动画',
} as const;
export type DerivedResourceNameKind =
keyof typeof DERIVED_RESOURCE_NAME_SUFFIXES;
/**
* 派生资源名的默认值 + 校验:去扩展名的基名(空则兜底「资源」)拼上对应后缀再检查。
*
* 后缀本身只有 4~5 字,所以超限几乎总是源名太长(例如 Agent 回执的 label 直接取任务标题)
* 或源名里带控制字符(任务标题里的换行就是)。
*/
export function resolveDerivedResourceNameCheck(
resource: ProjectResource,
kind: DerivedResourceNameKind,
): ResourceEditNameCheck {
const base = resourceBaseName(resource) || '资源';
return resolveResourceEditNameCheck(
`${base}${DERIVED_RESOURCE_NAME_SUFFIXES[kind]}`,
);
}
/**
* 快速编辑派生资源的默认名称。
*
* 只负责拼名字,不校验也不截断:名字是否能在 Rust 侧通过由
* `resolveDerivedResourceNameCheck(resource, 'edit')` 回答,调用方必须在提交前用它拦下
* 不可用的名字。这里保持返回不合法原名(而不是悄悄截到 120),是为了让上层的检查
* 拿到真实长度并给出提示。
*/
export function defaultDerivedResourceName(resource: ProjectResource) {
return `${resourceBaseName(resource) || '资源'}-编辑版`;
return resolveDerivedResourceNameCheck(resource, 'edit').name;
}
/**
@@ -217,11 +331,14 @@ export function defaultDerivedResourceName(resource: ProjectResource) {
*
* 与 `defaultDerivedResourceName` 同一条口径(去扩展名 + 兜底名),只换后缀:动作序列帧
* 不是"编辑版",用「-角色动画」让新素材在资源卡上一眼认出来源与产出类型。
*
* 同样只是默认名,提交前必须走
* `resolveDerivedResourceNameCheck(resource, 'character-animation')`。
*/
export function defaultCharacterAnimationResourceName(
resource: ProjectResource,
) {
return `${resourceBaseName(resource) || '资源'}-角色动画`;
return resolveDerivedResourceNameCheck(resource, 'character-animation').name;
}
/**
@@ -1,13 +1,12 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { AgentMessageContent } from '../../../packages/shared/src/components/AgentMessageContent';
import { ChatMarkdownMessage } from '../src/components/ChatMarkdownMessage';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
@@ -15,14 +14,11 @@ import {
} from './styleCascade';
const sharedCss = readFileSync(
resolve(
process.cwd(),
'packages/shared/src/components/AgentMessageContent.css',
),
repoPath('packages/shared/src/components/AgentMessageContent.css'),
'utf8',
);
const appCss = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const base = '.agent-message-content[data-agent-content]';
@@ -1,6 +1,5 @@
/** @vitest-environment jsdom */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
act,
@@ -28,6 +27,7 @@ import {
ResourceDependencyOverlay,
type ResourceDependencyOverlayHandle,
} from '../src/view/project-development/ResourceDependencyOverlay';
import { repoPath } from './repoPath';
function position(
resourceId: string,
@@ -1030,7 +1030,7 @@ describe('ResourceDependencyOverlay', () => {
it('uses a persistent orange with at least 3:1 canvas contrast', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
@@ -25,6 +25,7 @@ import { normalizeProjectResourceGraph } from '../../src/view/project-developmen
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel';
import { projectResourcesFromReadModels } from '../../src/view/project-development/resourceProjectionModel';
import { repoPath } from '../repoPath';
import {
generationPromptText,
typeGenerationPrompt,
@@ -60,7 +61,6 @@ import {
render,
renderAppAt,
renderLauncherProjectsAt,
resolve,
screen,
setComposerText,
submitChat,
@@ -1455,12 +1455,11 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps the resource filter panel above the titlebar band and anchored to the dock', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const tsxSource = readFileSync(
resolve(
process.cwd(),
repoPath(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
@@ -1538,7 +1537,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps the resource overview grid off fit-content so its columns stay responsive', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
// 先剥掉 CSS 注释再取声明体:这条修复的注释里就写着 `width: 100%` 等字样,
@@ -1668,7 +1667,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps the shared selection overlay colours resolvable from the scene root', () => {
const sharedStyles = readFileSync(
resolve(process.cwd(), 'packages/image-canvas-react/src/styles.css'),
repoPath('packages/image-canvas-react/src/styles.css'),
'utf8',
);
// 消费端 `border/background: var(...)` 没有 fallbacktoken 缺失或被写透明,框选就没有颜色。
@@ -1896,12 +1895,11 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps the overview-return button out of the floating notice layer hit region', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const tsxSource = readFileSync(
resolve(
process.cwd(),
repoPath(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
@@ -3479,7 +3477,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('资源卡 chrome 只保留选中按钮与当前版本边框', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
/*
@@ -3573,7 +3571,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('资源卡的 @ 引用入口只留在选中工具条里', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
// 卡片右上角那个圆钮连同它撑出来的 44×44 热区一起退役:卡片本体只剩
@@ -3581,8 +3579,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).not.toContain('.game-resource-card-reference');
const source = readFileSync(
resolve(
process.cwd(),
repoPath(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
@@ -5837,7 +5834,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps the landscape workbench edge-to-edge with internal chat scrolling', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
@@ -5993,12 +5990,11 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps the wallet entry available when the workbench opens the UI editor', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const projectDevelopmentSource = readFileSync(
resolve(
process.cwd(),
repoPath(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
@@ -6307,7 +6303,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
@@ -6318,7 +6314,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps the chat composer an inset block inside the conversation dialog', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
// 对话框就是消息列表那只铺满会话区的盒子;输入区是它内部的一块,不再是贴在它下边、
@@ -6367,7 +6363,7 @@ export function registerProjectWorkbenchFoundationTests() {
it('keeps workbench chat bubbles aligned without shrinking process cards', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const messageListRule =
@@ -7079,7 +7075,7 @@ export function registerProjectSupervisorSurfaceTests() {
it('anchors the model dropdown to its own trigger instead of the composer box', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
@@ -7139,7 +7135,7 @@ export function registerProjectSupervisorSurfaceTests() {
it('bounds the model dropdown height so a long catalog cannot cover the composer', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const menu = styleRuleBody(styles, '\\.conversation-model-menu');
@@ -11639,7 +11635,7 @@ export function registerProjectAgentStatusTests() {
).not.toBeNull();
// 宿主几何与栏目卡共用同一条规则,视觉上不是另写一套。
const previewStyles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const previewHostRule = styleRuleBody(
@@ -11747,7 +11743,7 @@ export function registerProjectAgentStatusTests() {
// 视觉不是另写一套:那一页没有任何专属选择器,卡与宿主都只用共享规则
// (声明级断言——它验的是"没有平行样式",验不到布局本身)。
const allPageStyles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(allPageStyles).not.toMatch(/\.game-resource-all-/);
@@ -13396,12 +13392,11 @@ export function registerProjectAgentStatusTests() {
it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const chromeStyles = readFileSync(
resolve(
process.cwd(),
repoPath(
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css',
),
'utf8',
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { APP_VERSION } from '../../src/app/appMetadata';
import { repoPath } from '../repoPath';
import {
act,
createGameCreationAppManifest,
@@ -23,7 +23,7 @@ import {
export function registerAgentStatusDerivationTests() {
it('keeps fixed overlays below the in-page window title bar', () => {
const styles = fs.readFileSync(
path.join(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toContain('--window-chrome-height: 50px;');
@@ -1,22 +1,17 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const STYLES_PATH = resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/styles.css',
);
const VIEW_PATH = resolve(
process.cwd(),
const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
const VIEW_PATH = repoPath(
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx',
);
@@ -291,6 +291,22 @@ describe('聊天输入区 AI 润色与发送前提醒', () => {
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
});
test('回包与原文相同时聊天输入区也给提示,且不冒出「恢复原文」', async () => {
const user = userEvent.setup();
installPolishingInvoke('原本的需求');
renderComposer({ initialText: '原本的需求' });
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
// 聊天输入区与资源侧共用 `usePromptPolish`:修好状态机还不够,共享输入区必须把
// notice 渲染出来,否则这三个宿主里「点了没反应」看起来还是按钮坏了。
expect(
await screen.findByText('AI 润色结果与原文相同,未做修改'),
).not.toBeNull();
expect(await composerText()).toBe('原本的需求');
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
});
test('holds a long prompt behind the reminder panel and sends the original on demand', async () => {
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
@@ -7,6 +7,7 @@ import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { ImageCanvasProjectAssetPickerDialog } from '../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
import { REPO_ROOT } from './repoPath';
/**
* 「选择替换素材」弹窗在 AGC 的面板底色契约。
@@ -25,7 +26,6 @@ import { ImageCanvasProjectAssetPickerDialog } from '../../../src/components/ima
* 这是**弱验证**(声明级,不是真机观感)。真机判据见文件末尾注释。
*/
const REPO_ROOT = process.cwd();
const AGC_ROOT = resolve(REPO_ROOT, 'apps/ai-game-creator-shell');
const PLATFORM_MODAL_SHELL = '.platform-modal-shell';
@@ -336,9 +336,14 @@ describe('「选择替换素材」弹窗在 AGC 的面板底色', () => {
});
it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
const hostRules = HOST_STYLE_SHEETS.filter(existsSync).flatMap((sheet) =>
readRules(absoluteSheetPath(sheet)),
);
/*
这里必须**先转绝对路径再判断存在**`filter(existsSync)` 拿到的是仓库相对路径,`existsSync`
按 `process.cwd()` 解析——从 `apps/ai-game-creator-shell` 跑时四张表全被判成不存在,
断言退化成「谁都没定义」的假绿/假红(本次就是从这条假红里揪出来的)。
*/
const hostRules = HOST_STYLE_SHEETS.filter((sheet) =>
existsSync(absoluteSheetPath(sheet)),
).flatMap((sheet) => readRules(absoluteSheetPath(sheet)));
for (const className of [
PLATFORM_MODAL_SHELL,
PLATFORM_MODAL_BACKDROP,
@@ -15,6 +15,7 @@ import {
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import { RESOURCE_EDIT_NAME_INVALID_NOTICE } from '../src/view/project-development/resourceEditModel';
import {
act,
createGameCreationAppManifest,
@@ -259,9 +260,17 @@ function ClassificationWorkbench() {
function DerivedWorkbench({
includeArt = false,
includeCharacter = false,
artName = 'source-art.png',
characterName = 'hero.png',
}: {
includeArt?: boolean;
includeCharacter?: boolean;
/**
* 源文件名可替换:派生资源名按源资源 label 拼出,用长名夹具复现
* `normalize_resource_edit_name` 的 120 码点门禁(AGC-025)。
*/
artName?: string;
characterName?: string;
}) {
const initial = createGameCreationAppManifest(
'live-canvas-project',
@@ -281,7 +290,7 @@ function DerivedWorkbench({
id: 'source-art',
kind: 'art-image',
mediaType: 'image/png',
localPath: 'assets/source-art.png',
localPath: `assets/${artName}`,
source: { kind: 'generated', resourceId: 'art-resource' },
},
]
@@ -293,7 +302,7 @@ function DerivedWorkbench({
kind: 'character',
category: 'character' as const,
mediaType: 'image/png',
localPath: 'assets/hero.png',
localPath: `assets/${characterName}`,
source: { kind: 'generated', resourceId: 'hero-resource' },
},
]
@@ -832,14 +841,33 @@ describe('project resource live canvas integration', () => {
'把角色头发设定改为红色',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
expect(await screen.findByRole('alert')).not.toBeNull();
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
// 提交即关面板:失败也不把面板拉回来,重试只能重开面板(任务与失败都在「生成任务」侧栏里)。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
);
fireEvent.click(await findResourceSelectButton('source-art.png'));
const retryToolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(
within(retryToolbar).getByRole('button', { name: '快速编辑' }),
);
const retryPanel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
// 草稿回填,提示词没变 → 沿用上一次那笔 operation 身份。
expect(
within(retryPanel).getByLabelText('快速编辑提示词').textContent,
).toContain('把角色头发设定改为红色');
fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId);
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
expect(deriveCalls[0]?.editKind).toBe('image-reference');
expect(deriveCalls[0]?.generationMode).toBe('derive');
// 名字合法时出站 assetName 就是提交前校验过的那个名字(默认名,逐字未改)。
expect(deriveCalls[0]?.assetName).toBe('source-art-编辑版');
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
@@ -891,6 +919,71 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
});
/**
* AGC-025:派生资源名由源资源 label 拼出,Rust `normalize_resource_edit_name`
* `resource_editor.rs:802-808`)对「超 120 码点 / 含控制字符 / trim 后为空」的名字整条
* 拒绝。前端必须在**任何正规化写盘与派生请求之前**用同一句中文回写面板失败态,否则用户得
* 提交一次才知道名字不合法,项目里还可能先多出一条正规化素材。
*/
it('快速编辑:源名超 120 码点时在派生请求之前拦下,并回写面板错误态', async () => {
const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
const { invoke, deriveCalls } = installTauri();
render(<DerivedWorkbench includeArt artName={longName} />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton(longName));
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: '修改' }));
expect((await screen.findByRole('alert')).textContent).toBe(
RESOURCE_EDIT_NAME_INVALID_NOTICE,
);
// 零写盘、零远程请求:派生请求一次没发,正规化也没有被调用。
expect(deriveCalls).toHaveLength(0);
expect(
invoke.mock.calls.some(
([command]) => command === 'normalize_local_project_raster_resource',
),
).toBe(false);
});
it('角色动画:源名超 120 码点时同样在派生请求之前拦下', async () => {
const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
const { invoke, deriveCalls } = installTauri();
render(<DerivedWorkbench includeCharacter characterName={longName} />);
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
fireEvent.click(await findResourceSelectButton(longName));
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: /生成[\d.]+泥点/ }),
);
expect((await screen.findByRole('alert')).textContent).toBe(
RESOURCE_EDIT_NAME_INVALID_NOTICE,
);
expect(deriveCalls).toHaveLength(0);
expect(
invoke.mock.calls.some(
([command]) => command === 'normalize_local_project_raster_resource',
),
).toBe(false);
});
it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => {
const { deriveCalls, polishCalls } = installTauri({
failFirstDerive: true,
@@ -912,13 +1005,28 @@ describe('project resource live canvas integration', () => {
'把角色头发改成红色',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
expect(await screen.findByRole('alert')).not.toBeNull();
// 提交即关面板,重开面板继续改:草稿回填的是原文。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
);
fireEvent.click(await findResourceSelectButton('source-art.png'));
const retryToolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(
within(retryToolbar).getByRole('button', { name: '快速编辑' }),
);
const retryPanel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
// 失败后提示词仍可改:润色一次,回填的是润色结果,而不是原文。
fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' }));
fireEvent.click(
within(retryPanel).getByRole('button', { name: 'AI 润色' }),
);
await waitFor(() => {
expect(
within(panel).getByLabelText('快速编辑提示词').textContent,
within(retryPanel).getByLabelText('快速编辑提示词').textContent,
).toContain('亮红色');
});
expect(polishCalls[0]).toMatchObject({ prompt: '把角色头发改成红色' });
@@ -926,7 +1034,7 @@ describe('project resource live canvas integration', () => {
'图片素材的快速编辑提示词',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
// Rust 的 request_fingerprint 含 prompt:提示词换过就必须换 operationId / 幂等键,
// 否则后端会判「已绑定到不同资源编辑请求」。
@@ -955,17 +1063,32 @@ describe('project resource live canvas integration', () => {
'把角色头发改成红色',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
expect(await screen.findByRole('alert')).not.toBeNull();
// 提交即关面板:重开面板后提示词仍是原文(草稿回填),再改再润色都在这一份草稿上。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
);
fireEvent.click(await findResourceSelectButton('source-art.png'));
const retryToolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(
within(retryToolbar).getByRole('button', { name: '快速编辑' }),
);
const retryPanel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' }));
fireEvent.click(
within(retryPanel).getByRole('button', { name: 'AI 润色' }),
);
expect(
await within(panel).findByText('AI 润色失败,可重试'),
await within(retryPanel).findByText('AI 润色失败,可重试'),
).not.toBeNull();
expect(
within(panel).getByLabelText('快速编辑提示词').textContent,
within(retryPanel).getByLabelText('快速编辑提示词').textContent,
).toContain('把角色头发改成红色');
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
// 提示词没变,身份也不该变:重试仍然命中同一 operation 账本。
expect(deriveCalls[1]?.operationId).toBe(deriveCalls[0]?.operationId);
@@ -0,0 +1,23 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* 仓库根与"按仓库相对路径取文件"的唯一入口。
*
* 读样式表 / 脚本 / 文档的用例原先一律写 `resolve(process.cwd(), 'apps/ai-game-creator-shell/…')`
* 从仓库根跑(CI 的 `npm run test`、根 `vitest.config.ts`)没问题,但只要从
* `apps/ai-game-creator-shell` 目录跑一次,路径就会翻成
* `…/apps/ai-game-creator-shell/apps/ai-game-creator-shell/…` 并整片 ENOENT——看起来像"这些用例本来
* 就红",实际是路径解析跟着 cwd 漂。这里按 `import.meta.url` 反推仓库根,两种跑法结论一致。
*/
export const REPO_ROOT = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
);
/** 传仓库相对路径(如 `apps/ai-game-creator-shell/src/styles.css`)。 */
export function repoPath(...segments: readonly string[]): string {
return resolve(REPO_ROOT, ...segments);
}
@@ -300,6 +300,94 @@ describe('生成面板的参考接线', () => {
});
});
test('点提示词输入区不该自己弹出素材选择框', async () => {
const user = userEvent.setup();
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
render(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '',
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={vi.fn()}
onClose={() => undefined}
/>,
);
await user.click(screen.getByLabelText('生成提示词'));
expect(screen.queryByRole('dialog', { name: '选择素材' })).toBeNull();
// 只有「插入素材引用」这一枚按钮才打开选择器。
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(
await screen.findByRole('dialog', { name: '选择素材' }),
).not.toBeNull();
});
test('紧凑版排布:动作行同时装下润色 / 参考计数 / 两个动作,参数与提示词各占一行', () => {
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
render(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '',
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={vi.fn()}
onClose={() => undefined}
/>,
);
const panel = screen.getByRole('dialog', { name: '生成图片' });
/*
验收现场那张图里面板要滚动:标签各占一行、提示词 6 行、比例与尺寸各占一行、润色与参考
计数又各占一行。这里钉住重排后的形状——动作行一行收口、参数两栏并排、提示词三行,
面板高度因此回落到几何上界以内(滚动条不该再出现)。
*/
const actions = panel.querySelector('.game-resource-generation-actions');
expect(actions).not.toBeNull();
expect(
within(actions as HTMLElement).getByRole('button', { name: 'AI 润色' }),
).not.toBeNull();
expect(
within(actions as HTMLElement).getByText('参考图 0/5'),
).not.toBeNull();
expect(
within(actions as HTMLElement).getByRole('button', { name: '取消' }),
).not.toBeNull();
expect(
within(actions as HTMLElement).getByRole('button', { name: '生成图片' }),
).not.toBeNull();
// 比例与尺寸同处一格(CSS 里排成两栏),不再各占一行。
const dimensions = panel.querySelectorAll(
'.resource-canvas-asset-generation-dimensions',
);
expect(dimensions).toHaveLength(1);
// 两组选择器(比例 5 项、尺寸 3 项)同处这一格,CSS 里排成两栏。
expect(
within(dimensions[0] as HTMLElement).getByRole('button', {
name: '生成图片比例 1:1',
}),
).not.toBeNull();
expect(
within(dimensions[0] as HTMLElement).getByRole('button', {
name: '生成图片尺寸 1K',
}),
).not.toBeNull();
});
test('超限时提交被挡住并给出原因', async () => {
const user = userEvent.setup();
const onSubmit =
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, test } from 'vitest';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
@@ -17,8 +17,7 @@ import {
* jsdom 不加载这个 CSS 文件,所以这里按仓库既有做法(`styleCascade`)直接解析**真实生效的声明**:
* 「状态 tone 映射」「等宽数字」「圆角 / 悬停」「过渡」「reduced-motion 关动效」都用声明钉住。
*/
const SIDEBAR_CSS_PATH = resolve(
process.cwd(),
const SIDEBAR_CSS_PATH = repoPath(
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css',
);
const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8'));

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