统一智能体对话层级并完善素材导出与文档预览
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m44s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m18s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 6m12s
Project CI / Repository checks (pull_request) Failing after 3m48s
Project CI / Backend tests (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m59s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m44s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m18s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 6m12s
Project CI / Repository checks (pull_request) Failing after 3m48s
Project CI / Backend tests (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m59s
当前Agent与策划Agent共用正文和过程表现组件,统一思考及工具调用的小字号浅色样式 素材工具栏按实际动作分组,导出紧邻删除并修复空分组重复分隔线 文档与代码使用共享Markdown预览和代码高亮,复用按需读取与缓存 移除未调用的活跃回合查询命令,保留正式快照恢复链路 补充回归用例并同步技术方案和团队约定
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
"react-colorful": "^5.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.14"
|
||||
|
||||
@@ -444,7 +444,8 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result<Stri
|
||||
})
|
||||
}
|
||||
|
||||
/// `read_direct_codex_active_turn` 的返回值:前端重进会话时用它恢复正在跑的回合。
|
||||
/// 测试读取进程内调用守卫快照,校验占用与释放生命周期。
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectActiveTurnView {
|
||||
@@ -458,6 +459,7 @@ pub(crate) struct DirectActiveTurnView {
|
||||
///
|
||||
/// 只读,不改变互斥语义与释放时机:守卫仍然只由回合自己的 `Drop` 或
|
||||
/// [`release_stale_direct_taonier_active_invocation`] 释放。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn read_direct_taonier_active_invocation_at(
|
||||
root: &Path,
|
||||
) -> Result<Option<DirectActiveTurnView>, String> {
|
||||
@@ -475,16 +477,6 @@ pub(crate) fn read_direct_taonier_active_invocation_at(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 只读命令:读取该项目当前登记的 Direct 活跃回合,供前端重进会话时恢复。
|
||||
///
|
||||
/// 不进入、不抢占、不释放守卫;没有回合时返回 `null`。
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_direct_codex_active_turn(
|
||||
project_path: String,
|
||||
) -> Result<Option<DirectActiveTurnView>, String> {
|
||||
read_direct_taonier_active_invocation_at(Path::new(project_path.trim()))
|
||||
}
|
||||
|
||||
/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DirectTaonierStaleGuardReason {
|
||||
|
||||
@@ -2677,7 +2677,6 @@ fn main() {
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
chat_with_game_creator_direct_codex,
|
||||
read_direct_codex_active_turn,
|
||||
cancel_direct_codex_turn,
|
||||
select_game_creator_reasoning_effort,
|
||||
start_planning_session_v2,
|
||||
|
||||
@@ -1204,19 +1204,6 @@ interface TurnStreamItemBase {
|
||||
*/
|
||||
export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem;
|
||||
|
||||
/**
|
||||
* `read_direct_codex_active_turn` 的返回值:Rust 进程内当前登记的 Direct 活跃回合。
|
||||
*
|
||||
* 重进会话时用它把前端的"当前活跃回合"接管回来,否则界面不知道有回合在跑,
|
||||
* 既不显示过程卡也不给终止入口,用户再发消息只会被守卫拒绝。
|
||||
*/
|
||||
export interface DirectActiveTurnView {
|
||||
/** 与回合事件的 `turnId` 同一个身份。 */
|
||||
clientTurnId: string;
|
||||
/** 这一轮登记的时刻(Unix 毫秒)。 */
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** `cancel_direct_codex_turn` 的返回值。 */
|
||||
export interface DirectTurnCancelView {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 只作用于共享 Markdown 渲染器,不改变普通正文及用户消息的字体颜色。 */
|
||||
.agc-markdown-code .hljs-comment,
|
||||
.agc-markdown-code .hljs-quote {
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-keyword,
|
||||
.agc-markdown-code .hljs-name,
|
||||
.agc-markdown-code .hljs-selector-tag,
|
||||
.agc-markdown-code .hljs-literal,
|
||||
.agc-markdown-code .hljs-deletion {
|
||||
color: #a6264c;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-string,
|
||||
.agc-markdown-code .hljs-regexp,
|
||||
.agc-markdown-code .hljs-addition {
|
||||
color: #276438;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-number,
|
||||
.agc-markdown-code .hljs-attr,
|
||||
.agc-markdown-code .hljs-variable,
|
||||
.agc-markdown-code .hljs-built_in {
|
||||
color: #075a9c;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-title,
|
||||
.agc-markdown-code .hljs-type,
|
||||
.agc-markdown-code .hljs-section {
|
||||
color: #6f42a0;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-meta,
|
||||
.agc-markdown-code .hljs-symbol {
|
||||
color: #8a4c0a;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import './codeHighlight.css';
|
||||
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import {
|
||||
Children,
|
||||
@@ -7,14 +9,20 @@ import {
|
||||
useContext,
|
||||
} from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
export type ChatMarkdownMessageProps = {
|
||||
text: string;
|
||||
role: 'assistant' | 'user';
|
||||
streaming?: boolean;
|
||||
/** 文件预览不压缩正文空行,保留源码与文档的原始排版。 */
|
||||
preserveBlankLines?: boolean;
|
||||
};
|
||||
|
||||
const MAX_HIGHLIGHT_CHARACTERS = 100_000;
|
||||
const CodeBlockContext = createContext(false);
|
||||
|
||||
/** 只压缩普通 Markdown 正文里多余的空行;代码块中的换行必须原样保留。 */
|
||||
function normalizeMarkdownBlankLines(text: string) {
|
||||
return text
|
||||
@@ -183,22 +191,50 @@ const markdownComponents: Components = {
|
||||
a: ({ children }) => children,
|
||||
img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'),
|
||||
h1: ({ children }) => (
|
||||
<h1 className="m-0 mt-4 !text-xl font-bold first:mt-0">{children}</h1>
|
||||
<h1
|
||||
className="m-0 mt-4 font-bold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 1.25rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="m-0 mt-4 !text-lg font-bold first:mt-0">{children}</h2>
|
||||
<h2
|
||||
className="m-0 mt-4 font-bold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 1.125rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="m-0 mt-3 !text-base font-semibold first:mt-0">{children}</h3>
|
||||
<h3
|
||||
className="m-0 mt-3 font-semibold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 1rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="m-0 mt-3 !text-sm font-semibold first:mt-0">{children}</h4>
|
||||
<h4
|
||||
className="m-0 mt-3 font-semibold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<h5 className="m-0 mt-2 !text-sm font-medium first:mt-0">{children}</h5>
|
||||
<h5
|
||||
className="m-0 mt-2 font-medium first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<h6 className="m-0 mt-2 !text-xs font-medium uppercase tracking-wide first:mt-0">
|
||||
<h6
|
||||
className="m-0 mt-2 font-medium uppercase tracking-wide first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 0.75rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
@@ -213,15 +249,18 @@ const markdownComponents: Components = {
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 first:mt-0">
|
||||
{children}
|
||||
<CodeBlockContext.Provider value={true}>
|
||||
{children}
|
||||
</CodeBlockContext.Provider>
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children, node: _node, ...props }) => {
|
||||
const isBlock =
|
||||
Boolean(className?.includes('language-')) ||
|
||||
String(children).includes('\n');
|
||||
code: function MarkdownCode({ className, children, node: _node, ...props }) {
|
||||
const isBlock = useContext(CodeBlockContext);
|
||||
return isBlock ? (
|
||||
<code {...props} className="font-mono whitespace-pre">
|
||||
<code
|
||||
{...props}
|
||||
className={`agc-markdown-code font-mono whitespace-pre ${className ?? ''}`}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
@@ -235,7 +274,10 @@ const markdownComponents: Components = {
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="mt-2 max-w-full overflow-x-auto first:mt-0">
|
||||
<table className="min-w-full border-collapse text-left text-sm">
|
||||
<table
|
||||
className="min-w-full border-collapse text-left"
|
||||
style={{ fontSize: 'var(--agent-message-table-size, 0.875rem)' }}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
@@ -264,6 +306,7 @@ export function ChatMarkdownMessage({
|
||||
text,
|
||||
role,
|
||||
streaming = false,
|
||||
preserveBlankLines = false,
|
||||
}: ChatMarkdownMessageProps) {
|
||||
if (role === 'user') {
|
||||
return <span className="whitespace-pre-wrap break-words">{text}</span>;
|
||||
@@ -274,11 +317,14 @@ export function ChatMarkdownMessage({
|
||||
<ReactMarkdown
|
||||
skipHtml
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={
|
||||
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
|
||||
}
|
||||
components={
|
||||
streaming ? streamingMarkdownComponents : markdownComponents
|
||||
}
|
||||
>
|
||||
{normalizeMarkdownBlankLines(text)}
|
||||
{preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
|
||||
</ReactMarkdown>
|
||||
</MarkdownErrorBoundary>
|
||||
);
|
||||
|
||||
+90
-53
@@ -8,6 +8,10 @@ import type {
|
||||
} from 'react';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
AgentMessageContent,
|
||||
type AgentMessageTone,
|
||||
} from '../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type {
|
||||
AgentStatusCard,
|
||||
ChatMessage,
|
||||
@@ -131,6 +135,7 @@ function TurnStreamSequence({
|
||||
active,
|
||||
userSentAt,
|
||||
className,
|
||||
tone = 'body',
|
||||
}: {
|
||||
items: readonly TurnStreamItem[];
|
||||
toolCalls: readonly GameCreatorDirectToolCall[];
|
||||
@@ -138,6 +143,7 @@ function TurnStreamSequence({
|
||||
active: boolean;
|
||||
userSentAt: number;
|
||||
className?: string;
|
||||
tone?: AgentMessageTone;
|
||||
}) {
|
||||
const runs = turnStreamRuns(items);
|
||||
const callsById = new Map<string, GameCreatorDirectToolCall>();
|
||||
@@ -159,11 +165,13 @@ function TurnStreamSequence({
|
||||
: 'message message--assistant'
|
||||
}
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={run.text}
|
||||
streaming={active && index === runs.length - 1}
|
||||
/>
|
||||
<AgentMessageContent tone={tone}>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={run.text}
|
||||
streaming={active && index === runs.length - 1}
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
</div>
|
||||
) : (
|
||||
<ToolCallGroup
|
||||
@@ -183,6 +191,30 @@ function TurnStreamSequence({
|
||||
);
|
||||
}
|
||||
|
||||
/** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */
|
||||
function AgentReasoning({
|
||||
text,
|
||||
label = '思考过程',
|
||||
testId,
|
||||
}: {
|
||||
text: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<AgentMessageContent
|
||||
as="details"
|
||||
tone="process"
|
||||
className="design-agent-reasoning"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{text}</pre>
|
||||
</AgentMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
|
||||
|
||||
function directStatusTitle(status: string | null | undefined) {
|
||||
@@ -437,7 +469,11 @@ export function ProjectSupervisorView({
|
||||
</p>
|
||||
);
|
||||
};
|
||||
const renderMessage = (message: ChatMessage, index: number) => {
|
||||
const renderMessageContent = (
|
||||
message: ChatMessage,
|
||||
index: number,
|
||||
tone: AgentMessageTone,
|
||||
) => {
|
||||
const sentAt =
|
||||
message.role === 'user' ? directMessageTimestamp(message.updatedAt) : 0;
|
||||
return (
|
||||
@@ -445,10 +481,12 @@ export function ProjectSupervisorView({
|
||||
key={message.messageId ?? `${message.role}-${index}`}
|
||||
className={`message message--${message.role}`}
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role={message.role}
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
/>
|
||||
<AgentMessageContent tone={tone}>
|
||||
<ChatMarkdownMessage
|
||||
role={message.role}
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
{sentAt > 0 ? (
|
||||
<time
|
||||
className="message-sent-at"
|
||||
@@ -459,17 +497,16 @@ export function ProjectSupervisorView({
|
||||
</time>
|
||||
) : null}
|
||||
{showDesignReasoning && message.reasoningText ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{message.reasoningText}</pre>
|
||||
</details>
|
||||
<AgentReasoning
|
||||
text={message.reasoningText}
|
||||
label="策划 Agent 思考过程"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderMessage = (message: ChatMessage, index: number) =>
|
||||
renderMessageContent(message, index, 'body');
|
||||
|
||||
const submitButton = (
|
||||
<button
|
||||
@@ -578,17 +615,21 @@ export function ProjectSupervisorView({
|
||||
{directCodex
|
||||
? directTurns.map((turn) => {
|
||||
const content = splitDirectTurnContent(turn);
|
||||
const renderStream = (items: TurnStreamItem[]) => (
|
||||
const renderStream = (
|
||||
items: TurnStreamItem[],
|
||||
tone: AgentMessageTone = 'body',
|
||||
) => (
|
||||
<TurnStreamSequence
|
||||
items={items}
|
||||
toolCalls={turn.calls}
|
||||
active={turn.active}
|
||||
userSentAt={turn.startedAt}
|
||||
tone={tone}
|
||||
/>
|
||||
);
|
||||
const process =
|
||||
turn.source === 'stream' ? (
|
||||
renderStream(content.processItems)
|
||||
renderStream(content.processItems, 'process')
|
||||
) : (
|
||||
<>
|
||||
{turn.calls.length > 0 ? (
|
||||
@@ -599,7 +640,9 @@ export function ProjectSupervisorView({
|
||||
className="message-tool-call"
|
||||
/>
|
||||
) : null}
|
||||
{content.processMessages.map(renderMessage)}
|
||||
{content.processMessages.map((message, index) =>
|
||||
renderMessageContent(message, index, 'process'),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const hasProcess =
|
||||
@@ -641,11 +684,13 @@ export function ProjectSupervisorView({
|
||||
aria-live="polite"
|
||||
data-runtime-owned="true"
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={turn.transientReply}
|
||||
streaming
|
||||
/>
|
||||
<AgentMessageContent>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={turn.transientReply}
|
||||
streaming
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
</div>
|
||||
) : null}
|
||||
{renderTurnUsage(turn)}
|
||||
@@ -654,35 +699,23 @@ export function ProjectSupervisorView({
|
||||
})
|
||||
: visibleMessages.map(renderMessage)}
|
||||
{directCodex && transientReasoning ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
data-testid="live-reasoning"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{transientReasoning}</pre>
|
||||
</details>
|
||||
<AgentReasoning text={transientReasoning} testId="live-reasoning" />
|
||||
) : null}
|
||||
{showDesignReasoning &&
|
||||
designReasoningEntries
|
||||
.filter((entry) => !entry.messageId)
|
||||
.map((entry) => (
|
||||
<details
|
||||
<AgentReasoning
|
||||
key={`reasoning-${entry.id}`}
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{entry.text}</pre>
|
||||
</details>
|
||||
text={entry.text}
|
||||
label="策划 Agent 思考过程"
|
||||
/>
|
||||
))}
|
||||
{showDesignReasoning && designReasoning ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{designReasoning}</pre>
|
||||
</details>
|
||||
<AgentReasoning
|
||||
text={designReasoning}
|
||||
label="策划 Agent 思考过程"
|
||||
/>
|
||||
) : null}
|
||||
{!directCodex && transientReply ? (
|
||||
<div
|
||||
@@ -693,11 +726,13 @@ export function ProjectSupervisorView({
|
||||
aria-live="polite"
|
||||
data-runtime-owned="true"
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={transientReply}
|
||||
streaming
|
||||
/>
|
||||
<AgentMessageContent>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={transientReply}
|
||||
streaming
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -768,7 +803,9 @@ export function ProjectSupervisorView({
|
||||
directStatus !== 'completed' &&
|
||||
directStatus !== 'failed' &&
|
||||
(runtimePanelProps.controlBusy || Boolean(activeTurnId)) ? (
|
||||
<section
|
||||
<AgentMessageContent
|
||||
as="section"
|
||||
tone="process"
|
||||
className={`project-supervisor-process-card${runtimePanelProps.controlBusy ? ' is-active' : ''}`}
|
||||
aria-label="陶泥儿执行过程"
|
||||
aria-live="polite"
|
||||
@@ -825,7 +862,7 @@ export function ProjectSupervisorView({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</AgentMessageContent>
|
||||
) : null}
|
||||
<form
|
||||
className={`project-supervisor-composer${
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
|
||||
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||
import {
|
||||
formatToolCallDuration,
|
||||
@@ -93,7 +94,9 @@ export function ToolCallGroup({
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
return (
|
||||
<section
|
||||
<AgentMessageContent
|
||||
as="section"
|
||||
tone="process"
|
||||
className={
|
||||
className
|
||||
? `agent-tool-call-group ${className}`
|
||||
@@ -146,7 +149,7 @@ export function ToolCallGroup({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</AgentMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
max-width: min(92vw, 640px);
|
||||
max-width: min(92vw, 800px);
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.3rem;
|
||||
@@ -909,6 +909,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
.game-resource-document-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(960px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
min-width: 0;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-resource-document-preview__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--platform-surface-border);
|
||||
}
|
||||
|
||||
.game-resource-document-preview__header strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-resource-document-preview__body {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */
|
||||
.game-resource-panel {
|
||||
width: min(880px, calc(100% - 32px));
|
||||
|
||||
@@ -87,6 +87,7 @@ export type ResourceCanvasHostOverlayState = {
|
||||
readonly isClassificationPanelOpen: boolean;
|
||||
readonly isRenameDialogOpen: boolean;
|
||||
readonly isRecoveryPanelOpen: boolean;
|
||||
readonly isDocumentPreviewOpen?: boolean;
|
||||
};
|
||||
|
||||
export function isResourceCanvasHostOverlayOpen(
|
||||
@@ -97,7 +98,8 @@ export function isResourceCanvasHostOverlayOpen(
|
||||
overlay.isGenerationPanelOpen ||
|
||||
overlay.isClassificationPanelOpen ||
|
||||
overlay.isRenameDialogOpen ||
|
||||
overlay.isRecoveryPanelOpen
|
||||
overlay.isRecoveryPanelOpen ||
|
||||
Boolean(overlay.isDocumentPreviewOpen)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
projectResourceCardPreviewKind,
|
||||
projectResourcePathExtension,
|
||||
} from '../../view/project-development/resourceCardPreviewModel';
|
||||
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
|
||||
|
||||
const CODE_LANGUAGES: Readonly<Record<string, string>> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
mts: 'typescript',
|
||||
cts: 'typescript',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
rs: 'rust',
|
||||
py: 'python',
|
||||
go: 'go',
|
||||
java: 'java',
|
||||
kt: 'kotlin',
|
||||
kts: 'kotlin',
|
||||
cs: 'csharp',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
cxx: 'cpp',
|
||||
c: 'c',
|
||||
h: 'c',
|
||||
hpp: 'cpp',
|
||||
swift: 'swift',
|
||||
php: 'php',
|
||||
rb: 'ruby',
|
||||
lua: 'lua',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
ps1: 'powershell',
|
||||
psm1: 'powershell',
|
||||
json: 'json',
|
||||
jsonc: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
toml: 'ini',
|
||||
xml: 'xml',
|
||||
html: 'xml',
|
||||
htm: 'xml',
|
||||
vue: 'xml',
|
||||
svelte: 'xml',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
less: 'less',
|
||||
sql: 'sql',
|
||||
graphql: 'graphql',
|
||||
gql: 'graphql',
|
||||
};
|
||||
|
||||
export function isResourceDocumentPreviewable(resource: ProjectResource) {
|
||||
const kind = projectResourceCardPreviewKind(resource);
|
||||
return (
|
||||
(kind === 'document' || kind === 'code') &&
|
||||
(resource.content !== undefined || resource.path.trim() !== '')
|
||||
);
|
||||
}
|
||||
|
||||
export function resourceDocumentPreviewMarkdown(
|
||||
resource: ProjectResource,
|
||||
content: string,
|
||||
) {
|
||||
if (projectResourceCardPreviewKind(resource) !== 'code') {
|
||||
return content;
|
||||
}
|
||||
const language =
|
||||
CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text';
|
||||
// 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。
|
||||
const longestRun = (content.match(/`+/g) ?? []).reduce(
|
||||
(length, run) => Math.max(length, run.length),
|
||||
0,
|
||||
);
|
||||
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
||||
return `${fence}${language}\n${content}${content.endsWith('\n') ? '' : '\n'}${fence}`;
|
||||
}
|
||||
@@ -2913,7 +2913,6 @@ textarea {
|
||||
border: 1px solid #b9d8c5;
|
||||
border-radius: 8px;
|
||||
background: #f1f8f3;
|
||||
color: #1f3d2a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -2966,7 +2965,7 @@ textarea {
|
||||
.project-supervisor-process-elapsed {
|
||||
margin-left: auto !important;
|
||||
color: inherit;
|
||||
font-size: 13px !important;
|
||||
font-size: inherit;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
@@ -2991,8 +2990,8 @@ textarea {
|
||||
|
||||
.project-supervisor-process-card p {
|
||||
margin: 0;
|
||||
color: #263142;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -3037,11 +3036,6 @@ textarea {
|
||||
.game-workbench-chat .project-supervisor-process-card {
|
||||
border-color: var(--platform-surface-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.game-workbench-chat .project-supervisor-process-card p {
|
||||
color: var(--platform-text-base);
|
||||
}
|
||||
|
||||
.project-supervisor-surface .agent-runtime-status {
|
||||
@@ -11648,8 +11642,6 @@ button.design-workspace-tree__entry:hover,
|
||||
|
||||
.design-agent-reasoning {
|
||||
margin: 8px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.design-agent-reasoning summary {
|
||||
cursor: pointer;
|
||||
@@ -11658,7 +11650,6 @@ button.design-workspace-tree__entry:hover,
|
||||
margin: 6px 0 0;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -11807,7 +11798,6 @@ button.design-workspace-tree__entry:hover,
|
||||
> .project-supervisor-process-card {
|
||||
border-color: var(--platform-line-soft);
|
||||
background: var(--platform-neutral-bg);
|
||||
color: var(--platform-text-base);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
@@ -12021,7 +12011,6 @@ button.design-workspace-tree__entry:hover,
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--platform-button-secondary-fill);
|
||||
color: var(--platform-text-base);
|
||||
}
|
||||
|
||||
/* 块头:点整行展开,浅底条目本身是块头。 */
|
||||
@@ -12209,7 +12198,7 @@ button.design-workspace-tree__entry:hover,
|
||||
border: 1px solid var(--platform-line-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--platform-neutral-bg);
|
||||
color: var(--platform-text-base);
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
@@ -12229,7 +12218,7 @@ button.design-workspace-tree__entry:hover,
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--platform-text-base);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -12295,7 +12284,6 @@ button.design-workspace-tree__entry:hover,
|
||||
.project-supervisor-conversation
|
||||
> .project-supervisor-process-card {
|
||||
margin: 0 16px 8px;
|
||||
color: var(--platform-text-base);
|
||||
}
|
||||
|
||||
/* 直连回合的「思考过程」块:默认折叠(<details> 默认收起),展开后按步骤列过程行。
|
||||
@@ -12389,7 +12377,7 @@ button.design-workspace-tree__entry:hover,
|
||||
|
||||
.message-turn-process {
|
||||
min-width: 0;
|
||||
color: var(--platform-text-base);
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
|
||||
.message-turn-process > summary {
|
||||
@@ -12426,8 +12414,6 @@ button.design-workspace-tree__entry:hover,
|
||||
.project-supervisor-message-list
|
||||
details[data-testid='live-reasoning'] {
|
||||
margin: 0;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
@@ -12445,7 +12431,7 @@ button.design-workspace-tree__entry:hover,
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-size: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Markdown 正文继承消息颜色,层级由字号和字重表达,不混入全局段落或引用颜色。 */
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformIconButton } from '../../../../../packages/shared/src/components/PlatformIconButton';
|
||||
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import { resourceDocumentPreviewMarkdown } from '../../features/resource-canvas/resourceDocumentPreviewModel';
|
||||
import type { ProjectResourceCardPreviewState } from './resourceCardPreviewModel';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
|
||||
export function ResourceDocumentPreviewDialog({
|
||||
resource,
|
||||
identity,
|
||||
preview,
|
||||
onRequestPreview,
|
||||
onClose,
|
||||
}: {
|
||||
resource: ProjectResource;
|
||||
identity: string;
|
||||
preview: ProjectResourceCardPreviewState;
|
||||
onRequestPreview: (
|
||||
resource: ProjectResource,
|
||||
identity: string,
|
||||
reason: 'detail',
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
onRequestPreview(resource, identity, 'detail');
|
||||
}, [resource, identity, onRequestPreview]);
|
||||
const content =
|
||||
preview.status === 'loaded' ? preview.preview.content : undefined;
|
||||
const markdown = useMemo(
|
||||
() =>
|
||||
content === undefined
|
||||
? ''
|
||||
: resourceDocumentPreviewMarkdown(resource, content),
|
||||
[resource, content],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel="文档预览"
|
||||
onClose={onClose}
|
||||
panelClassName="game-resource-document-preview"
|
||||
>
|
||||
<header className="game-resource-document-preview__header">
|
||||
<strong title={resource.label}>{resource.label}</strong>
|
||||
<PlatformIconButton
|
||||
label="关闭文档预览"
|
||||
title="关闭"
|
||||
icon={<X size={18} aria-hidden="true" />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</header>
|
||||
<div className="game-resource-document-preview__body">
|
||||
{preview.status === 'idle' || preview.status === 'loading' ? (
|
||||
<p role="status">正在加载文档…</p>
|
||||
) : preview.status === 'failed' ? (
|
||||
<>
|
||||
<p role="alert">{preview.error}</p>
|
||||
{preview.retryable ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
onClick={() => onRequestPreview(resource, identity, 'detail')}
|
||||
>
|
||||
重试
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</>
|
||||
) : content === undefined ? (
|
||||
<p role="alert">没有可预览的文本内容</p>
|
||||
) : content === '' ? (
|
||||
<p role="status">文档为空</p>
|
||||
) : (
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={markdown}
|
||||
preserveBlankLines
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
||||
import {
|
||||
AtSign,
|
||||
Crosshair,
|
||||
Eye,
|
||||
FileCode2,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
@@ -190,6 +191,7 @@ import {
|
||||
currentVersionResourceBindingIds,
|
||||
isResourceUsedByCurrentVersion,
|
||||
} from '../../features/resource-canvas/resourceCanvasVersionBindingModel';
|
||||
import { isResourceDocumentPreviewable } from '../../features/resource-canvas/resourceDocumentPreviewModel';
|
||||
import { ResourcePromptPolishSlot } from '../../features/resource-canvas/ResourcePromptPolishSlot';
|
||||
import {
|
||||
type LocalProjectVersionReplacementCandidate,
|
||||
@@ -308,6 +310,7 @@ import {
|
||||
ResourceDependencyOverlay,
|
||||
type ResourceDependencyOverlayHandle,
|
||||
} from './ResourceDependencyOverlay';
|
||||
import { ResourceDocumentPreviewDialog } from './ResourceDocumentPreviewDialog';
|
||||
import {
|
||||
canonicalProjectedResourceMediaType,
|
||||
createResourceEditRequestIdentity,
|
||||
@@ -1582,6 +1585,8 @@ export default function ProjectDevelopmentView({
|
||||
createResourceCanvasHistory,
|
||||
);
|
||||
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
|
||||
const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] =
|
||||
useState<string | null>(null);
|
||||
/**
|
||||
* 「生成素材」浮层的本次放行类型。
|
||||
*
|
||||
@@ -1939,6 +1944,7 @@ export default function ProjectDevelopmentView({
|
||||
isClassificationPanelOpen: resourceClassificationOverlayOpen,
|
||||
isRenameDialogOpen: resourceRenameAssetId !== null,
|
||||
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
|
||||
isDocumentPreviewOpen: resourceDocumentPreviewIdentity !== null,
|
||||
},
|
||||
}),
|
||||
boundaryRefs: [resourceBookManagerRef],
|
||||
@@ -1966,6 +1972,7 @@ export default function ProjectDevelopmentView({
|
||||
isClassificationPanelOpen: resourceClassificationOverlayOpen,
|
||||
isRenameDialogOpen: resourceRenameAssetId !== null,
|
||||
isRecoveryPanelOpen: resourceRecoveryPanelOpen,
|
||||
isDocumentPreviewOpen: resourceDocumentPreviewIdentity !== null,
|
||||
},
|
||||
})
|
||||
) {
|
||||
@@ -1987,6 +1994,7 @@ export default function ProjectDevelopmentView({
|
||||
resourceClassificationOverlayOpen,
|
||||
resourcePanelOpen,
|
||||
resourceRecoveryPanelOpen,
|
||||
resourceDocumentPreviewIdentity,
|
||||
resourceRenameAssetId,
|
||||
selectedResourceIds,
|
||||
uiEditorRoute,
|
||||
@@ -6350,6 +6358,27 @@ export default function ProjectDevelopmentView({
|
||||
null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
mode !== 'resources' ||
|
||||
uiEditorRoute ||
|
||||
resourceDocumentPreviewIdentity !== selectedResourcePreviewIdentity
|
||||
) {
|
||||
setResourceDocumentPreviewIdentity(null);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
uiEditorRoute,
|
||||
resourceDocumentPreviewIdentity,
|
||||
selectedResourcePreviewIdentity,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resourceDocumentPreviewIdentity) return;
|
||||
protectResourceCardPreview(resourceDocumentPreviewIdentity);
|
||||
return () => protectResourceCardPreview(null);
|
||||
}, [protectResourceCardPreview, resourceDocumentPreviewIdentity]);
|
||||
|
||||
/**
|
||||
* 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。
|
||||
*
|
||||
@@ -7863,13 +7892,38 @@ export default function ProjectDevelopmentView({
|
||||
selectedToolbarStyle &&
|
||||
((selectedToolbarActions?.size ?? 0) > 0 ||
|
||||
Boolean(selectedResource?.manifestAssetId) ||
|
||||
Boolean(
|
||||
selectedResource &&
|
||||
isResourceDocumentPreviewable(selectedResource),
|
||||
) ||
|
||||
selectedResourceOpensUiEditor) ? (
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
selectedLayer={selectedResourceLayer}
|
||||
selectedToolbarStyle={selectedToolbarStyle}
|
||||
supportedActions={selectedToolbarActions}
|
||||
downloadLabel="导出"
|
||||
extraActions={
|
||||
<>
|
||||
{selectedResource &&
|
||||
selectedResourcePreviewIdentity &&
|
||||
isResourceDocumentPreviewable(
|
||||
selectedResource,
|
||||
) ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="预览"
|
||||
title="预览"
|
||||
icon={<Eye className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
stopActiveCardMedia();
|
||||
setResourceDocumentPreviewIdentity(
|
||||
selectedResourcePreviewIdentity,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span>预览</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResourceOpensUiEditor ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
@@ -7998,49 +8052,36 @@ export default function ProjectDevelopmentView({
|
||||
<span>替换素材</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{/*
|
||||
破坏性动作排在最后,并用共享工具条同一套分隔线(
|
||||
`image-canvas-editor__floating-toolbar-divider`)把它与前面的
|
||||
非破坏性动作隔开。只删素材登记:磁盘文件保留,确认面板里再问一次
|
||||
「是否连带删除引用它的游戏版本」。
|
||||
*/}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="删除素材"
|
||||
title="删除素材"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
disabled={
|
||||
resourceAssetDeleteFlow.deleting ||
|
||||
resourceAssetDeleteFlow.preparing
|
||||
}
|
||||
onClick={() => {
|
||||
if (!selectedResource.manifestAssetId) {
|
||||
return;
|
||||
}
|
||||
void resourceAssetDeleteFlow.requestDelete(
|
||||
{
|
||||
assetId:
|
||||
selectedResource.manifestAssetId,
|
||||
// 资源投影的 `path` 就是 manifest 资产的
|
||||
// `localPath`(见 `resourceProjectionModel`),
|
||||
// 与面板曾用的副标题同源。
|
||||
localPath: selectedResource.path,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span>删除素材</span>
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
endActions={
|
||||
selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="删除素材"
|
||||
title="删除素材"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
disabled={
|
||||
resourceAssetDeleteFlow.deleting ||
|
||||
resourceAssetDeleteFlow.preparing
|
||||
}
|
||||
onClick={() => {
|
||||
if (!selectedResource.manifestAssetId) {
|
||||
return;
|
||||
}
|
||||
void resourceAssetDeleteFlow.requestDelete({
|
||||
assetId: selectedResource.manifestAssetId,
|
||||
// 资源投影的 `path` 就是 manifest 资产的
|
||||
// `localPath`(见 `resourceProjectionModel`),
|
||||
// 与面板曾用的副标题同源。
|
||||
localPath: selectedResource.path,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>删除素材</span>
|
||||
</CanvasChromeButton>
|
||||
) : null
|
||||
}
|
||||
onOpenQuickEditPanel={openResourceQuickEditPanel}
|
||||
onOpenRedrawPanel={() => {}}
|
||||
onOpenCropExpandPanel={() => {}}
|
||||
@@ -8765,6 +8806,24 @@ export default function ProjectDevelopmentView({
|
||||
onFocusTask={focusResourceAssetGenerationTask}
|
||||
/>
|
||||
|
||||
{mode === 'resources' &&
|
||||
!uiEditorRoute &&
|
||||
selectedResource &&
|
||||
resourceDocumentPreviewIdentity &&
|
||||
resourceDocumentPreviewIdentity === selectedResourcePreviewIdentity ? (
|
||||
<ResourceDocumentPreviewDialog
|
||||
key={resourceDocumentPreviewIdentity}
|
||||
resource={selectedResource}
|
||||
identity={resourceDocumentPreviewIdentity}
|
||||
preview={
|
||||
resourceCardPreviews.previews.get(
|
||||
resourceDocumentPreviewIdentity,
|
||||
) ?? resourceCardPreviews.idlePreview
|
||||
}
|
||||
onRequestPreview={resourceCardPreviews.requestPreview}
|
||||
onClose={() => setResourceDocumentPreviewIdentity(null)}
|
||||
/>
|
||||
) : null}
|
||||
{resourcePanelOpen ? (
|
||||
<ResourceCanvasPanelView
|
||||
entries={resourcePanelEntries}
|
||||
|
||||
@@ -224,7 +224,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu;
|
||||
const cardCodeExtension =
|
||||
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|json|jsonc|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
|
||||
/** 纯文本:按纯文本处理,显示前几行但不做标记清理。 */
|
||||
const plainTextExtension = /\.(txt|text|csv|tsv|log|ini|conf|cfg|properties|env)$/iu;
|
||||
const plainTextExtension =
|
||||
/\.(txt|text|csv|tsv|log|ini|conf|cfg|properties|env)$/iu;
|
||||
|
||||
/**
|
||||
* 取路径末段的扩展名(小写、不含点)。取不到(无扩展名)时返回 `null`。
|
||||
@@ -330,7 +331,7 @@ export function projectResourceCardPreviewVariant(
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码卡**不做文件读取**的判据:卡面只用路径画图标,读内容既无用又占读取槽。
|
||||
* 卡面是否预取正文:代码卡只用路径画图标,不为卡面占读取槽;显式详情请求单独放行。
|
||||
*
|
||||
* 与 `projectResourceCardPreviewVariant` 同源,避免"卡面不画内容、却仍在后台读内容"的分叉。
|
||||
*/
|
||||
|
||||
+8
-17
@@ -26,8 +26,8 @@ import {
|
||||
projectResourceCardPreviewImageDimensions,
|
||||
type ProjectResourceCardPreviewKind,
|
||||
projectResourceCardPreviewKind,
|
||||
projectResourceCardPreviewReadsContent,
|
||||
type ProjectResourceCardPreviewPayload,
|
||||
projectResourceCardPreviewReadsContent,
|
||||
type ProjectResourceCardPreviewState,
|
||||
type ProjectResourceCardPreviewTransportPayload,
|
||||
projectResourceMediaPreviewCategory,
|
||||
@@ -492,11 +492,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
job: PreviewJob,
|
||||
): Promise<ProjectResourceCardPreviewTransportPayload> => {
|
||||
const kind = projectResourceCardPreviewKind(job.resource);
|
||||
/**
|
||||
* 这段之前的 `kind === 'document' || kind === 'code'` 双分支已收窄:代码卡在
|
||||
* `requestPreview` 的入队门禁就被挡下(**判据同源**,见 `projectResourceCardPreviewReadsContent`),
|
||||
* 走到这里的只可能是需要读内容的文档卡(Markdown / 纯文本)。
|
||||
*/
|
||||
// 内联文档和按需打开的代码详情共用现有预览结果,不另建读取或缓存链路。
|
||||
if (job.resource.content !== undefined) {
|
||||
return {
|
||||
path: job.resource.path,
|
||||
@@ -508,7 +504,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
throw new Error(
|
||||
kind === 'document'
|
||||
kind === 'document' || kind === 'code'
|
||||
? '文档预览需要在客户端内打开'
|
||||
: '媒体预览需要在客户端内打开',
|
||||
);
|
||||
@@ -636,16 +632,11 @@ export function useProjectResourceCardPreviews(input: {
|
||||
return;
|
||||
}
|
||||
const kind = projectResourceCardPreviewKind(resource);
|
||||
/**
|
||||
* 代码卡**不入队列、不读内容**:卡面只用路径画图标 + 类型标签,读回来的内容没有任何
|
||||
* 消费方。此前代码卡与文档卡走同一条读取分支,读回一大段源码再被折成一行摘要 ——
|
||||
* 既是"丑预览"的来源,也白占全局 3 个读取槽。
|
||||
*
|
||||
* 判据与卡面分流(`projectResourceCardPreviewVariant`)同源,避免"卡面不画内容、
|
||||
* 后台仍在读内容"的分叉。这里直接 `return` 而不是入队后失败:卡停在 `idle`,
|
||||
* 卡面照常画代码图标,不产生误导性的失败态。
|
||||
*/
|
||||
if (!projectResourceCardPreviewReadsContent(resource)) {
|
||||
// 代码卡不预取正文;只有显式打开详情才占读取槽,沿用同一队列和项目权限边界。
|
||||
if (
|
||||
!projectResourceCardPreviewReadsContent(resource) &&
|
||||
reason !== 'detail'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AgentMessageContent } from '../../../packages/shared/src/components/AgentMessageContent';
|
||||
import { ChatMarkdownMessage } from '../src/components/ChatMarkdownMessage';
|
||||
import {
|
||||
declaration,
|
||||
parseStyleSheet,
|
||||
resolveDeclarations,
|
||||
} from './styleCascade';
|
||||
|
||||
const sharedCss = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'packages/shared/src/components/AgentMessageContent.css',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const appCss = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const base = '.agent-message-content[data-agent-content]';
|
||||
const processTone = ".agent-message-content[data-agent-content='process']";
|
||||
|
||||
describe('AgentMessageContent', () => {
|
||||
it('正文与过程复用表现组件,保留折叠语义和宿主属性', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<AgentMessageContent>最终回复</AgentMessageContent>
|
||||
<AgentMessageContent as="details" tone="process" aria-label="思考过程">
|
||||
<summary>思考过程</summary>
|
||||
<pre>先分析需求</pre>
|
||||
</AgentMessageContent>
|
||||
</>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-agent-content="body"]')?.textContent,
|
||||
).toBe('最终回复');
|
||||
const details = container.querySelector('details')!;
|
||||
expect(details.getAttribute('data-agent-content')).toBe('process');
|
||||
expect(details.getAttribute('aria-label')).toBe('思考过程');
|
||||
expect(details.open).toBe(false);
|
||||
});
|
||||
|
||||
it('过程的标题和表格继承层级变量,不用强制字号压回正文大小', () => {
|
||||
const { container } = render(
|
||||
<AgentMessageContent tone="process">
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={
|
||||
'# 分析\n\n| 项目 |\n| --- |\n| 内容 |\n\n```js\nconst value = 1;\n```'
|
||||
}
|
||||
/>
|
||||
</AgentMessageContent>,
|
||||
);
|
||||
expect(container.querySelector('h1')?.getAttribute('style')).toContain(
|
||||
'--agent-message-heading-size',
|
||||
);
|
||||
expect(container.querySelector('table')?.getAttribute('style')).toContain(
|
||||
'--agent-message-table-size',
|
||||
);
|
||||
expect(container.querySelector('pre code .hljs-keyword')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([sharedCss + appCss, appCss + sharedCss])(
|
||||
'共享层级不受宿主 CSS 加载顺序影响',
|
||||
(css) => {
|
||||
const rules = parseStyleSheet(css);
|
||||
const body = resolveDeclarations(rules, [base], 1024);
|
||||
expect(declaration(body, 'font-size')).toBe('14px');
|
||||
expect(declaration(body, 'color')).toContain('--platform-text-strong');
|
||||
for (const host of [
|
||||
['.design-agent-reasoning'],
|
||||
['.agent-tool-call-group'],
|
||||
[
|
||||
'.project-supervisor-process-card',
|
||||
'.game-workbench-chat .project-supervisor-process-card',
|
||||
'.game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-conversation > .project-supervisor-process-card',
|
||||
],
|
||||
]) {
|
||||
const process = resolveDeclarations(
|
||||
rules,
|
||||
[base, processTone, ...host],
|
||||
1024,
|
||||
);
|
||||
expect(declaration(process, 'font-size')).toBe('12px');
|
||||
expect(declaration(process, 'color')).toContain('--platform-text-soft');
|
||||
expect(declaration(process, '--agent-message-heading-size')).toBe(
|
||||
'1em',
|
||||
);
|
||||
}
|
||||
const failure = resolveDeclarations(
|
||||
rules,
|
||||
[
|
||||
'.agent-tool-call-row-status',
|
||||
".agent-tool-call-group-row[data-status='failed'] .agent-tool-call-row-status",
|
||||
],
|
||||
1024,
|
||||
);
|
||||
expect(declaration(failure, 'color')).toBe(
|
||||
'var(--platform-button-danger-text)',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,46 @@ function FailingChild({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
}
|
||||
|
||||
describe('ChatMarkdownMessage', () => {
|
||||
it('代码块带语言标识及高亮节点,文件预览保留空行和 HTML 源码', () => {
|
||||
const source =
|
||||
'const value = "<script>alert(1)</script>";\n\n\n return value;\n';
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
preserveBlankLines
|
||||
text={`\`\`\`\`typescript\n${source}\`\`\`\``}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('pre code')?.textContent).toBe(source);
|
||||
expect(
|
||||
container.querySelector('code.language-typescript .hljs-keyword'),
|
||||
).not.toBeNull();
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('未知语言保留代码而不进入错误回退', () => {
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={'```unknown-language\n原始内容\n```'}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('pre code')?.textContent).toBe('原始内容\n');
|
||||
});
|
||||
|
||||
it('大文件跳过高亮但保留完整代码', () => {
|
||||
const source = 'const value = 42;\n'.repeat(6000);
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
preserveBlankLines
|
||||
text={`\`\`\`js\n${source}\`\`\``}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('pre code')?.textContent).toBe(source);
|
||||
expect(container.querySelector('.hljs-keyword')).toBeNull();
|
||||
});
|
||||
|
||||
it('渲染 assistant 的 GFM 内容与代码块', () => {
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ResourceDocumentPreviewDialog } from '../src/view/project-development/ResourceDocumentPreviewDialog';
|
||||
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
|
||||
|
||||
vi.mock('../src/components/modal/ThemedModal', () => ({
|
||||
ThemedModal: ({
|
||||
children,
|
||||
ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<section role="dialog" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
|
||||
const resource: ProjectResource = {
|
||||
id: 'doc',
|
||||
path: 'docs/design.md',
|
||||
label: '设计文档',
|
||||
mediaType: 'text/markdown',
|
||||
category: 'document',
|
||||
subtype: 'document',
|
||||
manifestAssetId: 'doc',
|
||||
sourceLabel: '',
|
||||
taskTitle: null,
|
||||
producerTaskId: null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
dependencies: [],
|
||||
dependencyDepth: 0,
|
||||
};
|
||||
|
||||
describe('ResourceDocumentPreviewDialog', () => {
|
||||
it('加载时通过现有详情回调请求正文,加载完成复用 Markdown 渲染', () => {
|
||||
const onRequestPreview = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const props = { resource, identity: 'doc:v1', onRequestPreview, onClose };
|
||||
const { rerender } = render(
|
||||
<ResourceDocumentPreviewDialog
|
||||
{...props}
|
||||
preview={{ status: 'loading' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status').textContent).toContain('加载');
|
||||
expect(onRequestPreview).toHaveBeenCalledWith(resource, 'doc:v1', 'detail');
|
||||
rerender(
|
||||
<ResourceDocumentPreviewDialog
|
||||
{...props}
|
||||
preview={{
|
||||
status: 'loaded',
|
||||
preview: {
|
||||
path: resource.path,
|
||||
mediaType: resource.mediaType,
|
||||
byteLen: 10,
|
||||
content: '# 标题\n\n**正文**',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: '标题' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭文档预览' }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('失败显示错误与重试,换资源进入加载态时不残留旧正文', () => {
|
||||
const onRequestPreview = vi.fn();
|
||||
const props = {
|
||||
resource,
|
||||
identity: 'doc:v1',
|
||||
onRequestPreview,
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ResourceDocumentPreviewDialog
|
||||
{...props}
|
||||
preview={{
|
||||
status: 'failed',
|
||||
error: '文档读取失败',
|
||||
retryable: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toBe('文档读取失败');
|
||||
onRequestPreview.mockClear();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
||||
expect(onRequestPreview).toHaveBeenCalledWith(resource, 'doc:v1', 'detail');
|
||||
rerender(
|
||||
<ResourceDocumentPreviewDialog
|
||||
{...props}
|
||||
identity="doc:v2"
|
||||
preview={{ status: 'loading' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
expect(screen.getByRole('status').textContent).toContain('加载');
|
||||
});
|
||||
|
||||
it('空文档显示空态而不是永久加载', () => {
|
||||
render(
|
||||
<ResourceDocumentPreviewDialog
|
||||
resource={resource}
|
||||
identity="doc:v1"
|
||||
onRequestPreview={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
preview={{
|
||||
status: 'loaded',
|
||||
preview: {
|
||||
path: resource.path,
|
||||
mediaType: resource.mediaType,
|
||||
byteLen: 0,
|
||||
content: '',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('status').textContent).toBe('文档为空');
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,7 @@ export function registerDesignAgentSurfaceTests() {
|
||||
|
||||
const summary = await screen.findByText('思考过程');
|
||||
const details = summary.closest('details') as HTMLDetailsElement;
|
||||
expect(details.getAttribute('data-agent-content')).toBe('process');
|
||||
expect(details.open).toBe(false);
|
||||
fireEvent.click(summary);
|
||||
expect(details.open).toBe(true);
|
||||
@@ -257,6 +258,11 @@ export function registerDesignAgentSurfaceTests() {
|
||||
(summary) => summary.closest('details') as HTMLDetailsElement,
|
||||
);
|
||||
expect(details.every((element) => !element.open)).toBe(true);
|
||||
expect(
|
||||
details.every(
|
||||
(element) => element.getAttribute('data-agent-content') === 'process',
|
||||
),
|
||||
).toBe(true);
|
||||
fireEvent.click(summaries[0]);
|
||||
expect(details[0].open).toBe(true);
|
||||
expect(details[1].open).toBe(false);
|
||||
|
||||
@@ -112,6 +112,7 @@ export function registerToolCallGroupTests() {
|
||||
'[data-testid="agent-tool-call-group"]',
|
||||
) as HTMLElement;
|
||||
expect(group).not.toBeNull();
|
||||
expect(group.getAttribute('data-agent-content')).toBe('process');
|
||||
const head = within(group).getByTestId('agent-tool-call-group-head');
|
||||
// 块头是一行按钮:图标 + 汇总 + 展开箭头,默认折叠,正文由 `hidden` 收起。
|
||||
expect(head.tagName).toBe('BUTTON');
|
||||
|
||||
@@ -240,6 +240,20 @@ describe('resourceCanvasFocusModel', () => {
|
||||
hostOverlay: { ...closed, isRenameDialogOpen: true },
|
||||
}),
|
||||
).toBe(false);
|
||||
const documentPreviewOverlay = { ...closed, isDocumentPreviewOpen: true };
|
||||
expect(
|
||||
resolveResourceCanvasFocusEscapeActive({
|
||||
...base,
|
||||
hostOverlay: documentPreviewOverlay,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveResourceCanvasFloatingPanelDismissOpen({
|
||||
isCanvasVisible: true,
|
||||
isFloatingPanelOpen: true,
|
||||
hostOverlay: documentPreviewOverlay,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -237,6 +237,34 @@ describe('resource canvas toolbar actions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['image/png', 'character', 'assets/hero.png'],
|
||||
['image/svg+xml', 'icon', 'assets/icon.svg'],
|
||||
['image/png', 'character-animation', 'assets/walk.png'],
|
||||
['video/mp4', 'video', 'assets/intro.mp4'],
|
||||
['audio/wav', 'sound-effect', 'assets/hit.wav'],
|
||||
['audio/mpeg', 'background-music', 'assets/music.mp3'],
|
||||
['text/html', 'ui-prototype', 'ui/menu.html'],
|
||||
['text/markdown', 'document', 'docs/design.md'],
|
||||
['application/json', 'other', 'assets/data.json'],
|
||||
['application/octet-stream', 'other', 'assets/model.bin'],
|
||||
])(
|
||||
'%s 素材导出不依赖媒体类型、预览或 manifest 身份',
|
||||
(mediaType, subtype, path) => {
|
||||
expect(
|
||||
toolbarActions(
|
||||
createManifest(),
|
||||
createResource({
|
||||
mediaType,
|
||||
subtype,
|
||||
path,
|
||||
manifestAssetId: null,
|
||||
}),
|
||||
),
|
||||
).toContain('download');
|
||||
},
|
||||
);
|
||||
|
||||
it('拿不到本地文件路径的资源不放行下载,避免渲染出点了报错的按钮', () => {
|
||||
const manifest = createManifest();
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isResourceDocumentPreviewable,
|
||||
resourceDocumentPreviewMarkdown,
|
||||
} from '../src/features/resource-canvas/resourceDocumentPreviewModel';
|
||||
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
|
||||
|
||||
function resource(path: string): ProjectResource {
|
||||
return {
|
||||
id: path,
|
||||
path,
|
||||
label: path,
|
||||
mediaType: 'text/plain',
|
||||
category: 'document',
|
||||
subtype: 'document',
|
||||
manifestAssetId: 'doc',
|
||||
sourceLabel: '',
|
||||
taskTitle: null,
|
||||
producerTaskId: null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
dependencies: [],
|
||||
dependencyDepth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resourceDocumentPreviewMarkdown', () => {
|
||||
it('文档与空行原样进入 Markdown 渲染', () => {
|
||||
const text = '# 标题\n\n\n|列|\n|-|\n|值|';
|
||||
expect(
|
||||
resourceDocumentPreviewMarkdown(resource('docs/design.md'), text),
|
||||
).toBe(text);
|
||||
expect(isResourceDocumentPreviewable(resource('docs/design.md'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['game/main.ts', 'typescript'],
|
||||
['game/main.js', 'javascript'],
|
||||
['assets/data.json', 'json'],
|
||||
['game/main.py', 'python'],
|
||||
])('%s 包为 %s 代码块', (path, language) => {
|
||||
expect(
|
||||
resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'),
|
||||
).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``);
|
||||
});
|
||||
|
||||
it('正文包含 Markdown 围栏时不会逃出代码块', () => {
|
||||
const text = 'const sample = "````";\n\n\n<script>alert(1)</script>';
|
||||
expect(
|
||||
resourceDocumentPreviewMarkdown(resource('game/main.js'), text),
|
||||
).toBe(`\`\`\`\`\`javascript\n${text}\n\`\`\`\`\``);
|
||||
});
|
||||
|
||||
it('图片没有文档预览入口,内联文档不要求文件路径', () => {
|
||||
expect(
|
||||
isResourceDocumentPreviewable({
|
||||
...resource('assets/image.png'),
|
||||
mediaType: 'image/png',
|
||||
subtype: 'image',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isResourceDocumentPreviewable({
|
||||
...resource(''),
|
||||
content: '# 回执',
|
||||
subtype: 'agent-result',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isResourceDocumentPreviewable(resource(''))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,65 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('useProjectResourceCardPreviews', () => {
|
||||
it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => {
|
||||
const code = resource('code', {
|
||||
path: 'game/main.ts',
|
||||
subtype: 'code',
|
||||
mediaType: 'text/typescript',
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, _args?: Record<string, unknown>) => ({
|
||||
path: code.path,
|
||||
mediaType: code.mediaType,
|
||||
byteLen: 19,
|
||||
content: 'const answer = 42;',
|
||||
}),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: async <Result>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => (await invoke(command, args)) as Result,
|
||||
},
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCardPreviews({
|
||||
projectPath: '/tmp/document-preview',
|
||||
projectId: 'document-preview',
|
||||
mode: 'dependency',
|
||||
resources: [code],
|
||||
canvasRef: { current: document.createElement('div') },
|
||||
eagerPreviewLimit: 12,
|
||||
}),
|
||||
);
|
||||
const identity = result.current.identityByResourceId.get(code.id)!;
|
||||
act(() => result.current.requestPreview(code, identity, 'visible'));
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'read_local_project_text_preview',
|
||||
),
|
||||
).toBe(false);
|
||||
act(() => result.current.requestPreview(code, identity, 'detail'));
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'read_local_project_text_preview',
|
||||
expect.objectContaining({
|
||||
relativePath: code.path,
|
||||
scopeId: expect.any(String),
|
||||
requestId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
act(() => result.current.requestPreview(code, identity, 'detail'));
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'read_local_project_text_preview',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prefetches initial previewable resources without requiring a detail click', async () => {
|
||||
const art = resource('initial-art');
|
||||
const invoke = vi.fn(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user