dsh-workspace-plus 0.6.0 → 0.7.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 +107 -572
- package/cordis.patch.yml +33 -1
- package/lib/client.js +448 -11
- package/lib/index.js +63 -6
- package/lib/notice/click-host.cs +515 -0
- package/lib/notice/index.js +114 -20
- package/lib/notice/launcher.js +455 -0
- package/lib/notice/win32.js +26 -2
- package/lib/sessionlog/icon.svg +19 -0
- package/lib/sessionlog/index.js +636 -0
- package/lib/sessionlog/locale/en.json +6 -0
- package/lib/sessionlog/locale/zh.json +6 -0
- package/lib/sessionlog/package.json +6 -0
- package/lib/sessionlog/redact.js +142 -0
- package/lib/sessionlog/settings.js +436 -0
- package/lib/sessionlog/wire.js +235 -0
- package/package.json +11 -3
package/cordis.patch.yml
CHANGED
|
@@ -25,8 +25,18 @@
|
|
|
25
25
|
# poll ride in the shared browser half, which serves them
|
|
26
26
|
# regardless and degrades silently when this row is off.
|
|
27
27
|
#
|
|
28
|
+
# sessionlog 会话日志上传 (exports "./sessionlog" ->
|
|
29
|
+
# lib/sessionlog/index.js). Node half only, and OFF by
|
|
30
|
+
# default. DSH's own "在使用官方模型 API 时上传 Session Log"
|
|
31
|
+
# only ever fires while the official DeepSeek adapter performs
|
|
32
|
+
# the request; this row reproduces it and adds a
|
|
33
|
+
# provider-independent path, so a user on a third-party model
|
|
34
|
+
# API can still hand the session log to DeepSeek. Its settings
|
|
35
|
+
# page rides in the shared browser half on the same terms as
|
|
36
|
+
# the notice page.
|
|
37
|
+
#
|
|
28
38
|
# Turning one row off removes exactly that half from the host while the bundle
|
|
29
|
-
# and the other sub-
|
|
39
|
+
# and the other sub-features keep working. The 通知 settings page also carries a
|
|
30
40
|
# runtime "启用桌面通知" switch, so notifications can be silenced without
|
|
31
41
|
# touching the row at all.
|
|
32
42
|
- insert:
|
|
@@ -51,3 +61,25 @@
|
|
|
51
61
|
# done: true # 任务完成
|
|
52
62
|
# aborted: true # 任务中断
|
|
53
63
|
# error: true # 任务出错
|
|
64
|
+
- id: sessionlog
|
|
65
|
+
name: 'dsh-workspace-plus/sessionlog'
|
|
66
|
+
# config: # deployment defaults for the session-log
|
|
67
|
+
# enabled: false # sub-feature. Ships OFF on purpose: this
|
|
68
|
+
# # hands conversation content to a remote
|
|
69
|
+
# # collector, so it must be an explicit act.
|
|
70
|
+
# # The 会话日志 settings page can still turn
|
|
71
|
+
# # it on per host.
|
|
72
|
+
# endpoint: '' # '' = $DSH_TELEMETRY_OTLP_URL, else the
|
|
73
|
+
# # DeepSeek collector the base profile uses
|
|
74
|
+
# requestField: false # also contribute the dsh_session_log request
|
|
75
|
+
# # field when nothing else owns it. OFF by
|
|
76
|
+
# # default: DSH performs no official-endpoint
|
|
77
|
+
# # check before attaching that field, so on a
|
|
78
|
+
# # host whose deepseek-official route points at
|
|
79
|
+
# # a relay it would send content to that relay.
|
|
80
|
+
# # Skipped anyway when the shipped
|
|
81
|
+
# # dsh-session-log-deepseek owns the field.
|
|
82
|
+
# includeHistory: true # when switched on mid-run, also send the
|
|
83
|
+
# # sessions that are already in the log
|
|
84
|
+
# maxRequestBytes: 4000000 # per-request ceiling for one upload
|
|
85
|
+
# shutdownTimeoutMillis: 3000 # drain deadline when the row unloads
|
package/lib/client.js
CHANGED
|
@@ -71,6 +71,18 @@
|
|
|
71
71
|
* click → `dsh-notice:` protocol → host route → pending
|
|
72
72
|
* intent → THIS half → `ctx.uiWorkspace.openSession()`.
|
|
73
73
|
*
|
|
74
|
+
* The 会话日志 (sessionlog) sub-feature — the plugin's own version of DSH's
|
|
75
|
+
* "在使用官方模型 API 时上传 Session Log" preference, shipped as the
|
|
76
|
+
* `dsh-workspace-plus/sessionlog` loader row and OFF by default — adds one more
|
|
77
|
+
* seat here, described in detail at its section below:
|
|
78
|
+
*
|
|
79
|
+
* `settings.section` the 会话日志 settings page, writing through
|
|
80
|
+
* `/dsh-sessionlog/api`. The host half owns the upload
|
|
81
|
+
* channel; this page only renders what the host reports
|
|
82
|
+
* and posts one switch or endpoint change at a time. When
|
|
83
|
+
* the sessionlog row is off the host answers 404 and the
|
|
84
|
+
* page explains that state instead of erroring.
|
|
85
|
+
*
|
|
74
86
|
* Only the shell's frozen platform module table may be `require`d here (react,
|
|
75
87
|
* react-dom, react/jsx-runtime, @deepseek-ai/cordis,
|
|
76
88
|
* @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
|
|
@@ -388,8 +400,36 @@ window.__ModuleLoader__.load({
|
|
|
388
400
|
} AUMID 发言:${toast.appId ?? '(未知)'}`
|
|
389
401
|
})()
|
|
390
402
|
|
|
403
|
+
// How a click is actually delivered, as the host reports it after
|
|
404
|
+
// registering the protocol. The host prefers a compiled windowless
|
|
405
|
+
// helper and falls back to windowless PowerShell, then to plain
|
|
406
|
+
// PowerShell, so the status line names the mode instead of promising the
|
|
407
|
+
// best case. The helper restores and focuses the window itself, but only
|
|
408
|
+
// when `focusOnClick` is on — with the switch off the host registers the
|
|
409
|
+
// same helper without that step — so the sentence must not claim a
|
|
410
|
+
// foreground window in that state. A host that predates `launcher` has no
|
|
411
|
+
// value here, and then this returns '' so the sentence reads exactly as
|
|
412
|
+
// it did before.
|
|
413
|
+
const launcherNote = (() => {
|
|
414
|
+
const focus = settings.focusOnClick === true
|
|
415
|
+
if (protocol.launcher === 'helper') {
|
|
416
|
+
return focus ? '点击助手会在无窗口的情况下把 Harness 切到前台' : '由无窗口的点击助手处理,不改变窗口的前后顺序'
|
|
417
|
+
}
|
|
418
|
+
if (protocol.launcher === 'windowless-powershell') {
|
|
419
|
+
return focus
|
|
420
|
+
? '使用无窗口 PowerShell 助手,由 Harness 负责切到前台;首次点击可能稍慢'
|
|
421
|
+
: '使用无窗口 PowerShell 助手;首次点击可能稍慢'
|
|
422
|
+
}
|
|
423
|
+
if (protocol.launcher === 'powershell') return '兼容模式:由 PowerShell 启动,可能有窗口闪烁'
|
|
424
|
+
return ''
|
|
425
|
+
})()
|
|
426
|
+
|
|
391
427
|
const protocolLine = (() => {
|
|
392
|
-
if (protocol.registered === true)
|
|
428
|
+
if (protocol.registered === true) {
|
|
429
|
+
return launcherNote
|
|
430
|
+
? `点击通知可打开对应对话(已注册 ${protocol.scheme}:,${launcherNote})。`
|
|
431
|
+
: `点击通知可打开对应对话(已注册 ${protocol.scheme}:)。`
|
|
432
|
+
}
|
|
393
433
|
if (settings.registerProtocol === false) return '已关闭协议注册:点击通知只会把 Harness 窗口切到前台。'
|
|
394
434
|
return `尚未注册 ${protocol.scheme ?? 'dsh-notice'}:,点击通知只会把 Harness 窗口切到前台。`
|
|
395
435
|
})()
|
|
@@ -434,7 +474,7 @@ window.__ModuleLoader__.load({
|
|
|
434
474
|
}),
|
|
435
475
|
React.createElement(NoticeSwitchRow, {
|
|
436
476
|
title: '点击时把 Harness 窗口切到前台',
|
|
437
|
-
description: '
|
|
477
|
+
description: '开启后点击通知会尽量把窗口从最小化、被遮挡或收进托盘的状态恢复并切到前台;关闭则只切换对话,不改变窗口的前后顺序。',
|
|
438
478
|
checked: settings.focusOnClick === true,
|
|
439
479
|
disabled,
|
|
440
480
|
onChange: (next) => void send({ op: 'patch', patch: { focusOnClick: next } }),
|
|
@@ -827,6 +867,357 @@ window.__ModuleLoader__.load({
|
|
|
827
867
|
document.head.appendChild(tag)
|
|
828
868
|
}
|
|
829
869
|
|
|
870
|
+
// -----------------------------------------------------------------------
|
|
871
|
+
// The 会话日志 (sessionlog) sub-feature — client side
|
|
872
|
+
//
|
|
873
|
+
// DSH's own "在使用官方模型 API 时上传 Session Log" switch lives in
|
|
874
|
+
// Settings → General and only ever fires while the official DeepSeek adapter
|
|
875
|
+
// performs the request. The `dsh-workspace-plus/sessionlog` loader row
|
|
876
|
+
// (lib/sessionlog/index.js) reproduces that behaviour and adds a
|
|
877
|
+
// provider-independent OTLP channel, so the switch means the same thing for
|
|
878
|
+
// a user on a third-party model API. It ships OFF: handing conversation
|
|
879
|
+
// content to a remote collector has to be an explicit act.
|
|
880
|
+
//
|
|
881
|
+
// This page is deliberately a *report*, not a second source of truth: the
|
|
882
|
+
// host owns enablement, the endpoint, the channel state and the counters,
|
|
883
|
+
// and answers every mutation with a fresh snapshot. A 404 means the loader
|
|
884
|
+
// row is switched off on the plugin page — a state, not a failure — and is
|
|
885
|
+
// rendered as an explanation.
|
|
886
|
+
// -----------------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
/** Settings section identity of the 会话日志 page. */
|
|
889
|
+
const SESSIONLOG_SETTINGS_SECTION_ID = 'sessionlog'
|
|
890
|
+
const SESSIONLOG_SETTINGS_SECTION_LABEL = '会话日志'
|
|
891
|
+
const SESSIONLOG_SETTINGS_SECTION_ORDER = 27
|
|
892
|
+
|
|
893
|
+
/** Host route this page talks to; must match lib/sessionlog/index.js. */
|
|
894
|
+
const SESSIONLOG_API_PATH = '/dsh-sessionlog/api'
|
|
895
|
+
|
|
896
|
+
/** Stylesheet identity for the 会话日志 settings page (one tag, rewritten on reload). */
|
|
897
|
+
const SESSIONLOG_STYLE_ID = 'dsh-workspace-plus/sessionlog.css'
|
|
898
|
+
|
|
899
|
+
/** Prefix for every class the sessionlog page injects, so it can never collide. */
|
|
900
|
+
const SESSIONLOG_CSS = `
|
|
901
|
+
.dsh-sessionlog-section { display: flex; flex-direction: column; gap: 14px; }
|
|
902
|
+
.dsh-sessionlog-group { display: flex; flex-direction: column; gap: 10px; }
|
|
903
|
+
.dsh-sessionlog-group-title { color: var(--dsw-alias-label-secondary); font-size: 12px; font-weight: 600; letter-spacing: .04em; }
|
|
904
|
+
.dsh-sessionlog-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
905
|
+
.dsh-sessionlog-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
906
|
+
.dsh-sessionlog-title { color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 600; }
|
|
907
|
+
.dsh-sessionlog-desc { color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.5; }
|
|
908
|
+
.dsh-sessionlog-switch { position: relative; flex: none; width: 38px; height: 22px; margin-top: 2px; padding: 0; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; background: var(--dsw-alias-bg-layer-2); transition: background .15s ease, border-color .15s ease; }
|
|
909
|
+
.dsh-sessionlog-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
|
|
910
|
+
.dsh-sessionlog-switch:disabled { cursor: default; opacity: .5; }
|
|
911
|
+
.dsh-sessionlog-knob { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--dsw-alias-bg-base); transition: transform .15s ease; }
|
|
912
|
+
.dsh-sessionlog-switch[aria-checked="true"] .dsh-sessionlog-knob { transform: translateX(16px); }
|
|
913
|
+
.dsh-sessionlog-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
914
|
+
.dsh-sessionlog-button { cursor: pointer; padding: 5px 12px; font-size: 13px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
|
|
915
|
+
.dsh-sessionlog-button:hover:not(:disabled) { border-color: var(--dsw-alias-border-l1); }
|
|
916
|
+
.dsh-sessionlog-button:disabled { cursor: default; opacity: .5; }
|
|
917
|
+
.dsh-sessionlog-input { flex: 1 1 260px; min-width: 0; padding: 5px 9px; font-size: 12.5px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); }
|
|
918
|
+
.dsh-sessionlog-status { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.6; }
|
|
919
|
+
.dsh-sessionlog-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }
|
|
920
|
+
.dsh-sessionlog-error { color: var(--dsw-alias-state-error-primary); }
|
|
921
|
+
.dsh-sessionlog-ok { color: var(--dsw-alias-state-success-primary); }
|
|
922
|
+
`
|
|
923
|
+
|
|
924
|
+
/** Install the sessionlog stylesheet, exactly one tag per document (rewritten on reload). */
|
|
925
|
+
function ensureSessionlogStyles() {
|
|
926
|
+
if (typeof document === 'undefined') return
|
|
927
|
+
const existing = document.querySelector('style[data-plugin-css=' + JSON.stringify(SESSIONLOG_STYLE_ID) + ']')
|
|
928
|
+
if (existing !== null) {
|
|
929
|
+
existing.textContent = SESSIONLOG_CSS
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
const tag = document.createElement('style')
|
|
933
|
+
tag.dataset.plugin = 'dsh-workspace-plus'
|
|
934
|
+
tag.dataset.pluginCss = SESSIONLOG_STYLE_ID
|
|
935
|
+
tag.textContent = SESSIONLOG_CSS
|
|
936
|
+
document.head.appendChild(tag)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Fetch the sessionlog host's snapshot.
|
|
941
|
+
*
|
|
942
|
+
* @param signal - caller cancellation.
|
|
943
|
+
* @returns the parsed payload.
|
|
944
|
+
*/
|
|
945
|
+
async function fetchSessionlogSnapshot(signal) {
|
|
946
|
+
const response = await fetch(SESSIONLOG_API_PATH, { cache: 'no-store', signal })
|
|
947
|
+
if (!response.ok) throw new Error(`host responded ${response.status}`)
|
|
948
|
+
return await response.json()
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Post one sessionlog control operation.
|
|
953
|
+
*
|
|
954
|
+
* @param body - operation payload.
|
|
955
|
+
* @returns the parsed payload.
|
|
956
|
+
*/
|
|
957
|
+
async function postSessionlogOperation(body) {
|
|
958
|
+
const response = await fetch(SESSIONLOG_API_PATH, {
|
|
959
|
+
method: 'POST',
|
|
960
|
+
cache: 'no-store',
|
|
961
|
+
headers: { 'content-type': 'application/json' },
|
|
962
|
+
body: JSON.stringify(body),
|
|
963
|
+
})
|
|
964
|
+
if (!response.ok && response.status >= 500) throw new Error(`host responded ${response.status}`)
|
|
965
|
+
return await response.json()
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* One labelled switch row of the 会话日志 page.
|
|
970
|
+
*
|
|
971
|
+
* @param props - `{ title, description, checked, disabled, onChange }`.
|
|
972
|
+
* @returns the row element.
|
|
973
|
+
*/
|
|
974
|
+
function SessionlogSwitchRow(props) {
|
|
975
|
+
return React.createElement(
|
|
976
|
+
'div',
|
|
977
|
+
{ className: 'dsh-sessionlog-row' },
|
|
978
|
+
React.createElement(
|
|
979
|
+
'div',
|
|
980
|
+
{ className: 'dsh-sessionlog-text' },
|
|
981
|
+
React.createElement('div', { className: 'dsh-sessionlog-title' }, props.title),
|
|
982
|
+
props.description ? React.createElement('div', { className: 'dsh-sessionlog-desc' }, props.description) : null,
|
|
983
|
+
),
|
|
984
|
+
React.createElement(
|
|
985
|
+
'button',
|
|
986
|
+
{
|
|
987
|
+
type: 'button',
|
|
988
|
+
role: 'switch',
|
|
989
|
+
'aria-checked': props.checked === true,
|
|
990
|
+
'aria-label': props.title,
|
|
991
|
+
disabled: props.disabled === true,
|
|
992
|
+
className: 'dsh-sessionlog-switch',
|
|
993
|
+
onClick: () => props.onChange(props.checked !== true),
|
|
994
|
+
},
|
|
995
|
+
React.createElement('span', { className: 'dsh-sessionlog-knob' }),
|
|
996
|
+
),
|
|
997
|
+
)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Human label for the request-field channel's current state. */
|
|
1001
|
+
function sessionlogRequestFieldLine(channel) {
|
|
1002
|
+
switch (channel?.requestField) {
|
|
1003
|
+
case 'active':
|
|
1004
|
+
return 'dsh_session_log 请求字段:由本插件提供(官方 DeepSeek 适配器请求时随请求发出)。'
|
|
1005
|
+
case 'taken':
|
|
1006
|
+
return 'dsh_session_log 请求字段:已由其它插件提供(通常是内置的 dsh-session-log-deepseek),本插件自动跳过。'
|
|
1007
|
+
case 'unavailable':
|
|
1008
|
+
return 'dsh_session_log 请求字段:当前组合里没有 DeepSeek 扩展注册表,这一路不可用。'
|
|
1009
|
+
default:
|
|
1010
|
+
return 'dsh_session_log 请求字段:未启用。'
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* The 会话日志 settings page. It never predicts what the host accepted: every
|
|
1016
|
+
* switch posts one key and adopts the snapshot that comes back, and a 404 —
|
|
1017
|
+
* the sessionlog loader row being switched off — renders as an explanation
|
|
1018
|
+
* rather than an error.
|
|
1019
|
+
*
|
|
1020
|
+
* `props.initial` is a test seam (the shell never passes it), matching the
|
|
1021
|
+
* workspace+ page: it seeds the snapshot/status and skips the mount fetch so
|
|
1022
|
+
* a test can render the ready and failure states without a live host.
|
|
1023
|
+
*
|
|
1024
|
+
* @param props - optional `{ initial }`.
|
|
1025
|
+
* @returns the settings section element.
|
|
1026
|
+
*/
|
|
1027
|
+
function SessionlogSettings(props) {
|
|
1028
|
+
const injected = props !== null && typeof props === 'object' ? props.initial : undefined
|
|
1029
|
+
const [snapshot, setSnapshot] = React.useState(injected?.snapshot ?? null)
|
|
1030
|
+
const [status, setStatus] = React.useState(injected?.status ?? { kind: 'loading', message: '' })
|
|
1031
|
+
const [busy, setBusy] = React.useState(injected?.busy === true)
|
|
1032
|
+
const [draft, setDraft] = React.useState(String(injected?.draft ?? ''))
|
|
1033
|
+
|
|
1034
|
+
React.useEffect(() => {
|
|
1035
|
+
if (injected !== undefined) return undefined
|
|
1036
|
+
let cancelled = false
|
|
1037
|
+
const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
|
|
1038
|
+
;(async () => {
|
|
1039
|
+
try {
|
|
1040
|
+
const payload = await fetchSessionlogSnapshot(controller?.signal)
|
|
1041
|
+
if (cancelled) return
|
|
1042
|
+
setSnapshot(payload)
|
|
1043
|
+
setDraft(String(payload?.settings?.endpoint ?? ''))
|
|
1044
|
+
setStatus({ kind: 'ready', message: '' })
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (cancelled) return
|
|
1047
|
+
setStatus({ kind: 'error', message: String((error && error.message) || error) })
|
|
1048
|
+
}
|
|
1049
|
+
})()
|
|
1050
|
+
return () => {
|
|
1051
|
+
cancelled = true
|
|
1052
|
+
controller?.abort()
|
|
1053
|
+
}
|
|
1054
|
+
}, [])
|
|
1055
|
+
|
|
1056
|
+
const send = React.useCallback(async (body) => {
|
|
1057
|
+
setBusy(true)
|
|
1058
|
+
try {
|
|
1059
|
+
const payload = await postSessionlogOperation(body)
|
|
1060
|
+
// A rejected operation still carries a snapshot (the host always
|
|
1061
|
+
// answers with one), so the page can adopt it and show the reason.
|
|
1062
|
+
if (payload !== null && typeof payload === 'object' && payload.settings !== undefined) {
|
|
1063
|
+
setSnapshot(payload)
|
|
1064
|
+
setDraft(String(payload.settings.endpoint ?? ''))
|
|
1065
|
+
}
|
|
1066
|
+
setStatus({
|
|
1067
|
+
kind: payload !== null && payload.ok === true ? 'ready' : 'error',
|
|
1068
|
+
message: String(payload?.message ?? ''),
|
|
1069
|
+
})
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
setStatus({ kind: 'error', message: String((error && error.message) || error) })
|
|
1072
|
+
} finally {
|
|
1073
|
+
setBusy(false)
|
|
1074
|
+
}
|
|
1075
|
+
}, [])
|
|
1076
|
+
|
|
1077
|
+
if (snapshot === null) {
|
|
1078
|
+
const message = status.kind === 'error' ? status.message : ''
|
|
1079
|
+
if (status.kind === 'error' && message.includes('404')) {
|
|
1080
|
+
return React.createElement(
|
|
1081
|
+
'div',
|
|
1082
|
+
{ className: 'dsh-sessionlog-section' },
|
|
1083
|
+
React.createElement(
|
|
1084
|
+
'div',
|
|
1085
|
+
{ className: 'dsh-sessionlog-desc' },
|
|
1086
|
+
'会话日志子功能当前未启用:在侧栏「插件」页的 dsh-workspace-plus 卡片里打开 sessionlog 这一行即可开启。',
|
|
1087
|
+
),
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
return React.createElement(
|
|
1091
|
+
'div',
|
|
1092
|
+
{ className: 'dsh-sessionlog-section' },
|
|
1093
|
+
React.createElement(
|
|
1094
|
+
'div',
|
|
1095
|
+
{ className: 'dsh-sessionlog-desc' },
|
|
1096
|
+
status.kind === 'error' ? `读取设置失败:${message}` : '正在读取设置…',
|
|
1097
|
+
),
|
|
1098
|
+
)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const settings = snapshot.settings ?? {}
|
|
1102
|
+
const channel = snapshot.channel ?? {}
|
|
1103
|
+
const stats = snapshot.stats ?? {}
|
|
1104
|
+
const on = settings.enabled === true
|
|
1105
|
+
const endpointSource =
|
|
1106
|
+
snapshot.endpointSource === 'settings' ? '设置页指定' : snapshot.endpointSource === 'environment' ? '环境变量' : '出厂默认'
|
|
1107
|
+
const reporting = channel.reporting === true
|
|
1108
|
+
const lastError = typeof stats.lastError === 'string' && stats.lastError !== '' ? stats.lastError : ''
|
|
1109
|
+
|
|
1110
|
+
return React.createElement(
|
|
1111
|
+
'div',
|
|
1112
|
+
{ className: 'dsh-sessionlog-section' },
|
|
1113
|
+
React.createElement(
|
|
1114
|
+
'div',
|
|
1115
|
+
{ className: 'dsh-sessionlog-group' },
|
|
1116
|
+
React.createElement(SessionlogSwitchRow, {
|
|
1117
|
+
title: '上传 Session Log(帮助改进 DeepSeek 模型与产品)',
|
|
1118
|
+
description:
|
|
1119
|
+
'默认关闭。开启后,本机会话日志的内容会发送到 DeepSeek 的收集端点;与使用哪个模型 API 无关,因此换用第三方模型 API 时同样生效。',
|
|
1120
|
+
checked: on,
|
|
1121
|
+
disabled: busy,
|
|
1122
|
+
onChange: (next) => void send({ op: 'patch', patch: { enabled: next } }),
|
|
1123
|
+
}),
|
|
1124
|
+
React.createElement(
|
|
1125
|
+
'div',
|
|
1126
|
+
{ className: 'dsh-sessionlog-status' },
|
|
1127
|
+
React.createElement('div', null, reporting ? `上报中:${stats.events ?? 0} 条事件 / ${stats.sessions ?? 0} 个会话` : on ? '已开启,等待通道就绪…' : '未开启:本机不会向 DeepSeek 发送任何会话内容。'),
|
|
1128
|
+
React.createElement('div', null, channel.otlpAvailable === true ? 'OTLP 会话日志通道:可用。' : 'OTLP 会话日志通道:当前组合未挂载 otel 服务,无法上传。'),
|
|
1129
|
+
React.createElement('div', null, sessionlogRequestFieldLine(channel)),
|
|
1130
|
+
// The host's process-level opt-out outranks this page's switch, and
|
|
1131
|
+
// saying so is the difference between "the switch is broken" and
|
|
1132
|
+
// "this machine has been told not to report".
|
|
1133
|
+
snapshot.killSwitch
|
|
1134
|
+
? React.createElement('div', { className: 'dsh-sessionlog-error' }, `进程级遥测开关:${String(snapshot.killSwitch)} —— 本页的开关不会覆盖它。`)
|
|
1135
|
+
: null,
|
|
1136
|
+
React.createElement(
|
|
1137
|
+
'div',
|
|
1138
|
+
{ className: 'dsh-sessionlog-mono' },
|
|
1139
|
+
`目标端点(${endpointSource}):${String(snapshot.effectiveEndpoint ?? '')}`,
|
|
1140
|
+
),
|
|
1141
|
+
// Redaction is unconditional, so this is a receipt rather than a control.
|
|
1142
|
+
stats.redactions > 0
|
|
1143
|
+
? React.createElement(
|
|
1144
|
+
'div',
|
|
1145
|
+
{ className: 'dsh-sessionlog-ok' },
|
|
1146
|
+
`已在发出前脱敏 ${String(stats.redactions)} 处凭据(token / 密钥 / Authorization)。`,
|
|
1147
|
+
)
|
|
1148
|
+
: null,
|
|
1149
|
+
lastError ? React.createElement('div', { className: 'dsh-sessionlog-error' }, `最近一次错误:${lastError}`) : null,
|
|
1150
|
+
),
|
|
1151
|
+
),
|
|
1152
|
+
React.createElement(
|
|
1153
|
+
'div',
|
|
1154
|
+
{ className: 'dsh-sessionlog-group' },
|
|
1155
|
+
React.createElement('div', { className: 'dsh-sessionlog-group-title' }, '上传范围'),
|
|
1156
|
+
React.createElement(SessionlogSwitchRow, {
|
|
1157
|
+
title: '同时提供 dsh_session_log 请求字段',
|
|
1158
|
+
description:
|
|
1159
|
+
'默认关闭。仅当官方 DeepSeek 适配器承担这次请求、且没有别的插件占用该字段时才生效;内置的 dsh-session-log-deepseek 在场时会自动跳过。注意:DSH 不检查该适配器的 baseURL 是否指向官方域名,字段会随请求发到它配置的任意端点。',
|
|
1160
|
+
checked: settings.requestField === true,
|
|
1161
|
+
disabled: busy || !on,
|
|
1162
|
+
onChange: (next) => void send({ op: 'patch', patch: { requestField: next } }),
|
|
1163
|
+
}),
|
|
1164
|
+
React.createElement(SessionlogSwitchRow, {
|
|
1165
|
+
title: '开启时补传已有会话历史',
|
|
1166
|
+
description: '关闭后,只上传开启之后新产生的事件;已经存在的对话不会补传。',
|
|
1167
|
+
checked: settings.includeHistory === true,
|
|
1168
|
+
disabled: busy || !on,
|
|
1169
|
+
onChange: (next) => void send({ op: 'patch', patch: { includeHistory: next } }),
|
|
1170
|
+
}),
|
|
1171
|
+
),
|
|
1172
|
+
React.createElement(
|
|
1173
|
+
'div',
|
|
1174
|
+
{ className: 'dsh-sessionlog-group' },
|
|
1175
|
+
React.createElement('div', { className: 'dsh-sessionlog-group-title' }, '收集端点'),
|
|
1176
|
+
React.createElement(
|
|
1177
|
+
'div',
|
|
1178
|
+
{ className: 'dsh-sessionlog-actions' },
|
|
1179
|
+
React.createElement('input', {
|
|
1180
|
+
type: 'text',
|
|
1181
|
+
className: 'dsh-sessionlog-input',
|
|
1182
|
+
spellCheck: false,
|
|
1183
|
+
placeholder: '留空则使用环境变量 DSH_TELEMETRY_OTLP_URL,否则用出厂端点',
|
|
1184
|
+
value: draft,
|
|
1185
|
+
disabled: busy,
|
|
1186
|
+
onChange: (event) => setDraft(String(event?.target?.value ?? '')),
|
|
1187
|
+
}),
|
|
1188
|
+
React.createElement(
|
|
1189
|
+
'button',
|
|
1190
|
+
{
|
|
1191
|
+
type: 'button',
|
|
1192
|
+
className: 'dsh-sessionlog-button',
|
|
1193
|
+
disabled: busy || draft === String(settings.endpoint ?? ''),
|
|
1194
|
+
onClick: () => void send({ op: 'patch', patch: { endpoint: draft } }),
|
|
1195
|
+
},
|
|
1196
|
+
'保存',
|
|
1197
|
+
),
|
|
1198
|
+
React.createElement(
|
|
1199
|
+
'button',
|
|
1200
|
+
{
|
|
1201
|
+
type: 'button',
|
|
1202
|
+
className: 'dsh-sessionlog-button',
|
|
1203
|
+
disabled: busy,
|
|
1204
|
+
onClick: () => void send({ op: 'reset' }),
|
|
1205
|
+
},
|
|
1206
|
+
'全部恢复默认',
|
|
1207
|
+
),
|
|
1208
|
+
),
|
|
1209
|
+
React.createElement(
|
|
1210
|
+
'div',
|
|
1211
|
+
{ className: 'dsh-sessionlog-desc' },
|
|
1212
|
+
'设置文件:' + String(snapshot.storePath ?? ''),
|
|
1213
|
+
),
|
|
1214
|
+
),
|
|
1215
|
+
status.kind === 'error' && status.message !== ''
|
|
1216
|
+
? React.createElement('div', { className: 'dsh-sessionlog-error' }, status.message)
|
|
1217
|
+
: null,
|
|
1218
|
+
)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
830
1221
|
ensureStyles()
|
|
831
1222
|
|
|
832
1223
|
// The notice page's stylesheet follows the same one-tag rule; calling it in
|
|
@@ -834,6 +1225,11 @@ window.__ModuleLoader__.load({
|
|
|
834
1225
|
// `<style>` element of its own and reloads stay duplicate-free.
|
|
835
1226
|
ensureNoticeStyles()
|
|
836
1227
|
|
|
1228
|
+
// The 会话日志 page's stylesheet follows the same rule and MUST be appended
|
|
1229
|
+
// last: the shell's own stylesheet checks read `head.children[0]`/`[1]` as
|
|
1230
|
+
// "workspace+ then notice", so a new sheet only ever goes on the end.
|
|
1231
|
+
ensureSessionlogStyles()
|
|
1232
|
+
|
|
837
1233
|
// -----------------------------------------------------------------------
|
|
838
1234
|
// The workspace-directory feature
|
|
839
1235
|
// -----------------------------------------------------------------------
|
|
@@ -1871,11 +2267,16 @@ window.__ModuleLoader__.load({
|
|
|
1871
2267
|
const bodyBlocks = (() => {
|
|
1872
2268
|
if (state.status === 'error') {
|
|
1873
2269
|
const unauthorized = state.message.includes('401')
|
|
2270
|
+
const unavailable = state.message.includes('503')
|
|
1874
2271
|
const unrouted = state.message.includes('404')
|
|
1875
2272
|
/**
|
|
1876
|
-
* The
|
|
2273
|
+
* The four failures need different advice:
|
|
1877
2274
|
* - 401: this document never completed the token exchange, so the
|
|
1878
2275
|
* connection layer has no authority to admit the request.
|
|
2276
|
+
* - 503: the Host has no usable authentication service, so the route
|
|
2277
|
+
* refused the request instead of accepting an unauthenticated
|
|
2278
|
+
* change. Restarting is the ordinary fix; the route stays shut until
|
|
2279
|
+
* the connection service is mounted.
|
|
1879
2280
|
* - 404: the request reached the Host and nothing claimed the path.
|
|
1880
2281
|
* The browser half hot-reloads from the served bundle, the host half
|
|
1881
2282
|
* does not, so a stale Host process is the ordinary cause.
|
|
@@ -1883,14 +2284,16 @@ window.__ModuleLoader__.load({
|
|
|
1883
2284
|
*/
|
|
1884
2285
|
const diagnosis = unauthorized
|
|
1885
2286
|
? '这个页面没有带授权的会话凭据。请用 `dsh` 启动时打印的带 token 的地址重新打开 GUI(它会写入 cookie,然后跳转到干净地址)。'
|
|
1886
|
-
:
|
|
1887
|
-
? '
|
|
1888
|
-
|
|
1889
|
-
'
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
'
|
|
2287
|
+
: unavailable
|
|
2288
|
+
? '宿主进程没有可用的认证服务,接口按安全默认拒绝了这次请求(503)。重启 dsh 通常即可恢复;如果一直出现,说明这个宿主没有挂载 connection 认证服务,这个接口在它上面不会放行任何读写。'
|
|
2289
|
+
: unrouted
|
|
2290
|
+
? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
|
|
2291
|
+
API_PATH +
|
|
2292
|
+
' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
|
|
2293
|
+
'重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
|
|
2294
|
+
: '这个面板通过 ' +
|
|
2295
|
+
API_PATH +
|
|
2296
|
+
' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
|
|
1894
2297
|
return [
|
|
1895
2298
|
noticeBlock(
|
|
1896
2299
|
'error',
|
|
@@ -2479,6 +2882,29 @@ window.__ModuleLoader__.load({
|
|
|
2479
2882
|
),
|
|
2480
2883
|
)
|
|
2481
2884
|
|
|
2885
|
+
// The 会话日志 sub-feature's settings page. Same guard as the notice page:
|
|
2886
|
+
// the browser half always loads with the bundle, so the page has to survive
|
|
2887
|
+
// its host row being switched off (the host then answers 404 and the page
|
|
2888
|
+
// renders that as a state), and a registration failure must not take the
|
|
2889
|
+
// rest of this bundle down with it.
|
|
2890
|
+
registerNotice(ctx, '会话日志设置页', () =>
|
|
2891
|
+
ctx.effect(
|
|
2892
|
+
() =>
|
|
2893
|
+
ctx.slots.inject('settings.section', () =>
|
|
2894
|
+
ctx.slots.register(
|
|
2895
|
+
{
|
|
2896
|
+
name: 'settings.section',
|
|
2897
|
+
id: SESSIONLOG_SETTINGS_SECTION_ID,
|
|
2898
|
+
order: SESSIONLOG_SETTINGS_SECTION_ORDER,
|
|
2899
|
+
label: SESSIONLOG_SETTINGS_SECTION_LABEL,
|
|
2900
|
+
},
|
|
2901
|
+
SessionlogSettings,
|
|
2902
|
+
),
|
|
2903
|
+
),
|
|
2904
|
+
'dsh-workspace-plus/sessionlog: settings section',
|
|
2905
|
+
),
|
|
2906
|
+
)
|
|
2907
|
+
|
|
2482
2908
|
// The dialog lives in the frame-wide overlay: the same seat the shell's own
|
|
2483
2909
|
// workspace Rename/Delete dialogs occupy.
|
|
2484
2910
|
ctx.effect(
|
|
@@ -2622,6 +3048,17 @@ window.__ModuleLoader__.load({
|
|
|
2622
3048
|
exports.NOTICE_PENDING_PATH = NOTICE_PENDING_PATH
|
|
2623
3049
|
exports.NOTICE_POLL_INTERVAL_MS = NOTICE_POLL_INTERVAL_MS
|
|
2624
3050
|
exports.NOTICE_POLL_RETRY_MS = NOTICE_POLL_RETRY_MS
|
|
3051
|
+
exports.SESSIONLOG_SETTINGS_SECTION_ID = SESSIONLOG_SETTINGS_SECTION_ID
|
|
3052
|
+
exports.SESSIONLOG_SETTINGS_SECTION_LABEL = SESSIONLOG_SETTINGS_SECTION_LABEL
|
|
3053
|
+
exports.SESSIONLOG_SETTINGS_SECTION_ORDER = SESSIONLOG_SETTINGS_SECTION_ORDER
|
|
3054
|
+
exports.SESSIONLOG_API_PATH = SESSIONLOG_API_PATH
|
|
3055
|
+
exports.SESSIONLOG_STYLE_ID = SESSIONLOG_STYLE_ID
|
|
3056
|
+
exports.ensureSessionlogStyles = ensureSessionlogStyles
|
|
3057
|
+
exports.fetchSessionlogSnapshot = fetchSessionlogSnapshot
|
|
3058
|
+
exports.postSessionlogOperation = postSessionlogOperation
|
|
3059
|
+
exports.SessionlogSwitchRow = SessionlogSwitchRow
|
|
3060
|
+
exports.SessionlogSettings = SessionlogSettings
|
|
3061
|
+
exports.sessionlogRequestFieldLine = sessionlogRequestFieldLine
|
|
2625
3062
|
exports.fetchNoticeSnapshot = fetchNoticeSnapshot
|
|
2626
3063
|
exports.postNoticeOperation = postNoticeOperation
|
|
2627
3064
|
exports.NoticeSwitchRow = NoticeSwitchRow
|
package/lib/index.js
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* agent sees the mapping without spending a tool call
|
|
13
13
|
* 3. a `workspace_dirs` Tool the agent uses to inspect or edit the mapping
|
|
14
14
|
* when the user asks in natural language
|
|
15
|
-
* 4. a small JSON HTTP route the browser half uses to manage the same store
|
|
15
|
+
* 4. a small JSON HTTP route the browser half uses to manage the same store —
|
|
16
|
+
* every request must pass the Host's `connection.admit` check, and the
|
|
17
|
+
* route refuses (503) when that service is unavailable
|
|
16
18
|
*
|
|
17
19
|
* The whole point is routing by label instead of by guesswork: the injected
|
|
18
20
|
* table gives absolute paths, so a similar-looking directory name elsewhere on
|
|
@@ -362,6 +364,55 @@ export function validateLabel(label) {
|
|
|
362
364
|
return { ok: true, label: text }
|
|
363
365
|
}
|
|
364
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Run the Host's browser trust check for one plugin request.
|
|
369
|
+
*
|
|
370
|
+
* `connection.admit(req)` is the only authority a plugin route has: the Host
|
|
371
|
+
* fence plus the GUI session cookie. This route both publishes the mapping and
|
|
372
|
+
* lets the caller rewrite it, and whatever it stores is rendered into every
|
|
373
|
+
* request's model context — so it fails closed when that authority is absent
|
|
374
|
+
* instead of falling through to "admitted":
|
|
375
|
+
*
|
|
376
|
+
* - no `connection` service mounted, or a service without `admit` — the host
|
|
377
|
+
* composition has no auth layer (or its API drifted): refuse with 503;
|
|
378
|
+
* - `admit` throwing, rejecting, or failing to answer: the decision is
|
|
379
|
+
* unavailable, so refuse with 503;
|
|
380
|
+
* - a rejection from `admit`: pass its 401 through, everything else is 403.
|
|
381
|
+
*
|
|
382
|
+
* The result is awaited, so a synchronous or an asynchronous `admit` is honored
|
|
383
|
+
* the same way — but only an answer that actually carries no rejection counts
|
|
384
|
+
* as an admission.
|
|
385
|
+
*
|
|
386
|
+
* The notice sub-feature deliberately grants a loopback exemption. This route
|
|
387
|
+
* does not: an unauthenticated writer could otherwise plant arbitrary text in
|
|
388
|
+
* the agent's prompt through a directory note.
|
|
389
|
+
*
|
|
390
|
+
* @param connection - the resolved `connection` service, or undefined.
|
|
391
|
+
* @param req - incoming request.
|
|
392
|
+
* @returns a promise of `{ ok: true }` or `{ ok: false, status, message }`.
|
|
393
|
+
*/
|
|
394
|
+
export async function checkAdmission(connection, req) {
|
|
395
|
+
const unavailable = (message) => ({ ok: false, status: 503, message })
|
|
396
|
+
if (connection === undefined || connection === null) {
|
|
397
|
+
return unavailable('host authentication service is not mounted; refusing to answer')
|
|
398
|
+
}
|
|
399
|
+
if (typeof connection.admit !== 'function') {
|
|
400
|
+
return unavailable('host authentication service has no admit(); refusing to answer')
|
|
401
|
+
}
|
|
402
|
+
let admission
|
|
403
|
+
try {
|
|
404
|
+
admission = await connection.admit(req)
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return unavailable(`host authentication service failed: ${String(error?.message ?? error)}`)
|
|
407
|
+
}
|
|
408
|
+
const rejection = admission !== null && typeof admission === 'object' ? admission.rejection : undefined
|
|
409
|
+
if (rejection !== undefined) {
|
|
410
|
+
const status = rejection === 401 ? 401 : 403
|
|
411
|
+
return { ok: false, status, message: status === 401 ? 'unauthorized' : 'forbidden' }
|
|
412
|
+
}
|
|
413
|
+
return { ok: true }
|
|
414
|
+
}
|
|
415
|
+
|
|
365
416
|
/**
|
|
366
417
|
* Apply one store operation. Every branch validates first and returns a
|
|
367
418
|
* discriminated result, so both the Tool and the HTTP route share one behavior
|
|
@@ -709,11 +760,17 @@ export function apply(ctx) {
|
|
|
709
760
|
kind: 'exact',
|
|
710
761
|
path: API_PATH,
|
|
711
762
|
handler: async (req, res) => {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
763
|
+
// Fail closed. A request that cannot be admitted by the Host's
|
|
764
|
+
// connection service gets no answer at all, read or write: the
|
|
765
|
+
// mapping this route returns — and the notes it stores — become
|
|
766
|
+
// model context, so an unauthenticated caller must never reach it.
|
|
767
|
+
const admission = await checkAdmission(ctx.get('connection'), req)
|
|
768
|
+
if (!admission.ok) {
|
|
769
|
+
res.writeHead(admission.status, {
|
|
770
|
+
'content-type': 'application/json; charset=utf-8',
|
|
771
|
+
'cache-control': 'no-store',
|
|
772
|
+
})
|
|
773
|
+
res.end(JSON.stringify({ ok: false, message: admission.message }))
|
|
717
774
|
return
|
|
718
775
|
}
|
|
719
776
|
const send = (status, payload) => {
|