重构/抽取带引用功能的输入框 #464

Merged
k88936 merged 13 commits from refactor/extract-dep-from-ref-inputer into master 2026-09-22 19:05:31 +08:00
39 changed files with 2652 additions and 1292 deletions
+32
View File
@@ -194,6 +194,34 @@ _Avoid_: 进度通知、快照轮询、第二套历史
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
**引用候选**:
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
**引用 provider**:
一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。
_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方
**静默 provider**:
不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。
_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支
**引用文本语法**:
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
**引用选择器**:
宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。
_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道
**附件芯片**:
聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。
_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -209,6 +237,10 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
- **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。
- **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。
- **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
-9
View File
@@ -170,7 +170,6 @@ type AppProps = {
initialProjectManifest?: GameCreationAppManifest;
initialProjectKind?: LocalProjectKind;
planningStartMode?: boolean;
activeVersionId?: ProjectChatComponentProps['activeVersionId'];
initialPlanningPrompt?: string;
initialPlanningPromptClaimScope?: string;
initialCreationType?: HomeCreationType | null;
@@ -207,7 +206,6 @@ export function App({
initialProjectManifest,
initialProjectKind = 'web',
planningStartMode = false,
activeVersionId = null,
initialPlanningPrompt = '',
initialPlanningPromptClaimScope = '',
initialCreationType = null,
@@ -2199,11 +2197,6 @@ export function App({
const chatProjectAssets = manifest.assets.filter(
(asset) => asset.localPath && !asset.localPath.startsWith('.agent/'),
);
// `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest,
// 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出,
// 传 `null` 表示回退到 manifest 中最新的版本。
const chatProjectVersions = manifest.versions ?? [];
const chatActiveVersionId = activeVersionId;
const visibleMessages = latestVisibleItems(
messages,
@@ -2332,7 +2325,6 @@ export function App({
<>
<PlanningChatView
initialPlanningPrompt={initialPlanningPrompt}
activeVersionId={chatActiveVersionId}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
hiddenConversationCount={hiddenConversationCount}
@@ -2462,7 +2454,6 @@ export function App({
attachmentNotice={chatFileImportNotice}
importingFiles={chatFilesImporting}
onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)}
versions={chatProjectVersions}
/>
{runtimeConfigOpen ? (
<RuntimeConfigDialog
@@ -869,7 +869,6 @@ export function WorkspaceLauncherShell({
initialPlanningPromptClaimScope={
switchedToGameRuntime ? 'approved-design-build' : ''
}
activeVersionId={activeVersionId}
planningStartMode={planningStartMode}
playRequest={playRequest}
onPlayRequestHandled={handlePlayRequestHandled}
@@ -59,11 +59,6 @@ export type ProjectChatComponentProps = {
importing: boolean,
) => void;
planningStartMode?: boolean;
/**
* C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。
* `null` 表示回退到 manifest 中最新的版本。
*/
activeVersionId?: string | null;
playRequest?: {
projectPath: string;
requestId: number;
@@ -2,13 +2,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { $getNodeByKey, type NodeKey } from 'lexical';
import { Paperclip, X } from 'lucide-react';
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
import type { AttachmentReference } from './resourceReferences';
export function AttachmentReferenceChip({
attachment,
nodeKey,
}: {
attachment: DirectCodexUserAttachmentReferencePart;
attachment: AttachmentReference;
nodeKey: NodeKey;
}) {
const [editor] = useLexicalComposerContext();
@@ -1,107 +0,0 @@
import {
$applyNodeReplacement,
DecoratorNode,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
} from 'lexical';
import type { ReactNode } from 'react';
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
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;
}
@@ -11,9 +11,20 @@ function chipTitle(reference: ChatReference) {
if (reference.type === 'skill') {
return `${reference.name} · Skill`;
}
if (reference.type === 'attachment') {
return `${reference.name} · 附件`;
}
return `${reference.label} · 运行区域`;
}
/** chip 显示的引用名:附件有自己的芯片(`AttachmentReferenceChip`),这里只是把联合收窄。 */
function chipLabel(reference: ChatReference) {
if (reference.type === 'skill' || reference.type === 'attachment') {
return reference.name;
}
return reference.label;
}
export function ResourceReferenceChip({
reference,
nodeKey,
@@ -39,11 +50,11 @@ export function ResourceReferenceChip({
>
<span aria-hidden="true">{reference.type === 'skill' ? '$' : '@'}</span>
<span className="resource-reference-chip-label">
{reference.type === 'skill' ? reference.name : reference.label}
{chipLabel(reference)}
</span>
<button
type="button"
aria-label={`移除引用 ${reference.type === 'skill' ? reference.name : reference.label}`}
aria-label={`移除引用 ${chipLabel(reference)}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.update(() => {
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ import {
} from 'lexical';
import type { ReactNode } from 'react';
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
import { ResourceReferenceChip } from './ResourceReferenceChip';
import type { ChatReference } from './resourceReferences';
@@ -89,7 +90,15 @@ export class ResourceReferenceNode extends DecoratorNode<ReactNode> {
}
decorate() {
return (
// 引用节点只有一个类型(`ChatReference` 联合),chip 的外观按引用成员选:
// 附件芯片的 DOM 契约(`data-attachment-reference` / `data-attachment-status` / title
// 由它自己那段组件保证,节点层不复制一份。
return this.__reference.type === 'attachment' ? (
<AttachmentReferenceChip
attachment={this.__reference}
nodeKey={this.__key}
/>
) : (
<ResourceReferenceChip
reference={this.__reference}
nodeKey={this.__key}
@@ -0,0 +1,43 @@
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { ChatReference } from '../resourceReferences';
import type { ReferenceProvider } from './types';
/** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */
export function attachmentReferenceFromPart(
part: DirectCodexUserContentPart & { type: 'agc_attachment_reference' },
): ChatReference {
return {
type: 'attachment',
name: part.name,
mediaType: part.mediaType,
size: part.size,
localPath: part.localPath,
status: part.status,
};
}
/**
* 附件的 provider:**静默**(没有触发符、没有候选)。
*
* 附件只由宿主的导入动作或跨会话草稿回填进入正文,所以它只需要回答「这个 part 是不是附件」
* 「它在正文里长什么样」。附件本身不进候选菜单,也不需要改名刷新。
*/
export function createAttachmentReferenceProvider(): ReferenceProvider {
return {
trigger: null,
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
part.type === 'agc_attachment_reference'
? attachmentReferenceFromPart(part)
: null,
refresh: (reference: ChatReference): ChatReference | null =>
reference.type === 'attachment' ? reference : null,
mentionToken: (part) =>
part.type === 'agc_attachment_reference' ? `@${part.name}` : null,
};
}
/**
* 附件 provider 无状态,宿主可以共用同一个实例。
* 它没有触发符,所以「注入它」不会给输入区加出任何候选入口。
*/
export const attachmentReferenceProvider = createAttachmentReferenceProvider();
@@ -0,0 +1,78 @@
import type { GameCreationAppAssetManifestEntry } from '../../../../../../packages/shared/src/contracts/gameCreationApp';
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import {
assetsSignature,
type ChatReference,
mentionableAssetReferences,
refreshResourceReference,
resourceDisplayName,
type ResourceReference,
resourceReferenceFromAsset,
resourceReferenceMatchesQuery,
} from '../resourceReferences';
import type { ReferenceProvider } from './types';
/** 候选菜单最多展示多少条:两条触发链(`@` / `$`)共用同一个上限。 */
const MENTION_OPTION_LIMIT = 8;
type ResourceProviderData = {
byId: ReadonlyMap<string, GameCreationAppAssetManifestEntry>;
references: ResourceReference[];
};
let cachedSignature = '\u0000';
let cachedData: ResourceProviderData | null = null;
function resourceProviderData(
assets: readonly GameCreationAppAssetManifestEntry[],
): ResourceProviderData {
const signature = assetsSignature(assets);
if (cachedData && signature === cachedSignature) {
return cachedData;
}
cachedSignature = signature;
cachedData = {
byId: new Map(assets.map((asset) => [asset.id, asset])),
references: mentionableAssetReferences(assets, 'asset-picker'),
};
return cachedData;
}
/**
* 资源引用的 provider:候选、身份解析、显示名刷新与 `@显示名` 文本形态都按宿主传进来的
* 这份 manifest 展开。输入区自己不再读清单。
*/
export function createResourceReferenceProvider({
assets,
}: {
assets: readonly GameCreationAppAssetManifestEntry[];
}): ReferenceProvider {
return {
trigger: '@',
match: (query) =>
resourceProviderData(assets)
.references.filter((reference) =>
resourceReferenceMatchesQuery(reference, query),
)
.slice(0, MENTION_OPTION_LIMIT),
toReference: (part: DirectCodexUserContentPart): ChatReference | null => {
if (part.type !== 'agc_resource_reference') return null;
const asset = resourceProviderData(assets).byId.get(part.resourceId);
// 资产已不在 manifest(已删除):这里不合成引用,由调用方按同一份清单显式报缺口,
// 而不是静默把一条「带参考」变成「无参考」。
return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null;
},
refresh: (reference) =>
reference.type === 'resource'
? refreshResourceReference(reference, resourceProviderData(assets).byId)
: null,
mentionToken: (part) => {
if (part.type !== 'agc_resource_reference') return null;
const asset = resourceProviderData(assets).byId.get(part.resourceId);
if (!asset) return `@${part.resourceId}`;
return `@${resourceDisplayName(asset)}`;
},
// manifest 还空着时先别把草稿落进编辑器:这时候落下去,引用会被当成「已删除」丢掉。
isReady: () => assets.length > 0,
};
}
@@ -0,0 +1,45 @@
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type {
ChatReference,
RuntimeRegionReference,
} from '../resourceReferences';
import type { ReferenceProvider } from './types';
/**
* 运行画面区域引用的 provider:**静默**(没有触发符、没有候选)。
*
* 这类引用来自本地游戏预览里的选点动作,走 `RESOURCE_REFERENCE_INSERT_EVENT` 直接进正文,
* 从来没有过候选菜单;它在这里负责的是跨会话草稿回填与 `@标签` 文本形态。
*/
export function createRuntimeRegionReferenceProvider(): ReferenceProvider {
return {
trigger: null,
toReference: (part: DirectCodexUserContentPart): ChatReference | null => {
if (part.type !== 'agc_runtime_region_reference') return null;
return {
type: 'runtime-region',
label: part.label,
runId: part.runId ?? undefined,
versionId: part.versionId ?? undefined,
elementTag: part.elementTag ?? undefined,
elementRole: part.elementRole ?? undefined,
text: part.text ?? undefined,
width: part.width ?? undefined,
height: part.height ?? undefined,
resourceIds: part.resourceIds,
source: 'runtime-picker',
} satisfies RuntimeRegionReference;
},
refresh: (reference: ChatReference): ChatReference | null =>
reference.type === 'runtime-region' ? reference : null,
mentionToken: (part) =>
part.type === 'agc_runtime_region_reference' ? `@${part.label}` : null,
};
}
/**
* 运行画面区域 provider 无状态,宿主可以共用同一个实例。
* 它没有触发符,所以「注入它」不会给输入区加出任何候选入口。
*/
export const runtimeRegionReferenceProvider =
createRuntimeRegionReferenceProvider();
@@ -0,0 +1,127 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../../app/tauri';
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { ChatReference } from '../resourceReferences';
import type { ReferenceProvider } from './types';
/** 候选菜单最多展示多少条:与资源候选同一上限。 */
const MENTION_OPTION_LIMIT = 8;
type SkillCatalogItem = { name: string; description?: string };
/**
* 读应用级 Skill 目录:内置 Skill 与已启用的客户端 Skill 合起来是同一份候选。
*
* 这是一次**应用级**读取(不是项目清单),所以只在用户第一次敲出 `$` 时发生:
* 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。
* 查询失败时放开已请求标记:一次瞬时失败不能让这个输入区此后永远拿不到技能候选。
*/
function loadSkillCatalog(): Promise<SkillCatalogItem[]> {
const invoke = resolveTauriInvoke();
if (!invoke) return Promise.resolve([]);
return Promise.all([
invoke<Array<{ name: string; description: string }>>(
'list_agc_skill_catalog',
).then((items) =>
items.map((item) => ({
name: item.name,
description: item.description,
})),
),
invoke<
Array<{
name: string;
extensionType: string;
enabled: boolean;
status: string;
}>
>('list_client_extensions').then((items) =>
items
.filter(
(item) =>
item.extensionType === 'skill' &&
item.enabled &&
item.status === 'enabled',
)
.map((item) => ({ name: item.name })),
),
]).then(([builtin, client]) => [...builtin, ...client]);
}
function matchesSkillQuery(skill: SkillCatalogItem, query: string) {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return (
skill.name.toLowerCase().includes(normalized) ||
(skill.description?.toLowerCase().includes(normalized) ?? false)
);
}
/**
* Skill 引用的 provider(宿主 hook)。
*
* 与资源 provider 不同,Skill 候选是**异步**的应用级读取,所以它必须是一份 React 状态:
* 用户敲出 `$` 打开候选菜单时(`onMenuQueryChange` 收到非 `null`,由输入区在 effect 里回调)
* 发起读取,结果到了之后宿主重渲染,输入区随之拿到新的候选。
* `match` 保持纯函数,候选只从已就绪的状态里过滤——渲染阶段不产生任何副作用。
* 读取本身不进输入区,只有宿主才知道这条路该不该存在——
* 目前只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill。
*/
export function useSkillReferenceProvider(): ReferenceProvider {
const [skills, setSkills] = useState<SkillCatalogItem[]>([]);
const requestedRef = useRef(false);
const ensureCatalog = useCallback(() => {
if (requestedRef.current) return;
requestedRef.current = true;
void loadSkillCatalog()
.then((items) => setSkills(items))
.catch((error) => {
// 失败不静默:一次瞬时失败会在下一次菜单查询变化(继续敲字或重开菜单)时重试,
// 但持续失败至少要在控制台留痕,否则用户看到的是「敲 `$` 什么都没有」,排障时没有任何线索。
console.warn(
'[skill-reference] Skill 目录读取失败,下次触发重试',
error,
);
requestedRef.current = false;
setSkills([]);
});
}, []);
return useMemo(
() => ({
trigger: '$',
// 懒加载走菜单回调:只有用户真的敲出 `$`(query 非 null)时才值得读这份应用级目录。
onMenuQueryChange: (query) => {
if (query !== null) ensureCatalog();
},
match: (query) => {
const seen = new Set<string>();
return skills
.filter((skill) => {
if (seen.has(skill.name)) return false;
seen.add(skill.name);
return matchesSkillQuery(skill, query);
})
.slice(0, MENTION_OPTION_LIMIT)
.map(
(skill): ChatReference => ({
type: 'skill',
name: skill.name,
description: skill.description,
}),
);
},
toReference: (part: DirectCodexUserContentPart): ChatReference | null =>
part.type === 'agc_skill_reference'
? { type: 'skill', name: part.name }
: null,
refresh: (reference: ChatReference): ChatReference | null =>
reference.type === 'skill' ? reference : null,
mentionToken: (part) =>
part.type === 'agc_skill_reference' ? `$${part.name}` : null,
}),
[ensureCatalog, skills],
);
}
@@ -0,0 +1,49 @@
import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart';
import type { ChatReference } from '../resourceReferences';
/**
* 宿主注入给引用输入区的一种引用来源。
*
* 一个 provider 只认一种引用:候选、身份解析、改名刷新与「它在正文里的文本形态」都归它。
* 输入区按 `providers` 数组顺序取**第一个非空回答**,不判断引用种类,也不认识任何具体字段;
* 「没注入就没有这类引用」是默认,可见性不需要额外的开关。
*
* 每种引用一个独立工厂,宿主按需选择性注入——只注入资源 provider 就只有 `@`
* 再注入 Skill provider 才出现 `$`。**不做总装 builder**。
*/
export type ReferenceProvider = {
/**
* 触发候选菜单的字符(`@` / `$`);`null` 表示静默 provider
* 它仍参与 part 身份解析、改名刷新与文本形态,但不会在输入区里开出候选菜单
* (附件、运行画面区域就是这样进来的)。
*/
trigger: string | null;
/**
* 候选项:过滤、排序与截断都在 provider 内部完成。静默 provider 不实现。
*
* **必须是纯函数**:输入区在渲染阶段(`useMemo`)调用它,读清单、写 ref、发请求都会
* 在渲染期生效。需要为「菜单打开」拉一次数据时,用下面的 `onMenuQueryChange`。
*/
match?: (query: string) => ChatReference[];
/**
* 候选菜单的查询变化(菜单关闭时收到 `null`);输入区在 `useEffect` 里调它,**只在
* 带触发符的 provider 上调用**。
*
* 这是懒加载的唯一入口:例如 Skill 目录是应用级异步读取,第一次收到非 `null` 时再发起,
* 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。
*/
onMenuQueryChange?: (query: string | null) => void;
/** canonical part → 引用;不属于本 provider 或暂时无法解析时返回 `null`。 */
toReference: (part: DirectCodexUserContentPart) => ChatReference | null;
/** 引用身份刷新(资源改名等);不属于本 provider 时返回 `null`,原样返回表示无需改写。 */
refresh: (reference: ChatReference) => ChatReference | null;
/** part 在正文文本里的 token(`@显示名` / `$名称`);不属于本 provider 时返回 `null`。 */
mentionToken: (part: DirectCodexUserContentPart) => string | null;
/**
* 数据是否已经到齐(例如 manifest 还没加载完时为 `false`);省略表示始终就绪。
*
* 输入区用它决定「要不要现在就把初始草稿落进编辑器」:草稿里有本 provider 认不出、
* 但它迟早能认出来的 part 时先不落,否则那条引用会被静默丢掉。
*/
isReady?: () => boolean;
};
@@ -53,10 +53,21 @@ export type SkillReference = {
description?: string;
};
/**
* 聊天附件在正文里的引用形态。
*
* 与 canonical part `agc_attachment_reference` 逐字同字段(名称、类型、大小、项目相对路径、状态),
* 因为「导入成功即入正文」之后,附件芯片就是本轮附件的唯一事实源,没有第二份状态可以回退。
*/
export type AttachmentReference = {
type: 'attachment';
} & DirectCodexUserAttachmentReferencePart;
export type ChatReference =
| ResourceReference
| RuntimeRegionReference
| SkillReference;
| SkillReference
| AttachmentReference;
export type ChatComposerDraft = {
/** Lexical 节点按顺序投影出的 canonical user content,唯一事实源。 */
@@ -67,7 +78,6 @@ export type ChatComposerDraft = {
export type DirectCodexLegacyContentDto = {
text: string;
references: ChatReference[];
attachments: DirectCodexUserAttachmentReferencePart[];
};
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
@@ -128,36 +138,82 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
}
/**
* canonical content → 可读文本;资源引用按当前 manifest 展开为显示名
* 资源显示名解析:由调用方注入,函数自己不去读 manifest。
*
* **逐字投影,不裁剪空白**:前端不替用户改写输入,只有整条 content 的全空白判定
* `hasMeaningfulDirectCodexContent`)算「空」。需要首尾裁剪的调用方在自己的边界做。
* 返回 `undefined` 时按引用自带的 `resourceId` 兜底显示,保持「引用不因清单缺失而消失」的口径。
*/
export type ResourceLabelResolver = (resourceId: string) => string | undefined;
/** 按 manifest 取资源显示名;引用已不在清单时返回 `undefined`。 */
export function resourceLabelResolver(
assets: readonly GameCreationAppAssetManifestEntry[],
): ResourceLabelResolver {
return (resourceId) => {
const asset = assets.find((item) => item.id === resourceId);
return asset ? resourceDisplayName(asset) : undefined;
};
}
/**
* canonical content → 可读文本;每个引用 part 经 `tokenOf` 展开,文本 part 逐字保留。
*
* **每个 token 前后各留一个空白**(相邻已是空白或相邻就是另一个 token 时不重复补):这样
* 出站文本与「前后是空白 / 行首行尾」的反解析口径自洽,`@显示名` 紧贴中文的写法不会出现。
* 逐字保留文本 part 自己的空白,只有整条 content 的全空白判定
* `hasMeaningfulDirectCodexContent`)算「空」;需要首尾裁剪的调用方在自己的边界做。
*/
export function joinMentionText(
content: readonly DirectCodexUserContentPart[],
tokenOf: (part: DirectCodexUserContentPart) => string | null,
): string {
let text = '';
// 上一个 token 的后置空白尚未落地:下一个 part 决定它是被吃掉(本来就以空白开头)
// 还是真的补出来,避免出现双空白。
let pendingBlank = false;
for (const part of content) {
const token = tokenOf(part);
if (token === null) {
if (part.type !== 'input_text' || part.text === '') continue;
if (pendingBlank) {
pendingBlank = false;
if (!/^\s/u.test(part.text)) text += ' ';
}
text += part.text;
continue;
}
if (pendingBlank) {
text += ' ';
pendingBlank = false;
} else if (!/\s$/u.test(text)) {
text += ' ';
}
text += token;
pendingBlank = true;
}
return pendingBlank ? `${text} ` : text;
}
/**
* canonical content → 可读文本;资源引用按调用方注入的解析器展开为显示名。
*
* 显示名解析不进这个函数:调用方(聊天控制器、队列 chip、legacy 投影)各自持有清单,
* 由它们把 `resourceLabelResolver(assets)` 传进来。
*
* TODO we will rewrite this with ref as component later.
* (引用最终要作为组件参与渲染,那时这层「content → 文本」的展开就由组件自己承担,
* 这里的 token 拼接只是当前口径的落点。)
*/
export function directCodexContentToPromptText(
content: readonly DirectCodexUserContentPart[],
assets: readonly GameCreationAppAssetManifestEntry[],
resolveResourceLabel: ResourceLabelResolver,
) {
const labels = new Map(
assets.map((asset) => [asset.id, resourceDisplayName(asset)]),
);
return content
.map((part) => {
if (part.type === 'input_text') return part.text;
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}`;
}
if (part.type === 'agc_attachment_reference') {
return `@${part.name}`;
}
return '';
})
.join('');
return joinMentionText(content, (part) => {
if (part.type === 'input_text') return null;
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
if (part.type === 'agc_skill_reference') return `$${part.name}`;
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
return `@${resolveResourceLabel(part.resourceId) ?? part.resourceId}`;
});
}
/** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */
@@ -205,6 +261,16 @@ export function chatReferenceToContentPart(
if (reference.type === 'skill') {
return { type: 'agc_skill_reference', name: reference.name };
}
if (reference.type === 'attachment') {
return {
type: 'agc_attachment_reference',
name: reference.name,
mediaType: reference.mediaType,
size: reference.size,
localPath: reference.localPath,
status: reference.status,
};
}
return {
type: 'agc_runtime_region_reference',
label: reference.label,
@@ -220,26 +286,25 @@ export function chatReferenceToContentPart(
}
/**
* part 在「文本 → content」反解析里的可扫描 token:与 `directCodexContentToPromptText`
* 出站口径逐字一致——附件是 `@附件名`资源与 runtime 引用是 `@显示名`Skill 是 `$名称`
* `input_text` 没有 token,返回 `null`。
* 单条引用在文本里的可扫描 token:与出站口径逐字一致——附件 `@附件名`、Skill `$名称`、
* 资源与运行画面区域 `@显示名`资源引用自带显示名,所以这里不必再查 manifest
*/
export function contentPartMentionToken(
part: DirectCodexUserContentPart,
resourceLabels: ReadonlyMap<string, string>,
): string | null {
if (part.type === 'input_text') return null;
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
if (part.type === 'agc_skill_reference') return `$${part.name}`;
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
return `@${resourceLabels.get(part.resourceId) ?? part.resourceId}`;
export function chatReferenceMentionToken(reference: ChatReference): string {
if (reference.type === 'skill') return `$${reference.name}`;
if (reference.type === 'attachment') return `@${reference.name}`;
return `@${reference.label}`;
}
/** 单条引用在文本里的可扫描 token(引用自带显示名,不必再查 manifest)。 */
export function chatReferenceMentionToken(reference: ChatReference): string {
return reference.type === 'skill'
? `$${reference.name}`
: `@${reference.label}`;
/**
* 候选菜单的副标题:引用自带的补充说明。
*
* 只在这里按引用种类分支——输入区与选择器都按这一份口径渲染,不各自再判一次类型。
*/
export function chatReferenceDisplayHint(reference: ChatReference): string {
if (reference.type === 'resource') return reference.kind;
if (reference.type === 'skill') return reference.description ?? 'Skill';
if (reference.type === 'attachment') return reference.mediaType || '附件';
return reference.elementTag ?? '运行区域';
}
/** 一条待恢复 part 与它在文本里的 token。 */
@@ -358,24 +423,18 @@ export function buildContentFromTextTokens(
*
* 面板重开草稿、润色回包等 legacy 输入都走这一条翻译;文本里保留着 `@显示名` / `$名称` /
* `@附件名` 就能原位恢复 chip,整体被改写的 part 补在末尾(见 `buildContentFromTextTokens`)。
* 附件就是 `references` 里的 `attachment` 成员:本轮附件只有「正文芯片」一份事实源,
* 这里不再有第二个 `attachments` 数组可以叠加。
*/
export function legacyContentDtoToContent(dto: {
text: string;
references: readonly ChatReference[];
attachments?: readonly DirectCodexUserAttachmentReferencePart[];
}): DirectCodexUserContentPart[] {
return buildContentFromTextTokens(dto.text, [
...dto.references.map((reference) => ({
token: chatReferenceMentionToken(reference),
part: chatReferenceToContentPart(reference),
})),
...(dto.attachments ?? []).map((attachment) => ({
token: `@${attachment.name}`,
part: {
type: 'agc_attachment_reference' as const,
...attachment,
},
})),
]);
}
@@ -419,7 +478,13 @@ function unresolvedResourceReference(resourceId: string): ResourceReference {
};
}
/** canonical content → legacy text + reference + attachment DTO。只允许单向翻译。 */
/**
* canonical content → legacy text + reference DTO。只允许单向翻译。
*
* 附件也在 `references` 里(`attachment` 成员),所以这一层不需要第二个附件数组;
* 资源引用要带回 manifest 的完整身份(kind / mediaType / 分类 / 标签),所以这一层
* 仍然收 `assets`,只是把显示名展开交给 `resourceLabelResolver` 这一份注入的口径。
*/
export function directCodexContentToLegacyContentDto(
content: readonly DirectCodexUserContentPart[],
assets: readonly GameCreationAppAssetManifestEntry[],
@@ -453,14 +518,25 @@ export function directCodexContentToLegacyContentDto(
}
if (part.type === 'agc_skill_reference') {
references.push({ type: 'skill', name: part.name });
return;
}
if (part.type === 'agc_attachment_reference') {
references.push({
type: 'attachment',
name: part.name,
mediaType: part.mediaType,
size: part.size,
localPath: part.localPath,
status: part.status,
});
}
});
return {
text: directCodexContentToPromptText(content, assets),
references,
attachments: content.flatMap((part) =>
part.type === 'agc_attachment_reference' ? [part] : [],
text: directCodexContentToPromptText(
content,
resourceLabelResolver(assets),
),
references,
};
}
@@ -485,6 +561,45 @@ export function resourceReferenceFromAsset(
};
}
/**
* `@` 候选只收「有本地文件、且不是 `.agent/` 内部产物」的已登记素材。
* 判据只影响候选与引用身份解析,不影响 manifest 本身。
*/
export function isMentionableAsset(asset: GameCreationAppAssetManifestEntry) {
return Boolean(asset.localPath) && !asset.localPath.startsWith('.agent/');
}
/** 可提及素材 → 候选引用(`@` 面板与 `@` 候选共用同一份投影)。 */
export function mentionableAssetReferences(
assets: readonly GameCreationAppAssetManifestEntry[],
source: ResourceReferenceSource,
) {
return assets
.filter(isMentionableAsset)
.map((asset) => resourceReferenceFromAsset(asset, source));
}
/**
* 引用显示名只由 manifest 资产内容决定,而调用方每次渲染都会重建 assets 数组,
* 这里用内容签名做缓存键,避免与改名无关的渲染反复重算。
*/
export function assetsSignature(
assets: readonly GameCreationAppAssetManifestEntry[],
) {
return assets
.map((asset) =>
[
asset.id,
asset.kind,
asset.mediaType,
asset.localPath,
asset.category ?? '',
gameCreationAppAssetTags(asset).join(','),
].join('\u0000'),
)
.join('\u0001');
}
export function sameResourceReference(
left: ResourceReference,
right: ResourceReference,
@@ -673,13 +788,23 @@ function runtimeRegionReferenceDiscriminators(
}:${reference.width ?? ''}:${reference.height ?? ''}:${resourceIds}`;
}
function chatReferenceKey(reference: ChatReference) {
/**
* 引用的身份键:只认稳定身份,**不含显示名**。
*
* 同一个显示名可以来自两条不同引用(`characters/hero.png` 与 `enemies/hero.png` 都展开成
* `@hero`),所以去重与候选菜单的 React key 都必须用它,而不是显示 token。
*/
export function chatReferenceKey(reference: ChatReference) {
if (reference.type === 'resource') {
return `resource:${reference.resourceId}:${reference.source}`;
}
if (reference.type === 'skill') {
return `skill:${reference.name}`;
}
if (reference.type === 'attachment') {
// 附件身份落在导入产物上:同名文件可以来自不同目录,项目相对路径才是它的唯一身份。
return `attachment:${reference.localPath}:${reference.name}`;
}
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
}
@@ -696,6 +821,9 @@ export function chatReferenceListKey(references: ChatReference[]) {
if (reference.type === 'skill') {
return `skill:${reference.name}`;
}
if (reference.type === 'attachment') {
return `attachment:${reference.localPath}:${reference.name}`;
}
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
})
.join('\u0001');
@@ -5,6 +5,7 @@ import {
type CSSProperties,
type FormEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -21,11 +22,20 @@ import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/i
import { ThemedModal } from '../../components/modal/ThemedModal';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
import { attachmentReferenceProvider } from '../project-workspace/reference-source/attachmentReferenceProvider';
import { createResourceReferenceProvider } from '../project-workspace/reference-source/resourceReferenceProvider';
import { runtimeRegionReferenceProvider } from '../project-workspace/reference-source/runtimeRegionReferenceProvider';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../project-workspace/ResourceReferenceInput';
import {
ResourceReferencePicker,
ResourceReferencePickerAction,
type ResourceReferencePickerHandle,
} from '../project-workspace/ResourceReferencePicker';
import {
assetsSignature,
type ChatComposerDraft,
directCodexContentKey,
directCodexContentToLegacyContentDto,
@@ -253,6 +263,24 @@ export function ResourceCanvasAssetGenerationPanelView({
const referenceAssets = resourceCanvasAssetGenerationReferenceAssets(
assets ?? [],
);
const referenceAssetsSignature = assetsSignature(referenceAssets);
/*
引用来源:画布生成面板只注入资源与两个静默 provider——它把 `@` 引用当参考图用,
而 `$` Skill 候选只有 DirectProject 回合那条路径会被解析成真 Skill,这里不注入。
*/
const referenceProviders = useMemo(
() => [
createResourceReferenceProvider({ assets: referenceAssets }),
attachmentReferenceProvider,
runtimeRegionReferenceProvider,
],
// `referenceAssets` 每次渲染都是新数组(`.filter()` 派生),依赖它等于从不命中缓存:
// provider 身份一变,输入区就会重新注册 Lexical 更新监听。按内容签名取依赖才真的能跳过重建。
// eslint-disable-next-line react-hooks/exhaustive-deps
[referenceAssetsSignature],
);
const promptInputRootRef = useRef<HTMLDivElement | null>(null);
const referencePickerRef = useRef<ResourceReferencePickerHandle | null>(null);
/*
content 是唯一事实源:提示词与引用在这里单向派生,面板不各存一份镜像。
@@ -473,14 +501,30 @@ export function ResourceCanvasAssetGenerationPanelView({
ariaLabel="生成提示词"
initialContent={content}
onChange={applyDraft}
assets={referenceAssets}
providers={referenceProviders}
projectPath={projectPath ?? ''}
versions={versions}
activeVersionId={activeVersionId}
multiline
rows={3}
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
showPolishAction={false}
containerRef={promptInputRootRef}
inputActions={
<ResourceReferencePickerAction
onOpen={() => referencePickerRef.current?.open()}
/>
}
/>
{/* 素材选择器由宿主渲染、自己拿数据;确认后只把选中的引用交回输入区句柄。 */}
<ResourceReferencePicker
ref={referencePickerRef}
anchorRef={promptInputRootRef}
assets={referenceAssets}
versions={versions}
activeVersionId={activeVersionId}
projectPath={projectPath ?? ''}
onInsert={(references) =>
promptInputRef.current?.insertReferences(references)
}
/>
</div>
) : (
+4 -24
View File
@@ -11328,11 +11328,7 @@ button.design-workspace-tree__entry:hover,
gap: 6px;
}
/* 待发附件 / 队列 chip:压在输入区上方,横向排布、超出换行。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-attachments,
/* 队列 chip:压在输入区上方,横向排布、超出换行。 */
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
@@ -11346,11 +11342,6 @@ button.design-workspace-tree__entry:hover,
list-style: none;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-attachments
li,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
@@ -11367,12 +11358,6 @@ button.design-workspace-tree__entry:hover,
font-size: 12px;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-attachments
li
span,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
@@ -11383,11 +11368,6 @@ button.design-workspace-tree__entry:hover,
white-space: nowrap;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
.project-chat-composer-attachments
button,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-composer.is-direct-codex
@@ -11622,9 +11602,9 @@ button.design-workspace-tree__entry:hover,
margin-top: 0;
}
/* direct-codex `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`
即输入框内 `.resource-reference-input-at` 根本不会渲染`@` 触发钮是下面控制排里的
`.project-chat-reference-trigger`这里原本有一条针对它的
/* direct-codex `@` 触发钮是下面控制排里的 `.project-chat-reference-trigger`
不是输入区操作排里 `.resource-reference-input-at`宿主不注入
`ResourceReferencePickerAction`改用控制排那个按钮这里原本有一条针对它的
`position: static !important` / 30px 主色方块规则永远匹配不到已删除
要改 direct-codex `@` 触发钮外观请改 `.project-chat-reference-trigger` */
@@ -122,8 +122,8 @@ export function DirectProjectChatView({
});
const {
appendLocalMessage,
attachments,
attachmentNotice,
attachmentsImporting,
cancelQueuedTurn,
cancelTurn,
composerNotice,
@@ -134,7 +134,6 @@ export function DirectProjectChatView({
localMessages,
pendingUserItemId,
queuedTurns,
removeAttachment,
startInitialTurn,
statusNotice,
submit,
@@ -247,8 +246,8 @@ export function DirectProjectChatView({
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
attachments={attachments}
attachmentNotice={attachmentNotice}
attachmentsImporting={attachmentsImporting}
queuedTurns={queuedTurns}
composerNotice={composerNotice}
busy={turnStatus.displayBusy}
@@ -260,9 +259,8 @@ export function DirectProjectChatView({
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
if (projectPath) void refresh(projectPath);
}}
onRemoveAttachment={removeAttachment}
onSubmit={submit}
onUploadFiles={(files) => void uploadFiles(files)}
onUploadFiles={uploadFiles}
/>
</div>
{settingsOpen ? (
@@ -2,7 +2,7 @@
* 输入盒控件(Codex 观感):左 `+`(上传本地文件 / 引用项目素材)、右侧推理强度 +
* 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。
*
* 这些组件只承载表现与交互;回合附件由 `App.tsx` 上传并落进 `DirectCodexTurnAttachment`
* 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源)
* 队列由 `chatComposerQueue.ts` 的纯函数维护。
*/
import {
@@ -30,7 +30,6 @@ import {
DEFAULT_COMPOSER_REASONING_EFFORT,
normalizeComposerReasoningEffort,
} from '../../../../../features/project-workspace/composerReasoningEffort';
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
import type { QueuedChatTurn } from './chatComposerQueue';
import { queuedChatTurnLabel } from './chatComposerQueue';
import {
@@ -152,41 +151,6 @@ export function ComposerAttachmentMenu({
);
}
/** 待发附件 chip:随下次提交一起进入回合,可单条移除。 */
export function ComposerPendingAttachments({
attachments,
onRemove,
}: {
attachments: readonly DirectCodexTurnAttachment[];
onRemove: (index: number) => void;
}) {
if (attachments.length === 0) {
return null;
}
return (
<ul className="project-chat-composer-attachments" aria-label="待发送附件">
{attachments.map((attachment, index) => (
<li
key={`${attachment.name}-${index}`}
data-attachment-status={attachment.status ?? 'ready'}
>
<span title={attachment.localPath ?? attachment.name}>
{attachment.name}
</span>
<button
type="button"
aria-label={`移除附件 ${attachment.name}`}
title={`移除附件 ${attachment.name}`}
onClick={() => onRemove(index)}
>
<X size={12} aria-hidden="true" />
</button>
</li>
))}
</ul>
);
}
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
export function ComposerTurnQueue({
turns,
@@ -1,5 +1,5 @@
import { ArrowUp, AtSign } from 'lucide-react';
import { useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type {
GameCreationAppAssetManifestEntry,
@@ -9,23 +9,39 @@ import {
ConversationModelSelect,
type ConversationModelSelectHandle,
} from '../../../../../features/project-workspace/ConversationModelSelect';
import { attachmentReferenceProvider } from '../../../../../features/project-workspace/reference-source/attachmentReferenceProvider';
import { createResourceReferenceProvider } from '../../../../../features/project-workspace/reference-source/resourceReferenceProvider';
import { runtimeRegionReferenceProvider } from '../../../../../features/project-workspace/reference-source/runtimeRegionReferenceProvider';
import { useSkillReferenceProvider } from '../../../../../features/project-workspace/reference-source/skillReferenceProvider';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../../../../../features/project-workspace/ResourceReferenceInput';
import {
ResourceReferencePicker,
type ResourceReferencePickerHandle,
} from '../../../../../features/project-workspace/ResourceReferencePicker';
import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
import { directCodexAttachmentContentParts } from '../../conversation/directCodexTurnAttachments';
import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerAttachmentMenu,
ComposerPendingAttachments,
ComposerReasoningEffortSelect,
ComposerStopButton,
ComposerTurnQueue,
ComposerVoiceButton,
} from './ComposerControls';
/** 草稿里已有的附件芯片数:附件上限按它算,不再另存一份待发列表。 */
function draftAttachmentCount(draft: {
content: DirectCodexUserContentPart[];
}) {
return draft.content.filter(
(part) => part.type === 'agc_attachment_reference',
).length;
}
/**
* DirectProject 输入区:队列、附件、`@` 引用输入框、模型/推理选择和发送/终止。
*
@@ -36,8 +52,8 @@ export function DirectProjectComposer({
assets,
versions,
projectPath,
attachments,
attachmentNotice,
attachmentsImporting,
queuedTurns,
composerNotice,
busy,
@@ -45,15 +61,15 @@ export function DirectProjectComposer({
onCancelQueuedTurn,
onCancelTurn,
onReferencePickerOpen,
onRemoveAttachment,
onSubmit,
onUploadFiles,
}: {
assets: GameCreationAppAssetManifestEntry[];
versions?: GameIterationVersion[];
projectPath: string | null;
attachments: DirectCodexTurnAttachment[];
attachmentNotice: string;
/** 附件导入进行中:导入结果还没落成芯片,这一轮先不许发出去。 */
attachmentsImporting: boolean;
queuedTurns: QueuedChatTurn[];
composerNotice: string;
busy: boolean;
@@ -68,16 +84,74 @@ export function DirectProjectComposer({
* 用户真正要看这份清单的时刻是打开选择器的这一刻,所以在这里补一次重读。
*/
onReferencePickerOpen?: () => void;
onRemoveAttachment: (index: number) => void;
onSubmit: (content: DirectCodexUserContentPart[]) => boolean;
onUploadFiles: (files: readonly File[]) => void;
/**
* 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。
* 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。
*/
onUploadFiles: (
files: readonly File[],
draftAttachmentCount: number,
) => Promise<DirectCodexTurnAttachment[]>;
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
const composerRootRef = useRef<HTMLDivElement | null>(null);
const pickerRef = useRef<ResourceReferencePickerHandle | null>(null);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
const [voiceNotice, setVoiceNotice] = useState('');
const [modelReady, setModelReady] = useState(false);
const [modelValidating, setModelValidating] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
/*
引用来源:DirectProject 是唯一会把 `agc_skill_reference` 解析成真 Skill 的宿主
Rust `direct_codex_user_item_to_codex_turn_input`),所以只有这里注入 Skill provider。
*/
const skillProvider = useSkillReferenceProvider();
const assetsContentSignature = assetsSignature(assets);
const referenceProviders = useMemo(
() => [
createResourceReferenceProvider({ assets }),
skillProvider,
attachmentReferenceProvider,
runtimeRegionReferenceProvider,
],
// `assets` 由 manifest 订阅每次渲染重新 `.filter()` 派生,依赖它等于从不命中缓存:
// provider 身份一变,输入区就重新注册 Lexical 更新监听(流式回复时每帧都换)。
// 按内容签名取依赖才真的能跳过重建。
// eslint-disable-next-line react-hooks/exhaustive-deps
[assetsContentSignature, skillProvider],
);
const openPicker = useCallback(() => {
onReferencePickerOpen?.();
pickerRef.current?.open();
}, [onReferencePickerOpen]);
/**
* 导入成功即插芯片:附件只有「正文芯片」这一份事实源,没有第二份待发列表可以回退。
* 失败 / 异常一律不插。导入期间允许多次点击:各自的结果插各自的芯片。
*/
const uploadFiles = useCallback(
async (files: readonly File[]) => {
const current = composerRef.current?.getDraft();
const imported = await onUploadFiles(
files,
current ? draftAttachmentCount(current) : 0,
);
if (imported.length === 0) return;
composerRef.current?.insertReferences(
imported.map((attachment) => ({
type: 'attachment' as const,
name: attachment.name,
mediaType: attachment.mediaType,
size: attachment.size ?? 0,
localPath: attachment.localPath ?? '',
status:
attachment.status ?? (attachment.localPath ? 'imported' : 'failed'),
})),
);
},
[onUploadFiles],
);
// 这个按钮只在非 busy 分支出现(busy 时由 ComposerStopButton 接管),所以不再带忙态逻辑。
const submitButton = (
<button
@@ -85,7 +159,7 @@ export function DirectProjectComposer({
className="project-chat-submit-button"
aria-label="发送"
title="发送"
disabled={!modelReady || modelValidating}
disabled={!modelReady || modelValidating || attachmentsImporting}
>
<ArrowUp size={16} aria-hidden="true" />
</button>
@@ -96,7 +170,7 @@ export function DirectProjectComposer({
className="project-chat-composer is-direct-codex"
onSubmit={(event) => {
event.preventDefault();
if (modelValidateInFlightRef.current) return;
if (modelValidateInFlightRef.current || attachmentsImporting) return;
modelValidateInFlightRef.current = true;
setModelValidating(true);
void (async () => {
@@ -105,10 +179,7 @@ export function DirectProjectComposer({
? await modelSelectRef.current.ensureUsable()
: modelReady;
if (ready) {
const content = [
...(composerRef.current?.getDraft().content ?? []),
...directCodexAttachmentContentParts(attachments),
];
const content = composerRef.current?.getDraft().content ?? [];
if (onSubmit(content)) {
composerRef.current?.clear();
}
@@ -125,20 +196,16 @@ export function DirectProjectComposer({
assets={assets}
onCancel={onCancelQueuedTurn}
/>
<ComposerPendingAttachments
attachments={attachments}
onRemove={onRemoveAttachment}
/>
<ResourceReferenceInput
ref={composerRef}
ariaLabel="陶泥儿对话内容"
versions={versions}
assets={assets}
providers={referenceProviders}
projectPath={projectPath ?? ''}
disabled={modelValidating}
rows={3}
placeholder="描述你的想法,或 @ 引用素材"
showTriggerButton={false}
submitSuppressed={pickerOpen}
containerRef={composerRootRef}
/>
<div className="project-chat-composer-controls">
<div className="project-chat-composer-controls-left">
@@ -148,20 +215,14 @@ export function DirectProjectComposer({
aria-label="插入素材引用"
title="插入素材引用"
disabled={busy}
onClick={() => {
onReferencePickerOpen?.();
composerRef.current?.openPicker();
}}
onClick={openPicker}
>
<AtSign size={15} aria-hidden="true" />
</button>
<ComposerAttachmentMenu
disabled={busy || modelValidating}
onPickFiles={(files) => void onUploadFiles(files)}
onOpenReferencePicker={() => {
onReferencePickerOpen?.();
composerRef.current?.openPicker();
}}
onPickFiles={(files) => void uploadFiles(files)}
onOpenReferencePicker={openPicker}
/>
</div>
<div className="project-chat-composer-controls-right">
@@ -192,6 +253,21 @@ export function DirectProjectComposer({
{composerNotice || attachmentNotice || voiceNotice}
</p>
) : null}
{/*
素材选择器由宿主渲染、自己拿数据:确认后只把选中的引用交给输入区句柄,
输入区与它之间只有 `insertReferences` 这一个接缝。
*/}
<ResourceReferencePicker
ref={pickerRef}
anchorRef={composerRootRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath ?? ''}
onInsert={(references) =>
composerRef.current?.insertReferences(references)
}
onOpenChange={setPickerOpen}
/>
</form>
);
}
@@ -5,7 +5,10 @@
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
import { directCodexContentToPromptText } from '../../../../../features/project-workspace/resourceReferences';
import {
directCodexContentToPromptText,
resourceLabelResolver,
} from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexUserItem } from '../../generated/DirectCodexUserItem';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
@@ -85,7 +88,10 @@ export function queuedChatTurnLabel(
turn: QueuedChatTurn,
assets: readonly GameCreationAppAssetManifestEntry[],
): string {
const text = directCodexContentToPromptText(turn.userItem.content, assets)
const text = directCodexContentToPromptText(
turn.userItem.content,
resourceLabelResolver(assets),
)
.trim()
.replace(/\s+/gu, ' ');
if (!text) return '未命名消息';
@@ -12,6 +12,7 @@ import {
directCodexContentToPromptText,
directCodexUserItemFromContent,
hasMeaningfulDirectCodexContent,
resourceLabelResolver,
} from '../../../../features/project-workspace/resourceReferences';
import { captureAgentRuntimeError } from '../../../../services/errorReporting';
import { requestPlatformSessionRefresh } from '../../../../services/platformSession';
@@ -152,10 +153,18 @@ export function useDirectProjectChatController({
projectPath,
refreshManifest,
}: DirectProjectChatControllerProps) {
const [attachments, setAttachments] = useState<DirectCodexTurnAttachment[]>(
[],
);
const [attachmentNotice, setAttachmentNotice] = useState('');
/**
* 附件导入进行中:导入结果还没落成正文芯片,这一轮先不许发出去,
* 否则用户以为带上的附件根本没进这一轮。并发导入共用一个计数。
*/
const [attachmentsImporting, setAttachmentsImporting] = useState(false);
const attachmentsImportingCountRef = useRef(0);
/**
* 已经在飞、还没落成正文芯片的附件条数:并发导入各算各的预算时,这些名额必须先占住,
* 否则两次并发导入会拿着同一份草稿附件数各自放行,插进正文的芯片就超过上限。
*/
const attachmentsPendingCountRef = useRef(0);
const [queuedTurns, setQueuedTurns] = useState<QueuedChatTurn[]>([]);
const queuedTurnsRef = useRef<QueuedChatTurn[]>([]);
queuedTurnsRef.current = queuedTurns;
@@ -189,7 +198,6 @@ export function useDirectProjectChatController({
const previousTurnRunningRef = useRef(currentTurnRunning);
useEffect(() => {
setAttachments([]);
setAttachmentNotice('');
setComposerNotice('');
setStatusNotice('');
@@ -312,22 +320,43 @@ export function useDirectProjectChatController({
if (next.length === 0) setComposerNotice('');
}
async function uploadFiles(files: readonly File[]) {
/**
* 导入文件:上限按 `draftAttachmentCount`(草稿里已有的附件芯片数)算,
* 只把**导入成功**的附件交回宿主,由宿主插成正文芯片;失败的一条都不返回。
*
* 控制器不再持有待发附件列表:本轮附件只有「正文芯片」一份事实源,
* 所以这里不做任何「提交时再拼一次」的事。
*
* 上限必须是硬上限:草稿里的附件芯片数 + 这次与其它并发导入已经放行、还没落地的条数
* 一起算预算,放行的那部分在 `finally` 里还回去。
*/
async function uploadFiles(
files: readonly File[],
draftAttachmentCount: number,
): Promise<DirectCodexTurnAttachment[]> {
const invoke = resolveTauriInvoke();
if (!invoke || !projectPath) {
setAttachmentNotice('需要先打开本地项目,才能上传文件');
return;
return [];
}
const accepted = files.slice(
0,
Math.max(MAX_CHAT_COMPOSER_ATTACHMENTS - attachments.length, 0),
Math.max(
MAX_CHAT_COMPOSER_ATTACHMENTS -
draftAttachmentCount -
attachmentsPendingCountRef.current,
0,
),
);
if (accepted.length === 0) {
setAttachmentNotice(
`最多同时携带 ${MAX_CHAT_COMPOSER_ATTACHMENTS} 个附件,请先移除已有附件`,
);
return;
return [];
}
attachmentsPendingCountRef.current += accepted.length;
attachmentsImportingCountRef.current += 1;
setAttachmentsImporting(true);
setAttachmentNotice('正在上传文件');
try {
const imported = await uploadLocalFilesAsAttachments(
@@ -336,39 +365,41 @@ export function useDirectProjectChatController({
accepted,
);
const nextAttachments = toDirectCodexTurnAttachments(imported);
if (projectPathRef.current !== projectPath) return;
setAttachments((current) =>
[...current, ...nextAttachments].slice(
0,
MAX_CHAT_COMPOSER_ATTACHMENTS,
),
);
if (projectPathRef.current !== projectPath) return [];
const failed = nextAttachments.filter(
(attachment) => attachment.status === 'failed',
);
setAttachmentNotice(
failed.length > 0
? `${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}`
: `已上传 ${nextAttachments.length} 个文件,将在下次发送时作为本轮附件`,
);
if (failed.length > 0) {
setAttachmentNotice(
`${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}`,
);
} else {
setAttachmentNotice(
`已上传 ${nextAttachments.length} 个文件,将在下次发送时作为本轮附件`,
);
}
void refreshManifest(projectPath);
return nextAttachments.filter(
(attachment) => attachment.status !== 'failed',
);
} catch (error) {
if (projectPathRef.current === projectPath) {
setAttachmentNotice(
error instanceof Error ? error.message : String(error),
);
}
return [];
} finally {
attachmentsPendingCountRef.current -= accepted.length;
attachmentsImportingCountRef.current -= 1;
if (attachmentsImportingCountRef.current <= 0) {
attachmentsImportingCountRef.current = 0;
setAttachmentsImporting(false);
}
}
}
function removeAttachment(index: number) {
setAttachments((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
);
}
function clearDraftInput() {
setAttachments([]);
setAttachmentNotice('');
}
@@ -396,7 +427,10 @@ export function useDirectProjectChatController({
}
return false;
}
const prompt = directCodexContentToPromptText(content, assets).trim();
const prompt = directCodexContentToPromptText(
content,
resourceLabelResolver(assets),
).trim();
if (prompt === '/history') {
clearPendingInput();
void reloadHistory();
@@ -448,7 +482,10 @@ export function useDirectProjectChatController({
role: 'user',
text:
input.messageText ??
directCodexContentToPromptText(input.userItem.content, assets),
directCodexContentToPromptText(
input.userItem.content,
resourceLabelResolver(assets),
),
runtimeOwned: true,
messageId: directCodexConversationMessageId(input.clientTurnId, 'user'),
updatedAt: Date.now(),
@@ -788,8 +825,8 @@ export function useDirectProjectChatController({
return {
appendLocalMessage,
attachments,
attachmentNotice,
attachmentsImporting,
cancelQueuedTurn,
cancelTurn,
composerNotice,
@@ -801,7 +838,6 @@ export function useDirectProjectChatController({
pendingUserItemId,
queuedTurns,
reloadHistory,
removeAttachment,
startInitialTurn: startTurn,
statusNotice,
submit,
@@ -113,10 +113,18 @@ import {
type LocalGamePreviewInspectSelection,
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import { attachmentReferenceProvider } from '../../features/project-workspace/reference-source/attachmentReferenceProvider';
import { createResourceReferenceProvider } from '../../features/project-workspace/reference-source/resourceReferenceProvider';
import { runtimeRegionReferenceProvider } from '../../features/project-workspace/reference-source/runtimeRegionReferenceProvider';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../../features/project-workspace/ResourceReferenceInput';
import {
ResourceReferencePicker,
ResourceReferencePickerAction,
type ResourceReferencePickerHandle,
} from '../../features/project-workspace/ResourceReferencePicker';
import {
type ChatComposerDraft,
type ChatReference,
@@ -130,6 +138,7 @@ import {
legacyContentDtoToContent,
resolveActiveIterationVersion,
resourceDisplayName,
resourceLabelResolver,
resourceReferenceCategoryLabel,
} from '../../features/project-workspace/resourceReferences';
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
@@ -1897,6 +1906,21 @@ export default function ProjectDevelopmentView({
const quickEditPromptInputRef = useRef<ResourceReferenceInputHandle | null>(
null,
);
const quickEditPromptRootRef = useRef<HTMLDivElement | null>(null);
const quickEditReferencePickerRef =
useRef<ResourceReferencePickerHandle | null>(null);
/*
provider
`@` `$` Skill 退
*/
const quickEditReferenceProviders = useMemo(
() => [
createResourceReferenceProvider({ assets: manifest.assets }),
attachmentReferenceProvider,
runtimeRegionReferenceProvider,
],
[manifest.assets],
);
/**
* 稿 `@`
*
@@ -7831,7 +7855,7 @@ export default function ProjectDevelopmentView({
currentDraft &&
directCodexContentToPromptText(
currentDraft.content,
manifest.assets,
resourceLabelResolver(manifest.assets),
) !== text
) {
quickEditPromptInputRef.current?.replaceText(text);
@@ -10239,10 +10263,8 @@ export default function ProjectDevelopmentView({
),
]}
onChange={applyResourceQuickEditDraft}
assets={manifest.assets}
providers={quickEditReferenceProviders}
projectPath={projectPath}
versions={projectVersions}
activeVersionId={activeVersionId}
multiline
rows={3}
placeholder="你希望素材如何修改?"
@@ -10250,6 +10272,28 @@ export default function ProjectDevelopmentView({
// (带资源编辑场景上下文与长度上限),
// 不在这里再渲染第二个润色入口。
showPolishAction={false}
containerRef={quickEditPromptRootRef}
inputActions={
<ResourceReferencePickerAction
onOpen={() =>
quickEditReferencePickerRef.current?.open()
}
/>
}
/>
{/* 素材选择器由宿主渲染、自己拿数据;确认后只把选中的引用交回输入区句柄。 */}
<ResourceReferencePicker
ref={quickEditReferencePickerRef}
anchorRef={quickEditPromptRootRef}
assets={manifest.assets}
versions={projectVersions}
activeVersionId={activeVersionId}
projectPath={projectPath}
onInsert={(references) =>
quickEditPromptInputRef.current?.insertReferences(
references,
)
}
/>
</div>
}
@@ -1,6 +1,6 @@
import { ArrowUp, FileUp, Loader2 } from 'lucide-react';
import type { FormEventHandler, RefObject, UIEventHandler } from 'react';
import { useRef } from 'react';
import { useMemo, useRef } from 'react';
import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent';
import type {
@@ -16,10 +16,14 @@ import {
projectRuntimeVisibleError,
} from '../../../features/agent-runtime';
import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect';
import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider';
import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider';
import { runtimeRegionReferenceProvider } from '../../../features/project-workspace/reference-source/runtimeRegionReferenceProvider';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../../../features/project-workspace/ResourceReferenceInput';
import { assetsSignature } from '../../../features/project-workspace/resourceReferences';
import { AgentReasoning } from '../chat/components/AgentReasoning/AgentReasoning';
import { ComposerReasoningEffortSelect } from '../chat/components/DirectProjectComposer/ComposerControls';
import { formatClockTime } from '../chat/components/ToolCallGroup/toolCallGroupPresentation';
@@ -55,7 +59,6 @@ function workspaceStatusForDisplay(workspaceStatus: string) {
}
type PlanningChatViewProps = {
activeVersionId?: string | null;
chatProjectAssets: import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
controlBusy: boolean;
@@ -84,7 +87,6 @@ type PlanningChatViewProps = {
visibleMessages: ChatMessage[];
conversationMessages?: ChatMessage[];
workspaceStatus: string;
versions?: import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion[];
designView?: DesignView | null;
onDesignApprove?: (requestId: string, approved: boolean) => void;
onDesignClarify?: (
@@ -96,7 +98,6 @@ type PlanningChatViewProps = {
};
export function PlanningChatView({
activeVersionId = null,
chatProjectAssets,
composerRef,
controlBusy,
@@ -123,13 +124,30 @@ export function PlanningChatView({
visibleMessages,
conversationMessages = visibleMessages,
workspaceStatus,
versions,
designView = null,
onDesignApprove,
onDesignClarify,
onDesignRetry,
}: PlanningChatViewProps) {
const designFileInputRef = useRef<HTMLInputElement>(null);
/*
策划输入盒的引用来源:**只注入资源与两个静默 provider**。
`$` Skill 候选只有 DirectProject 回合那条路径会把 `agc_skill_reference` 解析成真 Skill
这里注入 Skill provider 只会得到一个退化成本文的误导入口,所以不注入。
*/
const chatProjectAssetsSignature = assetsSignature(chatProjectAssets);
const referenceProviders = useMemo(
() => [
createResourceReferenceProvider({ assets: chatProjectAssets }),
attachmentReferenceProvider,
runtimeRegionReferenceProvider,
],
// `chatProjectAssets` 由工作台壳每次渲染重新 `.filter()` 派生,依赖它等于从不命中缓存:
// provider 身份一变,输入区就重新注册 Lexical 更新监听。按内容签名取依赖才真的能跳过重建。
// eslint-disable-next-line react-hooks/exhaustive-deps
[chatProjectAssetsSignature],
);
let submitLabel = '发送';
if (needsUserInput) {
submitLabel = '等待回答';
@@ -306,13 +324,10 @@ export function PlanningChatView({
<ResourceReferenceInput
ref={composerRef}
ariaLabel="项目需求"
activeVersionId={activeVersionId}
versions={versions}
assets={chatProjectAssets}
providers={referenceProviders}
projectPath={projectPath}
disabled={composerDisabled}
rows={3}
showTriggerButton={false}
showPolishAction={false}
placeholder=""
/>

Some files were not shown because too many files have changed in this diff Show More