Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
T
suzmii 870d6457bc
Project CI / Backend tests (pull_request) Failing after 26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m30s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m4s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m12s
Project CI / Native shell tests (pull_request) Successful in 10m32s
输入区工具条改回唯一一行:润色钮并入工具条,nowrap + 实测 min-width
- 口径:工具条只允许一行——`@` / `+` / 「AI 润色 / 恢复原文」/ 模型 / 语音 / 发送 六颗控件平铺,任何面板宽下都不换行、不堆叠、不重叠;宽度不够时按 `min-width` 横向溢出可见,发送钮仍在可视区内
- 换行来源(首轮为「避免重叠」加的 wrap):styles.css:11893 `.project-chat-composer-controls`(6 类)`flex-wrap: wrap`、styles.css:11933 `.project-chat-composer-controls-left` `flex-wrap: wrap`、styles.css:13747 `.conversation-model-select` `flex-wrap: wrap` → 三处都改 `nowrap`;左组补 `flex: 0 0 auto`(固定 28px 动作钮不参与收缩,否则槽位被压窄会把润色钮挤到模型钮上)
- 润色钮进工具条:`ResourceReferenceInput` 新增 `actionsPortalTarget`(portal 目标,不传时行为不变,策划面照旧),`DirectProjectComposer` 在左组 `+` 之后放挂点 `.project-chat-composer-controls-actions` 并传入 → 空输入态不再为它留一行;输入盒第二行只剩「润色状态」(无内容时 0 高,`gap` 的 row 分量 6→0、状态自带 `margin-top: 6px`)
- min-width 算式(落到两个元素):控件排 `.project-chat-composer-controls` = `@`28 + `+`28 + 润色28 + 模型钮 64(`min-width: 64px`,名字省略号)+ 语音28 + 发送28 = 204,加 5×8px 列间距 = 244,再留 8 余量 → **252px**;外壳 `form.project-chat-composer.is-direct-codex` = 252 + 左右内边距 12×2 + 边框 1×2 → **278px**。夹具实测:自然宽度 290.4、七档(240…900)控件 `top` 差值 ≤1px、两两相交 0 处、`outsideControlsRow` 空
- 低于 min-width:外壳保持 278px、**内容按自身宽度溢出可见**(面板与 composer 都不裁切,选它而不是给工具条 `overflow-x: auto` 是因为工具条里挂着模型菜单浮层,加滚动容器会把它裁掉——#600 的老问题);240px 实测发送钮 rect 仍在窗口内且 `elementFromPoint` 命中它
- 测试:`chatDialogFrameLayout.test.ts` 新增「工具条只有一行…」——七个宽度下 `flex-wrap` 必须 `nowrap`(右组本来就不换行)、控件排 `min-width: 252` / 外壳 `min-width: 278`、组件源码里润色钮渲染在控件排之后的挂点;最高高度算式按新结构更新为 222。**改前红已记录**:把三个源文件 stash 回旧实现后单跑,`240px 下工具条必须保持单行: expected 'wrap' to be 'nowrap'` 与 `expected 228 to be 222` 两条同时红
- 校验:`npx vitest run tests/appSurface.test.ts tests/chatDialogFrameLayout.test.ts` → **225 passed / 9 skipped**;`agc:typecheck` pass 3 / fail 0;eslint、prettier、encoding 5280 files、spacetime-schema 91 tables、`git diff --check` 全绿;对照图与 PR 正文已更新

Refs #600, PR #607
2026-10-06 00:19:17 +08:00

1146 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_CRITICAL,
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type LexicalNode,
PASTE_COMMAND,
PASTE_TAG,
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 { captureClientError } from '../../services/errorReporting';
import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart';
import {
chatPromptDraftKey,
readChatPromptPolishReminderDisabled,
shouldRemindChatPromptPolish,
subscribeChatPromptPolishReminderDisabled,
writeChatPromptPolishReminderDisabled,
} from './chatPromptPolish';
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
import type { ReferenceProvider } from './reference-source/types';
import {
$createResourceReferenceNode,
$isResourceReferenceNode,
ResourceReferenceNode,
} from './ResourceReferenceNode';
import {
buildContentFromPastedText,
buildContentFromTextTokens,
type ChatComposerDraft,
type ChatReference,
chatReferenceDisplayHint,
chatReferenceKey,
chatReferenceMentionToken,
chatReferenceToContentPart,
contentPartText,
dedupeChatReferences,
joinMentionText,
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
/**
* Lexical 自己的剪贴板负载(导入优先级最高的一条):带着它复制粘贴时,真 chip 会被原样还原,
* 所以粘贴解析要让位给默认导入。
*/
const LEXICAL_EDITOR_CLIPBOARD_TYPE = 'application/x-lexical-editor';
type ResourceReferenceInputProps = {
onChange?: (draft: ChatComposerDraft) => void;
onEditorStateChange?: (editorState: EditorState) => void;
initialContent?: UserContentPart[];
/**
* 宿主注入的引用来源,**按数组顺序取第一个非空回答**。
*
* 输入区不认识任何引用种类:候选菜单按各 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;
/**
* 把「AI 润色 / 恢复原文」这一排**渲染到宿主指定的容器里**(`createPortal`)。
*
* Direct 输入区把它挪进下方那条工具条,让润色钮与 `@` / `+` / 模型 / 语音 / 发送 同一水平线
* (否则它会自成一行,窄面板下看起来像"堆叠")。不传时仍然渲染在输入盒内(策划面等保持原样)。
*/
actionsPortalTarget?: HTMLElement | null;
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: UserContentPart[],
) {
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: UserContentPart[];
};
/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */
function readDraftProjectionFromNodes(): DraftProjection {
const references: ChatReference[] = [];
const content: UserContentPart[] = [];
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: UserContentPart,
): 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: UserContentPart,
): string | null {
for (const provider of providers) {
const token = provider.mentionToken(part);
if (token) return token;
}
return null;
}
/**
* part 落进正文时用的文本:provider 的 `mentionToken`,它答不出来(provider 契约被破坏)时退到
* `contentPartText` 的通用文本形态。文本 part 自己就是文本,返回 `null`。
*
* 粘贴插入、润色回写的候选扫描与落点都走这一条:只要 part 不是文本,就一定有一段正常文本可落,
* 「认不出的引用走文本、绝不静默丢」在几个入口是同一份实现,不是各写一遍兜底。
*/
function mentionTokenOrText(
providers: readonly ReferenceProvider[],
part: UserContentPart,
): string | null {
return mentionTokenFromPart(providers, part) ?? contentPartText(part);
}
/**
* 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。
*
* 与出站(发给润色服务 / 队列 chip / agent)的口径是同一份 `joinMentionText`:
* 所以润色回包还能按同一批 token 原位换回真 part,不需要第二套文本口径。
*/
function promptTextFromContent(
providers: readonly ReferenceProvider[],
content: readonly UserContentPart[],
): 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。
// provider 答不出 token 的 part 也照样进候选(token 退到 `mentionTokenOrText` 的通用文本形态):
// 它在回包里没被提到时会作为末尾孤儿补回来,而不是从这门翻译里直接消失。
const candidates = current.content.flatMap((part) => {
const token = mentionTokenOrText(providers, part);
return token ? [{ token, part }] : [];
});
applyContentToRoot(buildContentFromTextTokens(value, candidates), providers);
}
function applyContentToRoot(
content: readonly UserContentPart[],
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));
return;
}
// 解析不出引用的 part 落它的文本(与粘贴同一条兜底链),整根替换同样不许把内容吃掉。
const token = mentionTokenOrText(providers, part);
if (token) paragraph.append($createTextNode(token));
});
}
/**
* 取当前可用的选区:选区缺失、或指向已被重建掉的节点时(跨会话恢复草稿后就是这种),
* 统一回落到草稿末尾,避免把内容插到一个已经不存在的位置。取不到时返回 `null`。
*/
function $selectionOrRootEnd() {
let selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached()
) {
$getRoot().selectEnd();
selection = $getSelection();
}
return $isRangeSelection(selection) ? selection : null;
}
/**
* 粘贴插入:在光标处就地插入 content 对应的节点,正文其余部分逐字不动。
*
* 与 `applyContentToRoot`(整根替换,供初始草稿与润色回写使用)的区别只在替换范围:
* 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成
* chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。
*
* 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本;连 provider 的
* `mentionToken` 都答不出来时退到 `contentPartText` 的通用文本形态。两条兜底合起来保证
* 「粘贴进来的内容一个字符都不会凭空消失」,这个分支不存在什么都不插的出路。
*
* 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行
* 编辑器的默认粘贴,否则这段文字两边都不管。
*/
function $insertContentAtSelection(
content: readonly UserContentPart[],
providers: readonly ReferenceProvider[],
): boolean {
// 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。
if (!$selectionOrRootEnd()) return false;
let inserted = false;
content.forEach((part) => {
if (part.type === 'input_text') {
part.text.split('\n').forEach((line, index) => {
if (index > 0) {
$selectionOrRootEnd()?.insertParagraph();
inserted = true;
}
if (line) {
$selectionOrRootEnd()?.insertText(line);
inserted = true;
}
});
return;
}
const reference = referenceFromPart(providers, part);
if (reference) {
$selectionOrRootEnd()?.insertNodes([
$createResourceReferenceNode(reference),
]);
inserted = true;
return;
}
// 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
const token = mentionTokenOrText(providers, part);
if (token) {
$selectionOrRootEnd()?.insertText(token);
inserted = true;
}
});
return inserted;
}
/**
* 引用身份刷新(资源改名等):每个节点问第一个认得它的 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,
actionsPortalTarget,
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(() => {
const selection = $selectionOrRootEnd();
if (!selection) return;
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(() => {
const selection = $selectionOrRootEnd();
if (!selection) return;
const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
selection.insertText(insert);
});
editor.focus();
},
[editor],
);
const applyContent = useCallback((content: readonly UserContentPart[]) => {
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]);
// 粘贴解析:只有「粘贴文本里真的解析出了引用 token」且「这一整段真的插进了正文」时才接管,
// 其余一律返回 false 放行 Lexical 的默认导入——同 namespace 复制出来的 Lexical payload
// 本来就能还原真 chip,不含 token 的纯文本、图片文件粘贴也都保持原行为。
//
// 接管时整段文本在一次 update 内插入(与默认粘贴同样打 PASTE_TAG),所以一次 Ctrl+Z 就整体
// 回退;token 之外的每个字符逐字保留(换行落成段落分隔,与默认纯文本粘贴同形状)。
useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
const clipboardData = (event as ClipboardEvent | null)?.clipboardData;
if (!clipboardData || typeof clipboardData.getData !== 'function') {
return false;
}
// 同 namespace 的 Lexical payload 自带真 chip,不抢它的默认导入。
if (clipboardData.getData(LEXICAL_EDITOR_CLIPBOARD_TYPE)) return false;
const text = clipboardData.getData('text/plain');
if (!text.trim()) return false;
const providers = providersRef.current;
// 只认「此刻就绪」的候选:数据还没到的种类本次按文本保留,输入区不等待也不补读。
const references = providers.flatMap(
(provider) => provider.lookup?.() ?? [],
);
const content = buildContentFromPastedText(text, references);
if (!content) return false;
// 先真的插进去,再决定接管这次粘贴:插入为空(取不到选区)时必须放行默认粘贴,
// 否则这段文字既没进我们的插入、又被 preventDefault 挡掉了默认导入,静默消失。
// 编辑器已经在一次更新里时 `editor.update` 会把回调排队,这时 `ranSync` 仍是 false,
// 按原口径先接管,等队列里的那次插入落地。
let ranSync = false;
let inserted = false;
editor.update(
() => {
ranSync = true;
inserted = $insertContentAtSelection(content, providersRef.current);
},
// 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。
{ tag: PASTE_TAG },
);
if (ranSync && !inserted) return false;
event.preventDefault();
return true;
},
COMMAND_PRIORITY_CRITICAL,
);
}, [editor]);
// —— C8 AI 润色与发送前提醒 ——
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
const [reminderOpen, setReminderOpen] = useState(false);
const [reminderPolishing, setReminderPolishing] = useState(false);
// 偏好正式数据在 Rust:初始值只是「未关闭」的占位,挂载后由快照与事件补齐。
const [reminderDisabled, setReminderDisabled] = useState(false);
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;
/**
* 偏好写入代次。
*
* 偏好正式数据在 Rust:挂载时读一次快照并订阅事件。用户点击「不再提醒」或 Rust 事件
* 到达都会推进代次,迟到的挂载快照因此不能把更新的选择覆盖回旧值。
*/
const reminderPreferenceGenerationRef = useRef(0);
useEffect(() => {
let disposed = false;
let unsubscribe: (() => void) | null = null;
void subscribeChatPromptPolishReminderDisabled((disabled) => {
if (disposed) return;
reminderPreferenceGenerationRef.current += 1;
setReminderDisabled(disabled);
})
.then((release) => {
if (disposed) release();
else unsubscribe = release;
})
.catch(() => undefined);
// 快照读取与订阅并发,但只有「读取期间没有任何更新」时才允许写回。
const generationAtRead = reminderPreferenceGenerationRef.current;
void readChatPromptPolishReminderDisabled().then((disabled) => {
if (
disposed ||
reminderPreferenceGenerationRef.current !== generationAtRead
) {
return;
}
setReminderDisabled(disabled);
});
return () => {
disposed = true;
unsubscribe?.();
};
}, []);
/**
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
* 否则刚显示出来的提示会被自己的回填立刻清掉。
*/
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) => {
// 先本地收口交互反馈,再以 Rust 落盘后的投影为准;失败回退并提示。
reminderPreferenceGenerationRef.current += 1;
setReminderDisabled(disabled);
void writeChatPromptPolishReminderDisabled(disabled)
.then((persisted) => setReminderDisabled(persisted))
.catch((error) => {
setReminderDisabled(!disabled);
captureClientError(error, {
source: 'chat-prompt-polish',
action: 'set-reminder-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]);
/**
* 「输入区操作排」这一块:宿主注入的 `inputActions` + 内置的「AI 润色 / 恢复原文」。
* 默认渲染在输入盒里;宿主给了 `actionsPortalTarget` 时改渲染到那个容器(Direct 把它放进
* 下方工具条,与 `@` / `+` / 模型 / 语音 / 发送 同一行)。
*/
const inputActionsBlock = (
<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>
);
return (
<>
{actionsPortalTarget
? createPortal(inputActionsBlock, actionsPortalTarget)
: inputActionsBlock}
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
{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],
);
// 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`fuzzyLookup` 始终保持纯函数,
// 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。
useEffect(() => {
provider.onMenuQueryChange?.(query);
}, [provider, query]);
const options = useMemo(() => {
if (query === null || !provider.fuzzyLookup) return [];
return provider
.fuzzyLookup(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>
);
});