diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index eb96fcd26..dce2f0225 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -7256,9 +7256,38 @@ iframe.preview-frame {
font-size: 11px;
line-height: 1.55;
text-align: left;
- white-space: normal;
+ /* 文档卡预览改为"前几行"(最多 3 行),必须保留换行:折成一行就看不出结构了。 */
+ white-space: pre-line;
+ word-break: break-word;
-webkit-box-orient: vertical;
- -webkit-line-clamp: 5;
+ -webkit-line-clamp: 3;
+}
+
+/*
+ * 代码文件卡:不显示文件内容,只给代码图标 + 扩展名标签。
+ * 与 `.game-resource-card-version-visual` 共用同一套居中排布口径,视觉上仍是同一族卡片。
+ */
+.game-resource-card-code-visual {
+ display: grid;
+ gap: 8px;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(145deg, #fff8f1, #f2ded2);
+ color: #8b5b45;
+ /* 图标与标签作为一组整体垂直居中,与 `.game-resource-card-version-visual` 同口径。 */
+ align-content: center;
+ place-items: center;
+}
+
+.game-resource-card-code-label {
+ padding: 1px 7px;
+ border: 1px solid rgb(139 91 69 / 28%);
+ border-radius: 999px;
+ background: rgb(255 255 255 / 72%);
+ color: #8b5b45;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
}
.game-resource-card-version-visual {
diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx
index 1da6ca17c..637ab9103 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx
@@ -1,9 +1,10 @@
-import { Image as ImageIcon, Music2, Video } from 'lucide-react';
+import { FileCode2, Image as ImageIcon, Music2, Video } from 'lucide-react';
import { useEffect, useRef } from 'react';
import {
projectResourceCardPreviewKind,
type ProjectResourceCardPreviewState,
+ projectResourceCardPreviewVariant,
} from './resourceCardPreviewModel';
import type { ProjectResource } from './resourceProjectionModel';
@@ -79,6 +80,13 @@ export function ResourcePreviewMedia({
}, [onObservePreview, previewIdentity, resource]);
const kind = resource ? projectResourceCardPreviewKind(resource) : null;
+ /**
+ * 代码文件走与资源卡一致的分流:不画内容(本组件本来也只画缩略图 / 占位),
+ * 占位图标换成代码图标,免得弹窗里代码文件看起来像"读不出来的图片"。
+ */
+ const previewVariant = resource
+ ? projectResourceCardPreviewVariant(resource)
+ : null;
const sourceUrl =
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
const visual = (() => {
@@ -111,6 +119,9 @@ export function ResourcePreviewMedia({
/>
);
}
+ if (previewVariant === 'code') {
+ return ;
+ }
return resourcePreviewPlaceholderIcon(
kind === 'video'
? 'video'
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
index 25bae8d6e..59dc4c2e5 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
@@ -18,6 +18,7 @@ import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import {
AtSign,
Crosshair,
+ FileCode2,
FileText,
FolderTree,
Gamepad2,
@@ -291,7 +292,9 @@ import {
import {
projectResourceCardPreviewKind,
type ProjectResourceCardPreviewState,
- summarizeProjectResourceDocument,
+ projectResourceCardPreviewVariant,
+ projectResourceCodeTypeLabel,
+ projectResourceDocumentPreviewText,
} from './resourceCardPreviewModel';
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
import {
@@ -767,9 +770,24 @@ const ResourceCard = memo(function ResourceCard({
const mediaActive = activeMediaIdentity === previewIdentity;
const sourceUrl =
preview.status === 'loaded' ? preview.preview.sourceUrl : null;
- const documentSummary =
- preview.status === 'loaded' && preview.preview.content !== undefined
- ? summarizeProjectResourceDocument(preview.preview.content)
+ /**
+ * 文档类卡片的卡面分流结论(`markdown` / `code` / `plain-text` / `null`)。
+ *
+ * 判据只看路径扩展名与 subtype,**不依赖是否读到内容** —— 代码卡的卡面本就不该有内容,
+ * 等读完再决定画什么只会把"丑预览"和多余 IO 一起留在链路上。
+ */
+ const previewVariant = projectResourceCardPreviewVariant(resource);
+ /** 代码卡的类型标签(`.tsx` → `TSX`);不是代码卡时为 `null`。 */
+ const codeTypeLabel = projectResourceCodeTypeLabel(resource.path);
+ const documentPreview =
+ preview.status === 'loaded' &&
+ preview.preview.content !== undefined &&
+ previewVariant !== null &&
+ previewVariant !== 'code'
+ ? projectResourceDocumentPreviewText(
+ preview.preview.content,
+ previewVariant === 'markdown',
+ )
: '';
useEffect(() => {
@@ -918,10 +936,31 @@ const ResourceCard = memo(function ResourceCard({
);
}
- if ((kind === 'document' || kind === 'code') && documentSummary) {
+ /**
+ * 代码文件卡:**不显示内容**,只给代码图标 + 类型标签。
+ *
+ * 这里的判据是路径分流(`previewVariant === 'code'`),与是否读到内容无关 ——
+ * 代码卡压根不发起读取(见 `useProjectResourceCardPreviews` 的入队门禁)。
+ */
+ if (previewVariant === 'code') {
return (
-
- {documentSummary}
+
+
+ {codeTypeLabel ? (
+
+ {codeTypeLabel}
+
+ ) : null}
+
+ );
+ }
+ if (previewVariant !== null && documentPreview) {
+ return (
+
+ {documentPreview}
);
}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
index 8791b8d25..2f73589f0 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
@@ -211,6 +211,40 @@ const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
const videoExtension = /\.(mp4|webm|mov)$/iu;
+/** Markdown 文档:卡面显示前几行,并做轻量标记清理。 */
+const markdownExtension = /\.(md|markdown|mdx)$/iu;
+/**
+ * 代码文件扩展名(**只看路径,不读文件内容**)。
+ *
+ * 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
+ * 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
+ * 这里按用户口径把 `.json` / `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
+ * 一并算代码 —— 它们在上游被登记成「文档」,但卡面当代码画更干净。
+ */
+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;
+
+/**
+ * 取路径末段的扩展名(小写、不含点)。取不到(无扩展名)时返回 `null`。
+ */
+export function projectResourcePathExtension(path: string): string | null {
+ const fileName = path.split(/[\\/]/u).filter(Boolean).pop() ?? '';
+ const matched = /\.([a-z0-9]+)$/iu.exec(fileName.trim());
+ return matched ? matched[1]!.toLowerCase() : null;
+}
+
+/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
+export function projectResourceCodeTypeLabel(path: string): string | null {
+ const trimmed = path.trim();
+ if (!cardCodeExtension.test(trimmed)) {
+ return null;
+ }
+ const extension = projectResourcePathExtension(trimmed);
+ return extension ? extension.toUpperCase() : null;
+}
+
export function projectResourceCardPreviewKind(
resource: ProjectResource,
): ProjectResourceCardPreviewKind {
@@ -220,6 +254,14 @@ export function projectResourceCardPreviewKind(
if (resource.subtype === 'agent-result') {
return 'document';
}
+ // Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
+ // 上游把 `.json` / `.tsx` 这类文件登记成「文档」,卡面却该按代码画。
+ if (markdownExtension.test(resource.path)) {
+ return 'document';
+ }
+ if (cardCodeExtension.test(resource.path)) {
+ return 'code';
+ }
const kind = projectResourceDisplayKind(resource);
if (kind === 'code') {
return 'code';
@@ -248,9 +290,56 @@ export function projectResourceCardPreviewKind(
) {
return 'media-image';
}
+ // 纯文本兜底:`.txt` / `.csv` / `.log` 这类文件在上游没有类型结论(判成 `null`),
+ // 卡面按纯文本预览比占位图标更有信息量。放在最后,不改动任何已有分支的结论。
+ if (kind === null && plainTextExtension.test(resource.path)) {
+ return 'document';
+ }
return 'placeholder';
}
+/**
+ * 文档类卡片(`document` / `code`)的卡面分流结论。
+ *
+ * 三种落点:
+ * - `markdown`:显示前几行,预览前做轻量标记清理;
+ * - `code`:**完全不读内容**,卡面改画代码图标 + 类型标签;
+ * - `plain-text`:显示前几行,不做标记清理。
+ */
+export type ProjectResourceCardPreviewVariant =
+ | 'markdown'
+ | 'code'
+ | 'plain-text'
+ | null;
+
+export function projectResourceCardPreviewVariant(
+ resource: ProjectResource,
+): ProjectResourceCardPreviewVariant {
+ if (resource.subtype === 'agent-result') {
+ return 'markdown';
+ }
+ if (markdownExtension.test(resource.path)) {
+ return 'markdown';
+ }
+ if (cardCodeExtension.test(resource.path)) {
+ return 'code';
+ }
+ return projectResourceCardPreviewKind(resource) === 'document'
+ ? 'plain-text'
+ : null;
+}
+
+/**
+ * 代码卡**不做文件读取**的判据:卡面只用路径画图标,读内容既无用又占读取槽。
+ *
+ * 与 `projectResourceCardPreviewVariant` 同源,避免"卡面不画内容、却仍在后台读内容"的分叉。
+ */
+export function projectResourceCardPreviewReadsContent(
+ resource: ProjectResource,
+): boolean {
+ return projectResourceCardPreviewVariant(resource) !== 'code';
+}
+
/**
* `read_local_project_media_preview` 的线上 `category` 入参:只按文件类型分美术 / 音频两支。
*
@@ -284,14 +373,63 @@ export function projectResourceCardPreviewIdentity(input: {
]);
}
-export function summarizeProjectResourceDocument(content: string) {
+/** 卡面文档预览的最大行数(超出由 CSS 省略号截断)。 */
+export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT = 3;
+/** 单行预览的最大字符数,超出截断成省略号。 */
+export const PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH = 72;
+
+/**
+ * Markdown 的**轻量**清理:只去掉标记符号,不是渲染器。
+ *
+ * - 去掉围栏标记行(``` / ~~~),保留代码内容本身;
+ * - 去掉行首标题 `#`、引用 `>` 与列表符号(保留缩进层级);
+ * - 去掉强调 / 行内代码 / 图片 / 链接的标记符号,保留可见文字;
+ * - **不折叠换行** —— 卡面要的是"前几行",压成一行就看不出结构了。
+ */
+function stripMarkdownMarkers(content: string): string {
return content
- .replace(/!\[[^\]]*\]\([^)]*\)/gu, ' ')
+ .replace(/^\s*(?:```|~~~).*$/gmu, '')
+ .replace(/^\s{0,3}#{1,6}\s*/gmu, '')
+ .replace(/^\s{0,3}>\s?/gmu, '')
+ .replace(/^\s*[-+*]\s+/gmu, '')
+ .replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
- .replace(/<[^>]*>/gu, ' ')
- .replace(/[`*_~>#|{}[\]]/gu, ' ')
- .replace(/^\s*[-+]\s+/gmu, '')
- .replace(/\s+/gu, ' ')
- .trim()
- .slice(0, 180);
+ .replace(/`{1,3}([^`]*)`{1,3}/gu, '$1')
+ .replace(/\*\*([^*]+)\*\*/gu, '$1')
+ .replace(/__([^_]+)__/gu, '$1')
+ .replace(/(^|[^*])\*([^*\n]+)\*/gu, '$1$2')
+ .replace(/(^|[^_])_([^_\n]+)_/gu, '$1$2');
+}
+
+/**
+ * 文档卡面的前几行预览文本。
+ *
+ * `isMarkdown` 为 `true` 时先做轻量标记清理;纯文本原样输出。
+ * 逐行去掉多余空白(缩进保留最多 2 个空格)并按 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH`
+ * 单行截断,最多取 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT` 行。
+ */
+export function projectResourceDocumentPreviewText(
+ content: string,
+ isMarkdown: boolean,
+): string {
+ const normalized = isMarkdown ? stripMarkdownMarkers(content) : content;
+ const lines: string[] = [];
+ for (const rawLine of normalized.split(/\r?\n/u)) {
+ const line = rawLine.replace(/\t/gu, ' ').replace(/\s+$/u, '');
+ const trimmed = line.trimStart();
+ if (!trimmed) {
+ continue;
+ }
+ const indent = line.slice(0, line.length - trimmed.length).slice(0, 2);
+ const text = `${indent}${trimmed}`;
+ lines.push(
+ text.length > PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH
+ ? `${text.slice(0, PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH)}…`
+ : text,
+ );
+ if (lines.length >= PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT) {
+ break;
+ }
+ }
+ return lines.join('\n');
}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
index 06309ad28..fffa0bd38 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
@@ -26,6 +26,7 @@ import {
projectResourceCardPreviewImageDimensions,
type ProjectResourceCardPreviewKind,
projectResourceCardPreviewKind,
+ projectResourceCardPreviewReadsContent,
type ProjectResourceCardPreviewPayload,
type ProjectResourceCardPreviewState,
type ProjectResourceCardPreviewTransportPayload,
@@ -491,10 +492,12 @@ export function useProjectResourceCardPreviews(input: {
job: PreviewJob,
): Promise => {
const kind = projectResourceCardPreviewKind(job.resource);
- if (
- (kind === 'document' || kind === 'code') &&
- job.resource.content !== undefined
- ) {
+ /**
+ * 这段之前的 `kind === 'document' || kind === 'code'` 双分支已收窄:代码卡在
+ * `requestPreview` 的入队门禁就被挡下(**判据同源**,见 `projectResourceCardPreviewReadsContent`),
+ * 走到这里的只可能是需要读内容的文档卡(Markdown / 纯文本)。
+ */
+ if (job.resource.content !== undefined) {
return {
path: job.resource.path,
mediaType: job.resource.mediaType,
@@ -505,7 +508,7 @@ export function useProjectResourceCardPreviews(input: {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error(
- kind === 'document' || kind === 'code'
+ kind === 'document'
? '文档预览需要在客户端内打开'
: '媒体预览需要在客户端内打开',
);
@@ -633,6 +636,18 @@ export function useProjectResourceCardPreviews(input: {
return;
}
const kind = projectResourceCardPreviewKind(resource);
+ /**
+ * 代码卡**不入队列、不读内容**:卡面只用路径画图标 + 类型标签,读回来的内容没有任何
+ * 消费方。此前代码卡与文档卡走同一条读取分支,读回一大段源码再被折成一行摘要 ——
+ * 既是"丑预览"的来源,也白占全局 3 个读取槽。
+ *
+ * 判据与卡面分流(`projectResourceCardPreviewVariant`)同源,避免"卡面不画内容、
+ * 后台仍在读内容"的分叉。这里直接 `return` 而不是入队后失败:卡停在 `idle`,
+ * 卡面照常画代码图标,不产生误导性的失败态。
+ */
+ if (!projectResourceCardPreviewReadsContent(resource)) {
+ return;
+ }
if (
kind === 'version' ||
kind === 'placeholder' ||