补齐 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:
2026-09-10 14:38:34 +08:00
parent 912254afa0
commit 89678173f5
12 changed files with 969 additions and 45 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 {
@@ -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,
+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;