润色回写按token原位恢复引用与附件

统一引用与附件的润色扫描口径,token 存活时原位换回真 part

token 被改写时剩余 part 补在末尾,附件不再丢出 canonical content

删除无调用方的 EMPTY_CHAT_COMPOSER_DRAFT

补充润色原位恢复与末尾补位两条回归用例
This commit is contained in:
2026-09-21 21:22:04 +08:00
parent 6981648796
commit 295d4af384
3 changed files with 115 additions and 45 deletions
@@ -269,7 +269,28 @@ export function readResourceReferenceDraft(
type DraftBuildSegment =
| { kind: 'text'; text: string }
| { kind: 'reference'; reference: ChatReference };
| { kind: 'part'; part: DirectCodexUserContentPart };
type DraftBuildCandidate = {
token: string;
part: DirectCodexUserContentPart;
};
/**
* part 在润色文本里的可扫描 token:与 `directCodexContentToPromptText` 出站给润色服务的
* 口径逐字一致——附件是 `@附件名`,资源与 runtime 引用是 `@显示名`Skill 是 `$名称`。
* `input_text` 没有 token,返回 `null`。
*/
function draftScanToken(
part: DirectCodexUserContentPart,
resourceLabels: ReadonlyMap<string, string>,
): string | null {
if (part.type === 'input_text') return null;
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
if (part.type === 'agc_skill_reference') return `$${part.name}`;
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
return `@${resourceLabels.get(part.resourceId) ?? part.resourceId}`;
}
function isDraftMentionBoundary(character: string | undefined) {
return character === undefined || character === '' || /\s/u.test(character);
@@ -294,23 +315,21 @@ function findDraftMentionToken(line: string, token: string, from: number) {
}
/**
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
* 把「润色回包文本 + 待恢复 part」切成编辑区要写的内容。
*
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
* 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
*
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
* 引用不会凭空消失;这只发生在一次明确的初始草稿/润色写入中。
* 文本里已经没有对应 token 的 part(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
* 引用与附件不会凭空消失;这只发生在一次明确的初始草稿/润色写入中。
*/
function buildDraftSegments(
value: string,
references: ChatReference[],
candidates: readonly DraftBuildCandidate[],
): DraftBuildSegment[][] {
const pending = references.map((reference) => ({
reference,
token:
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
const pending = candidates.map((candidate) => ({
...candidate,
used: false,
}));
const lines: DraftBuildSegment[][] = [];
@@ -332,7 +351,7 @@ function buildDraftSegments(
segments.push({ kind: 'text', text: line.slice(cursor, match.index) });
}
match.item.used = true;
segments.push({ kind: 'reference', reference: match.item.reference });
segments.push({ kind: 'part', part: match.item.part });
cursor = match.index + match.item.token.length;
}
if (cursor < line.length) {
@@ -342,12 +361,7 @@ function buildDraftSegments(
}
const orphans = pending
.filter((item) => !item.used)
.map(
(item): DraftBuildSegment => ({
kind: 'reference',
reference: item.reference,
}),
);
.map((item): DraftBuildSegment => ({ kind: 'part', part: item.part }));
if (orphans.length > 0) {
const lastLine = lines.at(-1);
if (!lastLine) return [orphans];
@@ -360,37 +374,46 @@ function buildDraftSegments(
}
/**
* 润色回写仍按文本中的 token 精确恢复资源/Skill/runtime chip;附件没有可安全反解析的
* 稳定 token,因此保留原 attachment part 并放在新文本之后。
* TODO: 提出“润色精确恢复附件原位置”的产品能力后,再把附件位置纳入协议,不猜字符串
* 润色回写:回包文本里还剩哪个 token,就在那个位置换回真的 part——引用与附件同一套扫描
* 口径(`draftScanToken`),所以润色改过的那段文字里如果还留着 `@显示名` / `$名称` /
* `@附件名`,chip 原位复活;整体被改写的那些 part 补在末尾,不丢
*
* TODO: 润色服务整体改写文本、把 `@名称` token 也删掉时,前端无法反推原位置,只能补在末尾。
* 需要“润色精确恢复引用/附件原位置”时,由产品补一个带位置信息的润色协议,不在前端猜字符串。
*/
function applyPolishedTextToRoot(
value: string,
current: DraftProjection,
assetsById: ReadonlyMap<string, GameCreationAppAssetManifestEntry>,
) {
const lines = value.split(/\r?\n/u);
const rebuiltContent: DirectCodexUserContentPart[] = [];
buildDraftSegments(value, current.references).forEach(
(segments, lineIndex) => {
segments.forEach((segment) => {
if (segment.kind === 'text') {
if (segment.text)
rebuiltContent.push({ type: 'input_text', text: segment.text });
return;
}
rebuiltContent.push(chatReferenceToContentPart(segment.reference));
});
if (lineIndex < lines.length - 1) {
rebuiltContent.push({ type: 'input_text', text: '\n' });
}
},
);
rebuiltContent.push(
...current.content.filter(
(part) => part.type === 'agc_attachment_reference',
const resourceLabels = new Map(
current.references.flatMap((reference) =>
reference.type === 'resource'
? [[reference.resourceId, reference.label] as const]
: [],
),
);
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
const candidates = current.content.flatMap((part) => {
const token = draftScanToken(part, resourceLabels);
return token ? [{ token, part }] : [];
});
const lines = value.split(/\r?\n/u);
const rebuiltContent: DirectCodexUserContentPart[] = [];
buildDraftSegments(value, candidates).forEach((segments, lineIndex) => {
segments.forEach((segment) => {
if (segment.kind === 'text') {
if (segment.text)
rebuiltContent.push({ type: 'input_text', text: segment.text });
return;
}
rebuiltContent.push(segment.part);
});
if (lineIndex < lines.length - 1) {
rebuiltContent.push({ type: 'input_text', text: '\n' });
}
});
applyContentToRoot(rebuiltContent, assetsById);
}
@@ -127,10 +127,6 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
);
}
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
content: [],
};
/** canonical content → 可读文本;资源引用按当前 manifest 展开为显示名。 */
export function directCodexContentToPromptText(
content: readonly DirectCodexUserContentPart[],
@@ -346,7 +346,7 @@ describe('ResourceReferenceInput', () => {
expect(draftText(draft)).toBe('@hero\n把这一版改成夜景');
});
test('润色回写保留原附件 part,不把 attachment chip 丢出 canonical content', async () => {
test('润色改写掉 `@附件名` token 时,附件 part 补在末尾而不是丢掉', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const attachment = {
@@ -383,11 +383,62 @@ describe('ResourceReferenceInput', () => {
const content = onChange.mock.calls.at(-1)?.[0].content;
expect(content).toEqual([
chatReferenceToContentPart(reference),
{ type: 'input_text', text: ' 润色后的需求' },
// 末尾补位用的分隔空格与相邻文本在 Lexical 里合并成一个 TextNode。
{ type: 'input_text', text: ' 润色后的需求 ' },
attachment,
]);
});
test('润色回包还留着 `@名称` token 时,引用与附件都在原位换回真 part', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const attachment = {
type: 'agc_attachment_reference' as const,
name: '参考图.png',
mediaType: 'image/png',
size: 12,
localPath: 'assets/reference.png',
status: 'imported',
};
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<>
<button
type="button"
onClick={() =>
composerRef.current?.replaceText('@hero @参考图.png 润色后的需求')
}
>
</button>
<ResourceReferenceInput
ref={composerRef}
initialContent={[
chatReferenceToContentPart(reference),
{ type: 'input_text', text: ' ' },
attachment,
{ type: 'input_text', text: ' 需求' },
]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>
</>,
);
await settleComposer();
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
await settleComposer();
const content = onChange.mock.calls.at(-1)?.[0].content;
expect(content).toEqual([
chatReferenceToContentPart(reference),
{ type: 'input_text', text: ' ' },
attachment,
{ type: 'input_text', text: ' 润色后的需求' },
]);
});
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();