tavern-harness 0.1.12 → 0.1.13
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/CHANGELOG.md +345 -0
- package/README.md +12 -4
- package/README.zh.md +12 -4
- package/bin/cli.js +16 -2
- package/index.html +3 -1
- package/package.json +18 -5
- package/persistence-server.mjs +303 -0
- package/public/sillytavern-logo.png +0 -0
- package/src/App.tsx +72 -8
- package/src/components/CharactersView.tsx +221 -68
- package/src/components/ChatView.tsx +98 -69
- package/src/components/FileDisplayModal.tsx +28 -17
- package/src/components/NewSessionMenu.tsx +2 -9
- package/src/components/PopoverMenu.tsx +54 -0
- package/src/components/SettingsView.tsx +21 -1
- package/src/components/Sidebar.tsx +133 -53
- package/src/components/VersionHistoryModal.tsx +114 -0
- package/src/components/shared.tsx +12 -3
- package/src/core/browserPreferences.ts +45 -0
- package/src/core/changelog.ts +43 -0
- package/src/core/characterBook.ts +32 -0
- package/src/core/clipboard.ts +32 -0
- package/src/core/editable.ts +26 -0
- package/src/core/gameplay.ts +2 -0
- package/src/core/i18n.ts +141 -10
- package/src/core/longPress.ts +209 -0
- package/src/core/longPressGesture.ts +110 -0
- package/src/core/mobileViewport.ts +362 -0
- package/src/core/openai.ts +39 -7
- package/src/core/sillyTavernWorldInfo.ts +159 -0
- package/src/core/sortableSensors.ts +21 -0
- package/src/core/stickyBottom.ts +45 -0
- package/src/core/tools/generatedSkillExecutor.ts +4 -20
- package/src/core/version.ts +46 -0
- package/src/core/viewport.ts +81 -0
- package/src/core/wechatTouch.ts +145 -0
- package/src/db/database.ts +313 -2
- package/src/generated/changelog.json +510 -0
- package/src/main.tsx +18 -2
- package/src/store/store.ts +99 -33
- package/src/theme/chat.css +174 -49
- package/src/theme/global.css +391 -10
- package/src/theme/theme.ts +9 -2
- package/src/theme/views.css +341 -8
- package/src/types/models.ts +9 -0
- package/src/vite-env.d.ts +4 -1
- package/vite.config.ts +184 -17
|
@@ -9,6 +9,11 @@ import { effectiveDisplayQueue, speakerLabel, suggestMagicCommands } from '../co
|
|
|
9
9
|
import { onChatScroll, onQueueScroll, onQueueScrollIntent, registerChatEl, registerQueueEl, scrollChatTo, scrollQueueToLoop } from '../core/linkedScroll';
|
|
10
10
|
import { useT, translate } from '../core/i18n';
|
|
11
11
|
import { escapeRegExp, prettyJson, trimEdgeNewlines } from '../core/strings';
|
|
12
|
+
import { copyText } from '../core/clipboard';
|
|
13
|
+
import { clearTextSelection, shouldSuppressContextMenuWhileTyping, useTouchLongPress } from '../core/longPress';
|
|
14
|
+
import { fixedViewportPoint } from '../core/viewport';
|
|
15
|
+
import { keepStickyBottom } from '../core/stickyBottom';
|
|
16
|
+
import { PopoverMenu } from './PopoverMenu';
|
|
12
17
|
|
|
13
18
|
const CharacterBookEditor = lazy(() => import('./CharacterBookEditor'));
|
|
14
19
|
|
|
@@ -137,6 +142,14 @@ export function ChatView({
|
|
|
137
142
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
143
|
}, [messages.length, updateStreaming, messages[messages.length - 1]?.content.length, isGroup]);
|
|
139
144
|
|
|
145
|
+
// 软键盘弹起/收起、输入框增高都会改变滚动容器高度;在绘制前同步贴底,
|
|
146
|
+
// 让内容与输入框在同一帧到位(实现见 core/stickyBottom.ts)。
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const el = scrollRef.current;
|
|
149
|
+
if (!el) return;
|
|
150
|
+
return keepStickyBottom(el);
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
140
153
|
const npcId = session.associatedId;
|
|
141
154
|
const npc = useStore((s) => s.npcs.find((n) => n.id === npcId));
|
|
142
155
|
const userPersonaName = useStore((s) => {
|
|
@@ -202,6 +215,10 @@ export function ChatView({
|
|
|
202
215
|
className="sys-banner fade-up compression-banner"
|
|
203
216
|
onContextMenu={(e) => {
|
|
204
217
|
e.preventDefault();
|
|
218
|
+
// 软键盘弹出期间不响应触摸长按,避免菜单打断输入;
|
|
219
|
+
// 触摸可能先让输入框失焦并收起键盘,还要看手势开始时的状态。
|
|
220
|
+
// 鼠标右键不受影响。
|
|
221
|
+
if (shouldSuppressContextMenuWhileTyping(e.nativeEvent)) return;
|
|
205
222
|
const compression = compressionsByEndMessageId.get(m.id!);
|
|
206
223
|
if (compression) openCompressionMenu(e, compression.summary, compression, t('chat.contextCompressed'));
|
|
207
224
|
}}
|
|
@@ -225,6 +242,14 @@ function lastMsgId(messages: ChatMessage[]): number | undefined {
|
|
|
225
242
|
// 单条消息气泡
|
|
226
243
|
// ============================================================
|
|
227
244
|
|
|
245
|
+
/** 消息气泡共用的长按 / 右键菜单手势。 */
|
|
246
|
+
function useMessageLongPress(msg: ChatMessage, session: ChatSession) {
|
|
247
|
+
return useTouchLongPress(
|
|
248
|
+
(point) => openMsgMenuAt(point, msg, session),
|
|
249
|
+
(event) => openMsgMenu(event, msg, session),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
228
253
|
function MessageBubble({
|
|
229
254
|
msg,
|
|
230
255
|
session,
|
|
@@ -250,6 +275,7 @@ function MessageBubble({
|
|
|
250
275
|
const t = useT();
|
|
251
276
|
const providers = useStore((s) => s.providers);
|
|
252
277
|
const turnStreaming = useStore((s) => s.streaming.sessionId === session.id);
|
|
278
|
+
const longPressProps = useMessageLongPress(msg, session);
|
|
253
279
|
// System 消息(/new 标记)
|
|
254
280
|
if (msg.role === 'system') {
|
|
255
281
|
const content = msg.content === '开始新话题' ? t('chat.newTopicMarker') : msg.content;
|
|
@@ -356,7 +382,7 @@ function MessageBubble({
|
|
|
356
382
|
className={`msg-row fade-up ${streaming ? 'streaming' : ''}`}
|
|
357
383
|
data-loop={loopIndex ?? undefined}
|
|
358
384
|
data-speaker={speakerKey ?? undefined}
|
|
359
|
-
|
|
385
|
+
{...longPressProps}
|
|
360
386
|
>
|
|
361
387
|
<Avatar name={speakerName} colorOrdinal={npcHue} imageUrl={npcAvatar} />
|
|
362
388
|
<div className="msg-body">
|
|
@@ -396,14 +422,8 @@ function MessageBubble({
|
|
|
396
422
|
))}
|
|
397
423
|
</div>
|
|
398
424
|
)}
|
|
399
|
-
<div className="bubble-
|
|
400
|
-
<
|
|
401
|
-
{displayContent ? <Markdown text={displayContent} mentionNames={mentionNames} /> : streaming && <span className="stream-cursor" />}
|
|
402
|
-
</div>
|
|
403
|
-
{/* 编辑按钮:位于正文气泡内最右侧,铅笔图标 */}
|
|
404
|
-
<button className="msg-edit-btn" title={t('chat.editMsg')} onClick={() => startEditingMsg(msg.id!)}>
|
|
405
|
-
<Icon name="pencil" size={13} />
|
|
406
|
-
</button>
|
|
425
|
+
<div className="bubble-text">
|
|
426
|
+
{displayContent ? <Markdown text={displayContent} mentionNames={mentionNames} /> : streaming && <span className="stream-cursor" />}
|
|
407
427
|
</div>
|
|
408
428
|
</div>
|
|
409
429
|
)}
|
|
@@ -497,7 +517,7 @@ function BubbleEditor({
|
|
|
497
517
|
</div>
|
|
498
518
|
)}
|
|
499
519
|
<div className="bubble-editor-actions">
|
|
500
|
-
<div
|
|
520
|
+
<div className="bubble-editor-actions-start">
|
|
501
521
|
<input
|
|
502
522
|
ref={fileRef}
|
|
503
523
|
type="file"
|
|
@@ -516,7 +536,7 @@ function BubbleEditor({
|
|
|
516
536
|
<Icon name="image" size={12} /> {t('chat.addAttachment')}
|
|
517
537
|
</button>
|
|
518
538
|
</div>
|
|
519
|
-
<div
|
|
539
|
+
<div className="bubble-editor-actions-end">
|
|
520
540
|
<span className="bubble-editor-hint">{t('chat.saveHint')}</span>
|
|
521
541
|
<button className="btn btn-sm" onClick={onDone}>{t('common.cancel')}</button>
|
|
522
542
|
<button className="btn btn-sm btn-primary" disabled={!text.trim() || streaming} onClick={primaryAction}>
|
|
@@ -531,13 +551,19 @@ function BubbleEditor({
|
|
|
531
551
|
function UserBubble({ msg, session, userPersonaName, editing, loopIndex }: { msg: ChatMessage; session: ChatSession; userPersonaName: string | null; editing?: boolean; loopIndex?: number | null }) {
|
|
532
552
|
const t = useT();
|
|
533
553
|
const participants = useStore((s) => (session.id != null ? (s.participants[session.id] ?? []) : []));
|
|
554
|
+
const longPressProps = useMessageLongPress(msg, session);
|
|
534
555
|
// 群聊中「用户消息」的 @角色名 也作为特殊指令高亮(点名)
|
|
535
556
|
const mentionNames =
|
|
536
557
|
session.mode === 'GROUP'
|
|
537
558
|
? participants.filter((p) => p.kind === 'NPC').map((p) => p.displayName)
|
|
538
559
|
: [];
|
|
539
560
|
return (
|
|
540
|
-
<div
|
|
561
|
+
<div
|
|
562
|
+
className="msg-row user-row fade-up"
|
|
563
|
+
data-loop={loopIndex ?? undefined}
|
|
564
|
+
data-speaker="player"
|
|
565
|
+
{...longPressProps}
|
|
566
|
+
>
|
|
541
567
|
<div className="avatar sm user-avatar">{t('common.me')}</div>
|
|
542
568
|
<div className="msg-body">
|
|
543
569
|
<div className="msg-head right">
|
|
@@ -555,13 +581,8 @@ function UserBubble({ msg, session, userPersonaName, editing, loopIndex }: { msg
|
|
|
555
581
|
))}
|
|
556
582
|
</div>
|
|
557
583
|
)}
|
|
558
|
-
<div className="bubble-
|
|
559
|
-
<
|
|
560
|
-
{msg.content && <Markdown text={displayUserPersonaPlaceholder(msg.content, userPersonaName)} mentionNames={mentionNames} />}
|
|
561
|
-
</div>
|
|
562
|
-
<button className="msg-edit-btn" title={t('chat.editMsg')} onClick={() => startEditingMsg(msg.id!)}>
|
|
563
|
-
<Icon name="pencil" size={13} />
|
|
564
|
-
</button>
|
|
584
|
+
<div className="bubble-text">
|
|
585
|
+
{msg.content && <Markdown text={displayUserPersonaPlaceholder(msg.content, userPersonaName)} mentionNames={mentionNames} />}
|
|
565
586
|
</div>
|
|
566
587
|
</div>
|
|
567
588
|
)}
|
|
@@ -646,6 +667,7 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
|
|
|
646
667
|
const providers = useStore((s) => s.providers);
|
|
647
668
|
const defaultModel = useStore((s) => s.settings?.defaultModel?.trim() || '');
|
|
648
669
|
const defaultProviderId = useStore((s) => s.settings?.defaultProviderId ?? null);
|
|
670
|
+
const composerPlaceholder = session?.mode === 'GROUP' ? t('chat.phGroup') : t('chat.phSingle');
|
|
649
671
|
|
|
650
672
|
const canSend = text.trim().length > 0 || attachments.length > 0;
|
|
651
673
|
|
|
@@ -958,11 +980,8 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
|
|
|
958
980
|
}}
|
|
959
981
|
onClick={(e) => detectMention(text, e.currentTarget.selectionStart ?? text.length)}
|
|
960
982
|
onKeyUp={(e) => detectMention(text, e.currentTarget.selectionStart ?? text.length)}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
? t('chat.phGroup')
|
|
964
|
-
: t('chat.phSingle')
|
|
965
|
-
}
|
|
983
|
+
aria-label={composerPlaceholder}
|
|
984
|
+
placeholder={composerPlaceholder}
|
|
966
985
|
rows={1}
|
|
967
986
|
style={{ height: 'auto', minHeight: 38 }}
|
|
968
987
|
/>
|
|
@@ -1093,11 +1112,15 @@ function notifyMsgMenu() {
|
|
|
1093
1112
|
msgMenuListeners.forEach((fn) => fn());
|
|
1094
1113
|
}
|
|
1095
1114
|
function openMsgMenu(e: React.MouseEvent, msg: ChatMessage, session: ChatSession) {
|
|
1096
|
-
|
|
1115
|
+
if (window.matchMedia('(pointer: coarse)').matches) clearTextSelection();
|
|
1116
|
+
openMsgMenuAt({ x: e.clientX, y: e.clientY }, msg, session);
|
|
1117
|
+
}
|
|
1118
|
+
function openMsgMenuAt(point: { x: number; y: number }, msg: ChatMessage, session: ChatSession) {
|
|
1119
|
+
msgMenuState = { ...fixedViewportPoint(point), msg, session };
|
|
1097
1120
|
notifyMsgMenu();
|
|
1098
1121
|
}
|
|
1099
1122
|
function openCompressionMenu(e: React.MouseEvent, summary: string | undefined, rawLog: RawLogData | undefined, title: string) {
|
|
1100
|
-
msgMenuState = { x: e.clientX, y: e.clientY, summary, rawLog, title };
|
|
1123
|
+
msgMenuState = { ...fixedViewportPoint({ x: e.clientX, y: e.clientY }), summary, rawLog, title };
|
|
1101
1124
|
notifyMsgMenu();
|
|
1102
1125
|
}
|
|
1103
1126
|
function closeMsgMenu() {
|
|
@@ -1174,36 +1197,45 @@ export function MessageMenu() {
|
|
|
1174
1197
|
if (!('msg' in msgMenuState)) {
|
|
1175
1198
|
const { x, y, summary, rawLog: compressionRawLog, title } = msgMenuState;
|
|
1176
1199
|
return (
|
|
1177
|
-
|
|
1178
|
-
<
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
</button>
|
|
1200
|
-
</div>
|
|
1201
|
-
</>
|
|
1200
|
+
<PopoverMenu key={`${x}:${y}:compress`} point={{ x, y }} onClose={closeMsgMenu}>
|
|
1201
|
+
<button
|
|
1202
|
+
className="msg-menu-item"
|
|
1203
|
+
disabled={!summary}
|
|
1204
|
+
onClick={() => {
|
|
1205
|
+
if (summary) setCompressionSummary({ content: summary, title });
|
|
1206
|
+
closeMsgMenu();
|
|
1207
|
+
}}
|
|
1208
|
+
>
|
|
1209
|
+
<Icon name="eye" size={13} /> {t('chat.viewMemory')}
|
|
1210
|
+
</button>
|
|
1211
|
+
<button
|
|
1212
|
+
className="msg-menu-item"
|
|
1213
|
+
disabled={!compressionRawLog?.rawRequestBody && !compressionRawLog?.rawResponseBody}
|
|
1214
|
+
onClick={() => {
|
|
1215
|
+
if (compressionRawLog) setRawLog({ data: compressionRawLog, title, filename: 'raw-log-context-compression.txt' });
|
|
1216
|
+
closeMsgMenu();
|
|
1217
|
+
}}
|
|
1218
|
+
>
|
|
1219
|
+
<Icon name="file" size={13} /> {t('chat.viewRaw')}
|
|
1220
|
+
</button>
|
|
1221
|
+
</PopoverMenu>
|
|
1202
1222
|
);
|
|
1203
1223
|
}
|
|
1204
1224
|
const { x, y, msg } = msgMenuState;
|
|
1205
1225
|
|
|
1206
1226
|
const actions: Array<{ label: string; icon: string; onClick: () => void; danger?: boolean }> = [];
|
|
1227
|
+
if (msg.content.trim()) {
|
|
1228
|
+
// 触屏下消息正文禁用了文本选择,复制必须走菜单
|
|
1229
|
+
actions.push({
|
|
1230
|
+
label: t('chat.copyMsg'),
|
|
1231
|
+
icon: 'copy',
|
|
1232
|
+
onClick: () => {
|
|
1233
|
+
void copyText(msg.content).then((copied) => {
|
|
1234
|
+
addToast(copied ? t('toast.copied') : t('toast.copyFailed'));
|
|
1235
|
+
});
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1207
1239
|
if (msg.role === 'user' || msg.role === 'assistant') {
|
|
1208
1240
|
actions.push({
|
|
1209
1241
|
label: t('chat.editMsg'),
|
|
@@ -1252,24 +1284,21 @@ export function MessageMenu() {
|
|
|
1252
1284
|
});
|
|
1253
1285
|
|
|
1254
1286
|
return (
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
onClick
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
{actions.length === 0 && <div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('chat.noActions')}</div>}
|
|
1271
|
-
</div>
|
|
1272
|
-
</>
|
|
1287
|
+
<PopoverMenu key={`${x}:${y}:${msg.id ?? ''}`} point={{ x, y }} onClose={closeMsgMenu}>
|
|
1288
|
+
{actions.map((a) => (
|
|
1289
|
+
<button
|
|
1290
|
+
key={a.label}
|
|
1291
|
+
className="msg-menu-item"
|
|
1292
|
+
onClick={() => {
|
|
1293
|
+
closeMsgMenu();
|
|
1294
|
+
a.onClick();
|
|
1295
|
+
}}
|
|
1296
|
+
>
|
|
1297
|
+
<Icon name={a.icon} size={13} /> {a.label}
|
|
1298
|
+
</button>
|
|
1299
|
+
))}
|
|
1300
|
+
{actions.length === 0 && <div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('chat.noActions')}</div>}
|
|
1301
|
+
</PopoverMenu>
|
|
1273
1302
|
);
|
|
1274
1303
|
}
|
|
1275
1304
|
|
|
@@ -6,6 +6,7 @@ import { useT } from '../core/i18n';
|
|
|
6
6
|
import { highlightCode } from '../core/markdown';
|
|
7
7
|
import { readWorkspaceFileText } from '../core/tools/generatedSkillExecutor';
|
|
8
8
|
import { applySessionWorkspace } from '../core/tools/toolExecutor';
|
|
9
|
+
import { fixedViewportBounds, isMobileViewport } from '../core/viewport';
|
|
9
10
|
import type { DisplayFileRef } from '../types/models';
|
|
10
11
|
|
|
11
12
|
// ============================================================
|
|
@@ -41,11 +42,18 @@ interface PipRect {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function createPipRect(): PipRect {
|
|
44
|
-
|
|
45
|
+
// 用 visualViewport 边界(而不是 innerWidth/innerHeight):Edge Android 的
|
|
46
|
+
// 底部工具栏不在布局视口里,PiP 落到底部时会被工具栏盖住。
|
|
47
|
+
const bounds = fixedViewportBounds();
|
|
48
|
+
const availableWidth = Math.max(0, bounds.right - bounds.left - PIP_EDGE_GAP * 2);
|
|
49
|
+
const availableHeight = Math.max(0, bounds.bottom - bounds.top - PIP_EDGE_GAP * 2);
|
|
50
|
+
const isMobile = isMobileViewport();
|
|
51
|
+
const width = Math.min(isMobile ? 360 : 440, availableWidth);
|
|
52
|
+
const height = Math.min(isMobile ? 300 : 380, availableHeight);
|
|
45
53
|
return {
|
|
46
|
-
x: PIP_EDGE_GAP,
|
|
47
|
-
y: PIP_EDGE_GAP,
|
|
48
|
-
width
|
|
54
|
+
x: Math.max(bounds.left + PIP_EDGE_GAP, bounds.right - width - PIP_EDGE_GAP),
|
|
55
|
+
y: Math.max(bounds.top + PIP_EDGE_GAP, bounds.bottom - height - PIP_EDGE_GAP),
|
|
56
|
+
width,
|
|
49
57
|
height,
|
|
50
58
|
};
|
|
51
59
|
}
|
|
@@ -230,23 +238,26 @@ export function FileDisplayModal() {
|
|
|
230
238
|
pipGestureRef.current = null;
|
|
231
239
|
return;
|
|
232
240
|
}
|
|
241
|
+
// 与 createPipRect 使用同一套视觉视口边界,避免拖到 Edge Android
|
|
242
|
+
// 底部工具栏下面(innerHeight 包含被工具栏覆盖的区域)。
|
|
243
|
+
const bounds = fixedViewportBounds();
|
|
233
244
|
if (gesture.type === 'move') {
|
|
234
|
-
setPipRect((rect) =>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
Math.max(
|
|
242
|
-
Math.max(
|
|
243
|
-
|
|
244
|
-
})
|
|
245
|
+
setPipRect((rect) => {
|
|
246
|
+
const minX = bounds.left + PIP_EDGE_GAP;
|
|
247
|
+
const maxX = Math.max(minX, bounds.right - rect.width - PIP_EDGE_GAP);
|
|
248
|
+
const minY = bounds.top + PIP_EDGE_GAP;
|
|
249
|
+
const maxY = Math.max(minY, bounds.bottom - rect.height - PIP_EDGE_GAP);
|
|
250
|
+
return {
|
|
251
|
+
...rect,
|
|
252
|
+
x: Math.min(maxX, Math.max(minX, gesture.startX + e.clientX - gesture.pointerX)),
|
|
253
|
+
y: Math.min(maxY, Math.max(minY, gesture.startY + e.clientY - gesture.pointerY)),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
245
256
|
return;
|
|
246
257
|
}
|
|
247
258
|
setPipRect((rect) => {
|
|
248
|
-
const availableWidth =
|
|
249
|
-
const availableHeight =
|
|
259
|
+
const availableWidth = Math.max(0, bounds.right - rect.x - PIP_EDGE_GAP);
|
|
260
|
+
const availableHeight = Math.max(0, bounds.bottom - rect.y - PIP_EDGE_GAP);
|
|
250
261
|
return {
|
|
251
262
|
...rect,
|
|
252
263
|
width: Math.min(availableWidth, Math.max(Math.min(PIP_MIN_WIDTH, availableWidth), gesture.startWidth + e.clientX - gesture.pointerX)),
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
DndContext,
|
|
4
|
-
PointerSensor,
|
|
5
|
-
KeyboardSensor,
|
|
6
4
|
closestCenter,
|
|
7
|
-
useSensor,
|
|
8
|
-
useSensors,
|
|
9
5
|
type Modifier,
|
|
10
6
|
type DragEndEvent,
|
|
11
7
|
} from '@dnd-kit/core';
|
|
12
8
|
import {
|
|
13
9
|
SortableContext,
|
|
14
10
|
arrayMove,
|
|
15
|
-
sortableKeyboardCoordinates,
|
|
16
11
|
verticalListSortingStrategy,
|
|
17
12
|
useSortable,
|
|
18
13
|
} from '@dnd-kit/sortable';
|
|
19
14
|
import { CSS } from '@dnd-kit/utilities';
|
|
15
|
+
import { useSortableSensors } from '../core/sortableSensors';
|
|
20
16
|
import { useStore, createSession } from '../store/store';
|
|
21
17
|
import { Avatar, Icon, Modal } from './shared';
|
|
22
18
|
import { useT } from '../core/i18n';
|
|
@@ -58,10 +54,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
58
54
|
const [enableGreeting, setEnableGreeting] = useState(editingSession?.enableGreeting !== false);
|
|
59
55
|
const selectedNpcIds = participantOrder.filter((id) => id !== -1);
|
|
60
56
|
const modeLabel = selectedNpcIds.length === 0 ? t('newSession.notSelected') : selectedNpcIds.length === 1 ? t('newSession.npcChat') : t('newSession.groupChat', { n: selectedNpcIds.length });
|
|
61
|
-
const sensors =
|
|
62
|
-
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
63
|
-
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
64
|
-
);
|
|
57
|
+
const sensors = useSortableSensors();
|
|
65
58
|
|
|
66
59
|
useEffect(() => {
|
|
67
60
|
if (!editingSession?.id) return;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { clampPopoverPosition, fixedViewportBounds, type ViewportPoint } from '../core/viewport';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 浮动菜单(消息菜单 / 会话菜单共用):遮罩 + 贴边钳制的卡片。
|
|
6
|
+
*
|
|
7
|
+
* 菜单项数量随上下文变化,用硬编码宽高去钳制会算错位置。这里先按触发点挂载,
|
|
8
|
+
* 再用 `useLayoutEffect` 在绘制前按实测尺寸重新钳制一次。测量完成前菜单保持
|
|
9
|
+
* `visibility: hidden`:即使个别 WebView 把布局副作用推迟到一帧之后,用户也
|
|
10
|
+
* 不会看到菜单从触发点跳到钳制位置。
|
|
11
|
+
* 切换菜单时由外层 `key` 触发重新挂载,保证每次打开都会重新测量。
|
|
12
|
+
*/
|
|
13
|
+
export function PopoverMenu({
|
|
14
|
+
point,
|
|
15
|
+
onClose,
|
|
16
|
+
children,
|
|
17
|
+
}: {
|
|
18
|
+
point: ViewportPoint;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
23
|
+
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
|
24
|
+
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
const element = ref.current;
|
|
27
|
+
if (!element) return;
|
|
28
|
+
setPosition(
|
|
29
|
+
clampPopoverPosition(
|
|
30
|
+
point,
|
|
31
|
+
{ width: element.offsetWidth, height: element.offsetHeight },
|
|
32
|
+
fixedViewportBounds(),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
// 点到点之间不重复渲染:point 对象每次都是新引用,按坐标值比较。
|
|
36
|
+
}, [point.x, point.y]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<div className="overlay-msg" onClick={onClose} />
|
|
41
|
+
<div
|
|
42
|
+
ref={ref}
|
|
43
|
+
className="msg-menu card"
|
|
44
|
+
style={{
|
|
45
|
+
left: position?.left ?? point.x,
|
|
46
|
+
top: position?.top ?? point.y,
|
|
47
|
+
visibility: position ? 'visible' : 'hidden',
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -7,6 +7,8 @@ import { Icon, Modal } from './shared';
|
|
|
7
7
|
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
|
8
8
|
import { isProxyUrl, isNetworkLikeError, toProxyUrl } from '../core/proxy';
|
|
9
9
|
import { useT } from '../core/i18n';
|
|
10
|
+
import { APP_VERSION, useHasUnseenVersion } from '../core/version';
|
|
11
|
+
import { VersionHistoryModal } from './VersionHistoryModal';
|
|
10
12
|
|
|
11
13
|
// ============================================================
|
|
12
14
|
// 设置视图(模型服务 / 超参数 / 主题)
|
|
@@ -20,7 +22,9 @@ export function SettingsView() {
|
|
|
20
22
|
const refreshProviders = useStore((s) => s.refreshProviders);
|
|
21
23
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
|
22
24
|
const [resetting, setResetting] = useState(false);
|
|
25
|
+
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
|
23
26
|
const t = useT();
|
|
27
|
+
const hasUnseenVersion = useHasUnseenVersion();
|
|
24
28
|
|
|
25
29
|
if (!settings) return null;
|
|
26
30
|
|
|
@@ -76,7 +80,7 @@ export function SettingsView() {
|
|
|
76
80
|
<table className="table param-table">
|
|
77
81
|
<tbody>
|
|
78
82
|
<tr>
|
|
79
|
-
<td
|
|
83
|
+
<td className="param-label">
|
|
80
84
|
<div className="s-label">Temperature</div>
|
|
81
85
|
<div className="s-desc">{t('settings.tempDesc')}</div>
|
|
82
86
|
</td>
|
|
@@ -197,6 +201,20 @@ export function SettingsView() {
|
|
|
197
201
|
</div>
|
|
198
202
|
</div>
|
|
199
203
|
|
|
204
|
+
<div className="set-section settings-about-section">
|
|
205
|
+
<div>
|
|
206
|
+
<h3>{t('settings.aboutTitle')}</h3>
|
|
207
|
+
<p className="settings-reset-desc">{t('settings.aboutDesc', { version: APP_VERSION })}</p>
|
|
208
|
+
</div>
|
|
209
|
+
<button
|
|
210
|
+
className="btn settings-about-button"
|
|
211
|
+
onClick={() => setShowVersionHistory(true)}
|
|
212
|
+
>
|
|
213
|
+
<Icon name="book" size={14} /> {t('settings.viewChangelog')}
|
|
214
|
+
{hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
200
218
|
<div className="set-section settings-reset-section">
|
|
201
219
|
<div>
|
|
202
220
|
<h3>{t('settings.resetTitle')}</h3>
|
|
@@ -208,6 +226,8 @@ export function SettingsView() {
|
|
|
208
226
|
</div>
|
|
209
227
|
</div>
|
|
210
228
|
|
|
229
|
+
{showVersionHistory && <VersionHistoryModal onClose={() => setShowVersionHistory(false)} />}
|
|
230
|
+
|
|
211
231
|
{showResetConfirm && (
|
|
212
232
|
<Modal onClose={() => !resetting && setShowResetConfirm(false)} width="min(420px, calc(100vw - 32px))">
|
|
213
233
|
<div className="modal-head">
|