Merge branch 'feat/agc-v3-c4-mentions' into feat/agc-canvas-resource-workbench-v3

This commit is contained in:
2026-09-10 14:43:58 +08:00
16 changed files with 1687 additions and 53 deletions
@@ -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,
+10
View File
@@ -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}
@@ -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}
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) => {
+69 -1
View File
@@ -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 二次校验并生成安全投影;
- 普通无引用消息保持原有行为
当前未完成:
- 跨会话恢复引用芯片的精确光标位置;
- 资源改名后的引用显示名自动刷新。
- 普通无引用消息保持原有行为
- 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态;
- 资源改名后引用芯片与候选列表的显示名自动刷新;
- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。