保留聊天草稿中的附件引用节点

为 agc_attachment_reference 增加 Lexical 附件节点与可移除 chip。

恢复初始 canonical content 时不再静默丢弃附件,并让编辑器读回完整附件 part。
This commit is contained in:
2026-09-18 13:17:31 +08:00
parent faed0ec251
commit d8af0fa882
3 changed files with 161 additions and 1 deletions
@@ -0,0 +1,40 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getNodeByKey, type NodeKey } from 'lexical';
import { Paperclip, X } from 'lucide-react';
import type { DirectCodexUserAttachmentReferencePart } from './generated';
export function AttachmentReferenceChip({
attachment,
nodeKey,
}: {
attachment: DirectCodexUserAttachmentReferencePart;
nodeKey: NodeKey;
}) {
const [editor] = useLexicalComposerContext();
return (
<span
className="resource-reference-chip resource-attachment-chip"
data-attachment-reference="true"
data-attachment-status={attachment.status}
contentEditable={false}
title={`${attachment.name} · ${attachment.mediaType}`}
>
<Paperclip size={12} aria-hidden="true" />
<span className="resource-reference-chip-label">{attachment.name}</span>
<button
type="button"
aria-label={`移除附件 ${attachment.name}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
node?.remove();
});
}}
>
<X size={11} aria-hidden="true" />
</button>
</span>
);
}
@@ -0,0 +1,107 @@
import {
$applyNodeReplacement,
DecoratorNode,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
} from 'lexical';
import type { ReactNode } from 'react';
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
import type { DirectCodexUserAttachmentReferencePart } from './generated';
export type SerializedAttachmentReferenceNode = Spread<
{
attachment: DirectCodexUserAttachmentReferencePart;
type: 'attachment-reference';
version: 1;
},
SerializedLexicalNode
>;
/** 附件在纯文本里的唯一占位字符:一个附件 chip 就当一个字符。 */
const OBJECT_REPLACEMENT = '\uFFFC';
/**
* Inline canonical attachment part rendered inside the Lexical composer.
* The node stores the complete part so read-back preserves the project path and status.
*/
export class AttachmentReferenceNode extends DecoratorNode<ReactNode> {
__attachment: DirectCodexUserAttachmentReferencePart;
static getType() {
return 'attachment-reference';
}
static clone(node: AttachmentReferenceNode) {
return new AttachmentReferenceNode(node.__attachment, node.__key);
}
static importJSON(serializedNode: SerializedAttachmentReferenceNode) {
return $createAttachmentReferenceNode(serializedNode.attachment);
}
constructor(
attachment: DirectCodexUserAttachmentReferencePart,
key?: NodeKey,
) {
super(key);
this.__attachment = attachment;
}
exportJSON(): SerializedAttachmentReferenceNode {
return {
...super.exportJSON(),
attachment: this.__attachment,
type: 'attachment-reference',
version: 1,
};
}
createDOM(_config: EditorConfig) {
return document.createElement('span');
}
updateDOM() {
return false;
}
getTextContent() {
return OBJECT_REPLACEMENT;
}
getTextContentSize() {
return OBJECT_REPLACEMENT.length;
}
isInline() {
return true;
}
isKeyboardSelectable() {
return true;
}
decorate() {
return (
<AttachmentReferenceChip
attachment={this.__attachment}
nodeKey={this.__key}
/>
);
}
}
export function $createAttachmentReferenceNode(
attachment: DirectCodexUserAttachmentReferencePart,
) {
return $applyNodeReplacement(new AttachmentReferenceNode(attachment));
}
export function $isAttachmentReferenceNode(
node: LexicalNode | null | undefined,
): node is AttachmentReferenceNode {
return node instanceof AttachmentReferenceNode;
}
@@ -61,6 +61,11 @@ import {
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import {
$createAttachmentReferenceNode,
$isAttachmentReferenceNode,
AttachmentReferenceNode,
} from './AttachmentReferenceNode';
import {
chatPromptDraftKey,
readChatPromptPolishReminderDisabled,
@@ -199,6 +204,10 @@ function collectDraftParts(
content.push({ type: 'input_text', text: '\n' });
return;
}
if ($isAttachmentReferenceNode(node)) {
content.push({ type: 'agc_attachment_reference', ...node.__attachment });
return;
}
if ($isResourceReferenceNode(node)) {
references.push(node.__reference);
content.push(chatReferenceToContentPart(node.__reference));
@@ -422,6 +431,10 @@ function applyContentToRoot(
});
return;
}
if (part.type === 'agc_attachment_reference') {
paragraph.append($createAttachmentReferenceNode(part));
return;
}
const reference = referenceFromContentPart(part, assetsById);
if (reference) {
paragraph.append($createResourceReferenceNode(reference));
@@ -1442,7 +1455,7 @@ export const ResourceReferenceInput = forwardRef<
return (
<RichTextInput
namespace="agc-resource-reference-input"
nodes={[ResourceReferenceNode]}
nodes={[ResourceReferenceNode, AttachmentReferenceNode]}
containerRef={rootRef}
containerClassName={`resource-reference-input${props.multiline ? '' : ' is-single-line'}`}
disabled={props.disabled}