WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -0,0 +1 @@
|
||||
你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。
|
||||
@@ -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<Option<String>,
|
||||
Ok(normalize_suggested_project_name(response.text.trim()))
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_project_prompt_polish_prompt(
|
||||
prompt: &str,
|
||||
context: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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<String>,
|
||||
) -> Result<String, String> {
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<SupervisorChatOnlyView
|
||||
activeVersionId={chatActiveVersionId}
|
||||
chatAgentBusy={chatAgentBusy}
|
||||
chatInput={chatInput}
|
||||
chatReferences={chatReferences}
|
||||
@@ -11394,6 +11399,7 @@ export function App({
|
||||
visibleMessages={visibleMessages}
|
||||
workspaceStatus={workspaceStatus}
|
||||
expectedRunId={projectSupervisorExpectedRunId}
|
||||
versions={chatProjectVersions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11401,6 +11407,7 @@ export function App({
|
||||
if (projectSupervisorOnly) {
|
||||
return (
|
||||
<ProjectSupervisorView
|
||||
activeVersionId={chatActiveVersionId}
|
||||
chatInput={chatInput}
|
||||
chatReferences={chatReferences}
|
||||
composerRef={chatComposerRef}
|
||||
@@ -11462,6 +11469,7 @@ export function App({
|
||||
runtimeByAgentId={agentRuntimeById}
|
||||
controlBusy={chatAgentBusy}
|
||||
readOnly={directCodexProductRuntime}
|
||||
versions={chatProjectVersions}
|
||||
professionalResultsByAgentId={professionalAgentResultsById}
|
||||
onToolAction={handleProjectSupervisorToolAction}
|
||||
onSupervisorRetry={handleProjectSupervisorRetry}
|
||||
@@ -11477,6 +11485,7 @@ export function App({
|
||||
className={`app-shell ${devMode ? 'app-shell--dev' : 'app-shell--user'}`}
|
||||
>
|
||||
<ProjectWorkspaceChatPane
|
||||
activeVersionId={chatActiveVersionId}
|
||||
agentRunStatus={agentRunStatus}
|
||||
agentRuntimeById={agentRuntimeById}
|
||||
agentStatusCards={agentStatusCards}
|
||||
@@ -11566,6 +11575,7 @@ export function App({
|
||||
showChatHelp={showChatHelp}
|
||||
showEarlierConversationMessages={showEarlierConversationMessages}
|
||||
visibleMainProjectAssets={visibleMainProjectAssets}
|
||||
versions={chatProjectVersions}
|
||||
visibleMainProjectCheckpoints={visibleMainProjectCheckpoints}
|
||||
visibleMainProjectFiles={visibleMainProjectFiles}
|
||||
visibleMessages={visibleMessages}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
closeDialogOnBackdropMouseDown,
|
||||
closeDialogOnEscape,
|
||||
useEscapeToClose,
|
||||
} from '../../app/dialogs';
|
||||
|
||||
type ChatPromptPolishReminderProps = {
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
reminderDisabled: boolean;
|
||||
onPolishAndSubmit: () => 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(
|
||||
<div
|
||||
className="launcher-dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (busy) return;
|
||||
closeDialogOnBackdropMouseDown(event, onClose);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
aria-labelledby="chat-prompt-polish-reminder-title"
|
||||
aria-modal="true"
|
||||
className="launcher-dialog chat-prompt-polish-reminder"
|
||||
role="dialog"
|
||||
onKeyDown={(event) => closeDialogOnEscape(event, onClose)}
|
||||
>
|
||||
<h2 id="chat-prompt-polish-reminder-title">发送前提醒</h2>
|
||||
{error ? (
|
||||
<p className="chat-prompt-polish-reminder-error" role="status">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<label className="chat-prompt-polish-reminder-preference">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reminderDisabled}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
onReminderDisabledChange(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<span>不再提醒</span>
|
||||
</label>
|
||||
<div className="launcher-dialog-actions">
|
||||
<button type="button" disabled={busy} onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onUseOriginalAndSubmit}
|
||||
>
|
||||
使用原文提交
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-busy={busy}
|
||||
disabled={busy}
|
||||
onClick={onPolishAndSubmit}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
)}
|
||||
AI 润色
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
onMakeGameFromApprovedGdd?: () => Promise<void>;
|
||||
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({
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
ariaLabel={directCodex ? '陶泥儿对话内容' : '项目需求'}
|
||||
activeVersionId={activeVersionId}
|
||||
versions={versions}
|
||||
assets={chatProjectAssets}
|
||||
projectPath={projectPath}
|
||||
disabled={
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Fragment } from 'react';
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameCreationAppCommandDescriptor,
|
||||
GameIterationVersion,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||||
import type {
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
|
||||
|
||||
type ProjectWorkspaceChatPaneProps = {
|
||||
activeVersionId?: string | null;
|
||||
agentRunStatus: string;
|
||||
agentRuntimeById: Record<string, AgentRuntimeState | undefined>;
|
||||
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}
|
||||
|
||||
+432
-44
File diff suppressed because it is too large
Load Diff
@@ -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({
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
ariaLabel={directCodex ? '陶泥儿对话内容' : '项目总控对话内容'}
|
||||
activeVersionId={activeVersionId}
|
||||
versions={versions}
|
||||
assets={chatProjectAssets}
|
||||
projectPath={projectPath}
|
||||
disabled={chatAgentBusy || needsUserInput}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import {
|
||||
type ChatComposerDraft,
|
||||
chatReferenceListKey,
|
||||
} from './resourceReferences';
|
||||
|
||||
/** 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 */
|
||||
export const CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY =
|
||||
'agc.chat.prompt-polish-reminder.disabled';
|
||||
|
||||
/**
|
||||
* 发送前提醒判据(长度阈值)。
|
||||
* 纯文本(trim 后)达到该长度且用户没有关闭提醒、且本轮草稿还没有润色过 / 确认过时,
|
||||
* 点发送会先弹出独立提醒面板,而不是直接发出。
|
||||
*/
|
||||
export const CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH = 40;
|
||||
|
||||
function readLocalStorage(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取本机的「不再提醒」偏好;存储不可用时按未关闭处理。 */
|
||||
export function readChatPromptPolishReminderDisabled(): boolean {
|
||||
const storage = readLocalStorage();
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return storage.getItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入本机的「不再提醒」偏好;存储不可用时静默跳过,不影响本次发送。 */
|
||||
export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
|
||||
const storage = readLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (disabled) {
|
||||
storage.setItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY, 'true');
|
||||
} else {
|
||||
storage.removeItem(CHAT_PROMPT_POLISH_REMINDER_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// 浏览器隐私模式等场景下 localStorage 不可写:偏好只当次生效,不阻断发送。
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
|
||||
export function chatPromptDraftKey(draft: ChatComposerDraft) {
|
||||
return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送前提醒判据(全部满足才提醒):
|
||||
* 1. 提醒没有被用户在偏好里关掉(本机 localStorage);
|
||||
* 2. 当前草稿指纹不等于「本轮已确认草稿」指纹 —— 即本轮还没有润色过、也没有选过「使用原文提交」;
|
||||
* 3. 纯文本(trim 后)长度达到 {@link CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH};
|
||||
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
|
||||
*/
|
||||
export function shouldRemindChatPromptPolish({
|
||||
draft,
|
||||
acknowledgedDraftKey,
|
||||
reminderDisabled,
|
||||
}: {
|
||||
draft: ChatComposerDraft;
|
||||
acknowledgedDraftKey: string | null;
|
||||
reminderDisabled: boolean;
|
||||
}) {
|
||||
if (reminderDisabled) {
|
||||
return false;
|
||||
}
|
||||
const text = draft.text.trim();
|
||||
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
if (text.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
return chatPromptDraftKey(draft) !== acknowledgedDraftKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 走平台 LLM 路由的短文本润色。
|
||||
* 复用 `polish_local_project_prompt` 这条短文本生成通道:计费在平台 LLM 路由侧完成,
|
||||
* 这里不自建计费、不落盘。
|
||||
* 失败 / 超时 / 未配置模型统一返回 `null`,调用方保留原文并给出可重试提示。
|
||||
*/
|
||||
export async function requestChatPromptPolish(
|
||||
prompt: string,
|
||||
context?: string | null,
|
||||
): Promise<string | null> {
|
||||
const invoke = resolveTauriInvoke();
|
||||
const trimmed = prompt.trim();
|
||||
if (!invoke || !trimmed) {
|
||||
return null;
|
||||
}
|
||||
const trimmedContext = context?.trim();
|
||||
try {
|
||||
const result = await invoke<string>('polish_local_project_prompt', {
|
||||
prompt: trimmed,
|
||||
context: trimmedContext ? trimmedContext : null,
|
||||
});
|
||||
const polished = typeof result === 'string' ? result.trim() : '';
|
||||
return polished ? polished : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<string, GameCreationAppAssetManifestEntry>,
|
||||
): 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<string>();
|
||||
return references.filter((reference) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
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<ChatComposerDraft>({
|
||||
text: initialText,
|
||||
references: initialReferences,
|
||||
});
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmitDraft(draft);
|
||||
}}
|
||||
>
|
||||
<ResourceReferenceInput
|
||||
value={draft.text}
|
||||
references={draft.references}
|
||||
onChange={setDraft}
|
||||
assets={[]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="创作想法"
|
||||
/>
|
||||
<button type="submit">发送</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComposer({
|
||||
initialText,
|
||||
initialReferences,
|
||||
onSubmitDraft = vi.fn(),
|
||||
}: {
|
||||
initialText: string;
|
||||
initialReferences?: ChatReference[];
|
||||
onSubmitDraft?: (draft: ChatComposerDraft) => void;
|
||||
}) {
|
||||
render(
|
||||
<ControlledChatComposer
|
||||
initialText={initialText}
|
||||
initialReferences={initialReferences}
|
||||
onSubmitDraft={onSubmitDraft}
|
||||
/>,
|
||||
);
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={vi.fn()}
|
||||
assets={assets}
|
||||
versions={[
|
||||
iterationVersion('v1', ['hero', 'enemy']),
|
||||
iterationVersion('v2', ['theme'], 'v1'),
|
||||
]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={vi.fn()}
|
||||
assets={assets}
|
||||
activeVersionId="v1"
|
||||
versions={[iterationVersion('v1', ['deleted-asset'])]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={vi.fn()}
|
||||
assets={assets}
|
||||
versions={[]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceReferenceInput
|
||||
value="用这个角色"
|
||||
references={[resourceReferenceFromAsset(assets[0]!, 'asset-picker')]}
|
||||
onChange={onChange}
|
||||
assets={renamedAssets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ResourceReferenceInput
|
||||
value="上一个会话的草稿"
|
||||
references={[]}
|
||||
onChange={onChange}
|
||||
assets={assets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
// 切换 / 重开会话:外部草稿被整体替换。
|
||||
rerender(
|
||||
<ResourceReferenceInput
|
||||
value="恢复出来的草稿"
|
||||
references={[]}
|
||||
onChange={onChange}
|
||||
assets={assets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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。
|
||||
@@ -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 二次校验并生成安全投影;
|
||||
- 普通无引用消息保持原有行为。
|
||||
|
||||
当前未完成:
|
||||
|
||||
- 跨会话恢复引用芯片的精确光标位置;
|
||||
- 资源改名后的引用显示名自动刷新。
|
||||
- 普通无引用消息保持原有行为;
|
||||
- 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态;
|
||||
- 资源改名后引用芯片与候选列表的显示名自动刷新;
|
||||
- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。
|
||||
|
||||
Reference in New Issue
Block a user