增加输入框Skill提及候选
扩展聊天引用模型支持 Skill 节点与 canonical content 复用 Lexical typeahead 增加 $Skill 候选和芯片展示 Conflicts: apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx Conflicts: apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
This commit is contained in:
@@ -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.
|
||||
|
||||
+11
-4
@@ -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(() => {
|
||||
|
||||
+166
-73
@@ -93,6 +93,7 @@ import {
|
||||
resourceReferenceMatchesTagSelection,
|
||||
type ResourceReferenceScope,
|
||||
resourceReferenceTagLibrary,
|
||||
type SkillReference,
|
||||
} from './resourceReferences';
|
||||
import { usePromptPolish } from './usePromptPolish';
|
||||
|
||||
@@ -101,6 +102,7 @@ type ResourceReferenceInputProps = {
|
||||
onEditorStateChange?: (editorState: EditorState) => void;
|
||||
initialContent?: DirectCodexUserContentPart[];
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
skills?: SkillReference[];
|
||||
projectPath: string;
|
||||
/**
|
||||
* `@` 面板「当前版本素材」页签使用的版本 id。
|
||||
@@ -174,6 +176,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') {
|
||||
@@ -275,7 +295,7 @@ function findDraftMentionToken(line: string, token: string, from: number) {
|
||||
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
|
||||
*
|
||||
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
|
||||
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
|
||||
* 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
|
||||
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
|
||||
*
|
||||
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
|
||||
@@ -287,7 +307,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[][] = [];
|
||||
@@ -360,6 +381,9 @@ function referenceFromContentPart(
|
||||
const asset = assetsById.get(part.resourceId);
|
||||
return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null;
|
||||
}
|
||||
if (part.type === 'agc_skill_reference') {
|
||||
return { type: 'skill', name: part.name };
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') {
|
||||
return {
|
||||
type: 'runtime-region',
|
||||
@@ -501,6 +525,7 @@ function ResourceReferenceEditor({
|
||||
onEditorStateChange,
|
||||
initialContent,
|
||||
assets,
|
||||
skills = [],
|
||||
projectPath,
|
||||
activeVersionId = null,
|
||||
versions,
|
||||
@@ -517,14 +542,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]);
|
||||
@@ -602,11 +628,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[]) => {
|
||||
@@ -800,6 +850,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`(资源侧两处入口共用同一份);这里只剩下
|
||||
// 聊天特有的「发送前提醒」:提醒偏好、本轮已确认草稿指纹与表单拦截。
|
||||
@@ -935,74 +1006,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) => {
|
||||
@@ -1119,7 +1197,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
|
||||
/>
|
||||
|
||||
+1
@@ -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 = {
|
||||
/** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */
|
||||
@@ -102,6 +111,9 @@ export function directCodexContentToPromptText(
|
||||
if (part.type === 'agc_resource_reference') {
|
||||
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
|
||||
}
|
||||
if (part.type === 'agc_skill_reference') {
|
||||
return `$${part.name}`;
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') {
|
||||
return `@${part.label}`;
|
||||
}
|
||||
@@ -125,6 +137,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,
|
||||
@@ -371,6 +386,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)}`;
|
||||
}
|
||||
|
||||
@@ -383,7 +401,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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user