dsh-pocket 2.5.0 → 2.6.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/client/client.js
CHANGED
|
@@ -1214,6 +1214,51 @@ var MOBILE_CSS = `
|
|
|
1214
1214
|
[data-mobile-nav="drawer-actions"] {
|
|
1215
1215
|
display: none !important;
|
|
1216
1216
|
}
|
|
1217
|
+
/* \u684C\u9762\u7AEF\u6C38\u8FDC\u4E0D\u9700\u8981\u300C\u26F6 \u653E\u5927\u8F93\u5165\u300D\u6309\u94AE\uFF08composer \u5DF2\u7ECF\u6709\u5B8C\u6574\u5DE5\u5177\u884C\uFF09 */
|
|
1218
|
+
[data-mobile-nav="composer-fullscreen"] {
|
|
1219
|
+
display: none !important;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/* ---------- \u653E\u5927\u8F93\u5165\uFF08issue #23\uFF09 ----------
|
|
1224
|
+
\u7A84\u5C4F\u9ED8\u8BA4\u9690\u85CF\u63D2\u4EF6\u6CE8\u518C\u5230 conversation.input.* slot \u7684 UI\uFF08\u4EC5\u7559\u5B98\u65B9 resident
|
|
1225
|
+
chrome\uFF09\uFF0C\u70B9\u300C\u26F6\u300D\u6309\u94AE\u628A composer \u5361\u7247\u56FA\u5B9A\u5230\u5168\u5C4F\u540E\u6240\u6709\u63D2\u4EF6 UI \u6062\u590D\u663E\u793A\u2014\u2014
|
|
1226
|
+
\u65B0\u63D2\u4EF6\u96F6\u9002\u914D\u3002
|
|
1227
|
+
\u5B9E\u73B0\u8BF4\u660E\uFF1A\u5F53\u524D dsh 0.1.1 \u6CA1\u628A input slot \u6E32\u67D3\u6210 [data-slot] \u5305\u88C5\uFF080.1.2+ \u624D\u6709\uFF09\uFF0C
|
|
1228
|
+
\u6240\u4EE5\u8FD9\u91CC\u7528\u300C\u767D\u540D\u5355\u300D\u53CD\u9009\uFF1AmobileApply \u6CE8\u518C\u7684 mobile \u81EA\u8EAB\u8282\u70B9\u52A0
|
|
1229
|
+
data-mobile-nav="composer-fullscreen" \u4E0D\u88AB\u9690\u85CF\uFF1B\u5176\u5B83\u63D2\u4EF6 UI \u9690\u85CF\u3002 */
|
|
1230
|
+
@media (max-width: 1023px) {
|
|
1231
|
+
/* \u9ED8\u8BA4\uFF08\u975E\u5168\u5C4F\uFF09\uFF1A\u9690\u85CF input slot \u5BB9\u5668\u4E0B\u7684\u6240\u6709\u76F4\u63A5\u5B50\u5143\u7D20\uFF08\u5305\u542B\u63D2\u4EF6\u548C\u5B98\u65B9\uFF09
|
|
1232
|
+
\u2014\u2014\u5B98\u65B9\u81EA\u5DF1\u5982\u679C\u60F3\u5728\u7A84\u5C4F\u4E5F\u9732\u51FA\uFF0C\u6CE8 input slot \u65F6\u52A0 data-mobile-nav-keep="1" */
|
|
1233
|
+
[data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
|
|
1234
|
+
[class$="_card"]:has(textarea) [data-slot^="conversation.input."] > :not([data-mobile-nav-keep]),
|
|
1235
|
+
[data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
|
|
1236
|
+
[class$="_card"]:has(textarea) [data-slot^="conversation.input."]:empty {
|
|
1237
|
+
display: none !important;
|
|
1238
|
+
}
|
|
1239
|
+
/* \u653E\u5927\u6309\u94AE\uFF1A\u5E38\u9A7B\u5728 conversation.input.right slot \u65C1\uFF0C\u684C\u9762\u7AEF\u5DF2\u88AB\u4E0A\u9762\u89C4\u5219\u9690\u85CF */
|
|
1240
|
+
[data-mobile-nav="composer-fullscreen"] {
|
|
1241
|
+
display: inline-flex;
|
|
1242
|
+
}
|
|
1243
|
+
/* \u5168\u5C4F\u6A21\u5F0F\uFF1Acomposer \u5361\u7247\u56FA\u5B9A\u5230\u89C6\u53E3\uFF0Ctextarea \u62C9\u9AD8\uFF0C\u5DE5\u5177\u884C\u53EF\u6362\u884C */
|
|
1244
|
+
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) {
|
|
1245
|
+
position: fixed !important;
|
|
1246
|
+
inset: 0 !important;
|
|
1247
|
+
z-index: 9999 !important;
|
|
1248
|
+
border-radius: 0 !important;
|
|
1249
|
+
margin: 0 !important;
|
|
1250
|
+
height: 100dvh !important;
|
|
1251
|
+
display: flex !important;
|
|
1252
|
+
flex-direction: column !important;
|
|
1253
|
+
}
|
|
1254
|
+
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) textarea {
|
|
1255
|
+
flex: 1 1 auto !important;
|
|
1256
|
+
min-height: 50dvh !important;
|
|
1257
|
+
max-height: none !important;
|
|
1258
|
+
}
|
|
1259
|
+
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) [data-slot^="conversation.input."] {
|
|
1260
|
+
flex-wrap: wrap !important;
|
|
1261
|
+
}
|
|
1217
1262
|
}
|
|
1218
1263
|
`;
|
|
1219
1264
|
|
|
@@ -1439,6 +1484,12 @@ function mobileApply(ctx) {
|
|
|
1439
1484
|
toggleSidebar: () => ctx.layout.toggleSidebar()
|
|
1440
1485
|
})
|
|
1441
1486
|
}, MobileNavToggle));
|
|
1487
|
+
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
|
|
1488
|
+
name: "conversation.input.right",
|
|
1489
|
+
id: "mobile-composer-fullscreen",
|
|
1490
|
+
order: 100,
|
|
1491
|
+
locale: NS
|
|
1492
|
+
}, MobileComposerFullscreen));
|
|
1442
1493
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
1443
1494
|
name: "shell.overlay",
|
|
1444
1495
|
id: "mobile-nav-overlay",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// 手机端「⛶ 放大输入」开关(issue #23)
|
|
2
|
+
//
|
|
3
|
+
// 行为:
|
|
4
|
+
// - 点击切换 body 属性 `data-dsh-pocket-composer-fullscreen`;
|
|
5
|
+
// - mobile.css.ts 监听这个属性,把 composer 卡片固定到全屏(textarea 拉高、工具行
|
|
6
|
+
// 全部展开),插件 UI 注册到 conversation.input.* slot 的按钮全部恢复显示;
|
|
7
|
+
// - 默认(无 fullscreen 属性)下,CSS 把这些 slot 全部隐藏,只保留官方 resident
|
|
8
|
+
// chrome(权限/plan/模型/发送)。新插件只要注册到 input slot 就自动遵守此规则,
|
|
9
|
+
// 无需逐个适配。
|
|
10
|
+
//
|
|
11
|
+
// 实现细节:
|
|
12
|
+
// - 不依赖 dsh web 把 data-slot 包装暴露给选择器(0.1.1 没,0.1.2+ 才有)。我们用
|
|
13
|
+
// `:scope > :not([data-mobile-nav-keep])` 这样的「保留」标记:所有插件如果
|
|
14
|
+
// 想在手机未放大时也露出,加 data-mobile-nav-keep="1" 即可。默认隐藏的是
|
|
15
|
+
// slot 容器下的所有直接子元素,**包含 dsh web 官方自己注入的**(这些是
|
|
16
|
+
// 我们想隐藏的"插件 UI"——只要不冲突)。所以需要 dsh web 在官方自己的 UI 上
|
|
17
|
+
// 加 `data-mobile-nav-keep` 标记——等 dsh 0.1.2+ data-slot 上线后改用那个
|
|
18
|
+
// 更优雅(CSS 选择器变成 `[data-slot=...] > :not([data-mobile-nav-keep])`)。
|
|
19
|
+
import { createElement as h, useEffect, useState } from 'react';
|
|
20
|
+
|
|
21
|
+
const FULLSCREEN_ATTR = 'data-dsh-pocket-composer-fullscreen';
|
|
22
|
+
|
|
23
|
+
function isFullscreen() {
|
|
24
|
+
return typeof document !== 'undefined' && document.body?.getAttribute(FULLSCREEN_ATTR) === '1';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setFullscreen(on) {
|
|
28
|
+
if (typeof document === 'undefined') return;
|
|
29
|
+
if (on) document.body.setAttribute(FULLSCREEN_ATTR, '1');
|
|
30
|
+
else document.body.removeAttribute(FULLSCREEN_ATTR);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 放大输入按钮:注册到 `conversation.input.right`,id `mobile-composer-fullscreen`。
|
|
35
|
+
* 仅在窄屏(max-width:1023px)显示,桌面端由 CSS 隐藏。
|
|
36
|
+
*/
|
|
37
|
+
export function MobileComposerFullscreen() {
|
|
38
|
+
const [on, setOn] = useState(isFullscreen);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const update = () => setOn(isFullscreen());
|
|
41
|
+
// 与其它 effect 协调:监听任何对 body 属性的修改即可(设置项自己改也算)
|
|
42
|
+
const observer = new MutationObserver(update);
|
|
43
|
+
observer.observe(document.body, { attributes: true, attributeFilter: [FULLSCREEN_ATTR] });
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
}, []);
|
|
46
|
+
return h('button', {
|
|
47
|
+
type: 'button',
|
|
48
|
+
'aria-label': on ? '收起输入' : '放大输入',
|
|
49
|
+
'aria-pressed': on,
|
|
50
|
+
title: on ? '收起输入' : '放大输入',
|
|
51
|
+
'data-mobile-nav': 'composer-fullscreen',
|
|
52
|
+
'data-mobile-nav-keep': '1', // 自己的按钮不该被 #23 的 slot 通用隐藏规则误伤
|
|
53
|
+
onClick: () => {
|
|
54
|
+
const next = !isFullscreen();
|
|
55
|
+
setFullscreen(next);
|
|
56
|
+
setOn(next);
|
|
57
|
+
},
|
|
58
|
+
style: {
|
|
59
|
+
appearance: 'none',
|
|
60
|
+
WebkitAppearance: 'none',
|
|
61
|
+
border: 'none',
|
|
62
|
+
background: 'transparent',
|
|
63
|
+
color: 'inherit',
|
|
64
|
+
cursor: 'pointer',
|
|
65
|
+
padding: '0 8px',
|
|
66
|
+
height: 32,
|
|
67
|
+
minWidth: 32,
|
|
68
|
+
borderRadius: 6,
|
|
69
|
+
display: 'inline-flex',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
justifyContent: 'center',
|
|
72
|
+
font: 'inherit',
|
|
73
|
+
fontSize: 16,
|
|
74
|
+
lineHeight: 1,
|
|
75
|
+
},
|
|
76
|
+
}, on ? '⤡' : '⤢');
|
|
77
|
+
}
|
|
@@ -272,6 +272,16 @@ export function mobileApply(ctx): void {
|
|
|
272
272
|
}),
|
|
273
273
|
}, MobileNavToggle))
|
|
274
274
|
|
|
275
|
+
// 「⛶ 放大输入」按钮(issue #23):注册到 conversation.input.right(发送键旁)
|
|
276
|
+
// ——桌面端由 mobile.css.ts 隐藏(min-width:1024px)。点按切换 body 上的
|
|
277
|
+
// data-dsh-pocket-composer-fullscreen 标记,由 CSS 把 composer 卡片全屏化。
|
|
278
|
+
ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
|
279
|
+
name: 'conversation.input.right',
|
|
280
|
+
id: 'mobile-composer-fullscreen',
|
|
281
|
+
order: 100,
|
|
282
|
+
locale: NS,
|
|
283
|
+
}, MobileComposerFullscreen))
|
|
284
|
+
|
|
275
285
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
276
286
|
name: 'shell.overlay',
|
|
277
287
|
id: 'mobile-nav-overlay',
|
|
@@ -886,5 +886,50 @@ export const MOBILE_CSS = `
|
|
|
886
886
|
[data-mobile-nav="drawer-actions"] {
|
|
887
887
|
display: none !important;
|
|
888
888
|
}
|
|
889
|
+
/* 桌面端永远不需要「⛶ 放大输入」按钮(composer 已经有完整工具行) */
|
|
890
|
+
[data-mobile-nav="composer-fullscreen"] {
|
|
891
|
+
display: none !important;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/* ---------- 放大输入(issue #23) ----------
|
|
896
|
+
窄屏默认隐藏插件注册到 conversation.input.* slot 的 UI(仅留官方 resident
|
|
897
|
+
chrome),点「⛶」按钮把 composer 卡片固定到全屏后所有插件 UI 恢复显示——
|
|
898
|
+
新插件零适配。
|
|
899
|
+
实现说明:当前 dsh 0.1.1 没把 input slot 渲染成 [data-slot] 包装(0.1.2+ 才有),
|
|
900
|
+
所以这里用「白名单」反选:mobileApply 注册的 mobile 自身节点加
|
|
901
|
+
data-mobile-nav="composer-fullscreen" 不被隐藏;其它插件 UI 隐藏。 */
|
|
902
|
+
@media (max-width: 1023px) {
|
|
903
|
+
/* 默认(非全屏):隐藏 input slot 容器下的所有直接子元素(包含插件和官方)
|
|
904
|
+
——官方自己如果想在窄屏也露出,注 input slot 时加 data-mobile-nav-keep="1" */
|
|
905
|
+
[data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
|
|
906
|
+
[class$="_card"]:has(textarea) [data-slot^="conversation.input."] > :not([data-mobile-nav-keep]),
|
|
907
|
+
[data-dsh-pocket-composer-fullscreen]:not([data-dsh-pocket-composer-fullscreen="1"])
|
|
908
|
+
[class$="_card"]:has(textarea) [data-slot^="conversation.input."]:empty {
|
|
909
|
+
display: none !important;
|
|
910
|
+
}
|
|
911
|
+
/* 放大按钮:常驻在 conversation.input.right slot 旁,桌面端已被上面规则隐藏 */
|
|
912
|
+
[data-mobile-nav="composer-fullscreen"] {
|
|
913
|
+
display: inline-flex;
|
|
914
|
+
}
|
|
915
|
+
/* 全屏模式:composer 卡片固定到视口,textarea 拉高,工具行可换行 */
|
|
916
|
+
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) {
|
|
917
|
+
position: fixed !important;
|
|
918
|
+
inset: 0 !important;
|
|
919
|
+
z-index: 9999 !important;
|
|
920
|
+
border-radius: 0 !important;
|
|
921
|
+
margin: 0 !important;
|
|
922
|
+
height: 100dvh !important;
|
|
923
|
+
display: flex !important;
|
|
924
|
+
flex-direction: column !important;
|
|
925
|
+
}
|
|
926
|
+
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) textarea {
|
|
927
|
+
flex: 1 1 auto !important;
|
|
928
|
+
min-height: 50dvh !important;
|
|
929
|
+
max-height: none !important;
|
|
930
|
+
}
|
|
931
|
+
[data-dsh-pocket-composer-fullscreen="1"] [class$="_card"]:has(textarea) [data-slot^="conversation.input."] {
|
|
932
|
+
flex-wrap: wrap !important;
|
|
933
|
+
}
|
|
889
934
|
}
|
|
890
935
|
`
|
package/lib/proxy.mjs
CHANGED
|
@@ -268,24 +268,51 @@ function isHtmlRequest(req) {
|
|
|
268
268
|
return accept.includes('text/html') || req.url === '/' || /\.html?$/i.test(String(req.url));
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
/**
|
|
271
|
+
/** 校验请求是否已认证。返回 { ok, rawQueryToken }:
|
|
272
|
+
* - ok=true:已认证;rawQueryToken 是 URL `?token=<PIN>` 命中的那条原始密码(用于种 cookie);
|
|
273
|
+
* - 已有 cookie 命中 → rawQueryToken=null(不要重复种)。 */
|
|
272
274
|
function authCheck(req, tokens, sessionKey) {
|
|
273
275
|
const list = (Array.isArray(tokens) ? tokens : tokens ? [tokens] : []).filter(Boolean);
|
|
274
|
-
if (list.length === 0) return true
|
|
276
|
+
if (list.length === 0) return { ok: true, rawQueryToken: null };
|
|
275
277
|
const cookies = parseCookies(req.headers.cookie);
|
|
276
278
|
const cookieTok = cookies[TOKEN_COOKIE];
|
|
277
279
|
if (cookieTok) {
|
|
278
280
|
for (const token of list) {
|
|
279
|
-
if (cookieTok === cookieFor(token, sessionKey)) return true;
|
|
281
|
+
if (cookieTok === cookieFor(token, sessionKey)) return { ok: true, rawQueryToken: null };
|
|
280
282
|
}
|
|
281
283
|
}
|
|
282
284
|
const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
|
|
283
285
|
if (qTok) {
|
|
286
|
+
// URL `?token=<原始 PIN>` 用于分享/扫码直达:用户拿到 URL 就能直输明文 PIN,
|
|
287
|
+
// 不需要把 sha256 哈希也告诉他们。匹配上后由调用方通过 maybeSeedAuthCookie
|
|
288
|
+
// 种 HttpOnly 哈希 cookie,让浏览器后续子资源(assets/*.js 等)也走 cookie
|
|
289
|
+
// 路径——避免「主页 200 但子资源 401」白屏(issue #35)。
|
|
284
290
|
for (const token of list) {
|
|
285
|
-
if (qTok ===
|
|
291
|
+
if (qTok === token) return { ok: true, rawQueryToken: qTok };
|
|
286
292
|
}
|
|
287
293
|
}
|
|
288
|
-
return false;
|
|
294
|
+
return { ok: false, rawQueryToken: null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 当请求通过 `?token=<原始 PIN>` 进入且尚无 cookie 时,在响应里种 HttpOnly cookie。
|
|
298
|
+
* monkey-patch res.writeHead 把 set-cookie 头注入到第一个响应里——同 POST /pocket-login
|
|
299
|
+
* 路径(lib/proxy.mjs 显式 res.writeHead 加 set-cookie)效果一致。 */
|
|
300
|
+
function maybeSeedAuthCookie(req, res, rawToken, sessionKey) {
|
|
301
|
+
if (!rawToken || !sessionKey) return;
|
|
302
|
+
if (parseCookies(req.headers.cookie)[TOKEN_COOKIE]) return; // 已有 cookie 就不重复种
|
|
303
|
+
const expected = cookieFor(rawToken, sessionKey);
|
|
304
|
+
if (!expected) return;
|
|
305
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
306
|
+
res.writeHead = function (statusCode, headers) {
|
|
307
|
+
const h = { ...(headers ?? {}) };
|
|
308
|
+
const cookie = `${TOKEN_COOKIE}=${expected}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`;
|
|
309
|
+
// 已有 set-cookie 头(数组/字符串都支持)则追加
|
|
310
|
+
const prev = h['set-cookie'];
|
|
311
|
+
if (Array.isArray(prev)) h['set-cookie'] = [...prev, cookie];
|
|
312
|
+
else if (typeof prev === 'string') h['set-cookie'] = [prev, cookie];
|
|
313
|
+
else h['set-cookie'] = cookie;
|
|
314
|
+
return origWriteHead(statusCode, h);
|
|
315
|
+
};
|
|
289
316
|
}
|
|
290
317
|
|
|
291
318
|
/** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
|
|
@@ -483,7 +510,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
483
510
|
});
|
|
484
511
|
return;
|
|
485
512
|
}
|
|
486
|
-
|
|
513
|
+
const authResult = authCheck(req, acceptedTokens, sessionKey);
|
|
514
|
+
if (!authResult.ok) {
|
|
487
515
|
if (isHtmlRequest(req)) {
|
|
488
516
|
// 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
|
|
489
517
|
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
|
|
@@ -495,6 +523,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
495
523
|
}
|
|
496
524
|
return;
|
|
497
525
|
}
|
|
526
|
+
// ?token=<原始 PIN> 直达时种 HttpOnly cookie,让浏览器后续子资源也走 cookie 路径(issue #35)
|
|
527
|
+
if (authResult.rawQueryToken) maybeSeedAuthCookie(req, res, authResult.rawQueryToken, sessionKey);
|
|
498
528
|
}
|
|
499
529
|
}
|
|
500
530
|
const headers = loopbackAuthority({ ...req.headers }, upstream);
|
|
@@ -599,7 +629,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
599
629
|
const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
|
|
600
630
|
const altTokens = token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
|
|
601
631
|
const acceptedTokens = token ? [token, ...altTokens] : [];
|
|
602
|
-
|
|
632
|
+
const wsAuth = authCheck(req, acceptedTokens, auth.sessionKey ?? null);
|
|
633
|
+
if (token && !wsAuth.ok) {
|
|
603
634
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
604
635
|
socket.destroy();
|
|
605
636
|
return;
|
package/package.json
CHANGED