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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 长按手势的纯逻辑(不依赖 React / DOM 实例),便于单元测试。
|
|
3
|
+
* 与浏览器相关的部分(计时器、事件绑定)在 `longPress.ts`;
|
|
4
|
+
* 视口坐标换算在 `viewport.ts`。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type Point = { x: number; y: number };
|
|
8
|
+
|
|
9
|
+
/** 长按触发时间。 */
|
|
10
|
+
export const LONG_PRESS_DELAY_MS = 500;
|
|
11
|
+
/** 手指移动超过该距离即视为滚动/拖拽,取消长按。 */
|
|
12
|
+
export const LONG_PRESS_MOVE_TOLERANCE_PX = 12;
|
|
13
|
+
/** 嵌套在这些控件内的触摸不触发长按菜单。 */
|
|
14
|
+
export const INTERACTIVE_SELECTOR = 'button, input, textarea, select, a, [role="button"]';
|
|
15
|
+
/** 触摸按下后该窗口内到达的原生 contextmenu 视为同一次触摸手势。 */
|
|
16
|
+
export const TOUCH_CONTEXT_MENU_WINDOW_MS = 1200;
|
|
17
|
+
/** 自定义长按菜单已弹出后,忽略原生 contextmenu 的时间窗口。 */
|
|
18
|
+
export const CONTEXT_MENU_DEDUPE_MS = 1000;
|
|
19
|
+
|
|
20
|
+
/** 移动距离是否已超出长按容差。 */
|
|
21
|
+
export function exceedsMoveTolerance(
|
|
22
|
+
start: Point,
|
|
23
|
+
current: Point,
|
|
24
|
+
tolerance = LONG_PRESS_MOVE_TOLERANCE_PX,
|
|
25
|
+
): boolean {
|
|
26
|
+
return Math.hypot(current.x - start.x, current.y - start.y) > tolerance;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 是否应该开始长按计时。
|
|
31
|
+
* - 仅触摸指针;鼠标 / 触控笔交给原生 contextmenu。
|
|
32
|
+
* - 命中嵌套交互控件时忽略;但监听器本身挂在按钮上(交互目标是 currentTarget)时必须放行。
|
|
33
|
+
*/
|
|
34
|
+
export function shouldTrackLongPress(
|
|
35
|
+
pointerType: string,
|
|
36
|
+
interactiveTarget: unknown,
|
|
37
|
+
currentTarget: unknown,
|
|
38
|
+
): boolean {
|
|
39
|
+
if (pointerType !== 'touch') return false;
|
|
40
|
+
return interactiveTarget == null || interactiveTarget === currentTarget;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 原生 contextmenu 的来源信息。
|
|
45
|
+
*
|
|
46
|
+
* - `sinceTouchDown`:距最近一次触摸 pointerdown 的毫秒数,没有触摸时为 `Infinity`;
|
|
47
|
+
* - `sinceLongPress`:距自定义长按菜单弹出的毫秒数,尚未弹出时为 `Infinity`。
|
|
48
|
+
*/
|
|
49
|
+
export type ContextMenuSource = {
|
|
50
|
+
pointerType?: string;
|
|
51
|
+
firesTouchEvents?: boolean;
|
|
52
|
+
sinceTouchDown: number;
|
|
53
|
+
sinceLongPress: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** 该 contextmenu 是否由触摸手势产生(Android 上长按会补发一次)。 */
|
|
57
|
+
export function isTouchContextMenu(
|
|
58
|
+
source: Pick<ContextMenuSource, 'pointerType' | 'firesTouchEvents' | 'sinceTouchDown'>,
|
|
59
|
+
touchWindowMs = TOUCH_CONTEXT_MENU_WINDOW_MS,
|
|
60
|
+
): boolean {
|
|
61
|
+
// 明确的鼠标/触控笔来源直接排除,避免混合设备上触摸后紧接右键被误判。
|
|
62
|
+
if (source.firesTouchEvents === true) return true;
|
|
63
|
+
if (source.pointerType === 'touch') return true;
|
|
64
|
+
if (source.pointerType) return false;
|
|
65
|
+
// 老 WebView 可能既没有 pointerType 也没有 sourceCapabilities:只能靠时间窗。
|
|
66
|
+
return source.sinceTouchDown <= touchWindowMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 软键盘弹出时,手势结束时是否应主动让输入框失焦(收起键盘)。
|
|
71
|
+
*
|
|
72
|
+
* 只有点击才失焦:滑动页面必须保留焦点,否则用户一滚动聊天记录键盘就没了;
|
|
73
|
+
* 长按已经由 `triggeredRef` 标记(可能是在选词或拖动光标),同样不算点击。
|
|
74
|
+
*/
|
|
75
|
+
export function shouldBlurAfterTap(
|
|
76
|
+
keyboardOpenAtDown: boolean,
|
|
77
|
+
moved: boolean,
|
|
78
|
+
longPressed: boolean,
|
|
79
|
+
): boolean {
|
|
80
|
+
return keyboardOpenAtDown && !moved && !longPressed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 软键盘弹出期间,触摸长按(以及系统随后补发的 contextmenu)不应弹出菜单。
|
|
85
|
+
*
|
|
86
|
+
* 鼠标右键不受影响:同一台设备可能既连着鼠标又弹着软键盘。
|
|
87
|
+
*/
|
|
88
|
+
export function shouldSuppressTouchContextMenu(
|
|
89
|
+
source: Pick<ContextMenuSource, 'pointerType' | 'firesTouchEvents' | 'sinceTouchDown'>,
|
|
90
|
+
keyboardOpen: boolean,
|
|
91
|
+
touchWindowMs = TOUCH_CONTEXT_MENU_WINDOW_MS,
|
|
92
|
+
): boolean {
|
|
93
|
+
return keyboardOpen && isTouchContextMenu(source, touchWindowMs);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 是否忽略这次原生 contextmenu。
|
|
98
|
+
*
|
|
99
|
+
* 触摸长按时自定义计时器已经弹出菜单,随后系统补发的 contextmenu 若再按
|
|
100
|
+
* 自己的坐标打开一次,菜单就会重新挂载并产生可见位移(Android 微信内置
|
|
101
|
+
* 浏览器上尤其明显)。只去重“菜单已经弹出”的这一段窗口;若原生手势抢先
|
|
102
|
+
* 取消了指针事件、计时器没跑起来,则仍然放行 contextmenu 作为兜底。
|
|
103
|
+
*/
|
|
104
|
+
export function shouldIgnoreContextMenu(
|
|
105
|
+
source: ContextMenuSource,
|
|
106
|
+
dedupeMs = CONTEXT_MENU_DEDUPE_MS,
|
|
107
|
+
touchWindowMs = TOUCH_CONTEXT_MENU_WINDOW_MS,
|
|
108
|
+
): boolean {
|
|
109
|
+
return isTouchContextMenu(source, touchWindowMs) && source.sinceLongPress <= dedupeMs;
|
|
110
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 移动端视口高度控制。
|
|
3
|
+
*
|
|
4
|
+
* Edge Android 的底部工具栏在键盘收起时会晚一步归位:
|
|
5
|
+
* 布局视口先长到 822(工具栏仍隐藏),约一帧后才缩回 765(工具栏占 57px)。
|
|
6
|
+
* 若壳层直接跟随 100%,内容就会先掉到 822 的底部、再被顶回 765,形成跳动。
|
|
7
|
+
*
|
|
8
|
+
* 这里用 `--app-height` 控制 fixed 壳层的高度:
|
|
9
|
+
* - 键盘收起、视口长过已知的“关闭高度”时,先按关闭高度排版(多出的部分由工具栏覆盖);
|
|
10
|
+
* - 等视口真正回到该高度后再恢复跟随;
|
|
11
|
+
* - 键盘弹起前紧邻的小幅地址栏/工具栏变化(约 17px)先按住 80ms,
|
|
12
|
+
* 若紧接着是键盘的大幅收缩就合并成一次位移;
|
|
13
|
+
* - 收起键盘时底部地址栏会先挤到键盘上方(视口再矮约 57px),这部分被键盘盖住,
|
|
14
|
+
* 壳层保持不动,避免输入框先被顶上去、等工具栏滑走后再落回底部。
|
|
15
|
+
*
|
|
16
|
+
* Edge Android 上壳层始终使用显式像素高度(而不是 100%):
|
|
17
|
+
* 即使 resize 事件晚于绘制,壳层也只会停在旧高度,不会先跟到 822 再弹回 765。
|
|
18
|
+
* 其他浏览器默认继续用 100% 跟随,只有真正观察到“关闭时超过已知高度”才切换为像素高度。
|
|
19
|
+
*
|
|
20
|
+
* 只在移动端断点内生效;桌面端会移除变量,不受影响。
|
|
21
|
+
*
|
|
22
|
+
* 此外对外提供 `isVirtualKeyboardOpen()` / `wasVirtualKeyboardOpenAtTouchStart()`:
|
|
23
|
+
* 触屏长按手势需要知道软键盘是否(或手势开始时是否)弹出,以便在输入期间
|
|
24
|
+
* 抑制长按菜单。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// 显式带 .ts 后缀:本文件被 mobile-viewport.test.mjs 直接 import,
|
|
28
|
+
// Node 的 ESM 解析不会补后缀(浏览器构建侧由 Vite 处理)。
|
|
29
|
+
import { isEditableTarget } from './editable.ts';
|
|
30
|
+
import { MOBILE_MEDIA_QUERY, isMobileViewport } from './viewport.ts';
|
|
31
|
+
|
|
32
|
+
/** 键盘引起的视口变化通常 > 200px;地址栏/工具栏变化通常 < 60px。 */
|
|
33
|
+
const KEYBOARD_DELTA_PX = 120;
|
|
34
|
+
/** 合并键盘弹起前的小幅变化。 */
|
|
35
|
+
const SMALL_CHANGE_HOLD_MS = 80;
|
|
36
|
+
/** 键盘展开阶段合并二次高度变化(候选栏/工具栏收起)的等待时间。 */
|
|
37
|
+
const OPEN_SETTLE_MS = 40;
|
|
38
|
+
/** 工具栏一直不归位时(极少见),多久后接受当前高度。 */
|
|
39
|
+
const CLOSE_SETTLE_MS = 300;
|
|
40
|
+
/** 键盘展开期间,底部地址栏/工具栏最多能挤占的高度(约 57px,留出候选栏余量)。 */
|
|
41
|
+
const TOOLBAR_SQUEEZE_PX = 96;
|
|
42
|
+
|
|
43
|
+
let started = false;
|
|
44
|
+
let lastHeight = 0;
|
|
45
|
+
let lastWidth = 0;
|
|
46
|
+
/** 最近一次键盘关闭状态下的视口高度(含地址栏/工具栏)。 */
|
|
47
|
+
let closedCandidate = 0;
|
|
48
|
+
/** 本次键盘关闭后应当回到的高度。 */
|
|
49
|
+
let closedHeight = 0;
|
|
50
|
+
let keyboardOpen = false;
|
|
51
|
+
let focused = false;
|
|
52
|
+
/** 最近一次触摸开始时软键盘是否弹出(长按判定可能晚于键盘收起)。 */
|
|
53
|
+
let keyboardAtLastTouch = false;
|
|
54
|
+
let pendingSmall: number | null = null;
|
|
55
|
+
let holdBaseHeight = 0;
|
|
56
|
+
let holdTimer = 0;
|
|
57
|
+
let closeTimer = 0;
|
|
58
|
+
let openTimer = 0;
|
|
59
|
+
let opening = false;
|
|
60
|
+
let openingStart = 0;
|
|
61
|
+
let appliedHeight: number | null = null;
|
|
62
|
+
/** 是否用显式像素高度控制壳层(不跟随 100%)。 */
|
|
63
|
+
let explicitHeight = false;
|
|
64
|
+
|
|
65
|
+
export function isEdgeAndroidUa(userAgent: string): boolean {
|
|
66
|
+
return /Android/i.test(userAgent) && /EdgA?\//.test(userAgent);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isEdgeAndroid(): boolean {
|
|
70
|
+
return isEdgeAndroidUa(navigator.userAgent);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function usesExplicitHeight(): boolean {
|
|
74
|
+
return explicitHeight || isEdgeAndroid();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyHeight(px: number | null): void {
|
|
78
|
+
const rounded = px == null ? null : Math.round(px);
|
|
79
|
+
if (rounded === appliedHeight) return;
|
|
80
|
+
appliedHeight = rounded;
|
|
81
|
+
const style = document.documentElement.style;
|
|
82
|
+
if (rounded == null) style.removeProperty('--app-height');
|
|
83
|
+
else style.setProperty('--app-height', `${rounded}px`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 跟随视口高度:Edge 用显式像素,其他浏览器交还 100%。 */
|
|
87
|
+
function followViewport(height: number): void {
|
|
88
|
+
applyHeight(usesExplicitHeight() ? height : null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clearHold(): void {
|
|
92
|
+
if (!holdTimer) return;
|
|
93
|
+
window.clearTimeout(holdTimer);
|
|
94
|
+
holdTimer = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clearCloseTimer(): void {
|
|
98
|
+
if (!closeTimer) return;
|
|
99
|
+
window.clearTimeout(closeTimer);
|
|
100
|
+
closeTimer = 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clearOpenTimer(): void {
|
|
104
|
+
if (!openTimer) return;
|
|
105
|
+
window.clearTimeout(openTimer);
|
|
106
|
+
openTimer = 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function reset(): void {
|
|
110
|
+
clearHold();
|
|
111
|
+
clearCloseTimer();
|
|
112
|
+
clearOpenTimer();
|
|
113
|
+
opening = false;
|
|
114
|
+
openingStart = 0;
|
|
115
|
+
lastHeight = window.innerHeight;
|
|
116
|
+
lastWidth = window.innerWidth;
|
|
117
|
+
closedCandidate = lastHeight;
|
|
118
|
+
closedHeight = lastHeight;
|
|
119
|
+
keyboardOpen = false;
|
|
120
|
+
focused = false;
|
|
121
|
+
keyboardAtLastTouch = false;
|
|
122
|
+
pendingSmall = null;
|
|
123
|
+
holdBaseHeight = 0;
|
|
124
|
+
// 需要显式像素高度时(Edge Android,或已观察到工具栏延迟归位)按当前高度排版;
|
|
125
|
+
// 其余情况交还 100%,让浏览器自己跟随布局视口。
|
|
126
|
+
applyHeight(isMobileViewport() && usesExplicitHeight() ? lastHeight : null);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 按住一次小幅变化,等可能的键盘收缩到来后合并。 */
|
|
130
|
+
function holdSmall(nextHeight: number, holdAt: number): void {
|
|
131
|
+
if (pendingSmall == null) holdBaseHeight = holdAt;
|
|
132
|
+
pendingSmall = nextHeight;
|
|
133
|
+
applyHeight(holdBaseHeight);
|
|
134
|
+
clearHold();
|
|
135
|
+
holdTimer = window.setTimeout(() => {
|
|
136
|
+
holdTimer = 0;
|
|
137
|
+
if (keyboardOpen || pendingSmall == null) return;
|
|
138
|
+
closedCandidate = pendingSmall;
|
|
139
|
+
pendingSmall = null;
|
|
140
|
+
followViewport(closedCandidate);
|
|
141
|
+
}, SMALL_CHANGE_HOLD_MS);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function scheduleCloseSettle(): void {
|
|
145
|
+
clearCloseTimer();
|
|
146
|
+
closeTimer = window.setTimeout(() => {
|
|
147
|
+
closeTimer = 0;
|
|
148
|
+
if (!keyboardOpen) return;
|
|
149
|
+
// 视口停在了一个更高的位置:接受它,避免内容一直悬浮
|
|
150
|
+
closeSettled(window.innerHeight);
|
|
151
|
+
}, CLOSE_SETTLE_MS);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function closeSettled(height: number): void {
|
|
155
|
+
keyboardOpen = false;
|
|
156
|
+
focused = false;
|
|
157
|
+
closedHeight = height;
|
|
158
|
+
closedCandidate = height;
|
|
159
|
+
pendingSmall = null;
|
|
160
|
+
holdBaseHeight = 0;
|
|
161
|
+
clearHold();
|
|
162
|
+
clearCloseTimer();
|
|
163
|
+
followViewport(height);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 键盘收起途中,若布局视口已经长过“工具栏归位后的高度”,
|
|
168
|
+
* 返回应该使用的壳层高度(先按最终高度排版);否则返回 null 表示跟随视口。
|
|
169
|
+
* 纯函数,便于单测。
|
|
170
|
+
*/
|
|
171
|
+
export function clampDuringClose(height: number, closedHeight: number): number | null {
|
|
172
|
+
return height > closedHeight + 2 ? closedHeight : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 键盘展开期间,视口比壳层当前高度矮、但只矮了一点:多半是底部地址栏/
|
|
177
|
+
* 工具栏挤到了键盘上方(Edge Android 收起键盘时地址栏会先占住页面底部)。
|
|
178
|
+
*
|
|
179
|
+
* 多出来的部分被键盘盖住,壳层不应该跟着压矮:否则输入框会先从键盘顶部
|
|
180
|
+
* 上浮一点、停在那里,等键盘和地址栏都收起后再落回页面底部。超过工具栏
|
|
181
|
+
* 高度的变化视为键盘本身变高,仍然要跟随,否则输入框会被藏到键盘后面。
|
|
182
|
+
*
|
|
183
|
+
* @param height 当前布局视口高度
|
|
184
|
+
* @param shellHeight 壳层当前使用的像素高度(不跟随时为 null)
|
|
185
|
+
* @param maxShortfall 允许被工具栏挤占的最大高度
|
|
186
|
+
*/
|
|
187
|
+
export function isToolbarSqueeze(
|
|
188
|
+
height: number,
|
|
189
|
+
shellHeight: number | null,
|
|
190
|
+
maxShortfall = TOOLBAR_SQUEEZE_PX,
|
|
191
|
+
): boolean {
|
|
192
|
+
if (shellHeight == null) return false;
|
|
193
|
+
const shortfall = shellHeight - height;
|
|
194
|
+
return shortfall > 0 && shortfall <= maxShortfall;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 视觉视口是否明显矮于布局视口。
|
|
199
|
+
*
|
|
200
|
+
* Android 声明 `interactive-widget=resizes-content` 后会直接缩小布局视口,
|
|
201
|
+
* 由 `keyboardOpen` 判断即可;iOS Safari 不认该声明,`innerHeight` 不变,
|
|
202
|
+
* 只有 `visualViewport.height` 会按键盘高度收缩,这里用高度差兜底。
|
|
203
|
+
*
|
|
204
|
+
* 视觉视口会随页面缩放一起变小(双指缩放 / iOS 输入框自动放大),
|
|
205
|
+
* 所以先按 `scale` 折算“没有键盘时的预期高度”,再判断是否被键盘压矮。
|
|
206
|
+
*/
|
|
207
|
+
export function isKeyboardViewportShrunk(
|
|
208
|
+
visualHeight: number,
|
|
209
|
+
layoutHeight: number,
|
|
210
|
+
scale = 1,
|
|
211
|
+
threshold = KEYBOARD_DELTA_PX,
|
|
212
|
+
): boolean {
|
|
213
|
+
const expectedHeight = layoutHeight / Math.max(scale, 1);
|
|
214
|
+
return visualHeight <= expectedHeight - threshold;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 软键盘是否处于弹出状态。
|
|
219
|
+
*
|
|
220
|
+
* 用于抑制键盘弹出期间的长按右键菜单:此时用户仍在输入,长按多半是
|
|
221
|
+
* 想移动光标 / 滚动内容,不应弹出自定义菜单。
|
|
222
|
+
*/
|
|
223
|
+
export function isVirtualKeyboardOpen(): boolean {
|
|
224
|
+
if (keyboardOpen) return true;
|
|
225
|
+
// iOS 等只收缩 visualViewport 的浏览器:键盘会把视觉视口压矮一大截,
|
|
226
|
+
// 地址栏 / 工具栏的小幅变化和页面缩放(在 isKeyboardViewportShrunk 中折算)
|
|
227
|
+
// 都不会触发。
|
|
228
|
+
const viewport = window.visualViewport;
|
|
229
|
+
return viewport != null
|
|
230
|
+
&& isKeyboardViewportShrunk(viewport.height, window.innerHeight, viewport.scale);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 最近一次触摸开始时软键盘是否弹出。
|
|
235
|
+
*
|
|
236
|
+
* 长按菜单要等 500ms 才判定,触摸本身可能先让输入框失焦、系统随即收起键盘;
|
|
237
|
+
* 没有接入 `useTouchLongPress` 的原生 contextmenu(例如对话压缩横幅)只能靠
|
|
238
|
+
* 手势开始时的状态兜底。
|
|
239
|
+
*/
|
|
240
|
+
export function wasVirtualKeyboardOpenAtTouchStart(): boolean {
|
|
241
|
+
return keyboardAtLastTouch;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function onResize(): void {
|
|
245
|
+
if (!isMobileViewport()) {
|
|
246
|
+
reset();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const height = window.innerHeight;
|
|
251
|
+
const width = window.innerWidth;
|
|
252
|
+
if (width !== lastWidth) {
|
|
253
|
+
// 旋转屏幕 / 断点变化:旧的“关闭高度”不再适用
|
|
254
|
+
reset();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// 同一帧可能同时收到 window.resize 与 visualViewport.resize,只处理一次
|
|
258
|
+
if (height === lastHeight) return;
|
|
259
|
+
|
|
260
|
+
const previous = lastHeight;
|
|
261
|
+
const delta = height - previous;
|
|
262
|
+
lastHeight = height;
|
|
263
|
+
|
|
264
|
+
if (keyboardOpen && opening) {
|
|
265
|
+
// 键盘高度还在展开过程中变化(例如候选栏收起):继续等,不先排版
|
|
266
|
+
if (height - openingStart < KEYBOARD_DELTA_PX) return;
|
|
267
|
+
// 还没稳定就开始收起了:取消合并,交给下面的关闭逻辑
|
|
268
|
+
opening = false;
|
|
269
|
+
clearOpenTimer();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (keyboardOpen) {
|
|
273
|
+
const clamped = clampDuringClose(height, closedHeight);
|
|
274
|
+
if (clamped != null) {
|
|
275
|
+
// 观察到“关闭时超过已知高度”:后续都改用显式像素高度,避免事件晚到一帧
|
|
276
|
+
explicitHeight = true;
|
|
277
|
+
// 底部工具栏尚未归位:先按工具栏归位后的高度排版
|
|
278
|
+
applyHeight(clamped);
|
|
279
|
+
scheduleCloseSettle();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (height >= closedHeight - 2) {
|
|
283
|
+
closeSettled(height);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// 底部地址栏/工具栏先挤到键盘上方:多的部分被键盘盖住,保持壳层高度。
|
|
287
|
+
// 跟随这次变矮会让输入框先上浮、停一会儿再落回底部,正是要避免的跳动。
|
|
288
|
+
if (isToolbarSqueeze(height, appliedHeight)) return;
|
|
289
|
+
// 仍在收起动画中(视口还没长到关闭高度),跟随
|
|
290
|
+
followViewport(height);
|
|
291
|
+
if (delta > 0) scheduleCloseSettle();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (delta <= -KEYBOARD_DELTA_PX) {
|
|
296
|
+
// 键盘弹起:合并此前按住的小幅地址栏变化
|
|
297
|
+
clearHold();
|
|
298
|
+
closedHeight = pendingSmall ?? closedCandidate;
|
|
299
|
+
pendingSmall = null;
|
|
300
|
+
holdBaseHeight = 0;
|
|
301
|
+
keyboardOpen = true;
|
|
302
|
+
// 键盘高度可能还会二次变化(候选栏/工具栏收起),等一小段再一次性落位,
|
|
303
|
+
// 避免输入框先弹到较高位置、再落回键盘上方。
|
|
304
|
+
opening = true;
|
|
305
|
+
openingStart = height;
|
|
306
|
+
clearOpenTimer();
|
|
307
|
+
openTimer = window.setTimeout(() => {
|
|
308
|
+
openTimer = 0;
|
|
309
|
+
if (!opening) return;
|
|
310
|
+
opening = false;
|
|
311
|
+
followViewport(lastHeight);
|
|
312
|
+
}, OPEN_SETTLE_MS);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
closedCandidate = height;
|
|
317
|
+
if (focused && delta < 0) {
|
|
318
|
+
holdSmall(height, previous);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
clearHold();
|
|
323
|
+
pendingSmall = null;
|
|
324
|
+
holdBaseHeight = 0;
|
|
325
|
+
followViewport(height);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function initMobileViewport(): void {
|
|
329
|
+
if (started) return;
|
|
330
|
+
started = true;
|
|
331
|
+
reset();
|
|
332
|
+
window.addEventListener('resize', onResize);
|
|
333
|
+
// visualViewport 的 resize 有时比 window.resize 更早到达,先设好 --app-height
|
|
334
|
+
window.visualViewport?.addEventListener('resize', onResize);
|
|
335
|
+
// 触摸刚开始时记下键盘状态:后续长按期间键盘可能被系统收起。
|
|
336
|
+
// 用 pointerdown 而不是 touchstart:它在焦点默认行为之前触发,能可靠地
|
|
337
|
+
// 读到“点击空白处之前”的键盘状态。
|
|
338
|
+
document.addEventListener(
|
|
339
|
+
'pointerdown',
|
|
340
|
+
(event) => {
|
|
341
|
+
if (event.pointerType === 'touch') keyboardAtLastTouch = isVirtualKeyboardOpen();
|
|
342
|
+
},
|
|
343
|
+
{ capture: true, passive: true },
|
|
344
|
+
);
|
|
345
|
+
document.addEventListener(
|
|
346
|
+
'focusin',
|
|
347
|
+
(event) => {
|
|
348
|
+
if (isEditableTarget(event.target)) focused = true;
|
|
349
|
+
},
|
|
350
|
+
true,
|
|
351
|
+
);
|
|
352
|
+
document.addEventListener(
|
|
353
|
+
'focusout',
|
|
354
|
+
(event) => {
|
|
355
|
+
if (isEditableTarget(event.target)) focused = false;
|
|
356
|
+
},
|
|
357
|
+
true,
|
|
358
|
+
);
|
|
359
|
+
window.matchMedia(MOBILE_MEDIA_QUERY).addEventListener?.('change', () => {
|
|
360
|
+
if (!isMobileViewport()) reset();
|
|
361
|
+
});
|
|
362
|
+
}
|
package/src/core/openai.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ChatCompletionRequest,
|
|
3
3
|
ChatStreamChunk,
|
|
4
4
|
NetworkMessage,
|
|
5
|
+
ReasoningEffort,
|
|
5
6
|
} from '../types/models';
|
|
6
7
|
import { fallbackToolCallId } from './turnLoop';
|
|
7
8
|
import { isNetworkLikeError, toProxyUrl } from './proxy';
|
|
@@ -387,6 +388,14 @@ export async function summarizeContext(
|
|
|
387
388
|
}
|
|
388
389
|
throw new Error('Context summary request failed');
|
|
389
390
|
}
|
|
391
|
+
|
|
392
|
+
/** App 的思考强度 → DeepSeek OpenAI 兼容接口允许的 reasoning_effort(none/low/high/max)。 */
|
|
393
|
+
const DEEPSEEK_REASONING_EFFORT: Partial<Record<ReasoningEffort, string>> = {
|
|
394
|
+
low: 'low',
|
|
395
|
+
medium: 'high',
|
|
396
|
+
xhigh: 'max',
|
|
397
|
+
};
|
|
398
|
+
|
|
390
399
|
export function buildChatRequest(
|
|
391
400
|
p: {
|
|
392
401
|
model: string;
|
|
@@ -399,7 +408,7 @@ export function buildChatRequest(
|
|
|
399
408
|
frequencyPenalty: number;
|
|
400
409
|
presencePenalty: number;
|
|
401
410
|
repetitionPenalty: number;
|
|
402
|
-
reasoningEffort:
|
|
411
|
+
reasoningEffort: ReasoningEffort;
|
|
403
412
|
isThinkingModeEnabled: boolean;
|
|
404
413
|
streaming: boolean;
|
|
405
414
|
baseUrl: string;
|
|
@@ -427,19 +436,42 @@ request.frequency_penalty = Math.round(p.frequencyPenalty * 100) / 100;
|
|
|
427
436
|
|
|
428
437
|
// 思考模式参数(与 App 逻辑一致)
|
|
429
438
|
const effort = p.reasoningEffort;
|
|
430
|
-
if (effort !== 'auto' && effort !== 'off') {
|
|
431
|
-
request.reasoning_effort = effort;
|
|
432
|
-
}
|
|
433
439
|
const thinkingEnabled = p.isThinkingModeEnabled && effort !== 'off';
|
|
434
440
|
const modelKey = p.model.toLowerCase();
|
|
435
441
|
const urlKey = p.baseUrl.toLowerCase();
|
|
442
|
+
// DeepSeek 官方接口(含经本地代理转发的官方地址)使用新的 thinking/reasoning_effort 字段;
|
|
443
|
+
// 其他 OpenAI 兼容供应商仍沿用 enable_thinking,避免影响 SiliconFlow 等托管的 DeepSeek。
|
|
444
|
+
const isDeepSeek = /deepseek\.com/.test(urlKey)
|
|
445
|
+
|| /^deepseek-(?:flash|v4-pro)(?:$|[-/:])/.test(modelKey);
|
|
436
446
|
const supportsThinking = /deepseek|qwen|qwq|r1|siliconflow|dashscope|ollama|vllm/.test(modelKey + ' ' + urlKey);
|
|
437
|
-
if (
|
|
438
|
-
request.
|
|
447
|
+
if (isDeepSeek) {
|
|
448
|
+
request.thinking = { type: thinkingEnabled ? 'enabled' : 'disabled' };
|
|
449
|
+
if (thinkingEnabled) {
|
|
450
|
+
// auto 不传,使用服务端默认值 high;off 已由 thinking.type=disabled 表达。
|
|
451
|
+
const mappedEffort = DEEPSEEK_REASONING_EFFORT[effort];
|
|
452
|
+
if (mappedEffort) request.reasoning_effort = mappedEffort;
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
if (effort !== 'auto' && effort !== 'off') {
|
|
456
|
+
request.reasoning_effort = effort;
|
|
457
|
+
}
|
|
458
|
+
if (supportsThinking && !/qwen/.test(modelKey)) {
|
|
459
|
+
request.enable_thinking = thinkingEnabled;
|
|
460
|
+
}
|
|
439
461
|
}
|
|
440
|
-
if (/qwen/.test(modelKey)) {
|
|
462
|
+
if (!isDeepSeek && /qwen/.test(modelKey)) {
|
|
441
463
|
request.chat_template_kwargs = { enable_thinking: thinkingEnabled, preserve_thinking: true };
|
|
442
464
|
}
|
|
465
|
+
// DeepSeek 思考模式要求所有回传的 assistant 消息携带 reasoning_content(含
|
|
466
|
+
// 历史工具调用与知识库注入的合成工具调用);没有原始思考内容时补空字符串。
|
|
467
|
+
// 参考:https://api-docs.deepseek.com/guides/thinking_mode
|
|
468
|
+
if (thinkingEnabled && isDeepSeek) {
|
|
469
|
+
request.messages = request.messages.map((message) =>
|
|
470
|
+
message.role === 'assistant' && message.reasoning_content === undefined
|
|
471
|
+
? { ...message, reasoning_content: '' }
|
|
472
|
+
: message
|
|
473
|
+
);
|
|
474
|
+
}
|
|
443
475
|
|
|
444
476
|
if (p.tools && p.tools.length > 0) {
|
|
445
477
|
request.tools = p.tools;
|