tavern-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -0
- package/README.zh.md +123 -0
- package/bin/cli.js +48 -0
- package/index.html +35 -0
- package/package.json +50 -0
- package/public/favicon.png +0 -0
- package/public/tavern-harness-logo.png +0 -0
- package/sandbox-server.mjs +997 -0
- package/src/App.tsx +268 -0
- package/src/assets/tavern-handbook.md +39 -0
- package/src/components/AchievementModal.tsx +158 -0
- package/src/components/CharactersView.tsx +904 -0
- package/src/components/ChatView.tsx +1639 -0
- package/src/components/ConfirmationDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +180 -0
- package/src/components/DeleteConfirmDialog.tsx +48 -0
- package/src/components/FileDisplayModal.tsx +556 -0
- package/src/components/GameplayDialogs.tsx +191 -0
- package/src/components/NewSessionMenu.tsx +362 -0
- package/src/components/SettingsView.tsx +517 -0
- package/src/components/Sidebar.tsx +392 -0
- package/src/components/StatsView.tsx +189 -0
- package/src/components/Toasts.tsx +20 -0
- package/src/components/WorkspaceFileManagerModal.tsx +216 -0
- package/src/components/shared.tsx +369 -0
- package/src/core/achievements.ts +85 -0
- package/src/core/fileDownload.ts +67 -0
- package/src/core/gameplay.ts +521 -0
- package/src/core/horizontalWheel.ts +93 -0
- package/src/core/i18n.ts +1403 -0
- package/src/core/linkedScroll.ts +236 -0
- package/src/core/markdown.ts +170 -0
- package/src/core/openai.ts +451 -0
- package/src/core/openingGreeting.ts +41 -0
- package/src/core/prompts.ts +254 -0
- package/src/core/proxy.ts +58 -0
- package/src/core/sillyTavernImporter.ts +317 -0
- package/src/core/stats.ts +91 -0
- package/src/core/strings.ts +19 -0
- package/src/core/toolDefinitions.ts +400 -0
- package/src/core/tools/builtinTools.ts +38 -0
- package/src/core/tools/generatedSkillExecutor.ts +706 -0
- package/src/core/tools/generatedWorkspace.ts +13 -0
- package/src/core/tools/toolExecutor.ts +718 -0
- package/src/core/turnLoop.ts +211 -0
- package/src/db/database.ts +647 -0
- package/src/main.tsx +23 -0
- package/src/store/store.ts +2419 -0
- package/src/theme/chat.css +1227 -0
- package/src/theme/global.css +1377 -0
- package/src/theme/theme.ts +143 -0
- package/src/theme/views.css +1430 -0
- package/src/types/models.ts +364 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +273 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Icon, Markdown } from './shared';
|
|
4
|
+
import { useStore } from '../store/store';
|
|
5
|
+
import { useT } from '../core/i18n';
|
|
6
|
+
import { highlightCode } from '../core/markdown';
|
|
7
|
+
import { readWorkspaceFileText } from '../core/tools/generatedSkillExecutor';
|
|
8
|
+
import { applySessionWorkspace } from '../core/tools/toolExecutor';
|
|
9
|
+
import type { DisplayFileRef } from '../types/models';
|
|
10
|
+
|
|
11
|
+
// ============================================================
|
|
12
|
+
// 文件展示弹窗(file_display 内置工具)
|
|
13
|
+
// - image: 图片直接展示,支持滚轮/按钮缩放与拖拽平移
|
|
14
|
+
// - html : 在隔离 iframe(sandbox)中渲染,可运行动画/交互脚本
|
|
15
|
+
// - text : 按扩展名智能渲染:markdown → MD 渲染(含代码高亮与公式),
|
|
16
|
+
// 代码文件 → 等宽字体 + 语法高亮,纯文本 → 原样展示
|
|
17
|
+
// ============================================================
|
|
18
|
+
|
|
19
|
+
const CODE_EXTS = new Set([
|
|
20
|
+
'js', 'ts', 'tsx', 'jsx', 'mjs', 'cjs', 'mts', 'cts',
|
|
21
|
+
'py', 'java', 'kt', 'kts', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'go', 'rs', 'rb',
|
|
22
|
+
'php', 'swift', 'scala', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
|
23
|
+
'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini', 'conf', 'cfg', 'env',
|
|
24
|
+
'css', 'scss', 'less', 'sql', 'graphql', 'xml', 'vue', 'svelte',
|
|
25
|
+
'lua', 'pl', 'r', 'dart', 'ex', 'exs', 'erl', 'hs', 'clj', 'ml',
|
|
26
|
+
]);
|
|
27
|
+
// .txt 是纯文本原样展示;markdown 才走 MD 渲染
|
|
28
|
+
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
|
|
29
|
+
const PIP_EDGE_GAP = 12;
|
|
30
|
+
const PIP_MIN_WIDTH = 280;
|
|
31
|
+
const PIP_MIN_HEIGHT = 180;
|
|
32
|
+
const PIP_REFRESH_INTERVAL = 1000;
|
|
33
|
+
const HTML_READY_MESSAGE = 'tavern-html-preview-ready';
|
|
34
|
+
const HTML_READY_FALLBACK_MS = 3500;
|
|
35
|
+
|
|
36
|
+
interface PipRect {
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createPipRect(): PipRect {
|
|
44
|
+
const height = Math.max(0, window.innerHeight - PIP_EDGE_GAP * 2);
|
|
45
|
+
return {
|
|
46
|
+
x: PIP_EDGE_GAP,
|
|
47
|
+
y: PIP_EDGE_GAP,
|
|
48
|
+
width: Math.min(440, Math.max(0, window.innerWidth - PIP_EDGE_GAP * 2)),
|
|
49
|
+
height,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extensionOf(path: string): string {
|
|
54
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(path);
|
|
55
|
+
return m ? m[1].toLowerCase() : '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function basenameOf(path: string): string {
|
|
59
|
+
const parts = path.split('/');
|
|
60
|
+
return parts[parts.length - 1] || path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createHtmlPreviewDocument(content: string, token: string): string {
|
|
64
|
+
const probe = `<script>(()=>{const token=${JSON.stringify(token)};const pause=(ms)=>new Promise(resolve=>setTimeout(resolve,ms));const settledImage=(image)=>image.complete?Promise.resolve():new Promise(resolve=>{image.addEventListener('load',resolve,{once:true});image.addEventListener('error',resolve,{once:true})});addEventListener('load',async()=>{const resources=Promise.all([document.fonts?.ready?.catch(()=>{})??Promise.resolve(),...Array.from(document.images,settledImage)]);await Promise.race([resources,pause(2500)]);await pause(180);requestAnimationFrame(()=>requestAnimationFrame(()=>parent.postMessage({type:${JSON.stringify(HTML_READY_MESSAGE)},token},'*')))},{once:true})})()<\/script>`;
|
|
65
|
+
const head = /<head(?:\s[^>]*)?>/i.exec(content);
|
|
66
|
+
if (head?.index !== undefined) {
|
|
67
|
+
const insertion = head.index + head[0].length;
|
|
68
|
+
return `${content.slice(0, insertion)}${probe}${content.slice(insertion)}`;
|
|
69
|
+
}
|
|
70
|
+
const html = /<html(?:\s[^>]*)?>/i.exec(content);
|
|
71
|
+
if (html?.index !== undefined) {
|
|
72
|
+
const insertion = html.index + html[0].length;
|
|
73
|
+
return `${content.slice(0, insertion)}<head>${probe}</head>${content.slice(insertion)}`;
|
|
74
|
+
}
|
|
75
|
+
return `<head>${probe}</head>${content}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function FileDisplayModal() {
|
|
79
|
+
const t = useT();
|
|
80
|
+
const active = useStore((s) => s.activeDisplay);
|
|
81
|
+
const setActiveDisplay = useStore((s) => s.setActiveDisplay);
|
|
82
|
+
const [content, setContent] = useState<string | null>(null);
|
|
83
|
+
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'error'>('loading');
|
|
84
|
+
const [imgError, setImgError] = useState(false);
|
|
85
|
+
const [htmlZoom, setHtmlZoom] = useState(1);
|
|
86
|
+
const [htmlReady, setHtmlReady] = useState(false);
|
|
87
|
+
const [textWrap, setTextWrap] = useState(true);
|
|
88
|
+
const [isPictureInPicture, setIsPictureInPicture] = useState(true);
|
|
89
|
+
const [pipRect, setPipRect] = useState<PipRect>(createPipRect);
|
|
90
|
+
const contentRef = useRef<string | null>(null);
|
|
91
|
+
const htmlRevealGenerationRef = useRef(0);
|
|
92
|
+
const htmlIframeRef = useRef<HTMLIFrameElement>(null);
|
|
93
|
+
const wasDisplayOpenRef = useRef(false);
|
|
94
|
+
const pipGestureRef = useRef<
|
|
95
|
+
| { type: 'move'; pointerX: number; pointerY: number; startX: number; startY: number }
|
|
96
|
+
| { type: 'resize'; pointerX: number; pointerY: number; startWidth: number; startHeight: number }
|
|
97
|
+
| null
|
|
98
|
+
>(null);
|
|
99
|
+
// 图片缩放 / 平移
|
|
100
|
+
const [zoom, setZoom] = useState(1);
|
|
101
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
102
|
+
const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
|
|
103
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
104
|
+
|
|
105
|
+
const hideHtml = () => {
|
|
106
|
+
htmlRevealGenerationRef.current += 1;
|
|
107
|
+
setHtmlReady(false);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const ref: DisplayFileRef | null = useMemo(() => {
|
|
111
|
+
if (!active) return null;
|
|
112
|
+
return { path: active.path, kind: active.kind, title: active.title };
|
|
113
|
+
}, [active]);
|
|
114
|
+
|
|
115
|
+
const htmlPreview = useMemo(() => {
|
|
116
|
+
if (active?.kind !== 'html' || content === null) return null;
|
|
117
|
+
const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
118
|
+
return { token, srcDoc: createHtmlPreviewDocument(content, token) };
|
|
119
|
+
}, [active?.kind, content]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!htmlPreview) return;
|
|
123
|
+
const generation = htmlRevealGenerationRef.current;
|
|
124
|
+
const reveal = () => {
|
|
125
|
+
if (htmlRevealGenerationRef.current === generation) setHtmlReady(true);
|
|
126
|
+
};
|
|
127
|
+
const handleMessage = (event: MessageEvent) => {
|
|
128
|
+
if (
|
|
129
|
+
event.source === htmlIframeRef.current?.contentWindow
|
|
130
|
+
&& event.data?.type === HTML_READY_MESSAGE
|
|
131
|
+
&& event.data?.token === htmlPreview.token
|
|
132
|
+
) reveal();
|
|
133
|
+
};
|
|
134
|
+
const fallback = window.setTimeout(reveal, HTML_READY_FALLBACK_MS);
|
|
135
|
+
window.addEventListener('message', handleMessage);
|
|
136
|
+
return () => {
|
|
137
|
+
window.clearTimeout(fallback);
|
|
138
|
+
window.removeEventListener('message', handleMessage);
|
|
139
|
+
};
|
|
140
|
+
}, [htmlPreview]);
|
|
141
|
+
|
|
142
|
+
useLayoutEffect(() => {
|
|
143
|
+
if (!active) {
|
|
144
|
+
wasDisplayOpenRef.current = false;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const isNewDisplay = !wasDisplayOpenRef.current;
|
|
149
|
+
wasDisplayOpenRef.current = true;
|
|
150
|
+
setLoadState('loading');
|
|
151
|
+
contentRef.current = null;
|
|
152
|
+
setContent(null);
|
|
153
|
+
setImgError(false);
|
|
154
|
+
setZoom(1);
|
|
155
|
+
setPan({ x: 0, y: 0 });
|
|
156
|
+
setHtmlZoom(1);
|
|
157
|
+
hideHtml();
|
|
158
|
+
setTextWrap(true);
|
|
159
|
+
if (isNewDisplay) {
|
|
160
|
+
setPipRect(createPipRect());
|
|
161
|
+
setIsPictureInPicture(active.presentation !== 'modal');
|
|
162
|
+
}
|
|
163
|
+
}, [active]);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!active || !isPictureInPicture) return;
|
|
167
|
+
let cancelled = false;
|
|
168
|
+
let reading = false;
|
|
169
|
+
const refresh = async () => {
|
|
170
|
+
if (reading) return;
|
|
171
|
+
reading = true;
|
|
172
|
+
try {
|
|
173
|
+
if (active.sessionId == null) throw new Error('缺少会话工作目录');
|
|
174
|
+
const workspaceDir = await applySessionWorkspace(active.sessionId);
|
|
175
|
+
const text = await readWorkspaceFileText(active.path, workspaceDir);
|
|
176
|
+
if (cancelled || text === null) return;
|
|
177
|
+
if (contentRef.current !== text) {
|
|
178
|
+
hideHtml();
|
|
179
|
+
contentRef.current = text;
|
|
180
|
+
setContent(text);
|
|
181
|
+
}
|
|
182
|
+
setLoadState('ok');
|
|
183
|
+
setImgError(false);
|
|
184
|
+
} finally {
|
|
185
|
+
reading = false;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const timer = window.setInterval(() => void refresh(), PIP_REFRESH_INTERVAL);
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
window.clearInterval(timer);
|
|
192
|
+
};
|
|
193
|
+
}, [active, isPictureInPicture]);
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!active) return;
|
|
197
|
+
let cancelled = false;
|
|
198
|
+
// 按产生该展示的会话设置工作目录:普通弹窗(active.sessionId = 当前会话)读当前会话工作区;
|
|
199
|
+
// 回看历史消息(sessionId 持久化)读该会话自己的工作区
|
|
200
|
+
const read = async () => {
|
|
201
|
+
try {
|
|
202
|
+
if (active.sessionId == null) throw new Error('缺少会话工作目录');
|
|
203
|
+
const workspaceDir = await applySessionWorkspace(active.sessionId);
|
|
204
|
+
const text = await readWorkspaceFileText(active.path, workspaceDir);
|
|
205
|
+
if (cancelled) return;
|
|
206
|
+
if (text === null) {
|
|
207
|
+
setLoadState('error');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
contentRef.current = text;
|
|
211
|
+
setContent(text);
|
|
212
|
+
setLoadState('ok');
|
|
213
|
+
} catch {
|
|
214
|
+
if (cancelled) return;
|
|
215
|
+
setLoadState('error');
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
void read();
|
|
219
|
+
return () => {
|
|
220
|
+
cancelled = true;
|
|
221
|
+
};
|
|
222
|
+
}, [active]);
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!isPictureInPicture) return;
|
|
226
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
227
|
+
const gesture = pipGestureRef.current;
|
|
228
|
+
if (!gesture) return;
|
|
229
|
+
if (e.buttons === 0) {
|
|
230
|
+
pipGestureRef.current = null;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (gesture.type === 'move') {
|
|
234
|
+
setPipRect((rect) => ({
|
|
235
|
+
...rect,
|
|
236
|
+
x: Math.min(
|
|
237
|
+
Math.max(PIP_EDGE_GAP, window.innerWidth - rect.width - PIP_EDGE_GAP),
|
|
238
|
+
Math.max(PIP_EDGE_GAP, gesture.startX + e.clientX - gesture.pointerX),
|
|
239
|
+
),
|
|
240
|
+
y: Math.min(
|
|
241
|
+
Math.max(PIP_EDGE_GAP, window.innerHeight - rect.height - PIP_EDGE_GAP),
|
|
242
|
+
Math.max(PIP_EDGE_GAP, gesture.startY + e.clientY - gesture.pointerY),
|
|
243
|
+
),
|
|
244
|
+
}));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
setPipRect((rect) => {
|
|
248
|
+
const availableWidth = window.innerWidth - rect.x - PIP_EDGE_GAP;
|
|
249
|
+
const availableHeight = window.innerHeight - rect.y - PIP_EDGE_GAP;
|
|
250
|
+
return {
|
|
251
|
+
...rect,
|
|
252
|
+
width: Math.min(availableWidth, Math.max(Math.min(PIP_MIN_WIDTH, availableWidth), gesture.startWidth + e.clientX - gesture.pointerX)),
|
|
253
|
+
height: Math.min(availableHeight, Math.max(Math.min(PIP_MIN_HEIGHT, availableHeight), gesture.startHeight + e.clientY - gesture.pointerY)),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
const stopGesture = () => (pipGestureRef.current = null);
|
|
258
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
259
|
+
window.addEventListener('pointerup', stopGesture);
|
|
260
|
+
window.addEventListener('pointercancel', stopGesture);
|
|
261
|
+
window.addEventListener('blur', stopGesture);
|
|
262
|
+
return () => {
|
|
263
|
+
pipGestureRef.current = null;
|
|
264
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
265
|
+
window.removeEventListener('pointerup', stopGesture);
|
|
266
|
+
window.removeEventListener('pointercancel', stopGesture);
|
|
267
|
+
window.removeEventListener('blur', stopGesture);
|
|
268
|
+
};
|
|
269
|
+
}, [isPictureInPicture]);
|
|
270
|
+
|
|
271
|
+
const title = active?.title?.trim() || (active ? basenameOf(active.path) : '');
|
|
272
|
+
const ext = active ? extensionOf(active.path) : '';
|
|
273
|
+
|
|
274
|
+
const close = () => setActiveDisplay(null);
|
|
275
|
+
|
|
276
|
+
const enterPictureInPicture = () => {
|
|
277
|
+
setPipRect(createPipRect());
|
|
278
|
+
setIsPictureInPicture(true);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const exitPictureInPicture = () => {
|
|
282
|
+
setIsPictureInPicture(false);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/** 图片数据:优先 data: URI;否则按扩展名拼 data URI(SVG 直接内联,位图按 base64 文本) */
|
|
286
|
+
const imageSrc = useMemo(() => {
|
|
287
|
+
if (content === null) return null;
|
|
288
|
+
if (/^data:/i.test(content)) return content;
|
|
289
|
+
const extMime: Record<string, string> = {
|
|
290
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
291
|
+
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
|
|
292
|
+
};
|
|
293
|
+
const mime = extMime[ext] || 'image/png';
|
|
294
|
+
if (ext === 'svg') {
|
|
295
|
+
// SVG 是文本格式:直接内联(含缩进的空格保留,避免破坏标记)
|
|
296
|
+
return `data:image/svg+xml,${encodeURIComponent(content)}`;
|
|
297
|
+
}
|
|
298
|
+
const cleaned = content.replace(/\s+/g, '');
|
|
299
|
+
return `data:${mime};base64,${cleaned}`;
|
|
300
|
+
}, [content, ext]);
|
|
301
|
+
|
|
302
|
+
if (!active || !ref) return null;
|
|
303
|
+
|
|
304
|
+
const zoomIn = () => setZoom((z) => Math.min(8, +(z * 1.3).toFixed(2)));
|
|
305
|
+
const zoomOut = () => setZoom((z) => Math.max(0.1, +(z / 1.3).toFixed(2)));
|
|
306
|
+
const htmlZoomIn = () => setHtmlZoom((z) => Math.min(2, +(z + 0.1).toFixed(1)));
|
|
307
|
+
const htmlZoomOut = () => setHtmlZoom((z) => Math.max(0.5, +(z - 0.1).toFixed(1)));
|
|
308
|
+
const resetView = () => {
|
|
309
|
+
setZoom(1);
|
|
310
|
+
setPan({ x: 0, y: 0 });
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const renderBody = () => {
|
|
314
|
+
if (loadState === 'loading') {
|
|
315
|
+
return (
|
|
316
|
+
<div className="display-loading">
|
|
317
|
+
<span className="spinner" style={{ width: 22, height: 22 }} />
|
|
318
|
+
<span>{t('display.loading')}</span>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (loadState === 'error' || content === null) {
|
|
323
|
+
return <div className="display-error">{t('display.fileError', { path: active.path })}</div>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (active.kind === 'image') {
|
|
327
|
+
return (
|
|
328
|
+
<div
|
|
329
|
+
ref={viewportRef}
|
|
330
|
+
className="display-image-viewport"
|
|
331
|
+
onWheel={(e) => {
|
|
332
|
+
if (e.ctrlKey || e.metaKey) {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
if (e.deltaY < 0) zoomIn();
|
|
335
|
+
else zoomOut();
|
|
336
|
+
}
|
|
337
|
+
}}
|
|
338
|
+
>
|
|
339
|
+
{imgError ? (
|
|
340
|
+
<div className="display-error">{t('display.imageLoadError')}</div>
|
|
341
|
+
) : (
|
|
342
|
+
<img
|
|
343
|
+
className="display-image"
|
|
344
|
+
src={imageSrc ?? undefined}
|
|
345
|
+
alt={basenameOf(active.path)}
|
|
346
|
+
draggable={false}
|
|
347
|
+
style={{
|
|
348
|
+
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
|
349
|
+
cursor: dragRef.current ? 'grabbing' : 'grab',
|
|
350
|
+
}}
|
|
351
|
+
onError={() => setImgError(true)}
|
|
352
|
+
onMouseDown={(e) => {
|
|
353
|
+
if (e.button !== 0) return;
|
|
354
|
+
dragRef.current = { startX: e.clientX - pan.x, startY: e.clientY - pan.y, panX: pan.x, panY: pan.y };
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
}}
|
|
357
|
+
onMouseMove={(e) => {
|
|
358
|
+
if (!dragRef.current) return;
|
|
359
|
+
setPan({ x: e.clientX - dragRef.current.startX, y: e.clientY - dragRef.current.startY });
|
|
360
|
+
}}
|
|
361
|
+
onMouseUp={() => (dragRef.current = null)}
|
|
362
|
+
onMouseLeave={() => (dragRef.current = null)}
|
|
363
|
+
onDoubleClick={resetView}
|
|
364
|
+
/>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (active.kind === 'html') {
|
|
371
|
+
return (
|
|
372
|
+
<div className="display-html-wrap">
|
|
373
|
+
<div className="display-html-viewport" aria-busy={!htmlReady}>
|
|
374
|
+
<div className={`display-html-loading ${htmlReady ? 'hidden' : ''}`}>
|
|
375
|
+
<span className="spinner" style={{ width: 22, height: 22 }} />
|
|
376
|
+
<span>{t('display.loading')}</span>
|
|
377
|
+
</div>
|
|
378
|
+
<iframe
|
|
379
|
+
ref={htmlIframeRef}
|
|
380
|
+
className={`display-iframe ${htmlReady ? 'ready' : ''}`}
|
|
381
|
+
sandbox="allow-scripts"
|
|
382
|
+
title={title}
|
|
383
|
+
srcDoc={htmlPreview?.srcDoc}
|
|
384
|
+
style={{
|
|
385
|
+
width: `calc(${100 / htmlZoom}% + ${2 / htmlZoom}px)`,
|
|
386
|
+
height: `calc(${100 / htmlZoom}% + ${2 / htmlZoom}px)`,
|
|
387
|
+
transform: `scale(${htmlZoom})`,
|
|
388
|
+
}}
|
|
389
|
+
/>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="display-html-note">{t('display.htmlSandboxNote')}</div>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// text:按扩展名智能渲染
|
|
397
|
+
if (MD_EXTS.has(ext)) {
|
|
398
|
+
return (
|
|
399
|
+
<div className="display-md-scroll">
|
|
400
|
+
<div className="md">
|
|
401
|
+
<Markdown text={content} />
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (CODE_EXTS.has(ext)) {
|
|
407
|
+
const lang = ext === 'tsx' || ext === 'jsx' || ext === 'ts' || ext === 'js' || ext === 'mjs' || ext === 'cjs' || ext === 'mts' ? 'tsx' : ext;
|
|
408
|
+
return (
|
|
409
|
+
<div className="display-code-scroll">
|
|
410
|
+
<pre className={`display-code mono ${textWrap ? 'wrapped' : ''}`}>
|
|
411
|
+
<code dangerouslySetInnerHTML={{ __html: highlightCode(content, lang) }} />
|
|
412
|
+
</pre>
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
// 纯文本(含未知扩展名)
|
|
417
|
+
return (
|
|
418
|
+
<div className="display-plain-scroll">
|
|
419
|
+
<pre className={`display-plain mono ${textWrap ? 'wrapped' : ''}`}>{content}</pre>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const preview = (
|
|
425
|
+
<>
|
|
426
|
+
<div className="modal-head display-modal-head">
|
|
427
|
+
<span className="display-modal-title">
|
|
428
|
+
<Icon name="file" size={15} />
|
|
429
|
+
<span className="mono" style={{ fontSize: 12.5, color: 'var(--text-dim)' }}>{active.path}</span>
|
|
430
|
+
<span className="display-title-text">{title}</span>
|
|
431
|
+
</span>
|
|
432
|
+
<div className="display-head-actions">
|
|
433
|
+
{active.kind === 'image' && loadState === 'ok' && !imgError && (
|
|
434
|
+
<span className="display-zoom-group">
|
|
435
|
+
<button className="icon-btn" title={t('display.zoomOut')} onClick={zoomOut}><Icon name="zoom-out" size={15} /></button>
|
|
436
|
+
<button className="icon-btn" title={t('display.resetView')} onClick={resetView}>{Math.round(zoom * 100)}%</button>
|
|
437
|
+
<button className="icon-btn" title={t('display.zoomIn')} onClick={zoomIn}><Icon name="zoom-in" size={15} /></button>
|
|
438
|
+
</span>
|
|
439
|
+
)}
|
|
440
|
+
{active.kind === 'html' && loadState === 'ok' && (
|
|
441
|
+
<span className="display-zoom-group">
|
|
442
|
+
<button className="icon-btn" title={t('display.htmlZoomOut')} onClick={htmlZoomOut}><Icon name="zoom-out" size={15} /></button>
|
|
443
|
+
<button className="icon-btn" title={t('display.resetHtmlZoom')} onClick={() => setHtmlZoom(1)}>{Math.round(htmlZoom * 100)}%</button>
|
|
444
|
+
<button className="icon-btn" title={t('display.htmlZoomIn')} onClick={htmlZoomIn}><Icon name="zoom-in" size={15} /></button>
|
|
445
|
+
</span>
|
|
446
|
+
)}
|
|
447
|
+
{active.kind === 'text' && !MD_EXTS.has(ext) && loadState === 'ok' && (
|
|
448
|
+
<button
|
|
449
|
+
className={`icon-btn ${textWrap ? 'active' : ''}`}
|
|
450
|
+
title={t(textWrap ? 'display.disableWrap' : 'display.enableWrap')}
|
|
451
|
+
aria-label={t(textWrap ? 'display.disableWrap' : 'display.enableWrap')}
|
|
452
|
+
aria-pressed={textWrap}
|
|
453
|
+
onClick={() => setTextWrap((wrapped) => !wrapped)}
|
|
454
|
+
>
|
|
455
|
+
<Icon name="text-wrap" size={17} />
|
|
456
|
+
</button>
|
|
457
|
+
)}
|
|
458
|
+
<button
|
|
459
|
+
className="icon-btn"
|
|
460
|
+
title={t(isPictureInPicture ? 'display.exitPictureInPicture' : 'display.pictureInPicture')}
|
|
461
|
+
aria-label={t(isPictureInPicture ? 'display.exitPictureInPicture' : 'display.pictureInPicture')}
|
|
462
|
+
onClick={() => isPictureInPicture ? exitPictureInPicture() : enterPictureInPicture()}
|
|
463
|
+
>
|
|
464
|
+
<Icon name={isPictureInPicture ? 'pip-exit' : 'pip'} size={17} />
|
|
465
|
+
</button>
|
|
466
|
+
<button className="icon-btn" title={t('common.close')} onClick={close}><Icon name="x" size={17} /></button>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
<div className="modal-body display-modal-body">{renderBody()}</div>
|
|
470
|
+
</>
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
return createPortal(
|
|
474
|
+
<>
|
|
475
|
+
{!isPictureInPicture && <div key="overlay" className="overlay" onClick={close} />}
|
|
476
|
+
<div
|
|
477
|
+
key="display-container"
|
|
478
|
+
className={isPictureInPicture ? 'display-pip card' : 'modal-root'}
|
|
479
|
+
style={isPictureInPicture
|
|
480
|
+
? { left: pipRect.x, top: pipRect.y, width: pipRect.width, height: pipRect.height }
|
|
481
|
+
: undefined}
|
|
482
|
+
onClick={!isPictureInPicture ? (e) => e.stopPropagation() : undefined}
|
|
483
|
+
>
|
|
484
|
+
<div
|
|
485
|
+
className={isPictureInPicture ? 'display-pip-drag-layer' : 'modal card fade-up display-modal'}
|
|
486
|
+
onPointerDown={(e) => {
|
|
487
|
+
if (!isPictureInPicture || (e.target as HTMLElement).closest('button')) return;
|
|
488
|
+
pipGestureRef.current = { type: 'move', pointerX: e.clientX, pointerY: e.clientY, startX: pipRect.x, startY: pipRect.y };
|
|
489
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
490
|
+
e.preventDefault();
|
|
491
|
+
}}
|
|
492
|
+
onLostPointerCapture={() => (pipGestureRef.current = null)}
|
|
493
|
+
>
|
|
494
|
+
{preview}
|
|
495
|
+
</div>
|
|
496
|
+
{isPictureInPicture && (
|
|
497
|
+
<button
|
|
498
|
+
className="display-pip-resize"
|
|
499
|
+
title={t('display.resizePictureInPicture')}
|
|
500
|
+
aria-label={t('display.resizePictureInPicture')}
|
|
501
|
+
onPointerDown={(e) => {
|
|
502
|
+
pipGestureRef.current = {
|
|
503
|
+
type: 'resize',
|
|
504
|
+
pointerX: e.clientX,
|
|
505
|
+
pointerY: e.clientY,
|
|
506
|
+
startWidth: pipRect.width,
|
|
507
|
+
startHeight: pipRect.height,
|
|
508
|
+
};
|
|
509
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
}}
|
|
512
|
+
onLostPointerCapture={() => (pipGestureRef.current = null)}
|
|
513
|
+
/>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
</>,
|
|
517
|
+
document.body,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** 解析消息上持久化的展示引用(displayRef JSON) */
|
|
522
|
+
export function parseStoredDisplayRef(json: string | null): DisplayFileRef | null {
|
|
523
|
+
if (!json) return null;
|
|
524
|
+
try {
|
|
525
|
+
const obj = JSON.parse(json) as Partial<DisplayFileRef>;
|
|
526
|
+
if (!obj || typeof obj.path !== 'string' || !['text', 'image', 'html'].includes(obj.kind ?? '')) return null;
|
|
527
|
+
return {
|
|
528
|
+
path: obj.path,
|
|
529
|
+
kind: obj.kind as DisplayFileRef['kind'],
|
|
530
|
+
title: obj.title,
|
|
531
|
+
sessionId: typeof obj.sessionId === 'number' ? obj.sessionId : null,
|
|
532
|
+
};
|
|
533
|
+
} catch {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 工具结果消息上的「查看」按钮:随时重开已展示过的文件 */
|
|
539
|
+
export function FileDisplayViewButton({ displayRef }: { displayRef: string | null }) {
|
|
540
|
+
const t = useT();
|
|
541
|
+
const setActiveDisplay = useStore((s) => s.setActiveDisplay);
|
|
542
|
+
if (!displayRef) return null;
|
|
543
|
+
return (
|
|
544
|
+
<button
|
|
545
|
+
className="btn btn-sm display-view-btn"
|
|
546
|
+
onClick={() => {
|
|
547
|
+
const ref = parseStoredDisplayRef(displayRef);
|
|
548
|
+
if (ref) {
|
|
549
|
+
setActiveDisplay({ path: ref.path, kind: ref.kind, title: ref.title, sessionId: ref.sessionId ?? null });
|
|
550
|
+
}
|
|
551
|
+
}}
|
|
552
|
+
>
|
|
553
|
+
<Icon name="eye" size={12} /> {t('display.view')}
|
|
554
|
+
</button>
|
|
555
|
+
);
|
|
556
|
+
}
|