diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/local-project-prompt-polish.md b/apps/ai-game-creator-shell/src-tauri/prompts/local-project-prompt-polish.md new file mode 100644 index 000000000..5d10aa646 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/local-project-prompt-polish.md @@ -0,0 +1 @@ +你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index def2f7ddd..0ad75c169 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -18,6 +18,13 @@ const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = include_str!("../prompts/automatic-project-name.md"); +// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写, +// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。 +const LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS: usize = 4_000; +const LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS: usize = 1_000; +const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048; +const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str = + include_str!("../prompts/local-project-prompt-polish.md"); fn is_chinese_project_name_character(value: char) -> bool { matches!( @@ -79,6 +86,122 @@ async fn request_automatic_project_name(prompt: &str) -> Result, Ok(normalize_suggested_project_name(response.text.trim())) } +pub(crate) fn build_local_project_prompt_polish_prompt( + prompt: &str, + context: Option<&str>, +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("待润色的内容为空,无法润色".to_string()); + } + let mut user_prompt: String = prompt + .chars() + .take(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS) + .collect(); + if let Some(context) = context.map(str::trim).filter(|value| !value.is_empty()) { + let context: String = context + .chars() + .take(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS) + .collect(); + user_prompt.push_str("\n\n当前项目上下文:\n"); + user_prompt.push_str(&context); + } + Ok(user_prompt) +} + +/// 只接受非空文本:空回复或纯空白一律视为润色失败,由调用方保留原文。 +fn normalize_polished_prompt(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +async fn request_local_project_prompt_polish( + prompt: &str, + context: Option<&str>, +) -> Result { + let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = crate::agent::direct_game_creator_home_codex_chat( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ) + .await?; + return normalize_polished_prompt(&reply) + .ok_or_else(|| "AI 润色没有返回可用文本".to_string()); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = LlmRunRequest::single_turn( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(), + user_prompt, + ) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = client + .run(request) + .await + .map_err(|error| format!("AI 润色失败:{error}"))?; + normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string()) +} + +#[cfg(test)] +mod local_project_prompt_polish_tests { + use super::*; + + #[test] + fn rejects_empty_prompt() { + assert!(build_local_project_prompt_polish_prompt(" ", None).is_err()); + } + + #[test] + fn appends_optional_project_context() { + let prompt = + build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" 像素风 ")).unwrap(); + assert_eq!(prompt, "做个跳跃游戏\n\n当前项目上下文:\n像素风"); + let without_context = + build_local_project_prompt_polish_prompt("做个跳跃游戏", None).unwrap(); + assert_eq!(without_context, "做个跳跃游戏"); + let blank_context = + build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" ")).unwrap(); + assert_eq!(blank_context, "做个跳跃游戏"); + } + + #[test] + fn truncates_prompt_and_context_to_their_limits() { + let long_prompt = "字".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS + 32); + let long_context = "文".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS + 32); + let prompt = + build_local_project_prompt_polish_prompt(&long_prompt, Some(&long_context)).unwrap(); + let (body, context) = prompt.split_once("\n\n当前项目上下文:\n").unwrap(); + assert_eq!( + body.chars().count(), + LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS + ); + assert_eq!( + context.chars().count(), + LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS + ); + } + + #[test] + fn empty_model_reply_is_not_accepted_as_polished_text() { + assert_eq!(normalize_polished_prompt(" \n "), None); + assert_eq!( + normalize_polished_prompt(" 做个像素风跳跃游戏 \n").as_deref(), + Some("做个像素风跳跃游戏") + ); + } +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AssetImportRequirements { @@ -1655,6 +1778,16 @@ pub(crate) async fn suggest_automatic_project_name( .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) } +#[tauri::command] +pub(crate) async fn polish_local_project_prompt( + prompt: String, + context: Option, +) -> Result { + request_local_project_prompt_polish(prompt.trim(), context.as_deref()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + #[tauri::command] pub(crate) fn read_platform_account_session_generation() -> u64 { current_platform_session_generation() diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index f6a13b90b..6cc169f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2563,6 +2563,7 @@ fn main() { pick_local_project_directory, rename_local_game_project, suggest_automatic_project_name, + polish_local_project_prompt, pick_local_file, pick_client_extension_file, pick_client_extension_directory, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 0666e92ac..d8cbe4035 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -11092,6 +11092,10 @@ export function App({ const chatProjectAssets = manifest.assets.filter( (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), ); + // `@` 面板「当前版本素材」的版本来源。本次只透传 manifest 版本列表, + // activeVersionId 传 null 表示回退到 manifest 中最新的版本。 + const chatProjectVersions = manifest.versions ?? []; + const chatActiveVersionId = null; const visibleMainProjectCheckpoints = projectCheckpoints.slice(0, 5); const currentProjectTitle = localProject ? manifest.name.trim() || projectNameFromPath(localProject.projectPath) @@ -11357,6 +11361,7 @@ export function App({ localProject?.projectPath || initialProjectPath || projectPath; return ( ); } @@ -11401,6 +11407,7 @@ export function App({ if (projectSupervisorOnly) { return ( void; + onUseOriginalAndSubmit: () => void; + onClose: () => void; + onReminderDisabledChange: (disabled: boolean) => void; +}; + +/** + * 发送前提醒面板:独立弹窗(不追加在输入区下方), + * 提供「AI 润色」先润色再发送、「使用原文提交」直接发送原文、「关闭」取消发送, + * 以及本机持久化的「不再提醒」偏好。 + */ +export function ChatPromptPolishReminder({ + busy, + error, + reminderDisabled, + onPolishAndSubmit, + onUseOriginalAndSubmit, + onClose, + onReminderDisabledChange, +}: ChatPromptPolishReminderProps) { + useEscapeToClose(onClose, !busy); + + return createPortal( +
{ + if (busy) return; + closeDialogOnBackdropMouseDown(event, onClose); + }} + > +
closeDialogOnEscape(event, onClose)} + > +

发送前提醒

+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + + +
+
+
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 2b3b34e0e..eaf41548d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -66,6 +66,7 @@ function directStatusTitle(status: string | null | undefined) { } type ProjectSupervisorViewProps = RuntimePanelProps & { + activeVersionId?: string | null; chatInput: string; chatReferences: ChatReference[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; @@ -104,9 +105,11 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { comment: string | null, ) => Promise; onMakeGameFromApprovedGdd?: () => Promise; + versions?: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion[]; }; export function ProjectSupervisorView({ + activeVersionId = null, chatInput, chatReferences, chatProjectAssets, @@ -142,6 +145,7 @@ export function ProjectSupervisorView({ onPlanGddRefresh, onPlanGddDecision, onMakeGameFromApprovedGdd, + versions, ...runtimePanelProps }: ProjectSupervisorViewProps) { const planningSurfaceActive = @@ -384,6 +388,8 @@ export function ProjectSupervisorView({ ; agentStatusCards: AgentStatusCard[]; @@ -200,10 +202,12 @@ type ProjectWorkspaceChatPaneProps = { visibleMainProjectCheckpoints: LocalProjectCheckpointSummary[]; visibleMainProjectFiles: LocalProjectFileEntry[]; visibleMessages: ChatMessage[]; + versions?: GameIterationVersion[]; workspaceStatus: string; }; export function ProjectWorkspaceChatPane({ + activeVersionId = null, agentRunStatus, agentRuntimeById, agentStatusCards, @@ -286,6 +290,7 @@ export function ProjectWorkspaceChatPane({ visibleMainProjectCheckpoints, visibleMainProjectFiles, visibleMessages, + versions, workspaceStatus, }: ProjectWorkspaceChatPaneProps) { return ( @@ -947,6 +952,8 @@ export function ProjectWorkspaceChatPane({ ref={composerRef} inputRef={chatInputRef} ariaLabel="创作想法" + activeVersionId={activeVersionId} + versions={versions} assets={chatProjectAssets} projectPath={projectPath} disabled={chatAgentBusy || projectSupervisorNeedsUserInput} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index fa45e9745..44e7780c9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -33,7 +33,9 @@ import { Image as ImageIcon, Loader2, Music2, + RotateCcw, Search, + Sparkles, Video, } from 'lucide-react'; import { @@ -47,16 +49,28 @@ import { useRef, useState, } from 'react'; -import { createPortal } from 'react-dom'; +import { createPortal, flushSync } from 'react-dom'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; -import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + type GameCreationAppAssetManifestEntry, + gameCreationAppAssetTags, + type GameIterationVersion, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, createProjectResourcePreviewScopeId, } from '../../services/projectResourcePreviewTransport'; +import { + chatPromptDraftKey, + readChatPromptPolishReminderDisabled, + requestChatPromptPolish, + shouldRemindChatPromptPolish, + writeChatPromptPolishReminderDisabled, +} from './chatPromptPolish'; +import { ChatPromptPolishReminder } from './ChatPromptPolishReminder'; import { $createResourceReferenceNode, $isResourceReferenceNode, @@ -65,14 +79,19 @@ import { import { type ChatComposerDraft, type ChatReference, + chatReferenceListKey, + currentIterationVersionAssets, dedupeChatReferences, + refreshResourceReference, RESOURCE_REFERENCE_FILTERS, + RESOURCE_REFERENCE_SCOPES, type ResourceReference, resourceReferenceCategoryLabel, type ResourceReferenceFilter, resourceReferenceFromAsset, resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, + type ResourceReferenceScope, } from './resourceReferences'; type ResourceReferenceInputProps = { @@ -81,6 +100,14 @@ type ResourceReferenceInputProps = { onChange: (draft: ChatComposerDraft) => void; assets: GameCreationAppAssetManifestEntry[]; projectPath: string; + /** + * `@` 面板「当前版本素材」页签使用的版本 id。 + * 没传 / `null` 时回退到 manifest `versions[]` 中最新的那个版本; + * 版本不存在或没有绑定资源时该页签显示空态。 + */ + activeVersionId?: string | null; + /** manifest 的正式版本列表,用于解析「当前版本素材」。 */ + versions?: GameIterationVersion[]; disabled?: boolean; placeholder?: string; ariaLabel: string; @@ -90,6 +117,26 @@ type ResourceReferenceInputProps = { inputRef?: RefObject; }; +type ResourcePickerScopeState = { + query: string; + filter: ResourceReferenceFilter; + selectedResourceIds: string[]; +}; + +function createResourcePickerScopeState(): ResourcePickerScopeState { + return { query: '', filter: 'all', selectedResourceIds: [] }; +} + +function createResourcePickerScopeStates(): Record< + ResourceReferenceScope, + ResourcePickerScopeState +> { + return { + 'current-version': createResourcePickerScopeState(), + 'all-canvas': createResourcePickerScopeState(), + }; +} + export type ResourceReferenceInputHandle = { insertReferences: (references: ChatReference[]) => void; openPicker: () => void; @@ -106,15 +153,7 @@ class ResourceMentionOption extends MenuOption { } function referenceListKey(references: ChatReference[]) { - return references - .map((reference) => - reference.type === 'resource' - ? `resource:${reference.resourceId}:${reference.source}:${reference.label}` - : `runtime-region:${reference.runId ?? ''}:${reference.label}:${ - reference.elementTag ?? '' - }:${reference.text ?? ''}`, - ) - .join('\u0001'); + return chatReferenceListKey(references); } function sameDraft(left: ChatComposerDraft, right: ChatComposerDraft) { @@ -177,12 +216,71 @@ function appendTextParagraphs( }); } +function isMentionableAsset(asset: GameCreationAppAssetManifestEntry) { + return Boolean(asset.localPath) && !asset.localPath.startsWith('.agent/'); +} + +/** + * 引用显示名只由 manifest 资产内容决定,而调用方每次渲染都会重建 assets 数组, + * 这里用内容签名做依赖,避免与改名无关的渲染反复刷新已有引用。 + */ +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'); +} + +function mentionableAssetReferences( + assets: GameCreationAppAssetManifestEntry[], + source: ResourceReference['source'], +) { + return assets + .filter(isMentionableAsset) + .map((asset) => resourceReferenceFromAsset(asset, source)); +} + +/** + * 资源改名后把编辑区里已有的引用 chip 刷成 manifest 的最新显示名。 + * 只改写仍能找到对应资产的引用;已删除资源保持原引用,不合成资源卡。 + */ +function $staleResourceReferenceNodes( + assetsById: ReadonlyMap, +) { + const staleNodes: { node: ResourceReferenceNode; reference: ChatReference }[] = + []; + $getRoot().getChildren().forEach((block) => { + if (!$isElementNode(block)) return; + block.getChildren().forEach((child) => { + if (!$isResourceReferenceNode(child)) return; + if (child.__reference.type !== 'resource') return; + const nextReference = refreshResourceReference( + child.__reference, + assetsById, + ); + if (nextReference === child.__reference) return; + staleNodes.push({ node: child, reference: nextReference }); + }); + }); + return staleNodes; +} + function ResourceReferenceEditor({ value, references, onChange, assets, projectPath, + activeVersionId = null, + versions, disabled, placeholder, ariaLabel, @@ -201,10 +299,11 @@ function ResourceReferenceEditor({ }); const [query, setQuery] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); - const [pickerQuery, setPickerQuery] = useState(''); - const [pickerFilter, setPickerFilter] = - useState('all'); - const [selectedResourceIds, setSelectedResourceIds] = useState([]); + const [pickerScopeStates, setPickerScopeStates] = useState< + Record + >(createResourcePickerScopeStates); + const [pickerScopeOverride, setPickerScopeOverride] = + useState(null); const [pickerPosition, setPickerPosition] = useState<{ left: number; bottom: number; @@ -213,14 +312,41 @@ function ResourceReferenceEditor({ const rootRef = useRef(null); const assetReferences = useMemo( - () => - assets - .filter( - (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), - ) - .map((asset) => resourceReferenceFromAsset(asset, 'asset-picker')), + () => mentionableAssetReferences(assets, 'asset-picker'), [assets], ); + const currentVersionAssetReferences = useMemo( + () => + mentionableAssetReferences( + currentIterationVersionAssets(assets, versions, activeVersionId), + 'version-asset', + ), + [activeVersionId, assets, versions], + ); + // 两个页签的默认落点:当前版本确实绑定了已登记素材时优先展示「当前版本素材」, + // 否则落到「全部画布素材」,避免没有版本的项目一打开就是空列表。 + const defaultPickerScope: ResourceReferenceScope = + currentVersionAssetReferences.length > 0 ? 'current-version' : 'all-canvas'; + const pickerScope = pickerScopeOverride ?? defaultPickerScope; + const pickerScopeState = pickerScopeStates[pickerScope]; + const pickerQuery = pickerScopeState.query; + const pickerFilter = pickerScopeState.filter; + const selectedResourceIds = pickerScopeState.selectedResourceIds; + + const scopeReferences = + pickerScope === 'current-version' + ? currentVersionAssetReferences + : assetReferences; + + const updatePickerScopeState = useCallback( + (patch: Partial) => { + setPickerScopeStates((current) => ({ + ...current, + [pickerScope]: { ...current[pickerScope], ...patch }, + })); + }, + [pickerScope], + ); const mentionOptions = useMemo(() => { if (query === null) return []; @@ -241,7 +367,12 @@ function ResourceReferenceEditor({ if (nextReferences.length === 0) return; editor.update(() => { let selection = $getSelection(); - if (!$isRangeSelection(selection)) { + // 跨会话恢复草稿后选区可能仍指向已被重建掉的节点,这里统一回落到草稿末尾, + // 避免把引用插到一个已经不存在的位置。 + if ( + !$isRangeSelection(selection) || + !selection.anchor.getNode().isAttached() + ) { $getRoot().selectEnd(); selection = $getSelection(); } @@ -260,9 +391,8 @@ function ResourceReferenceEditor({ ); const openPicker = useCallback(() => { - setPickerQuery(''); - setPickerFilter('all'); - setSelectedResourceIds([]); + setPickerScopeStates(createResourcePickerScopeStates()); + setPickerScopeOverride(null); setPickerOpen(true); }, []); @@ -305,10 +435,44 @@ function ResourceReferenceEditor({ ); root.append(referenceParagraph); } + // 程序化重建草稿(切会话 / 重开会话恢复草稿)后把光标收回草稿末尾: + // 既保证恢复后光标落在文本末尾,也保证后续 @ 引用按顺序追加而不是插到旧位置。 + root.selectEnd(); }); lastEmittedDraftRef.current = nextDraft; }, [editor, references, value]); + const assetsContentSignature = assetsSignature(assets); + const assetsById = useMemo( + () => new Map(assets.map((asset) => [asset.id, asset])), + // 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。 + // eslint-disable-next-line react-hooks/exhaustive-deps + [assetsContentSignature], + ); + + // 资源改名后刷新已有引用 chip 的显示名:改写节点会触发 OnChangePlugin, + // 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。 + // Lexical 的更新可能排到微任务里提交,这里额外挂一次更新监听兜底。 + const refreshResourceReferenceLabels = useCallback(() => { + if (assetsById.size === 0) return; + const hasStaleReferences = editor + .getEditorState() + .read(() => $staleResourceReferenceNodes(assetsById).length > 0); + if (!hasStaleReferences) return; + editor.update(() => { + $staleResourceReferenceNodes(assetsById).forEach(({ node, reference }) => { + node.replace($createResourceReferenceNode(reference)); + }); + }); + }, [assetsById, editor]); + + useEffect(() => { + refreshResourceReferenceLabels(); + return editor.registerUpdateListener(() => { + refreshResourceReferenceLabels(); + }); + }, [editor, refreshResourceReferenceLabels]); + useEffect( () => editor.registerCommand( @@ -368,6 +532,155 @@ function ResourceReferenceEditor({ [], ); + // —— C8 AI 润色与发送前提醒 —— + // 原文快照只在第一次成功润色时落下,之后反复润色只覆盖结果, + // 因此「恢复原文」永远回到最初原文。 + const [promptPolish, setPromptPolish] = useState<{ + originalText: string; + resultText: string; + } | null>(null); + const [polishing, setPolishing] = useState(false); + const [polishError, setPolishError] = useState(null); + const [reminderOpen, setReminderOpen] = useState(false); + const [reminderPolishing, setReminderPolishing] = useState(false); + const [reminderDisabled, setReminderDisabled] = useState(() => + readChatPromptPolishReminderDisabled(), + ); + const acknowledgedDraftKeyRef = useRef(null); + // 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。 + const liveDraftRef = useRef({ text: value, references }); + liveDraftRef.current = { text: value, references }; + const reminderDisabledRef = useRef(reminderDisabled); + reminderDisabledRef.current = reminderDisabled; + + const applyPromptText = useCallback( + (text: string) => { + flushSync(() => { + onChange({ text, references: liveDraftRef.current.references }); + }); + }, + [onChange], + ); + + const runPromptPolish = useCallback(async () => { + const draft = liveDraftRef.current; + if (!draft.text.trim()) return null; + return requestChatPromptPolish(draft.text, projectPath || null); + }, [projectPath]); + + const polishPrompt = useCallback(async () => { + if (polishing) return; + setPolishing(true); + setPolishError(null); + try { + const polished = await runPromptPolish(); + if (!polished) { + // 失败 / 超时 / 未配置模型:保留原文,只给出可重试提示。 + setPolishError('AI 润色失败,可重试'); + return; + } + setPromptPolish((current) => ({ + originalText: current?.originalText ?? liveDraftRef.current.text, + resultText: polished, + })); + applyPromptText(polished); + } finally { + setPolishing(false); + } + }, [applyPromptText, polishing, runPromptPolish]); + + const restoreOriginalPrompt = useCallback(() => { + const originalText = promptPolish?.originalText; + if (originalText === undefined) return; + setPromptPolish(null); + setPolishError(null); + applyPromptText(originalText); + }, [applyPromptText, promptPolish]); + + const closeReminder = useCallback(() => { + setReminderOpen(false); + setPolishError(null); + }, []); + + // 用户已在提醒面板里做出选择:记下本轮草稿指纹,再真正提交表单。 + // 输入区不在表单里时(例如单独渲染的单元测试)没有可提交的表单事件,仅取消提醒状态。 + const submitCurrentDraft = useCallback(() => { + setReminderOpen(false); + acknowledgedDraftKeyRef.current = chatPromptDraftKey(liveDraftRef.current); + rootRef.current?.closest('form')?.requestSubmit(); + }, []); + + const useOriginalAndSubmit = useCallback(() => { + setPolishError(null); + submitCurrentDraft(); + }, [submitCurrentDraft]); + + const polishAndSubmitFromReminder = useCallback(async () => { + if (reminderPolishing) return; + setReminderPolishing(true); + setPolishError(null); + try { + const polished = await runPromptPolish(); + if (!polished) { + // 保留原文并留在提醒面板里,用户可以选择重试或直接使用原文提交。 + setPolishError('AI 润色失败,可重试或使用原文提交'); + return; + } + const nextPromptPolish = { + originalText: promptPolish?.originalText ?? liveDraftRef.current.text, + resultText: polished, + }; + setPromptPolish(nextPromptPolish); + applyPromptText(polished); + submitCurrentDraft(); + } finally { + setReminderPolishing(false); + } + }, [ + applyPromptText, + promptPolish, + reminderPolishing, + runPromptPolish, + submitCurrentDraft, + ]); + + const setPromptPolishReminderDisabled = useCallback( + (disabled: boolean) => { + writeChatPromptPolishReminderDisabled(disabled); + setReminderDisabled(disabled); + }, + [], + ); + + // 发送前提醒拦截:在捕获阶段拦下 form 的 submit,阻止 React 的表单提交处理器执行, + // 等用户在独立面板里做出选择后再用 requestSubmit() 真正提交。 + useEffect(() => { + const form = rootRef.current?.closest('form'); + if (!form) return; + const handleFormSubmit = (event: Event) => { + if (!shouldRemindChatPromptPolish({ + draft: liveDraftRef.current, + acknowledgedDraftKey: acknowledgedDraftKeyRef.current, + reminderDisabled: reminderDisabledRef.current, + })) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setReminderOpen(true); + }; + form.addEventListener('submit', handleFormSubmit, true); + return () => form.removeEventListener('submit', handleFormSubmit, true); + }, []); + + // 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。 + useEffect(() => { + if (value.trim() !== '' || references.length > 0) return; + setPromptPolish(null); + setPolishError(null); + acknowledgedDraftKeyRef.current = null; + }, [references, value]); + const renderMentionMenu: MenuRenderFn = useCallback( (_anchorElementRef, itemProps) => { const inputRect = rootRef.current?.getBoundingClientRect(); @@ -438,11 +751,11 @@ function ResourceReferenceEditor({ ); const pickerReferences = useMemo(() => { - return assetReferences.filter((reference) => { + return scopeReferences.filter((reference) => { if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false; return resourceReferenceMatchesCategoryFilter(reference, pickerFilter); }); - }, [assetReferences, pickerFilter, pickerQuery]); + }, [pickerFilter, pickerQuery, scopeReferences]); const visiblePickerReferences = pickerReferences.slice(0, 120); @@ -504,8 +817,8 @@ function ResourceReferenceEditor({ } ErrorBoundary={LexicalErrorBoundary} /> - {showTriggerButton ? ( -
+
+ {showTriggerButton ? ( -
+ ) : null} + + {promptPolish ? ( + + ) : null} +
+ {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} + {!reminderOpen && (polishing || polishError) ? ( + + {polishing ? '润色中…' : polishError} + ) : null} options={mentionOptions} @@ -554,21 +906,43 @@ function ResourceReferenceEditor({ × + { + setPickerScopeOverride(nextScope); + }} + gap="sm" + frame="bare" + surface="transparent" + size="sm" + semantics="tabs" + ariaLabel="素材范围" + className="platform-theme platform-theme--light resource-reference-picker-scopes" + />
+ updatePickerScopeState({ filter: nextFilter }) + } layout="scroll" gap="sm" frame="bare" @@ -579,8 +953,10 @@ function ResourceReferenceEditor({
{visiblePickerReferences.length === 0 ? (

- {assetReferences.length === 0 - ? '当前项目还没有已登记素材' + {scopeReferences.length === 0 + ? pickerScope === 'current-version' + ? '当前版本还没有绑定素材' + : '当前项目还没有已登记素材' : '没有匹配的素材'}

) : ( @@ -596,20 +972,21 @@ function ResourceReferenceEditor({ key={`${reference.resourceId}:${reference.kind}`} className={selected ? 'is-selected' : undefined} onClick={() => - setSelectedResourceIds((current) => - current.includes(reference.resourceId) - ? current.filter( + updatePickerScopeState({ + selectedResourceIds: selected + ? selectedResourceIds.filter( (resourceId) => resourceId !== reference.resourceId, ) - : [...current, reference.resourceId], - ) + : [ + ...selectedResourceIds, + reference.resourceId, + ], + }) } > asset.id === reference.resourceId, - )} + asset={assetsById.get(reference.resourceId)} projectPath={projectPath} /> @@ -639,7 +1016,7 @@ function ResourceReferenceEditor({ disabled={selectedResourceIds.length === 0} onClick={() => { insertReferences( - assetReferences.filter((reference) => + scopeReferences.filter((reference) => selectedResourceIds.includes(reference.resourceId), ), ); @@ -653,6 +1030,17 @@ function ResourceReferenceEditor({ document.body, ) : null} + {reminderOpen ? ( + void polishAndSubmitFromReminder()} + onUseOriginalAndSubmit={useOriginalAndSubmit} + onClose={closeReminder} + onReminderDisabledChange={setPromptPolishReminderDisabled} + /> + ) : null} { const nextDraft = readDraftFromEditorState(editorState); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index c53d4f302..c4aa1640c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -42,6 +42,7 @@ type RuntimeControlProps = ComponentProps< const CHAT_SCROLL_BOTTOM_THRESHOLD = 24; type SupervisorChatOnlyViewProps = { + activeVersionId?: string | null; chatAgentBusy: boolean; chatInput: string; chatReferences: ChatReference[]; @@ -74,9 +75,11 @@ type SupervisorChatOnlyViewProps = { visibleMessages: ChatMessage[]; workspaceStatus: string; expectedRunId?: string | null; + versions?: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion[]; }; export function SupervisorChatOnlyView({ + activeVersionId = null, chatAgentBusy, chatInput, chatReferences, @@ -108,6 +111,7 @@ export function SupervisorChatOnlyView({ needsUserInput, visibleMessages, workspaceStatus, + versions, }: SupervisorChatOnlyViewProps) { const shouldFollowLatestRef = useRef(true); const running = Boolean( @@ -312,6 +316,8 @@ export function SupervisorChatOnlyView({ { + const invoke = resolveTauriInvoke(); + const trimmed = prompt.trim(); + if (!invoke || !trimmed) { + return null; + } + const trimmedContext = context?.trim(); + try { + const result = await invoke('polish_local_project_prompt', { + prompt: trimmed, + context: trimmedContext ? trimmedContext : null, + }); + const polished = typeof result === 'string' ? result.trim() : ''; + return polished ? polished : null; + } catch { + return null; + } +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 5c921501f..1d1990987 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -3,6 +3,7 @@ import { gameCreationAppAssetCategory, type GameCreationAppAssetManifestEntry, gameCreationAppAssetTags, + type GameIterationVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; export type ResourceReferenceSource = @@ -152,6 +153,79 @@ export function resourceReferenceMatchesCategoryFilter( return filter === 'all' || resourceReferenceCategory(reference) === filter; } +/** + * `@` 面板的两个页签。两个页签各自持有独立的搜索与筛选状态, + * 也不与资源画布的筛选联动。 + */ +export const RESOURCE_REFERENCE_SCOPES = [ + { id: 'current-version', label: '当前版本素材' }, + { id: 'all-canvas', label: '全部画布素材' }, +] as const; + +export type ResourceReferenceScope = + (typeof RESOURCE_REFERENCE_SCOPES)[number]['id']; + +/** + * 解析「当前版本素材」对应的正式版本: + * - 传了 `activeVersionId` → 按 `versionId` 精确匹配; + * - 没传 / `null` → 回退到 manifest `versions[]` 中最后写入的那个版本 + * (manifest 按写入顺序记录版本,最后一项即最新版本); + * - `versions[]` 为空或显式版本 id 不存在 → `null`,调用方按空态处理。 + */ +export function resolveActiveIterationVersion( + versions: readonly GameIterationVersion[] | undefined, + activeVersionId: string | null | undefined, +): GameIterationVersion | null { + const items = versions ?? []; + if (items.length === 0) { + return null; + } + if (activeVersionId) { + return ( + items.find((version) => version.versionId === activeVersionId) ?? null + ); + } + return items[items.length - 1] ?? null; +} + +/** + * 「当前版本素材」= 当前版本 `resourceBindings` 里绑定的、且仍登记在 manifest 的资产。 + * 绑定指向已删除资源(悬空绑定)时按资产 id 过滤会自然丢掉,因此不会合成资源卡; + * 没有版本或没有绑定时返回空数组,由调用方渲染空态。 + */ +export function currentIterationVersionAssets( + assets: readonly GameCreationAppAssetManifestEntry[], + versions: readonly GameIterationVersion[] | undefined, + activeVersionId: string | null | undefined, +): GameCreationAppAssetManifestEntry[] { + const version = resolveActiveIterationVersion(versions, activeVersionId); + if (!version || version.resourceBindings.length === 0) { + return []; + } + const boundResourceIds = new Set( + version.resourceBindings.map((binding) => binding.resourceId), + ); + return assets.filter((asset) => boundResourceIds.has(asset.id)); +} + +/** + * 资源改名后把引用刷新到 manifest 的最新显示名。 + * 资源已不在 manifest(已删除)时原样返回,不合成引用。 + */ +export function refreshResourceReference( + reference: ResourceReference, + assetsById: ReadonlyMap, +): ResourceReference { + const asset = assetsById.get(reference.resourceId); + if (!asset) { + return reference; + } + const nextReference = resourceReferenceFromAsset(asset, reference.source); + return sameResourceReference(reference, nextReference) + ? reference + : nextReference; +} + function chatReferenceKey(reference: ChatReference) { if (reference.type === 'resource') { return `resource:${reference.resourceId}:${reference.source}`; @@ -161,6 +235,22 @@ function chatReferenceKey(reference: ChatReference) { }:${reference.text ?? ''}`; } +/** + * 草稿里引用列表的完整指纹(含显示名)。用于判断「外部传进来的草稿是否真的变了」, + * 也用于发送前提醒的「本轮草稿」判定。 + */ +export function chatReferenceListKey(references: ChatReference[]) { + return references + .map((reference) => + reference.type === 'resource' + ? `resource:${reference.resourceId}:${reference.source}:${reference.label}` + : `runtime-region:${reference.runId ?? ''}:${reference.label}:${ + reference.elementTag ?? '' + }:${reference.text ?? ''}`, + ) + .join('\u0001'); +} + export function dedupeChatReferences(references: ChatReference[]) { const seen = new Set(); return references.filter((reference) => { diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index e0b6de6f2..5776568fe 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4497,6 +4497,74 @@ h2 { opacity: 0.5; } +.resource-reference-input-polish, +.resource-reference-input-restore { + display: grid; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid #cfd6df; + border-radius: 8px; + background: #f8fafc; + color: #475569; + place-items: center; + cursor: pointer; +} + +.resource-reference-input-polish:hover:not(:disabled), +.resource-reference-input-restore:hover:not(:disabled) { + border-color: #94a3b8; + color: #0f172a; +} + +.resource-reference-input-polish:disabled, +.resource-reference-input-restore:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.resource-reference-input-status { + grid-column: 1 / -1; + color: #b45309; + font-size: 12px; + line-height: 1.4; +} + +.chat-prompt-polish-reminder { + display: grid; + gap: 12px; +} + +.chat-prompt-polish-reminder-error { + color: #b45309; +} + +.chat-prompt-polish-reminder-preference { + display: flex; + align-items: center; + gap: 8px; + color: #6b7280; + font-size: 13px; +} + +.chat-prompt-polish-reminder-actions, +.chat-prompt-polish-reminder .launcher-dialog-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.chat-prompt-polish-reminder .launcher-dialog-actions button { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.resource-reference-picker-scopes { + padding: 0 12px 4px; +} + .resource-reference-chip { display: inline-flex; align-items: center; @@ -4600,7 +4668,7 @@ h2 { right: 0; bottom: calc(100% + 8px); display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) auto; + grid-template-rows: auto auto auto auto minmax(0, 1fr) auto; width: min(440px, 88vw); max-height: min(480px, 70vh); overflow: hidden; diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx new file mode 100644 index 000000000..5d9119429 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -0,0 +1,429 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH, + CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, + chatPromptDraftKey, + readChatPromptPolishReminderDisabled, + requestChatPromptPolish, + shouldRemindChatPromptPolish, + writeChatPromptPolishReminderDisabled, +} from '../src/features/project-workspace/chatPromptPolish'; +import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; +import { + type ChatComposerDraft, + type ChatReference, + resourceReferenceFromAsset, +} from '../src/features/project-workspace/resourceReferences'; + +type TauriInvoke = ( + command: string, + args?: Record, +) => Promise; + +const LONG_PROMPT = + '做一个像素风横版动作小游戏,包含三段跳跃关卡、三个 Boss 和可以收集的金币与道具。'; + +function installTauriInvoke(invoke: TauriInvoke) { + const mock = vi.fn(invoke); + ( + window as unknown as { + __TAURI__?: { core?: { invoke?: typeof mock } }; + } + ).__TAURI__ = { core: { invoke: mock } }; + return mock; +} + +function installPolishingInvoke(...results: string[]) { + const queue = [...results]; + return installTauriInvoke(async (command) => { + if (command !== 'polish_local_project_prompt') return undefined; + const next = queue.shift(); + if (next === undefined) { + throw new Error('fixture has no more polish results'); + } + return next; + }); +} + +// Lexical 的编辑器状态提交排在微任务里,读取输入区文本前先让 React 追平编辑器内容。 +async function settleComposer() { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function composerText() { + await settleComposer(); + return screen.getByLabelText('创作想法').textContent ?? ''; +} + +function ControlledChatComposer({ + initialText, + initialReferences = [], + onSubmitDraft, +}: { + initialText: string; + initialReferences?: ChatReference[]; + onSubmitDraft: (draft: ChatComposerDraft) => void; +}) { + const [draft, setDraft] = useState({ + text: initialText, + references: initialReferences, + }); + return ( +
{ + event.preventDefault(); + onSubmitDraft(draft); + }} + > + + + + ); +} + +function renderComposer({ + initialText, + initialReferences, + onSubmitDraft = vi.fn(), +}: { + initialText: string; + initialReferences?: ChatReference[]; + onSubmitDraft?: (draft: ChatComposerDraft) => void; +}) { + render( + , + ); + return onSubmitDraft; +} + +function sendButton() { + return screen.getByRole('button', { name: '发送' }); +} + +function reminderPanel() { + return screen.getByRole('dialog', { name: '发送前提醒' }); +} + +afterEach(() => { + cleanup(); + delete ( + window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } } + ).__TAURI__; + window.localStorage.clear(); +}); + +describe('发送前提醒判据', () => { + test('only reminds for long plain prompts that were not acknowledged this round', () => { + const draft: ChatComposerDraft = { + text: '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH), + references: [], + }; + expect( + shouldRemindChatPromptPolish({ + draft, + acknowledgedDraftKey: null, + reminderDisabled: false, + }), + ).toBe(true); + expect( + shouldRemindChatPromptPolish({ + draft: { text: '短需求', references: [] }, + acknowledgedDraftKey: null, + reminderDisabled: false, + }), + ).toBe(false); + expect( + shouldRemindChatPromptPolish({ + draft, + acknowledgedDraftKey: null, + reminderDisabled: true, + }), + ).toBe(false); + expect( + shouldRemindChatPromptPolish({ + draft, + acknowledgedDraftKey: chatPromptDraftKey(draft), + reminderDisabled: false, + }), + ).toBe(false); + expect( + shouldRemindChatPromptPolish({ + draft: { text: `/${'长'.repeat(60)}`, references: [] }, + acknowledgedDraftKey: null, + reminderDisabled: false, + }), + ).toBe(false); + }); + + test('changes the draft key when the references change', () => { + const reference = resourceReferenceFromAsset( + { + id: 'hero', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'uploaded' }, + }, + 'asset-picker', + ); + expect( + chatPromptDraftKey({ text: '需求', references: [] }), + ).not.toBe(chatPromptDraftKey({ text: '需求', references: [reference] })); + }); + + test('persists the 不再提醒 preference on this machine only', () => { + expect(readChatPromptPolishReminderDisabled()).toBe(false); + writeChatPromptPolishReminderDisabled(true); + expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY)) + .toBe('true'); + expect(readChatPromptPolishReminderDisabled()).toBe(true); + writeChatPromptPolishReminderDisabled(false); + expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY)) + .toBeNull(); + expect(readChatPromptPolishReminderDisabled()).toBe(false); + }); +}); + +describe('requestChatPromptPolish', () => { + test('goes through the short-text Tauri command with trimmed input', async () => { + const invoke = installPolishingInvoke(' 润色后的需求 '); + await expect( + requestChatPromptPolish(' 原始需求 ', ' 项目上下文 '), + ).resolves.toBe('润色后的需求'); + expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { + prompt: '原始需求', + context: '项目上下文', + }); + }); + + test('omits blank context and keeps failures recoverable', async () => { + const invoke = installPolishingInvoke('润色结果'); + await requestChatPromptPolish('原始需求', ' '); + expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { + prompt: '原始需求', + context: null, + }); + + const failing = vi.fn(async () => { + throw new Error('platform llm unavailable'); + }); + installTauriInvoke(failing); + await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull(); + await expect(requestChatPromptPolish(' ')).resolves.toBeNull(); + }); + + test('treats an empty model reply as a failure', async () => { + installPolishingInvoke(' '); + await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull(); + }); + + test('returns null when the native bridge is unavailable', async () => { + delete ( + window as unknown as { __TAURI__?: { core?: { invoke?: TauriInvoke } } } + ).__TAURI__; + await expect(requestChatPromptPolish('原始需求')).resolves.toBeNull(); + }); +}); + +describe('聊天输入区 AI 润色与发送前提醒', () => { + test('fills back the polished result, can polish again, and always restores the first original', async () => { + const user = userEvent.setup(); + const invoke = installPolishingInvoke('第一版润色结果', '第二版润色结果'); + renderComposer({ initialText: '原本的需求' }); + + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + expect(await composerText()).toBe('第一版润色结果'); + expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', { + prompt: '原本的需求', + context: 'C:/project', + }); + + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + expect(await composerText()).toBe('第二版润色结果'); + expect(invoke.mock.calls.at(-1)?.[1]).toEqual({ + prompt: '第一版润色结果', + context: 'C:/project', + }); + + await user.click(screen.getByRole('button', { name: '恢复原文' })); + expect(await composerText()).toBe('原本的需求'); + expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); + }); + + test('keeps the original text and shows a retry hint when polishing fails', async () => { + const user = userEvent.setup(); + installTauriInvoke(async (command) => { + if (command !== 'polish_local_project_prompt') return undefined; + throw new Error('platform llm timeout'); + }); + renderComposer({ initialText: '原本的需求' }); + + await user.click(screen.getByRole('button', { name: 'AI 润色' })); + expect(await composerText()).toBe('原本的需求'); + expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull(); + expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); + }); + + test('holds a long prompt behind the reminder panel and sends the original on demand', async () => { + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + expect(reminderPanel()).not.toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '使用原文提交' }), + ); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + }); + + test('closing the reminder cancels the send and keeps the reminder armed', async () => { + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '关闭' }), + ); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + + fireEvent.click(sendButton()); + expect(reminderPanel()).not.toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + }); + + test('polishes first and then submits the polished prompt', async () => { + installPolishingInvoke('润色后的长需求'); + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: 'AI 润色' }), + ); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: '润色后的长需求', + references: [], + }); + }); + expect(await composerText()).toBe('润色后的长需求'); + }); + + test('keeps the reminder open with the original text when the in-panel polish fails', async () => { + installTauriInvoke(async () => { + throw new Error('platform llm timeout'); + }); + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: 'AI 润色' }), + ); + + expect( + await screen.findByText('AI 润色失败,可重试或使用原文提交'), + ).not.toBeNull(); + expect(onSubmitDraft).not.toHaveBeenCalled(); + expect(await composerText()).toBe(LONG_PROMPT); + + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '使用原文提交' }), + ); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + }); + + test('persists 不再提醒 locally and stops holding later sends', async () => { + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + fireEvent.click( + within(reminderPanel()).getByRole('checkbox', { name: '不再提醒' }), + ); + expect(window.localStorage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY)) + .toBe('true'); + + fireEvent.click( + within(reminderPanel()).getByRole('button', { name: '关闭' }), + ); + fireEvent.click(sendButton()); + await waitFor(() => { + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + }); + + test('reads the stored 不再提醒 preference when the composer mounts', () => { + window.localStorage.setItem( + CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, + 'true', + ); + const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT }); + + fireEvent.click(sendButton()); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(onSubmitDraft).toHaveBeenCalledWith({ + text: LONG_PROMPT, + references: [], + }); + }); + + test('does not hold short prompts or slash commands', () => { + const shortSubmit = renderComposer({ initialText: '做个跳跃游戏' }); + fireEvent.click(sendButton()); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(shortSubmit).toHaveBeenCalledWith({ + text: '做个跳跃游戏', + references: [], + }); + + cleanup(); + const commandSubmit = renderComposer({ + initialText: `/${'命令'.repeat(40)}`, + }); + fireEvent.click(sendButton()); + expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); + expect(commandSubmit).toHaveBeenCalledWith({ + text: `/${'命令'.repeat(40)}`, + references: [], + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index c78f121a8..9bb477de5 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -4,7 +4,10 @@ import userEvent from '@testing-library/user-event'; import { StrictMode } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetManifestEntry, + GameIterationVersion, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, parseLocalGamePreviewInspectMessage, @@ -13,13 +16,16 @@ import { ResourceReferenceInput } from '../src/features/project-workspace/Resour import { type ChatComposerDraft, type ChatReference, + currentIterationVersionAssets, dispatchResourceReferenceInsert, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_SCOPES, resourceReferenceCategory, resourceReferenceFromAsset, resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, + resolveActiveIterationVersion, } from '../src/features/project-workspace/resourceReferences'; function asset( @@ -37,6 +43,30 @@ function asset( }; } +function iterationVersion( + versionId: string, + resourceIds: string[], + parentVersionId: string | null = null, +): GameIterationVersion { + return { + versionId, + parentVersionId, + projectRevision: 1, + resourceBindings: resourceIds.map((resourceId, index) => ({ + slotId: `slot-${index}`, + resourceId, + })), + createdReason: 'initial', + createdAt: 1, + }; +} + +async function settleComposer() { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + const assets = [ asset('hero', 'character', 'image/png', 'assets/hero.png'), asset('enemy', 'character', 'image/png', 'assets/enemy.png'), @@ -273,4 +303,207 @@ describe('ResourceReferenceInput', () => { expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); }); }); + + test('exposes the current-version and all-canvas scopes as the only two tabs', () => { + expect(RESOURCE_REFERENCE_SCOPES).toEqual([ + { id: 'current-version', label: '当前版本素材' }, + { id: 'all-canvas', label: '全部画布素材' }, + ]); + }); + + test('resolves the current version from activeVersionId or the newest manifest version', () => { + const versions = [ + iterationVersion('v1', ['hero']), + iterationVersion('v2', ['theme'], 'v1'), + ]; + expect(resolveActiveIterationVersion(versions, 'v1')?.versionId).toBe('v1'); + expect(resolveActiveIterationVersion(versions, null)?.versionId).toBe('v2'); + expect(resolveActiveIterationVersion(versions, undefined)?.versionId).toBe( + 'v2', + ); + expect(resolveActiveIterationVersion(versions, 'missing')).toBeNull(); + expect(resolveActiveIterationVersion([], null)).toBeNull(); + expect(resolveActiveIterationVersion(undefined, null)).toBeNull(); + }); + + test('derives current-version assets from bindings and drops dangling bindings', () => { + const versions = [iterationVersion('v1', ['hero', 'deleted-asset'])]; + expect( + currentIterationVersionAssets(assets, versions, 'v1').map( + (entry) => entry.id, + ), + ).toEqual(['hero']); + expect(currentIterationVersionAssets(assets, versions, 'missing')).toEqual( + [], + ); + expect(currentIterationVersionAssets(assets, [], null)).toEqual([]); + expect( + currentIterationVersionAssets(assets, [iterationVersion('v1', [])], 'v1'), + ).toEqual([]); + }); + + test('keeps an independent search and filter state per picker scope', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + // 没传 activeVersionId 时回退到 manifest 最新版本 v2,只列出该版本绑定的资源。 + expect( + screen + .getByRole('tab', { name: '当前版本素材' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); + expect(screen.queryByRole('option', { name: /hero/u })).toBeNull(); + + await user.type(screen.getByLabelText('搜索当前版本素材'), 'the'); + expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); + + await user.click(screen.getByRole('tab', { name: '全部画布素材' })); + // 另一个页签有自己的搜索与筛选,不受当前版本页签影响。 + const allCanvasSearch = screen.getByLabelText( + '搜索全部画布素材', + ) as HTMLInputElement; + expect(allCanvasSearch.value).toBe(''); + expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); + expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull(); + + await user.type(allCanvasSearch, 'hero'); + expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull(); + await user.click(screen.getByRole('button', { name: '音频' })); + expect(screen.queryByRole('option', { name: /hero/u })).toBeNull(); + expect(screen.getByText('没有匹配的素材')).not.toBeNull(); + + await user.click(screen.getByRole('tab', { name: '当前版本素材' })); + expect( + (screen.getByLabelText('搜索当前版本素材') as HTMLInputElement).value, + ).toBe('the'); + expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); + }); + + test('shows an empty state when the active version has no bound resources', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + // 当前版本没有可用素材时默认落到「全部画布素材」,切回当前版本页签是空态。 + await user.click(screen.getByRole('tab', { name: '当前版本素材' })); + expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull(); + expect(screen.queryByRole('option')).toBeNull(); + + await user.click(screen.getByRole('tab', { name: '全部画布素材' })); + expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); + }); + + test('shows the current-version empty state when the project has no versions', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + await user.click(screen.getByRole('tab', { name: '当前版本素材' })); + expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull(); + }); + + test('refreshes chip and candidate display names after a resource rename', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const renamedAssets = [ + asset('hero', 'character', 'image/png', 'assets/hero-final.png'), + assets[1]!, + assets[2]!, + ]; + render( + , + ); + + await waitFor(() => { + expect( + document.querySelector('.resource-reference-chip-label')?.textContent, + ).toBe('hero-final'); + }); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe( + 'hero-final', + ); + }); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull(); + expect(screen.queryByRole('option', { name: /hero /u })).toBeNull(); + }); + + test('restores a cross-session draft with the caret at the end of the text', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const { rerender } = render( + , + ); + + // 切换 / 重开会话:外部草稿被整体替换。 + rerender( + , + ); + + await user.click(screen.getByRole('button', { name: '插入素材引用' })); + await user.click(screen.getByRole('option', { name: /hero/u })); + await user.click(screen.getByRole('button', { name: '插入引用' })); + await settleComposer(); + + expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero'); + }); }); diff --git a/docs/【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md b/docs/【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md new file mode 100644 index 000000000..bae42e325 --- /dev/null +++ b/docs/【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md @@ -0,0 +1,43 @@ +# AGC 聊天 AI 润色与发送前提醒 + +更新时间:2026-09-10 + +AGC 项目聊天输入区(`ResourceReferenceInput`,三个聊天入口共用)提供 AI 润色与发送前提醒。 + +## AI 润色 + +- 输入区右侧的「AI 润色」按钮对当前草稿做一次改写,结果直接回填到输入区; +- 第一次润色成功时记下当时的输入区文本作为「原文快照」; +- 对同一段草稿可以反复润色,每次都覆盖上一次结果,原文快照保持不变; +- 「恢复原文」始终回到最初那份原文,并清空快照与润色结果; +- 润色进行中按钮禁用并显示「润色中…」,期间不能重复触发; +- 失败、超时或未配置模型时保留原文,只在输入区显示「AI 润色失败,可重试」,用户可直接重试; +- 草稿发送或被清空后,润色结果与原文快照一起重置。 + +## 发送前提醒 + +点发送时若命中提醒判据,先弹出独立的「发送前提醒」面板(不追加在输入区下方),面板提供: + +- 「AI 润色」:先润色再发送; +- 「使用原文提交」:直接发送原文; +- 「关闭」:取消本次发送,回到输入区; +- 「不再提醒」:勾选后写入本机偏好,后续不再弹该提醒。 + +提醒判据(全部满足才拦截发送): + +1. 提醒没有被用户在偏好里关闭; +2. 当前草稿指纹与「本轮已确认草稿」指纹不同,即本轮还没有润色过、也没有选过「使用原文提交」; +3. 纯文本 `trim` 后长度不短于 40 个字符; +4. 草稿不是以 `/` 开头的命令(命令走直通路径)。 + +面板里的「AI 润色」失败时保留原文并留在面板内,用户可以重试或改用「使用原文提交」。 + +「不再提醒」偏好写在客户端本机 `localStorage`(键 `agc.chat.prompt-polish-reminder.disabled`),不进项目 manifest、不进后端。 + +## 实现位置 + +- 前端服务与判据:`apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts` +- 提醒面板:`apps/ai-game-creator-shell/src/features/project-workspace/ChatPromptPolishReminder.tsx` +- Tauri 命令:`polish_local_project_prompt`(`apps/ai-game-creator-shell/src-tauri/src/commands.rs`,系统提示词 `src-tauri/prompts/local-project-prompt-polish.md`) + +润色复用已有的短文本生成通道:`codex_app_server` 模式走 Codex direct 单轮对话,其余模式走客户端 LLM 客户端单轮请求。计费(1 泥点)由平台 LLM 路由 `/api/llm/chat/completions`、`/api/llm/responses` 在 `server-rs/crates/api-server/src/llm/mod.rs` 内完成,客户端不自建计费,也不改 server-rs。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 65b401428..83dd817b9 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -4,7 +4,14 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 -素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选。确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析。 +素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选;面板顶部有两个页签: + +- 「当前版本素材」:列出版本 `resourceBindings` 里绑定的、且仍登记在 manifest 的素材; +- 「全部画布素材」:列出全部已登记素材。 + +两个页签各自持有独立的搜索与类型筛选状态,互不影响,也不与资源画布筛选联动。当前版本取 `ResourceReferenceInput` 的 `activeVersionId`;未传或传 `null` 时回退到 manifest `versions[]` 中最新的那个版本。版本不存在或该版本没有绑定素材时页签显示空态,不合成资源卡;绑定指向已删除资源(悬空绑定)时按资源 `id` 过滤掉。 + +确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 提交时前端同时发送用户文本和 `references` 数组。Rust 在发起 Agent 回合前读取当前项目 manifest,逐项复核资源是否存在、路径是否安全,并以 manifest 中的 `id / kind / mediaType / localPath` 作为权威投影;客户端传入的路径、名称和类型不会被直接信任。已删除或不存在的资源会阻止发送并提示用户移除后重新选择。 @@ -19,9 +26,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; - 提交请求携带结构化 `references`; - Rust 按 manifest 二次校验并生成安全投影; -- 普通无引用消息保持原有行为。 - -当前未完成: - -- 跨会话恢复引用芯片的精确光标位置; -- 资源改名后的引用显示名自动刷新。 +- 普通无引用消息保持原有行为; +- 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态; +- 资源改名后引用芯片与候选列表的显示名自动刷新; +- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。