润色回写按token原位恢复引用与附件
统一引用与附件的润色扫描口径,token 存活时原位换回真 part token 被改写时剩余 part 补在末尾,附件不再丢出 canonical content 删除无调用方的 EMPTY_CHAT_COMPOSER_DRAFT 补充润色原位恢复与末尾补位两条回归用例
This commit is contained in:
+62
-39
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user