WIP: fix/引用持久化 #365

Draft
k88936 wants to merge 44 commits from fix/ref-persistent into master
39 changed files with 1348 additions and 690 deletions
@@ -5,8 +5,9 @@ mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole,
DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
@@ -36,6 +36,21 @@ pub(crate) enum DirectCodexUserContentPart {
AgcResourceReference { resource_id: String },
#[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) name: String,
pub(crate) media_type: String,
#[ts(type = "number")]
pub(crate) size: u64,
pub(crate) local_path: String,
pub(crate) status: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
@@ -20,18 +20,16 @@ pub(crate) fn validate_direct_codex_user_item(
if message.id.trim().is_empty() {
return Err("DirectProject user item 缺少稳定 id".to_string());
}
if message.content.is_empty() {
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
if !content_has_meaningful_input(&message.content) {
return Err("DirectProject user item content 不能为空".to_string());
}
let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
if text.trim().is_empty() {
return Err("DirectProject input_text 不能为空".to_string());
}
}
DirectCodexUserContentPart::InputText { .. } => {}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?;
@@ -40,6 +38,18 @@ pub(crate) fn validate_direct_codex_user_item(
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
if reference.name.trim().is_empty() {
return Err("附件缺少文件名".to_string());
}
if !reference.local_path.trim().is_empty() {
sanitize_attachment_local_path(&reference.local_path)
.ok_or_else(|| "附件项目路径无效".to_string())?;
}
if !matches!(reference.status.trim(), "imported" | "failed") {
return Err("附件状态无效".to_string());
}
}
}
}
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
@@ -48,6 +58,14 @@ pub(crate) fn validate_direct_codex_user_item(
Ok(())
}
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
content.iter().any(|part| match part {
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
_ => true,
})
}
pub(crate) fn validate_resource_id_and_manifest(
manifest: &GameCreationAppManifest,
resource_id: &str,
@@ -86,3 +104,48 @@ fn validate_runtime_region_reference(
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::content_has_meaningful_input;
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
fn input_text(text: &str) -> DirectCodexUserContentPart {
DirectCodexUserContentPart::InputText {
text: text.to_string(),
}
}
#[test]
fn only_all_blank_content_counts_as_empty_input() {
// 空数组与「整条只有空白」是同一种空输入。
assert!(!content_has_meaningful_input(&[]));
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
assert!(!content_has_meaningful_input(&[
input_text("\n"),
input_text(" "),
]));
}
#[test]
fn whitespace_parts_are_valid_next_to_meaningful_input() {
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
assert!(content_has_meaningful_input(&[
input_text("\n"),
input_text(""),
]));
assert!(content_has_meaningful_input(&[
input_text(""),
input_text("\n\n"),
]));
}
#[test]
fn non_text_parts_always_count_as_input() {
assert!(content_has_meaningful_input(&[
DirectCodexUserContentPart::AgcResourceReference {
resource_id: "asset-hero".to_string(),
},
]));
}
}
@@ -100,6 +100,20 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
summary.push(']');
summary
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
reference.name.trim(),
reference.media_type.trim(),
reference.size
);
if !reference.local_path.trim().is_empty() {
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
summary
}
};
input.push(serde_json::json!({ "type": "text", "text": text }));
}
@@ -130,7 +144,8 @@ pub(crate) fn direct_codex_user_item_to_prompt(
#[cfg(test)]
mod tests {
use super::direct_codex_user_item_to_response_item;
use super::{direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input};
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
use serde_json::json;
use std::path::Path;
@@ -174,4 +189,70 @@ mod tests {
.expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}");
}
#[test]
fn attachment_parts_remain_in_canonical_order_when_projected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
let content = projected["content"].as_array().expect("content array");
assert_eq!(content.len(), 2);
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
}
#[test]
fn whitespace_only_text_parts_survive_validation() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect("whitespace-only part next to real text must pass");
let parts = wire.as_array().expect("wire input array");
assert_eq!(parts.len(), 3);
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
assert_eq!(parts[2]["text"].as_str(), Some(" "));
}
#[test]
fn all_blank_content_is_rejected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect_err("all-blank content must fail closed");
assert!(error.contains("不能为空"), "{error}");
}
}
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
mut user_item: DirectCodexUserItem,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,24 +42,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let mut audit = DirectCodexTurnAudit::start(
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context =
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
audit.finish(false);
error
})?;
let DirectCodexUserItem::Message(message) = &mut user_item;
message.content.push(DirectCodexUserContentPart::InputText {
text: attachment_context,
});
}
let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]);
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
audit.finish(false);
error
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import {
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type Klass,
type LexicalNode,
} from 'lexical';
import type { ReactElement, ReactNode, Ref } from 'react';
import { useEffect } from 'react';
type RichTextInputProps = {
namespace: string;
nodes: Klass<LexicalNode>[];
initialEditorState?: EditorState | null;
contentEditable?: ReactElement<typeof ContentEditable>;
placeholder?: ReactElement;
containerClassName?: string;
containerRef?: Ref<HTMLDivElement>;
disabled?: boolean;
onChange?: (editorState: EditorState) => void;
onEnter?: () => void;
children?: ReactNode;
};
function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!onEnter) return undefined;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) return false;
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, onEnter]);
return null;
}
function SetEditorEditable({ disabled }: { disabled: boolean }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
return null;
}
export default function RichTextInput({
namespace,
nodes,
initialEditorState,
contentEditable = <ContentEditable />,
placeholder,
containerClassName,
containerRef,
disabled = false,
onChange,
onEnter,
children,
}: RichTextInputProps) {
return (
<LexicalComposer
initialConfig={{
namespace,
nodes,
editorState: initialEditorState ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div
ref={containerRef}
className={containerClassName}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={contentEditable}
placeholder={placeholder}
ErrorBoundary={LexicalErrorBoundary}
/>
{children}
<SetEditorEditable disabled={disabled} />
<SubmitOnEnter onEnter={onEnter} />
{onChange ? <OnChangePlugin onChange={onChange} /> : null}
</div>
</LexicalComposer>
);
}
@@ -19,6 +19,7 @@ import {
import type { RefObject } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
GameCreatorAppConfigView,
@@ -192,9 +193,12 @@ export function ComposerPendingAttachments({
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
export function ComposerTurnQueue({
turns,
assets,
onCancel,
}: {
turns: readonly QueuedChatTurn[];
/** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */
assets: readonly GameCreationAppAssetManifestEntry[];
onCancel: (id: string) => void;
}) {
if (turns.length === 0) {
@@ -211,11 +215,11 @@ export function ComposerTurnQueue({
{index + 1}
</span>
<span className="project-supervisor-composer-queue-text">
{queuedChatTurnLabel(turn)}
{queuedChatTurnLabel(turn, assets)}
</span>
<button
type="button"
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn)}`}
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn, assets)}`}
title="取消这条排队消息"
onClick={() => onCancel(turn.id)}
>
@@ -79,7 +79,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import type { ChatComposerDraft } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import {
formatClockTime,
@@ -242,8 +242,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
attachments?: DirectCodexTurnAttachment[];
/** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */
attachmentNotice?: string;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
directCodex?: boolean;
@@ -327,8 +325,6 @@ export function ProjectSupervisorView({
activeVersionId = null,
attachments = [],
attachmentNotice = '',
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
directCodex = false,
@@ -893,6 +889,7 @@ export function ProjectSupervisorView({
>
<ComposerTurnQueue
turns={directCodex ? queuedTurns : []}
assets={chatProjectAssets}
onCancel={(id) => onCancelQueuedTurn?.(id)}
/>
<ComposerPendingAttachments
@@ -916,8 +913,6 @@ export function ProjectSupervisorView({
Boolean(designView?.session.pendingClarification)
}
rows={3}
value={chatInput}
references={chatReferences}
showTriggerButton={!directCodex}
placeholder={
directCodex
@@ -55,7 +55,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import type { ChatComposerDraft } from './resourceReferences';
type ProjectWorkspaceChatPaneProps = {
activeVersionId?: string | null;
@@ -65,8 +65,6 @@ type ProjectWorkspaceChatPaneProps = {
cancelProjectCreateInNonEmptyFolder: () => void;
cancelUiCommandConfirmation: () => void;
chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>;
chatInputRef: RefObject<HTMLDivElement | null>;
@@ -215,8 +213,6 @@ export function ProjectWorkspaceChatPane({
cancelProjectCreateInNonEmptyFolder,
cancelUiCommandConfirmation,
chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
chatInputRef,
@@ -962,8 +958,6 @@ export function ProjectWorkspaceChatPane({
projectPath={projectPath}
disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
multiline={false}
value={chatInput}
references={chatReferences}
placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源"
onChange={onChatComposerChange}
/>
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import type { ChatComposerDraft } from './resourceReferences';
type RuntimeControlProps = ComponentProps<
typeof ProjectSupervisorRuntimeControls
@@ -44,8 +44,6 @@ const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = {
activeVersionId?: string | null;
chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>;
directCodex?: boolean;
@@ -82,8 +80,6 @@ type SupervisorChatOnlyViewProps = {
export function SupervisorChatOnlyView({
activeVersionId = null,
chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
directCodex = false,
@@ -328,8 +324,6 @@ export function SupervisorChatOnlyView({
projectPath={projectPath}
disabled={chatAgentBusy || needsUserInput}
rows={3}
value={chatInput}
references={chatReferences}
placeholder={
directCodex ? '描述你的想法' : '给项目总控 Agent 发消息'
}
@@ -4,36 +4,33 @@
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import type { DirectCodexUserContentPart } from './generated';
import type { ChatReference } from './resourceReferences';
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { DirectCodexUserItem } from './generated';
import { directCodexContentToPromptText } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
export const MAX_QUEUED_CHAT_TURNS = 5;
export type QueuedChatTurn = {
id: string;
prompt: string;
attachments: DirectCodexTurnAttachment[];
references: ChatReference[];
content?: DirectCodexUserContentPart[];
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
};
export function createQueuedChatTurn(input: {
id: string;
prompt: string;
attachments?: readonly DirectCodexTurnAttachment[];
references?: readonly ChatReference[];
content?: readonly DirectCodexUserContentPart[];
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
}): QueuedChatTurn {
return {
id: input.id,
prompt: input.prompt,
attachments: [...(input.attachments ?? [])],
references: [...(input.references ?? [])],
content: [...(input.content ?? [])],
clientTurnId: input.clientTurnId,
userItem: {
...input.userItem,
content: [...input.userItem.content],
},
createdAt: input.createdAt,
};
}
@@ -77,16 +74,30 @@ export function chatQueueFullNotice(): string {
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
}
/** 队列 chip 上显示的文字:单行、有长度上限。 */
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
const text = turn.prompt.trim().replace(/\s+/gu, ' ');
/**
* 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`),
* 再压成单行并限长。
*
* `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip
* 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。
*/
export function queuedChatTurnLabel(
turn: QueuedChatTurn,
assets: readonly GameCreationAppAssetManifestEntry[],
): string {
const text = directCodexContentToPromptText(turn.userItem.content, assets)
.trim()
.replace(/\s+/gu, ' ');
if (text) {
return text.length > 24 ? `${text.slice(0, 24)}` : text;
}
if (turn.attachments.length > 0) {
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`;
const attachment = turn.userItem.content.find(
(part) => part.type === 'agc_attachment_reference',
);
if (attachment?.type === 'agc_attachment_reference') {
return `附件 · ${attachment.name || '未命名'}`;
}
if (turn.references.length > 0) {
if (turn.userItem.content.some((part) => part.type !== 'input_text')) {
return '素材引用';
}
return '未命名消息';
@@ -1,8 +1,5 @@
import { resolveTauriInvoke } from '../../app/tauri';
import {
type ChatComposerDraft,
chatReferenceListKey,
} from './resourceReferences';
import type { DirectCodexUserContentPart } from './generated';
/**
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
@@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
}
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
export function chatPromptDraftKey(draft: ChatComposerDraft) {
return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`;
export function chatPromptDraftKey(
content: readonly DirectCodexUserContentPart[],
) {
return JSON.stringify(content);
}
/**
@@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) {
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
*/
export function shouldRemindChatPromptPolish({
draft,
content,
prompt,
acknowledgedDraftKey,
reminderDisabled,
}: {
draft: ChatComposerDraft;
content: readonly DirectCodexUserContentPart[];
prompt: string;
acknowledgedDraftKey: string | null;
reminderDisabled: boolean;
}) {
if (reminderDisabled) {
return false;
}
const text = draft.text.trim();
const text = prompt.trim();
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
return false;
}
if (text.startsWith('/')) {
return false;
}
return chatPromptDraftKey(draft) !== acknowledgedDraftKey;
return chatPromptDraftKey(content) !== acknowledgedDraftKey;
}
/**
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserAttachmentReferencePart = {
name: string;
mediaType: string;
size: number;
localPath: string;
status: string;
};
@@ -1,5 +1,5 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type DirectCodexUserContentPart =
@@ -7,4 +7,7 @@ export type DirectCodexUserContentPart =
| { type: 'agc_resource_reference'; resourceId: string }
| ({
type: 'agc_runtime_region_reference';
} & DirectCodexUserRuntimeRegionPart);
} & DirectCodexUserRuntimeRegionPart)
| ({
type: 'agc_attachment_reference';
} & DirectCodexUserAttachmentReferencePart);
@@ -1,5 +1,9 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type DirectCodexUserItem = DirectCodexUserMessageItem;
/**
* DirectProject 本轮 user input 的唯一结构化入口。
*/
export type DirectCodexUserItem = {
type: 'message';
} & DirectCodexUserMessageItem;
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserItem } from './DirectCodexUserItem';
export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem };
@@ -1,11 +1,9 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
import type { DirectCodexUserRole } from './DirectCodexUserRole';
export type DirectCodexUserMessageItem = {
type: 'message';
role: DirectCodexUserRole;
content: DirectCodexUserContentPart[];
content: Array<DirectCodexUserContentPart>;
id: string;
};
@@ -1,3 +1,3 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserRole = 'user';
@@ -1,13 +1,13 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserRuntimeRegionPart = {
label: string;
runId?: string;
versionId?: string;
elementTag?: string;
elementRole?: string;
text?: string;
width?: number;
height?: number;
resourceIds: string[];
runId: string | null;
versionId: string | null;
elementTag: string | null;
elementRole: string | null;
text: string | null;
width: number | null;
height: number | null;
resourceIds: Array<string>;
};
@@ -1,5 +1,7 @@
export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
export type { DirectCodexUserItem } from './DirectCodexUserItem';
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type { DirectCodexUserRole } from './DirectCodexUserRole';
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope';
@@ -13,7 +13,6 @@ import {
import type {
DirectCodexUserContentPart,
DirectCodexUserItem,
DirectCodexUserMessageItem,
} from './generated';
export type ResourceReferenceSource =
@@ -50,10 +49,8 @@ export type RuntimeRegionReference = {
export type ChatReference = ResourceReference | RuntimeRegionReference;
export type ChatComposerDraft = {
text: string;
references: ChatReference[];
/** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */
content?: DirectCodexUserContentPart[];
/** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */
content: DirectCodexUserContentPart[];
};
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
@@ -89,11 +86,47 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
}
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
text: '',
references: [],
content: [],
};
/**
* canonical content → 可读文本:**消息正文、队列 chip、润色判据、出站 prompt 只有这一个口径**。
*
* `assets` 是当前项目的 manifest 且必填:`agc_resource_reference` 按 `@显示名` 展开,与用户
* 在编辑器里看到、以及聊天消息里渲染出来的字面量逐字一致。没有默认值 —— 漏传素材清单会
* 静默退化成 `@内部 id`(用户看到的文案与真正落盘的 prompt 分叉)。只有素材已不在清单里时
* 才回落到 `resourceId`,那是「引用指向已消失的素材」的兜底而不是常态。
*/
export function directCodexContentToPromptText(
content: readonly DirectCodexUserContentPart[],
assets: readonly GameCreationAppAssetManifestEntry[],
) {
const labels = new Map(
assets.map((asset) => [asset.id, resourceDisplayName(asset)]),
);
return content
.map((part) => {
if (part.type === 'input_text') return part.text;
if (part.type === 'agc_resource_reference') {
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
}
if (part.type === 'agc_runtime_region_reference') {
return `@${part.label}`;
}
return '';
})
.join('')
.trim();
}
export function hasMeaningfulDirectCodexContent(
content: readonly DirectCodexUserContentPart[],
) {
return content.some(
(part) => part.type !== 'input_text' || part.text.trim().length > 0,
);
}
export function chatReferenceToContentPart(
reference: ChatReference,
): DirectCodexUserContentPart {
@@ -103,13 +136,13 @@ export function chatReferenceToContentPart(
return {
type: 'agc_runtime_region_reference',
label: reference.label,
runId: reference.runId,
versionId: reference.versionId,
elementTag: reference.elementTag,
elementRole: reference.elementRole,
text: reference.text,
width: reference.width,
height: reference.height,
runId: reference.runId ?? null,
versionId: reference.versionId ?? null,
elementTag: reference.elementTag ?? null,
elementRole: reference.elementRole ?? null,
text: reference.text ?? null,
width: reference.width ?? null,
height: reference.height ?? null,
resourceIds: reference.resourceIds,
};
}
@@ -118,24 +151,19 @@ export function chatComposerDraftToDirectCodexUserItem(
draft: ChatComposerDraft,
id: string,
): DirectCodexUserItem {
const content = draft.content?.length
? draft.content
: draft.references.length > 0
? [
...(draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: []),
...draft.references.map(chatReferenceToContentPart),
]
: draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: [];
return {
type: 'message',
role: 'user',
content,
content: [...draft.content],
id,
} satisfies DirectCodexUserMessageItem;
} satisfies DirectCodexUserItem;
}
export function directCodexUserItemFromContent(
content: readonly DirectCodexUserContentPart[],
id: string,
): DirectCodexUserItem {
return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id);
}
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
@@ -1,9 +1,5 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager';
import {
$createParagraphNode,
@@ -13,13 +9,13 @@ import {
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH,
createCommand,
KEY_ENTER_COMMAND,
type LexicalCommand,
PASTE_COMMAND,
} from 'lexical';
import { Upload } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import RichTextInput from '../../../../components/RichTextInput';
import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore';
import { $createAttachmentNode, AttachmentNode } from './attachmentNode';
@@ -101,29 +97,9 @@ function selectEditableEndWhenNeeded() {
root.selectEnd();
}
function EditorPlugins({
onChange,
onEnter,
}: Pick<RichInputAreaProps, 'onChange' | 'onEnter'>) {
function EditorPlugins() {
const [editor] = useLexicalComposerContext();
useEffect(
() =>
editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) {
return false;
}
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
),
[editor, onEnter],
);
useEffect(
() =>
editor.registerCommand(
@@ -188,13 +164,7 @@ function EditorPlugins({
[editor],
);
return (
<OnChangePlugin
onChange={(editorState) => {
onChange(editorState);
}}
/>
);
return null;
}
export function UploadButton() {
@@ -229,34 +199,27 @@ export function UploadButton() {
export default function RichInputArea(props: RichInputAreaProps) {
return (
<LexicalComposer
initialConfig={{
namespace: 'home-rich-input',
nodes: [AttachmentNode],
editorState: props.value ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div className="relative grid min-h-9 gap-2">
<RichTextPlugin
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
}
placeholder={
<span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
<RichTextInput
namespace="home-rich-input"
nodes={[AttachmentNode]}
initialEditorState={props.value}
onChange={props.onChange}
onEnter={props.onEnter}
containerClassName="relative grid min-h-9 gap-2"
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
{props.children}
</div>
<EditorPlugins onChange={props.onChange} onEnter={props.onEnter} />
</LexicalComposer>
}
placeholder={
<span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
>
<EditorPlugins />
{props.children}
</RichTextInput>
);
}
@@ -105,10 +105,13 @@ import {
type LocalGamePreviewInspectSelection,
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../../features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
directCodexContentToPromptText,
dispatchResourceReferenceInsert,
isResourceReferenceOverlayTarget,
resolveActiveIterationVersion,
@@ -1555,6 +1558,9 @@ export default function ProjectDevelopmentView({
useState<CanvasLayer | null>(null);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const quickEditPromptInputRef = useRef<ResourceReferenceInputHandle | null>(
null,
);
/**
* `@`
*
@@ -1563,9 +1569,6 @@ export default function ProjectDevelopmentView({
* `@`
*
*/
const [quickEditReferences, setQuickEditReferences] = useState<
ChatReference[]
>([]);
/**
*
*
@@ -6470,7 +6473,6 @@ export default function ProjectDevelopmentView({
setQuickEditSourceLayer(layer);
const panelDraft = createResourceQuickEditPanelDraft(layer);
setQuickEditPanel(panelDraft);
setQuickEditReferences([]);
resourceQuickEditRequestRef.current = {
...createResourceEditRequestIdentity(panelDraft.prompt),
sourceLayerId: layer.id,
@@ -6487,33 +6489,51 @@ export default function ProjectDevelopmentView({
*
* composer `resetFailedDialogStatus`
* idle
* 稿稿 `manifest.assets`
* chip `@内部 id` `@显示名`
* `replaceText`
*/
const applyResourceQuickEditPrompt = useCallback((text: string) => {
setQuickEditPanel((current) =>
current
? {
...current,
prompt: text,
status: current.status === 'failed' ? 'idle' : current.status,
errorMessage:
current.status === 'failed' ? undefined : current.errorMessage,
}
: current,
);
}, []);
const applyResourceQuickEditPrompt = useCallback(
(text: string) => {
const currentDraft = quickEditPromptInputRef.current?.getDraft();
if (
currentDraft &&
directCodexContentToPromptText(
currentDraft.content,
manifest.assets,
) !== text
) {
quickEditPromptInputRef.current?.replaceText(text);
}
setQuickEditPanel((current) =>
current
? {
...current,
prompt: text,
status: current.status === 'failed' ? 'idle' : current.status,
errorMessage:
current.status === 'failed' ? undefined : current.errorMessage,
}
: current,
);
},
[manifest.assets],
);
/**
* 稿 + `@`
*
* `applyResourceQuickEditPrompt`
* chip payload `@`
* chip payload `@`
* `@` `manifest.assets`
*/
const applyResourceQuickEditDraft = useCallback(
(draft: ChatComposerDraft) => {
setQuickEditReferences(draft.references);
applyResourceQuickEditPrompt(draft.text);
applyResourceQuickEditPrompt(
directCodexContentToPromptText(draft.content, manifest.assets),
);
},
[applyResourceQuickEditPrompt],
[applyResourceQuickEditPrompt, manifest.assets],
);
/**
@@ -8128,9 +8148,15 @@ export default function ProjectDevelopmentView({
// 由它拼装,因此出站 payload 与聊天 `@` 一致。
<div className="resource-canvas-quick-edit-prompt-input">
<ResourceReferenceInput
ref={quickEditPromptInputRef}
key={quickEditSourceLayer?.id}
ariaLabel="快速编辑提示词"
value={quickEditPanel.prompt}
references={quickEditReferences}
initialContent={[
{
type: 'input_text',
text: quickEditPanel.prompt,
},
]}
onChange={applyResourceQuickEditDraft}
assets={manifest.assets}
projectPath={projectPath}

Some files were not shown because too many files have changed in this diff Show More