增加输入框Skill提及候选

扩展聊天引用模型支持 Skill 节点与 canonical content

复用 Lexical typeahead 增加 $Skill 候选和芯片展示

 Conflicts:
	apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
This commit is contained in:
2026-09-15 17:19:43 +08:00
parent 02a6a91303
commit 5bd12bcfe6
5 changed files with 201 additions and 80 deletions
@@ -34,6 +34,8 @@ pub(crate) enum DirectCodexUserContentPart {
InputText { text: String },
#[serde(rename = "agc_resource_reference")]
AgcResourceReference { resource_id: String },
#[serde(rename = "agc_skill_reference")]
AgcSkillReference { name: String },
#[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
@@ -21,18 +21,25 @@ export function ResourceReferenceChip({
data-runtime-region-reference={
reference.type === 'runtime-region' ? 'true' : undefined
}
data-skill-reference-name={
reference.type === 'skill' ? reference.name : undefined
}
contentEditable={false}
title={
reference.type === 'resource'
? `${reference.label} · ${reference.kind}`
: `${reference.label} · 运行区域`
: reference.type === 'skill'
? `${reference.name} · Skill`
: `${reference.label} · 运行区域`
}
>
<span aria-hidden="true">@</span>
<span className="resource-reference-chip-label">{reference.label}</span>
<span aria-hidden="true">{reference.type === 'skill' ? '$' : '@'}</span>
<span className="resource-reference-chip-label">
{reference.type === 'skill' ? reference.name : reference.label}
</span>
<button
type="button"
aria-label={`移除引用 ${reference.label}`}
aria-label={`移除引用 ${reference.type === 'skill' ? reference.name : reference.label}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.update(() => {
@@ -92,6 +92,7 @@ import {
resourceReferenceMatchesTagSelection,
type ResourceReferenceScope,
resourceReferenceTagLibrary,
type SkillReference,
} from './resourceReferences';
import { usePromptPolish } from './usePromptPolish';
@@ -103,6 +104,7 @@ type ResourceReferenceInputProps = {
onEditorStateChange?: (editorState: EditorState) => void;
initialDraft?: Pick<ChatComposerDraft, 'text' | 'references'>;
assets: GameCreationAppAssetManifestEntry[];
skills?: SkillReference[];
projectPath: string;
/**
* `@` 面板「当前版本素材」页签使用的版本 id。
@@ -176,6 +178,24 @@ class ResourceMentionOption extends MenuOption {
}
}
class SkillMentionOption extends MenuOption {
skill: SkillReference;
constructor(skill: SkillReference) {
super(skill.name);
this.skill = skill;
}
}
const BUILTIN_SKILL_REFERENCES: SkillReference[] = [
'agc-browser-playtest',
'agc-client-projection',
'agc-game-production-workflow',
'agc-project-structure',
'agc-web-game-development',
'taonier-art-assets',
].map((name) => ({ type: 'skill', name }));
function appendInputText(content: DirectCodexUserContentPart[], text: string) {
const previous = content[content.length - 1];
if (previous?.type === 'input_text') {
@@ -205,7 +225,11 @@ function collectDraftParts(
return;
}
if ($isResourceReferenceNode(node)) {
textParts.push(`@${node.__reference.label}`);
textParts.push(
node.__reference.type === 'skill'
? `$${node.__reference.name}`
: `@${node.__reference.label}`,
);
references.push(node.__reference);
content.push(chatReferenceToContentPart(node.__reference));
return;
@@ -275,7 +299,7 @@ function findDraftMentionToken(line: string, token: string, from: number) {
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
*
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
*
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
@@ -287,7 +311,8 @@ function buildDraftSegments(
): DraftBuildSegment[][] {
const pending = references.map((reference) => ({
reference,
token: `@${reference.label}`,
token:
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
used: false,
}));
const lines: DraftBuildSegment[][] = [];
@@ -448,6 +473,7 @@ function ResourceReferenceEditor({
onEditorStateChange,
initialDraft,
assets,
skills = [],
projectPath,
activeVersionId = null,
versions,
@@ -464,14 +490,15 @@ function ResourceReferenceEditor({
const [editor] = useLexicalComposerContext();
const skipInitialDraftChangeRef = useRef(false);
const [query, setQuery] = useState<string | null>(null);
const [skillQuery, setSkillQuery] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。
const mentionMenuOpenRef = useRef(false);
const pickerVisibleRef = useRef(false);
useEffect(() => {
mentionMenuOpenRef.current = query !== null;
}, [query]);
mentionMenuOpenRef.current = query !== null || skillQuery !== null;
}, [query, skillQuery]);
useEffect(() => {
pickerVisibleRef.current = pickerOpen;
}, [pickerOpen]);
@@ -543,11 +570,35 @@ function ResourceReferenceEditor({
.map((reference) => new ResourceMentionOption(reference));
}, [assetReferences, query]);
const skillOptions = useMemo(() => {
if (skillQuery === null) return [];
const normalized = skillQuery.trim().toLowerCase();
const allSkills = [...BUILTIN_SKILL_REFERENCES, ...skills];
const seen = new Set<string>();
return allSkills
.filter((skill) => {
if (seen.has(skill.name)) return false;
seen.add(skill.name);
return (
!normalized ||
skill.name.toLowerCase().includes(normalized) ||
skill.description?.toLowerCase().includes(normalized)
);
})
.slice(0, 8)
.map((skill) => new SkillMentionOption(skill));
}, [skillQuery, skills]);
const triggerFn = useBasicTypeaheadTriggerMatch('@', {
minLength: 0,
maxLength: 64,
allowWhitespace: false,
});
const skillTriggerFn = useBasicTypeaheadTriggerMatch('$', {
minLength: 0,
maxLength: 64,
allowWhitespace: false,
});
const insertReferences = useCallback(
(nextReferences: ChatReference[]) => {
@@ -748,6 +799,27 @@ function ResourceReferenceEditor({
[],
);
const handleSelectSkill = useCallback(
(
option: SkillMentionOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
) => {
textNodeContainingQuery?.remove();
const selection = $getSelection();
const node = $createResourceReferenceNode(option.skill);
if ($isRangeSelection(selection)) {
selection.insertNodes([node, $createTextNode(' ')]);
} else {
const paragraph = $createParagraphNode();
paragraph.append(node, $createTextNode(' '));
$getRoot().append(paragraph);
}
closeMenu();
},
[],
);
// —— C8 AI 润色与发送前提醒 ——
// 润色状态机抽到 `usePromptPolish`(资源侧两处入口共用同一份);这里只剩下
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
@@ -870,74 +942,81 @@ function ResourceReferenceEditor({
acknowledgedDraftKeyRef.current = null;
}, [resetPromptPolish]);
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = 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.reference.label}</span>
<small>{option.reference.kind}</small>
</button>
))}
</div>,
document.body,
);
},
[rootRef],
);
const renderMentionMenu: MenuRenderFn<
ResourceMentionOption | SkillMentionOption
> = 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>
{'reference' in option
? `@${option.reference.label}`
: `$${option.skill.name}`}
</span>
<small>
{'reference' in option
? option.reference.kind
: (option.skill.description ?? 'Skill')}
</small>
</button>
))}
</div>,
document.body,
);
}, [rootRef]);
const pickerReferences = useMemo(() => {
return scopeReferences.filter((reference) => {
@@ -1056,7 +1135,22 @@ function ResourceReferenceEditor({
onSelectOption={(option, textNode, closeMenu) =>
handleSelectMention(option, textNode, closeMenu)
}
menuRenderFn={renderMentionMenu}
menuRenderFn={
renderMentionMenu as unknown as MenuRenderFn<ResourceMentionOption>
}
anchorClassName="resource-reference-menu-anchor"
preselectFirstItem
/>
<LexicalTypeaheadMenuPlugin<SkillMentionOption>
options={skillOptions}
triggerFn={skillTriggerFn}
onQueryChange={setSkillQuery}
onSelectOption={(option, textNode, closeMenu) =>
handleSelectSkill(option, textNode, closeMenu)
}
menuRenderFn={
renderMentionMenu as unknown as MenuRenderFn<SkillMentionOption>
}
anchorClassName="resource-reference-menu-anchor"
preselectFirstItem
/>
@@ -5,6 +5,7 @@ import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeR
export type DirectCodexUserContentPart =
| { type: 'input_text'; text: string }
| { type: 'agc_resource_reference'; resourceId: string }
| { type: 'agc_skill_reference'; name: string }
| ({
type: 'agc_runtime_region_reference';
} & DirectCodexUserRuntimeRegionPart)
@@ -46,7 +46,16 @@ export type RuntimeRegionReference = {
source: 'runtime-picker';
};
export type ChatReference = ResourceReference | RuntimeRegionReference;
export type SkillReference = {
type: 'skill';
name: string;
description?: string;
};
export type ChatReference =
| ResourceReference
| RuntimeRegionReference
| SkillReference;
export type ChatComposerDraft = {
text: string;
@@ -99,6 +108,9 @@ export function chatReferenceToContentPart(
if (reference.type === 'resource') {
return { type: 'agc_resource_reference', resourceId: reference.resourceId };
}
if (reference.type === 'skill') {
return { type: 'agc_skill_reference', name: reference.name };
}
return {
type: 'agc_runtime_region_reference',
label: reference.label,
@@ -341,6 +353,9 @@ function chatReferenceKey(reference: ChatReference) {
if (reference.type === 'resource') {
return `resource:${reference.resourceId}:${reference.source}`;
}
if (reference.type === 'skill') {
return `skill:${reference.name}`;
}
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
}
@@ -353,7 +368,9 @@ export function chatReferenceListKey(references: ChatReference[]) {
.map((reference) =>
reference.type === 'resource'
? `resource:${reference.resourceId}:${reference.source}:${reference.label}`
: `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`,
: reference.type === 'skill'
? `skill:${reference.name}`
: `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`,
)
.join('\u0001');
}