fbfc7e5e62
- 导出 resourceReferences 的 chatReferenceKey(只认稳定身份、不含显示名的身份键),并加注释说明它为什么不能省 - ReferenceMentionOption 由 super(chatReferenceMentionToken(...)) 改为 super(chatReferenceKey(...)):characters/hero.png 与 enemies/hero.png 都展开成 @hero,拿显示 token 当 key 会让 React 撞 key、复用错 DOM,键盘高亮与选中落到另一条候选上 - referenceSourceProviders 用例补一批同名素材:断言两条候选的显示 token 相同、身份键不同
950 lines
32 KiB
TypeScript
950 lines
32 KiB
TypeScript
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||
import {
|
||
LexicalTypeaheadMenuPlugin,
|
||
MenuOption,
|
||
type MenuRenderFn,
|
||
useBasicTypeaheadTriggerMatch,
|
||
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
|
||
import {
|
||
$createParagraphNode,
|
||
$createTextNode,
|
||
$getRoot,
|
||
$getSelection,
|
||
$isElementNode,
|
||
$isLineBreakNode,
|
||
$isRangeSelection,
|
||
$isTextNode,
|
||
COMMAND_PRIORITY_HIGH,
|
||
type EditorState,
|
||
KEY_ENTER_COMMAND,
|
||
type LexicalNode,
|
||
type TextNode,
|
||
} from 'lexical';
|
||
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||
import {
|
||
forwardRef,
|
||
type ReactNode,
|
||
type Ref,
|
||
type RefObject,
|
||
useCallback,
|
||
useEffect,
|
||
useImperativeHandle,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import RichTextInput from '../../components/RichTextInput';
|
||
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||
import {
|
||
chatPromptDraftKey,
|
||
readChatPromptPolishReminderDisabled,
|
||
shouldRemindChatPromptPolish,
|
||
writeChatPromptPolishReminderDisabled,
|
||
} from './chatPromptPolish';
|
||
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
|
||
import type { ReferenceProvider } from './reference-source/types';
|
||
import {
|
||
$createResourceReferenceNode,
|
||
$isResourceReferenceNode,
|
||
ResourceReferenceNode,
|
||
} from './ResourceReferenceNode';
|
||
import {
|
||
buildContentFromTextTokens,
|
||
type ChatComposerDraft,
|
||
type ChatReference,
|
||
chatReferenceDisplayHint,
|
||
chatReferenceKey,
|
||
chatReferenceMentionToken,
|
||
chatReferenceToContentPart,
|
||
dedupeChatReferences,
|
||
joinMentionText,
|
||
} from './resourceReferences';
|
||
import { usePromptPolish } from './usePromptPolish';
|
||
|
||
type ResourceReferenceInputProps = {
|
||
onChange?: (draft: ChatComposerDraft) => void;
|
||
onEditorStateChange?: (editorState: EditorState) => void;
|
||
initialContent?: DirectCodexUserContentPart[];
|
||
/**
|
||
* 宿主注入的引用来源,**按数组顺序取第一个非空回答**。
|
||
*
|
||
* 输入区不认识任何引用种类:候选菜单按各 provider 的触发符动态派生,part 的身份解析、
|
||
* 改名刷新与正文文本形态也都问它们。没注入就没有这类引用——只注入资源 provider 就只有 `@`。
|
||
*/
|
||
providers: readonly ReferenceProvider[];
|
||
/**
|
||
* 项目路径只给输入区自己的润色链路用(润色服务按项目上下文改写提示词)。
|
||
* 引用候选与素材选择器都不再从这里拿数据。
|
||
*/
|
||
projectPath: string;
|
||
disabled?: boolean;
|
||
placeholder?: string;
|
||
ariaLabel: string;
|
||
multiline?: boolean;
|
||
rows?: number;
|
||
/**
|
||
* 内置「AI 润色 / 恢复原文」动作是否渲染,默认 `true`。
|
||
*
|
||
* 聊天输入区用内置那一份(不带资源编辑场景上下文);资源侧快速编辑由宿主自己的
|
||
* `ResourcePromptPolishSlot` 承担润色(带场景上下文与长度上限),传 `false`
|
||
* 避免同一个面板里出现两个润色入口。
|
||
*/
|
||
showPolishAction?: boolean;
|
||
/**
|
||
* 宿主的外部浮层(素材选择器)打开时为 `true`:Enter 让位给下层默认行为,不提交表单。
|
||
* 与候选菜单打开时的处置同一口径。
|
||
*/
|
||
submitSuppressed?: boolean;
|
||
/** 输入区操作排(编辑器右侧那一列)里宿主注入的内容,例如素材选择器的 `@` 触发钮。 */
|
||
inputActions?: ReactNode;
|
||
inputRef?: RefObject<HTMLDivElement | null>;
|
||
/** 输入区容器元素:宿主定位自己的浮层时用它当锚点。 */
|
||
containerRef?: RefObject<HTMLDivElement | null>;
|
||
};
|
||
|
||
export type ResourceReferenceInputHandle = {
|
||
insertReferences: (references: ChatReference[]) => void;
|
||
insertText: (text: string) => void;
|
||
focus: () => void;
|
||
clear: () => void;
|
||
replaceText: (text: string) => void;
|
||
/** 直接读取 Lexical 当前状态,不在宿主组件复制一份编辑器 state。 */
|
||
getDraft: () => ChatComposerDraft;
|
||
};
|
||
|
||
class ReferenceMentionOption extends MenuOption {
|
||
reference: ChatReference;
|
||
label: string;
|
||
hint: string;
|
||
|
||
constructor(reference: ChatReference) {
|
||
// key 必须是引用的唯一身份:显示 token(`@显示名`)不唯一,同名素材会撞 key 并让
|
||
// React 复用错 DOM、键盘高亮落到别的候选上。显示 token 只留给 `label`。
|
||
super(chatReferenceKey(reference));
|
||
this.reference = reference;
|
||
this.label = chatReferenceMentionToken(reference);
|
||
this.hint = chatReferenceDisplayHint(reference);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 编辑器节点 → canonical content part,**原样透传**:段落分隔(root 子节点之间补的
|
||
* `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。
|
||
* 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust
|
||
* `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。
|
||
*/
|
||
function collectDraftParts(
|
||
node: LexicalNode,
|
||
references: ChatReference[],
|
||
content: DirectCodexUserContentPart[],
|
||
) {
|
||
if ($isTextNode(node)) {
|
||
const text = node.getTextContent();
|
||
// Lexical 不会留下空 TextNode;这只防「空串 part」落进 app-server 输入。
|
||
if (text) {
|
||
content.push({ type: 'input_text', text });
|
||
}
|
||
return;
|
||
}
|
||
if ($isLineBreakNode(node)) {
|
||
content.push({ type: 'input_text', text: '\n' });
|
||
return;
|
||
}
|
||
if ($isResourceReferenceNode(node)) {
|
||
references.push(node.__reference);
|
||
content.push(chatReferenceToContentPart(node.__reference));
|
||
return;
|
||
}
|
||
if ($isElementNode(node)) {
|
||
node.getChildren().forEach((child, index) => {
|
||
if (index > 0 && node.getType() === 'root') {
|
||
content.push({ type: 'input_text', text: '\n' });
|
||
}
|
||
collectDraftParts(child, references, content);
|
||
});
|
||
}
|
||
}
|
||
|
||
type DraftProjection = {
|
||
references: ChatReference[];
|
||
content: DirectCodexUserContentPart[];
|
||
};
|
||
|
||
/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */
|
||
function readDraftProjectionFromNodes(): DraftProjection {
|
||
const references: ChatReference[] = [];
|
||
const content: DirectCodexUserContentPart[] = [];
|
||
collectDraftParts($getRoot(), references, content);
|
||
return {
|
||
references: dedupeChatReferences(references),
|
||
content,
|
||
};
|
||
}
|
||
|
||
/** 按编辑器自己的口径读 canonical 草稿;只能在 Lexical 读/更新上下文里调用。 */
|
||
function readDraftFromNodes(): ChatComposerDraft {
|
||
const projection = readDraftProjectionFromNodes();
|
||
return { content: projection.content };
|
||
}
|
||
|
||
// The pure projection is exported for submit-time reads and focused tests.
|
||
// eslint-disable-next-line react-refresh/only-export-components
|
||
export function readResourceReferenceDraft(
|
||
editorState: EditorState | null,
|
||
): ChatComposerDraft {
|
||
if (!editorState) {
|
||
return { content: [] };
|
||
}
|
||
return editorState.read(readDraftFromNodes);
|
||
}
|
||
|
||
/** 第一个认得这个 part 的 provider 给出的引用;都不认得时返回 `null`。 */
|
||
function referenceFromPart(
|
||
providers: readonly ReferenceProvider[],
|
||
part: DirectCodexUserContentPart,
|
||
): ChatReference | null {
|
||
for (const provider of providers) {
|
||
const reference = provider.toReference(part);
|
||
if (reference) return reference;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 第一个认得这个 part 的 provider 给出的正文 token;都不认得(例如文本 part)时返回 `null`。 */
|
||
function mentionTokenFromPart(
|
||
providers: readonly ReferenceProvider[],
|
||
part: DirectCodexUserContentPart,
|
||
): string | null {
|
||
for (const provider of providers) {
|
||
const token = provider.mentionToken(part);
|
||
if (token) return token;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。
|
||
*
|
||
* 与出站(发给润色服务 / 队列 chip / agent)的口径是同一份 `joinMentionText`:
|
||
* 所以润色回包还能按同一批 token 原位换回真 part,不需要第二套文本口径。
|
||
*/
|
||
function promptTextFromContent(
|
||
providers: readonly ReferenceProvider[],
|
||
content: readonly DirectCodexUserContentPart[],
|
||
): string {
|
||
return joinMentionText(content, (part) =>
|
||
mentionTokenFromPart(providers, part),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 润色回写:回包文本里还剩哪个 token,就在那个位置换回真的 part——引用与附件同一套扫描
|
||
* 口径(provider 的 `mentionToken`),所以润色改过的那段文字里如果还留着 `@显示名` /
|
||
* `$名称` / `@附件名`,chip 原位复活;整体被改写的那些 part 补在末尾,不丢。
|
||
*
|
||
* TODO: 润色服务整体改写文本、把 `@名称` token 也删掉时,前端无法反推原位置,只能补在末尾。
|
||
* 需要“润色精确恢复引用/附件原位置”时,由产品补一个带位置信息的润色协议,不在前端猜字符串。
|
||
*/
|
||
function applyPolishedTextToRoot(
|
||
value: string,
|
||
current: DraftProjection,
|
||
providers: readonly ReferenceProvider[],
|
||
) {
|
||
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
|
||
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
|
||
const candidates = current.content.flatMap((part) => {
|
||
const token = mentionTokenFromPart(providers, part);
|
||
return token ? [{ token, part }] : [];
|
||
});
|
||
applyContentToRoot(buildContentFromTextTokens(value, candidates), providers);
|
||
}
|
||
|
||
function applyContentToRoot(
|
||
content: readonly DirectCodexUserContentPart[],
|
||
providers: readonly ReferenceProvider[],
|
||
) {
|
||
const root = $getRoot();
|
||
root.clear();
|
||
let paragraph = $createParagraphNode();
|
||
root.append(paragraph);
|
||
content.forEach((part) => {
|
||
if (part.type === 'input_text') {
|
||
const lines = part.text.split('\n');
|
||
lines.forEach((line, index) => {
|
||
if (line) paragraph.append($createTextNode(line));
|
||
if (index < lines.length - 1) {
|
||
paragraph = $createParagraphNode();
|
||
root.append(paragraph);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const reference = referenceFromPart(providers, part);
|
||
if (reference) {
|
||
paragraph.append($createResourceReferenceNode(reference));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 引用身份刷新(资源改名等):每个节点问第一个认得它的 provider 要新引用,
|
||
* 拿到不同实例就换成新节点。providers 原样返回表示无需改写。
|
||
*/
|
||
function $refreshReferenceNodes(providers: readonly ReferenceProvider[]) {
|
||
const staleNodes: {
|
||
node: ResourceReferenceNode;
|
||
reference: ChatReference;
|
||
}[] = [];
|
||
$getRoot()
|
||
.getChildren()
|
||
.forEach((block) => {
|
||
if (!$isElementNode(block)) return;
|
||
block.getChildren().forEach((child) => {
|
||
if (!$isResourceReferenceNode(child)) return;
|
||
for (const provider of providers) {
|
||
const nextReference = provider.refresh(child.__reference);
|
||
if (!nextReference) continue;
|
||
if (nextReference !== child.__reference) {
|
||
staleNodes.push({ node: child, reference: nextReference });
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
});
|
||
return staleNodes;
|
||
}
|
||
|
||
function ResourceReferenceEditor({
|
||
onChange,
|
||
onEditorStateChange,
|
||
initialContent,
|
||
providers,
|
||
projectPath,
|
||
disabled,
|
||
multiline,
|
||
showPolishAction = true,
|
||
submitSuppressed = false,
|
||
inputActions,
|
||
composerRef,
|
||
rootRef,
|
||
}: ResourceReferenceInputProps & {
|
||
composerRef?: Ref<ResourceReferenceInputHandle>;
|
||
rootRef: RefObject<HTMLDivElement | null>;
|
||
}) {
|
||
const [editor] = useLexicalComposerContext();
|
||
const skipInitialDraftChangeRef = useRef(false);
|
||
// providers 每次渲染都可能是新数组(宿主按 assets 派生)。回调和命令处理只经 ref 读最新值,
|
||
// 避免因为 identity 变化反复重注册 HIGH 优先级命令。
|
||
const providersRef = useRef(providers);
|
||
providersRef.current = providers;
|
||
const submitSuppressedRef = useRef(submitSuppressed);
|
||
submitSuppressedRef.current = submitSuppressed;
|
||
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
|
||
// 打开哪个菜单、宿主的浮层开关变化反复重注册 HIGH 优先级命令。
|
||
const mentionMenuOpenRef = useRef(false);
|
||
const openMenuTriggersRef = useRef<Set<string>>(new Set());
|
||
|
||
const handleMenuOpenChange = useCallback((trigger: string, open: boolean) => {
|
||
if (open) {
|
||
openMenuTriggersRef.current.add(trigger);
|
||
} else {
|
||
openMenuTriggersRef.current.delete(trigger);
|
||
}
|
||
mentionMenuOpenRef.current = openMenuTriggersRef.current.size > 0;
|
||
}, []);
|
||
|
||
const insertReferences = useCallback(
|
||
(nextReferences: ChatReference[]) => {
|
||
if (nextReferences.length === 0) return;
|
||
editor.update(() => {
|
||
let selection = $getSelection();
|
||
// 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾,
|
||
// 避免把引用插到一个已经不存在的位置。
|
||
if (
|
||
!$isRangeSelection(selection) ||
|
||
!selection.anchor.getNode().isAttached()
|
||
) {
|
||
$getRoot().selectEnd();
|
||
selection = $getSelection();
|
||
}
|
||
if ($isRangeSelection(selection)) {
|
||
selection.insertNodes(
|
||
nextReferences.flatMap((reference) => [
|
||
$createResourceReferenceNode(reference),
|
||
$createTextNode(' '),
|
||
]),
|
||
);
|
||
}
|
||
});
|
||
editor.focus();
|
||
},
|
||
[editor],
|
||
);
|
||
|
||
const insertText = useCallback(
|
||
(text: string) => {
|
||
const insert = text.replace(/\s+$/u, '');
|
||
if (!insert.trim()) return;
|
||
editor.update(() => {
|
||
let selection = $getSelection();
|
||
if (
|
||
!$isRangeSelection(selection) ||
|
||
!selection.anchor.getNode().isAttached()
|
||
) {
|
||
$getRoot().selectEnd();
|
||
selection = $getSelection();
|
||
}
|
||
if ($isRangeSelection(selection)) {
|
||
const rootText = $getRoot().getTextContent();
|
||
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
|
||
selection.insertText(insert);
|
||
}
|
||
});
|
||
editor.focus();
|
||
},
|
||
[editor],
|
||
);
|
||
|
||
const applyContent = useCallback(
|
||
(content: readonly DirectCodexUserContentPart[]) => {
|
||
applyContentToRoot(content, providersRef.current);
|
||
},
|
||
[],
|
||
);
|
||
|
||
useImperativeHandle(
|
||
composerRef,
|
||
() => ({
|
||
insertReferences,
|
||
insertText,
|
||
focus: () => editor.focus(),
|
||
clear: () => {
|
||
editor.update(() => {
|
||
applyContent([]);
|
||
$getRoot().selectEnd();
|
||
});
|
||
},
|
||
replaceText: (text: string) => {
|
||
editor.update(() => {
|
||
const current = readDraftProjectionFromNodes();
|
||
applyPolishedTextToRoot(text, current, providersRef.current);
|
||
$getRoot().selectEnd();
|
||
});
|
||
},
|
||
getDraft: () => readResourceReferenceDraft(editor.getEditorState()),
|
||
}),
|
||
[applyContent, editor, insertReferences, insertText],
|
||
);
|
||
|
||
useEffect(() => {
|
||
editor.setEditable(!disabled);
|
||
}, [disabled, editor]);
|
||
|
||
const initialDraftAppliedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (initialDraftAppliedRef.current || !initialContent) {
|
||
return;
|
||
}
|
||
// provider 的数据可能晚于编辑器挂载到达(例如 manifest 还在加载)。
|
||
// 草稿里已经有它认不出、但它迟早能认出来的 part 时先不落:落下就等于把那条引用静默丢掉。
|
||
const hasUnresolvedPart = initialContent.some(
|
||
(part) =>
|
||
part.type !== 'input_text' &&
|
||
referenceFromPart(providers, part) === null,
|
||
);
|
||
const pendingProvider = providers.some(
|
||
(provider) => provider.isReady && !provider.isReady(),
|
||
);
|
||
if (hasUnresolvedPart && pendingProvider) {
|
||
return;
|
||
}
|
||
initialDraftAppliedRef.current = true;
|
||
editor.update(() => {
|
||
applyContent(initialContent);
|
||
skipInitialDraftChangeRef.current = initialContent.length > 0;
|
||
$getRoot().selectEnd();
|
||
});
|
||
}, [applyContent, editor, initialContent, providers]);
|
||
|
||
// 引用身份变化(资源改名)后刷新已有 chip 的显示名:改写节点会触发 OnChangePlugin,
|
||
// 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。
|
||
// Lexical 的更新可能排到微任务里提交,这里额外挂一次更新监听兜底。
|
||
const refreshReferenceLabels = useCallback(() => {
|
||
const hasStaleReferences = editor
|
||
.getEditorState()
|
||
.read(() => $refreshReferenceNodes(providersRef.current).length > 0);
|
||
if (!hasStaleReferences) return;
|
||
editor.update(() => {
|
||
$refreshReferenceNodes(providersRef.current).forEach(
|
||
({ node, reference }) => {
|
||
node.replace($createResourceReferenceNode(reference));
|
||
},
|
||
);
|
||
});
|
||
}, [editor]);
|
||
|
||
useEffect(() => {
|
||
refreshReferenceLabels();
|
||
return editor.registerUpdateListener(() => {
|
||
refreshReferenceLabels();
|
||
});
|
||
// providers 一并当依赖:宿主刚把新清单换进来时立刻刷一次,不必等下一次编辑。
|
||
}, [editor, providers, refreshReferenceLabels]);
|
||
|
||
// Enter 提交只属于单行输入区(聊天 / 项目总控)。多行输入区(资源卡快速编辑提示词)
|
||
// 的 Enter 必须留给换行,否则用户没法在提示词里分行。
|
||
useEffect(() => {
|
||
if (multiline) return undefined;
|
||
return editor.registerCommand(
|
||
KEY_ENTER_COMMAND,
|
||
(event) => {
|
||
if (
|
||
!event ||
|
||
event.shiftKey ||
|
||
event.isComposing ||
|
||
// 候选菜单 / 宿主的素材选择器开着时 Enter 属于它们:返回 false 把按键让给
|
||
// 下层默认行为(LexicalTypeaheadMenuPlugin 在 NORMAL 优先级用 Enter 选候选),
|
||
// 而不是在 HIGH 优先级抢先提交表单。
|
||
mentionMenuOpenRef.current ||
|
||
submitSuppressedRef.current
|
||
) {
|
||
return false;
|
||
}
|
||
event.preventDefault();
|
||
(event.target as HTMLElement | null)?.closest('form')?.requestSubmit();
|
||
return true;
|
||
},
|
||
COMMAND_PRIORITY_HIGH,
|
||
);
|
||
}, [editor, multiline]);
|
||
|
||
// —— C8 AI 润色与发送前提醒 ——
|
||
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
|
||
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
|
||
const [reminderOpen, setReminderOpen] = useState(false);
|
||
const [reminderPolishing, setReminderPolishing] = useState(false);
|
||
const [reminderDisabled, setReminderDisabled] = useState(() =>
|
||
readChatPromptPolishReminderDisabled(),
|
||
);
|
||
const acknowledgedDraftKeyRef = useRef<string | null>(null);
|
||
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
|
||
const liveDraftRef = useRef<ChatComposerDraft>({
|
||
content: initialContent ?? [],
|
||
});
|
||
const [draftText, setDraftText] = useState(() =>
|
||
promptTextFromContent(providers, initialContent ?? []),
|
||
);
|
||
const reminderDisabledRef = useRef(reminderDisabled);
|
||
reminderDisabledRef.current = reminderDisabled;
|
||
|
||
/**
|
||
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
|
||
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
|
||
* 否则刚显示出来的提示会被自己的回填立刻清掉。
|
||
*/
|
||
const appliedPromptRef = useRef<string | null>(null);
|
||
const applyPromptText = useCallback(
|
||
(text: string) => {
|
||
appliedPromptRef.current = text;
|
||
editor.update(() => {
|
||
const current = readDraftProjectionFromNodes();
|
||
applyPolishedTextToRoot(text, current, providersRef.current);
|
||
$getRoot().selectEnd();
|
||
});
|
||
},
|
||
[editor],
|
||
);
|
||
|
||
const readPromptText = useCallback(
|
||
() =>
|
||
promptTextFromContent(providersRef.current, liveDraftRef.current.content),
|
||
[],
|
||
);
|
||
const resolvePolishContext = useCallback(
|
||
() => projectPath || null,
|
||
[projectPath],
|
||
);
|
||
|
||
const promptPolishState = usePromptPolish({
|
||
readPrompt: readPromptText,
|
||
applyPrompt: applyPromptText,
|
||
resolveContext: resolvePolishContext,
|
||
});
|
||
const {
|
||
polishing,
|
||
error: polishError,
|
||
notice: polishNotice,
|
||
originalText: polishedOriginalText,
|
||
polish: runPolish,
|
||
restoreOriginal: restoreOriginalPrompt,
|
||
clearError: clearPolishError,
|
||
clearNotice: clearPolishNotice,
|
||
reset: resetPromptPolish,
|
||
} = promptPolishState;
|
||
|
||
useEffect(() => {
|
||
if (
|
||
appliedPromptRef.current !== null &&
|
||
draftText === appliedPromptRef.current
|
||
) {
|
||
return;
|
||
}
|
||
clearPolishNotice();
|
||
}, [clearPolishNotice, draftText]);
|
||
|
||
const polishPrompt = useCallback(async () => {
|
||
await runPolish();
|
||
}, [runPolish]);
|
||
|
||
const closeReminder = useCallback(() => {
|
||
setReminderOpen(false);
|
||
clearPolishError();
|
||
}, [clearPolishError]);
|
||
|
||
// 用户已在提醒面板里做出选择:记下本轮草稿指纹,再真正提交表单。
|
||
// 输入区不在表单里时(例如单独渲染的单元测试)没有可提交的表单事件,仅取消提醒状态。
|
||
const submitCurrentDraft = useCallback(() => {
|
||
setReminderOpen(false);
|
||
acknowledgedDraftKeyRef.current = chatPromptDraftKey(
|
||
liveDraftRef.current.content,
|
||
);
|
||
rootRef.current?.closest('form')?.requestSubmit();
|
||
}, [rootRef]);
|
||
|
||
const useOriginalAndSubmit = useCallback(() => {
|
||
clearPolishError();
|
||
submitCurrentDraft();
|
||
}, [clearPolishError, submitCurrentDraft]);
|
||
|
||
const polishAndSubmitFromReminder = useCallback(async () => {
|
||
if (reminderPolishing) return;
|
||
setReminderPolishing(true);
|
||
try {
|
||
const polished = await runPolish({
|
||
failureMessage: 'AI 润色失败,可重试或使用原文提交',
|
||
});
|
||
// 失败时留在提醒面板里,用户可以选择重试或直接使用原文提交。
|
||
if (!polished) return;
|
||
submitCurrentDraft();
|
||
} finally {
|
||
setReminderPolishing(false);
|
||
}
|
||
}, [reminderPolishing, runPolish, submitCurrentDraft]);
|
||
|
||
const setPromptPolishReminderDisabled = useCallback((disabled: boolean) => {
|
||
writeChatPromptPolishReminderDisabled(disabled);
|
||
setReminderDisabled(disabled);
|
||
}, []);
|
||
|
||
// 发送前提醒拦截:在捕获阶段拦下 form 的 submit,阻止 React 的表单提交处理器执行,
|
||
// 等用户在独立面板里做出选择后再用 requestSubmit() 真正提交。
|
||
useEffect(() => {
|
||
const form = rootRef.current?.closest('form');
|
||
if (!form) return;
|
||
const handleFormSubmit = (event: Event) => {
|
||
if (
|
||
!shouldRemindChatPromptPolish({
|
||
content: liveDraftRef.current.content,
|
||
prompt: promptTextFromContent(
|
||
providersRef.current,
|
||
liveDraftRef.current.content,
|
||
),
|
||
acknowledgedDraftKey: acknowledgedDraftKeyRef.current,
|
||
reminderDisabled: reminderDisabledRef.current,
|
||
})
|
||
) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setReminderOpen(true);
|
||
};
|
||
form.addEventListener('submit', handleFormSubmit, true);
|
||
return () => form.removeEventListener('submit', handleFormSubmit, true);
|
||
}, [rootRef]);
|
||
|
||
return (
|
||
<>
|
||
<div className="resource-reference-input-actions">
|
||
{inputActions}
|
||
{showPolishAction ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="resource-reference-input-polish"
|
||
aria-label="AI 润色"
|
||
title="AI 润色"
|
||
aria-busy={polishing}
|
||
disabled={disabled || polishing || draftText.trim() === ''}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => void polishPrompt()}
|
||
>
|
||
{polishing ? (
|
||
<Loader2
|
||
size={15}
|
||
className="animate-spin"
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<Sparkles size={15} aria-hidden="true" />
|
||
)}
|
||
</button>
|
||
{polishedOriginalText !== null ? (
|
||
<button
|
||
type="button"
|
||
className="resource-reference-input-restore"
|
||
aria-label="恢复原文"
|
||
title="恢复原文"
|
||
disabled={disabled || polishing}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={restoreOriginalPrompt}
|
||
>
|
||
<RotateCcw size={15} aria-hidden="true" />
|
||
</button>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
|
||
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
|
||
{showPolishAction &&
|
||
!reminderOpen &&
|
||
(polishing || polishError || polishNotice) ? (
|
||
<span
|
||
className="resource-reference-input-status"
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
{polishing ? '润色中…' : (polishError ?? polishNotice)}
|
||
</span>
|
||
) : null}
|
||
{providers.map((provider) =>
|
||
provider.trigger ? (
|
||
<ProviderMentionMenu
|
||
key={provider.trigger}
|
||
provider={provider}
|
||
rootRef={rootRef}
|
||
onOpenChange={handleMenuOpenChange}
|
||
/>
|
||
) : null,
|
||
)}
|
||
{reminderOpen ? (
|
||
<ChatPromptPolishReminder
|
||
busy={reminderPolishing}
|
||
error={polishError}
|
||
reminderDisabled={reminderDisabled}
|
||
onPolishAndSubmit={() => void polishAndSubmitFromReminder()}
|
||
onUseOriginalAndSubmit={useOriginalAndSubmit}
|
||
onClose={closeReminder}
|
||
onReminderDisabledChange={setPromptPolishReminderDisabled}
|
||
/>
|
||
) : null}
|
||
<OnChangePlugin
|
||
onChange={(editorState) => {
|
||
const nextDraft = readResourceReferenceDraft(editorState);
|
||
liveDraftRef.current = nextDraft;
|
||
setDraftText(
|
||
promptTextFromContent(providersRef.current, nextDraft.content),
|
||
);
|
||
if (nextDraft.content.length === 0) {
|
||
resetPromptPolish();
|
||
acknowledgedDraftKeyRef.current = null;
|
||
}
|
||
if (skipInitialDraftChangeRef.current) {
|
||
skipInitialDraftChangeRef.current = false;
|
||
return;
|
||
}
|
||
onEditorStateChange?.(editorState);
|
||
onChange?.(nextDraft);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 一种引用来源的候选菜单:触发符、候选与插入全按 provider 走。
|
||
*
|
||
* 输入区不再写死 `@` / `$` 两条链,而是「有几个带触发符的 provider 就有几个菜单」。
|
||
*/
|
||
function ProviderMentionMenu({
|
||
provider,
|
||
rootRef,
|
||
onOpenChange,
|
||
}: {
|
||
provider: ReferenceProvider;
|
||
rootRef: RefObject<HTMLDivElement | null>;
|
||
onOpenChange: (trigger: string, open: boolean) => void;
|
||
}) {
|
||
const trigger = provider.trigger ?? '';
|
||
const [query, setQuery] = useState<string | null>(null);
|
||
const triggerFn = useBasicTypeaheadTriggerMatch(trigger, {
|
||
minLength: 0,
|
||
maxLength: 64,
|
||
allowWhitespace: false,
|
||
});
|
||
|
||
const handleQueryChange = useCallback(
|
||
(nextQuery: string | null) => {
|
||
setQuery(nextQuery);
|
||
onOpenChange(trigger, nextQuery !== null);
|
||
},
|
||
[onOpenChange, trigger],
|
||
);
|
||
|
||
const options = useMemo(() => {
|
||
if (query === null || !provider.match) return [];
|
||
return provider
|
||
.match(query)
|
||
.map((reference) => new ReferenceMentionOption(reference));
|
||
}, [provider, query]);
|
||
|
||
const renderMenu: MenuRenderFn<ReferenceMentionOption> = useCallback(
|
||
(_anchorElementRef, itemProps) => {
|
||
const inputRect = rootRef.current?.getBoundingClientRect();
|
||
if (!inputRect || itemProps.options.length === 0) {
|
||
return null;
|
||
}
|
||
const viewportPadding = 12;
|
||
const menuWidth = Math.min(
|
||
Math.max(280, inputRect.width),
|
||
Math.min(420, window.innerWidth - viewportPadding * 2),
|
||
);
|
||
const left = Math.min(
|
||
Math.max(viewportPadding, inputRect.left),
|
||
Math.max(
|
||
viewportPadding,
|
||
window.innerWidth - menuWidth - viewportPadding,
|
||
),
|
||
);
|
||
const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8);
|
||
const availableBelow = Math.max(
|
||
0,
|
||
window.innerHeight - inputRect.bottom - viewportPadding - 8,
|
||
);
|
||
const openAbove = availableBelow < 160 && availableAbove > availableBelow;
|
||
const maxHeight = Math.max(
|
||
120,
|
||
Math.min(240, openAbove ? availableAbove : availableBelow),
|
||
);
|
||
const top = openAbove
|
||
? Math.max(viewportPadding, inputRect.top - maxHeight - 8)
|
||
: inputRect.bottom + 8;
|
||
return createPortal(
|
||
<div
|
||
className="resource-reference-menu"
|
||
role="listbox"
|
||
aria-label="候选引用"
|
||
style={{
|
||
position: 'fixed',
|
||
top: `${top}px`,
|
||
left: `${left}px`,
|
||
width: `${menuWidth}px`,
|
||
maxHeight: `${maxHeight}px`,
|
||
}}
|
||
>
|
||
{itemProps.options.map((option, index) => (
|
||
<button
|
||
type="button"
|
||
role="option"
|
||
key={option.key}
|
||
ref={(element) => option.setRefElement(element)}
|
||
aria-selected={itemProps.selectedIndex === index}
|
||
className={
|
||
itemProps.selectedIndex === index ? 'is-active' : undefined
|
||
}
|
||
onMouseEnter={() => itemProps.setHighlightedIndex(index)}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => itemProps.selectOptionAndCleanUp(option)}
|
||
>
|
||
<span>{option.label}</span>
|
||
<small>{option.hint}</small>
|
||
</button>
|
||
))}
|
||
</div>,
|
||
document.body,
|
||
);
|
||
},
|
||
[rootRef],
|
||
);
|
||
|
||
const handleSelectOption = useCallback(
|
||
(
|
||
option: ReferenceMentionOption,
|
||
textNodeContainingQuery: TextNode | null,
|
||
closeMenu: () => void,
|
||
) => {
|
||
textNodeContainingQuery?.remove();
|
||
const selection = $getSelection();
|
||
const node = $createResourceReferenceNode(option.reference);
|
||
if ($isRangeSelection(selection)) {
|
||
selection.insertNodes([node, $createTextNode(' ')]);
|
||
} else {
|
||
const paragraph = $createParagraphNode();
|
||
paragraph.append(node, $createTextNode(' '));
|
||
$getRoot().append(paragraph);
|
||
}
|
||
closeMenu();
|
||
},
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<LexicalTypeaheadMenuPlugin<ReferenceMentionOption>
|
||
options={options}
|
||
triggerFn={triggerFn}
|
||
onQueryChange={handleQueryChange}
|
||
onSelectOption={handleSelectOption}
|
||
menuRenderFn={renderMenu}
|
||
anchorClassName="resource-reference-menu-anchor"
|
||
preselectFirstItem
|
||
/>
|
||
);
|
||
}
|
||
|
||
export const ResourceReferenceInput = forwardRef<
|
||
ResourceReferenceInputHandle,
|
||
ResourceReferenceInputProps
|
||
>(function ResourceReferenceInput({ containerRef, ...props }, ref) {
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
const assignContainer = useCallback(
|
||
(element: HTMLDivElement | null) => {
|
||
rootRef.current = element;
|
||
if (containerRef) containerRef.current = element;
|
||
},
|
||
[containerRef],
|
||
);
|
||
return (
|
||
<RichTextInput
|
||
namespace="agc-resource-reference-input"
|
||
nodes={[ResourceReferenceNode]}
|
||
containerRef={assignContainer}
|
||
containerClassName={`resource-reference-input${props.multiline ? '' : ' is-single-line'}`}
|
||
disabled={props.disabled}
|
||
contentEditable={
|
||
<ContentEditable
|
||
aria-label={props.ariaLabel}
|
||
className="resource-reference-input-editor"
|
||
ref={props.inputRef}
|
||
style={
|
||
props.multiline && props.rows
|
||
? { minHeight: `${Math.max(72, props.rows * 22)}px` }
|
||
: undefined
|
||
}
|
||
/>
|
||
}
|
||
placeholder={
|
||
<span className="resource-reference-input-placeholder">
|
||
{props.placeholder}
|
||
</span>
|
||
}
|
||
>
|
||
<ResourceReferenceEditor {...props} composerRef={ref} rootRef={rootRef} />
|
||
</RichTextInput>
|
||
);
|
||
});
|