补齐 AGC V3 C4 聊天通用引用与 C8 AI 润色发送前提醒
- @ 面板新增「当前版本素材 / 全部画布素材」两个页签,两个页签各自持有独立的搜索与分类筛选状态 - ResourceReferenceInput 新增可选 prop activeVersionId 与 versions,并把两者透传到 ProjectWorkspaceChatPane、ProjectSupervisorView、SupervisorChatOnlyView 和 App - 未传 / null 的 activeVersionId 回退到 manifest versions[] 中最新的版本;版本不存在或没有绑定时页签显示空态,不报错也不合成资源卡 - 「当前版本素材」按该版本 resourceBindings 取仍登记在 manifest 的资产,悬空绑定按资源 id 过滤掉 - 资源改名后按资源 id 刷新编辑区已有引用 chip 与候选列表的显示名,并把新显示名同步回父级草稿 - 程序化重建草稿后把光标收回草稿末尾,跨会话恢复草稿时后续引用不再插到已失效的位置 - 新增 Tauri 命令 polish_local_project_prompt(复用短文本生成通道,计费仍由平台 LLM 路由侧完成,未改 server-rs) - 输入区新增「AI 润色 / 恢复原文」按钮、润色中状态与失败可重试提示,失败、超时或未配置模型时保留原文 - 原文快照只在首次润色成功时落下,反复润色只覆盖结果,「恢复原文」始终回到最初原文 - 新增发送前提醒独立面板:AI 润色先润色再发送、使用原文提交、关闭取消发送、不再提醒 - 提醒判据:纯文本 trim 后不短于 40 字符、不是 / 开头的命令、本轮草稿未确认过、且用户未关闭提醒 - 「不再提醒」偏好写入本机 localStorage,不进 manifest、不进后端 - 补充润色按钮、状态提示、素材页签与提醒面板样式
This commit is contained in:
@@ -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 {
|
||||
@@ -1645,6 +1768,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;
|
||||
|
||||
Reference in New Issue
Block a user