dsh-sidecard-ask 1.8.4 → 1.9.1
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 +98 -0
- package/README.md +110 -6
- package/client.js +496 -2
- package/index.js +772 -8
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
|
|
|
47
47
|
* the Host and the module's `api.version` both read it, so a report can
|
|
48
48
|
* never claim a generation the browser is not actually running.
|
|
49
49
|
*/
|
|
50
|
-
const CLIENT_VERSION = '1.
|
|
50
|
+
const CLIENT_VERSION = '1.9.1'
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Tab kind served by the DSH native right rail (also its implementation id).
|
|
@@ -120,6 +120,17 @@ window.__ModuleLoader__.load({
|
|
|
120
120
|
customPersona: '',
|
|
121
121
|
// 1.8.0 P2-4: model-written follow-up chips (opt-in).
|
|
122
122
|
followUpSuggestions: false,
|
|
123
|
+
// Empty by design: a maintainer mailbox is injected at run time (the
|
|
124
|
+
// settings field or DSH_SIDECARD_FEEDBACK_TO), never shipped in a repo.
|
|
125
|
+
feedbackAddress: '',
|
|
126
|
+
// SMTP transport, editable in the settings page. The password is
|
|
127
|
+
// write-only: `/state` never returns it, so this default is always ''.
|
|
128
|
+
feedbackSmtpHost: '',
|
|
129
|
+
feedbackSmtpPort: 465,
|
|
130
|
+
feedbackSmtpSecure: true,
|
|
131
|
+
feedbackSmtpUser: '',
|
|
132
|
+
feedbackSmtpPass: '',
|
|
133
|
+
feedbackSmtpFrom: '',
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
/**
|
|
@@ -134,6 +145,26 @@ window.__ModuleLoader__.load({
|
|
|
134
145
|
shortcutLength: 40,
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Fallback bounds for the feedback form. `/state` reports the live limits
|
|
150
|
+
* (`capabilities.feedback.limits`) and those win; these exist only so an
|
|
151
|
+
* older Host that reports nothing still gets sane Client-side checks.
|
|
152
|
+
*/
|
|
153
|
+
const FEEDBACK_DEFAULTS = {
|
|
154
|
+
titleMin: 4,
|
|
155
|
+
titleMax: 120,
|
|
156
|
+
bodyMin: 10,
|
|
157
|
+
bodyMax: 4000,
|
|
158
|
+
contactMax: 120,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The Host's mailbox rule, mirrored so the form refuses a bad `Reply-To`
|
|
163
|
+
* before the round trip. Kept deliberately simple: the relay is the final
|
|
164
|
+
* authority, and this only has to reject obvious non-addresses.
|
|
165
|
+
*/
|
|
166
|
+
const FEEDBACK_EMAIL_PATTERN = /^[^\s@<>()[\]\\,;:"/]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/
|
|
167
|
+
|
|
137
168
|
/**
|
|
138
169
|
* Built-in quick actions. The config list only carries `builtin:<id>`;
|
|
139
170
|
* the localized label and question live HERE so the wire/config layers
|
|
@@ -465,6 +496,7 @@ window.__ModuleLoader__.load({
|
|
|
465
496
|
errorNoSideEngine: '这台 DSH 组合没有可用的子代理,无法在侧边卡片作答;可以改到主对话提问。',
|
|
466
497
|
errorTooLarge: '请求体过大',
|
|
467
498
|
errorBadRequest: '请求无效(问题或选中文本为空)',
|
|
499
|
+
errorFeedbackInvalid: '反馈内容不符合要求,检查标题与详细描述的长度',
|
|
468
500
|
errorUnloaded: '插件正在卸载,无法受理新的追问',
|
|
469
501
|
errorDuplicate: '该追问已在处理中,请等它完成',
|
|
470
502
|
errorBusy: '并发追问已达上限({n}),请稍后再试',
|
|
@@ -656,6 +688,49 @@ window.__ModuleLoader__.load({
|
|
|
656
688
|
suggestionsLabel: '建议追问',
|
|
657
689
|
familyLegacyDiag: '旧版共载',
|
|
658
690
|
familyPeers: '同页实例',
|
|
691
|
+
setFeedback: '反馈',
|
|
692
|
+
setFeedbackHint: '遇到问题或有建议,在这里直接发邮件给我;邮件会自动附带插件版本与运行环境(不含聊天内容)',
|
|
693
|
+
feedbackOpen: '反馈问题',
|
|
694
|
+
feedbackClose: '关闭',
|
|
695
|
+
feedbackDialogTitle: '发送反馈',
|
|
696
|
+
feedbackKind: '问题类型',
|
|
697
|
+
feedbackKindBug: '缺陷',
|
|
698
|
+
feedbackKindIdea: '建议',
|
|
699
|
+
feedbackKindQuestion: '疑问',
|
|
700
|
+
feedbackKindOther: '其他',
|
|
701
|
+
feedbackTitle: '标题',
|
|
702
|
+
feedbackTitlePlaceholder: '一句话说明问题(4–120 字)',
|
|
703
|
+
feedbackBody: '详细描述',
|
|
704
|
+
feedbackBodyPlaceholder: '复现步骤 / 期望结果 / 实际结果(10–4000 字)',
|
|
705
|
+
feedbackContact: '你的邮箱(选填,作为回信地址)',
|
|
706
|
+
feedbackContactPlaceholder: 'you@example.com',
|
|
707
|
+
feedbackContactHint: '填了维护者就能直接回信给你;留空即匿名。',
|
|
708
|
+
feedbackContactInvalid: '请填写正确的邮箱地址,或留空',
|
|
709
|
+
feedbackSend: '发送邮件',
|
|
710
|
+
feedbackSending: '发送中…',
|
|
711
|
+
feedbackSent: '已发送,谢谢反馈',
|
|
712
|
+
feedbackMailto: '用邮件客户端发送',
|
|
713
|
+
feedbackCopy: '复制内容',
|
|
714
|
+
feedbackCopied: '已复制到剪贴板',
|
|
715
|
+
feedbackUnconfigured: '没有可用的发信服务,可用“用邮件客户端发送”自行寄出',
|
|
716
|
+
feedbackMailtoOnly: '收件人已填好,只缺发信凭据(SMTP 账号与授权码,由环境变量或用户层配置提供);现在也可以“用邮件客户端发送”',
|
|
717
|
+
feedbackNeedsTitle: '标题至少 4 个字',
|
|
718
|
+
feedbackNeedsBody: '详细描述至少 10 个字',
|
|
719
|
+
feedbackContactTooLong: '联系方式太长(≤{n} 字)',
|
|
720
|
+
feedbackFailed: '发送失败:{message}',
|
|
721
|
+
feedbackEnvNote: '自动附带:插件版本、Node/平台、发送时间',
|
|
722
|
+
feedbackSmtpShow: '设置邮件发送(SMTP)',
|
|
723
|
+
feedbackSmtpHide: '收起邮件发送设置',
|
|
724
|
+
feedbackSmtpEnvNote: '当前凭证来自环境变量(优先级高于用户层配置)',
|
|
725
|
+
feedbackSmtpHost: 'SMTP 服务器',
|
|
726
|
+
feedbackSmtpSecure: 'SSL/TLS(465 端口)',
|
|
727
|
+
feedbackSmtpPassSaved: '已保存,留空则不改动',
|
|
728
|
+
feedbackSmtpPass: '密码 / 授权码',
|
|
729
|
+
feedbackSmtpFrom: '发件邮箱(留空=同账号)',
|
|
730
|
+
feedbackSmtpHint: 'QQ、163 等邮箱要用「授权码」而不是登录密码;密码只写进本地 config.json,页面读不回来,也不会随插件发布',
|
|
731
|
+
feedbackSmtpSaveHint: '填完点「保存」(会把本页设置一起保存);不保存就点发送,密码不会被记住',
|
|
732
|
+
feedbackTooFast: '刚发过一封,请稍等半分钟',
|
|
733
|
+
feedbackTooMany: '发送过于频繁,请稍后再试',
|
|
659
734
|
},
|
|
660
735
|
en: {
|
|
661
736
|
triggerLabel: 'Ask about selection',
|
|
@@ -706,6 +781,7 @@ window.__ModuleLoader__.load({
|
|
|
706
781
|
errorNoSideEngine: 'This DSH composition has no usable subagent, so the side card cannot answer; ask in the main chat instead.',
|
|
707
782
|
errorTooLarge: 'The request body is too large',
|
|
708
783
|
errorBadRequest: 'Invalid request (empty question or empty selection)',
|
|
784
|
+
errorFeedbackInvalid: 'The report does not meet the length limits — check the title and details',
|
|
709
785
|
errorUnloaded: 'The plugin is unloading and cannot take new follow-ups',
|
|
710
786
|
errorDuplicate: 'This follow-up is already being answered; wait for it to finish',
|
|
711
787
|
errorBusy: 'Too many concurrent answers (limit {n}); try again shortly',
|
|
@@ -897,6 +973,49 @@ window.__ModuleLoader__.load({
|
|
|
897
973
|
suggestionsLabel: 'Suggested follow-ups',
|
|
898
974
|
familyLegacyDiag: 'Legacy co-load',
|
|
899
975
|
familyPeers: 'Copies on page',
|
|
976
|
+
setFeedback: 'Feedback',
|
|
977
|
+
setFeedbackHint: 'Report a bug or send a suggestion by mail from here; the plugin version and runtime details are attached automatically (no chat content)',
|
|
978
|
+
feedbackOpen: 'Send feedback',
|
|
979
|
+
feedbackClose: 'Close',
|
|
980
|
+
feedbackDialogTitle: 'Send feedback',
|
|
981
|
+
feedbackKind: 'Kind',
|
|
982
|
+
feedbackKindBug: 'Bug',
|
|
983
|
+
feedbackKindIdea: 'Idea',
|
|
984
|
+
feedbackKindQuestion: 'Question',
|
|
985
|
+
feedbackKindOther: 'Other',
|
|
986
|
+
feedbackTitle: 'Title',
|
|
987
|
+
feedbackTitlePlaceholder: 'One line (4–120 characters)',
|
|
988
|
+
feedbackBody: 'Details',
|
|
989
|
+
feedbackBodyPlaceholder: 'Steps to reproduce / expected / actual (10–4000 characters)',
|
|
990
|
+
feedbackContact: 'Your email (optional, used as the reply address)',
|
|
991
|
+
feedbackContactPlaceholder: 'you@example.com',
|
|
992
|
+
feedbackContactHint: 'Filled in, a reply reaches you directly; leave it empty to stay anonymous.',
|
|
993
|
+
feedbackContactInvalid: 'Enter a valid email address, or leave it empty',
|
|
994
|
+
feedbackSend: 'Send mail',
|
|
995
|
+
feedbackSending: 'Sending…',
|
|
996
|
+
feedbackSent: 'Sent — thank you',
|
|
997
|
+
feedbackMailto: 'Send with my mail app',
|
|
998
|
+
feedbackCopy: 'Copy text',
|
|
999
|
+
feedbackCopied: 'Copied to the clipboard',
|
|
1000
|
+
feedbackUnconfigured: 'No mail service is configured; use “Send with my mail app” instead',
|
|
1001
|
+
feedbackMailtoOnly: 'The recipient is set; only the SMTP credentials (environment variables) are missing, so use “Send with my mail app”',
|
|
1002
|
+
feedbackNeedsTitle: 'The title needs at least 4 characters',
|
|
1003
|
+
feedbackNeedsBody: 'The details need at least 10 characters',
|
|
1004
|
+
feedbackContactTooLong: 'The contact line is too long (max {n} characters)',
|
|
1005
|
+
feedbackFailed: 'Send failed: {message}',
|
|
1006
|
+
feedbackEnvNote: 'Attached automatically: plugin version, Node/platform, send time',
|
|
1007
|
+
feedbackSmtpShow: 'Set up mail delivery (SMTP)',
|
|
1008
|
+
feedbackSmtpHide: 'Hide mail delivery settings',
|
|
1009
|
+
feedbackSmtpEnvNote: 'Credentials come from the environment right now (it outranks this page), so the fields below have no effect.',
|
|
1010
|
+
feedbackSmtpHost: 'SMTP server',
|
|
1011
|
+
feedbackSmtpSecure: 'SSL/TLS (port 465)',
|
|
1012
|
+
feedbackSmtpPassSaved: 'Saved — leave empty to keep it',
|
|
1013
|
+
feedbackSmtpPass: 'Password / app code',
|
|
1014
|
+
feedbackSmtpFrom: 'From address (empty = the account)',
|
|
1015
|
+
feedbackSmtpHint: 'QQ/163 need an app-specific code, not the login password. The password is written to this machine\'s config.json only; the page never reads it back and it never ships with the plugin.',
|
|
1016
|
+
feedbackSmtpSaveHint: 'Press “Save” after filling this in (it saves the whole page). Without saving, the code is not remembered and sending keeps failing.',
|
|
1017
|
+
feedbackTooFast: 'One mail just went out — wait half a minute',
|
|
1018
|
+
feedbackTooMany: 'Too many reports for now — try again later',
|
|
900
1019
|
},
|
|
901
1020
|
}
|
|
902
1021
|
|
|
@@ -967,6 +1086,12 @@ window.__ModuleLoader__.load({
|
|
|
967
1086
|
'bad-response': { key: 'errorBadResponseBody' },
|
|
968
1087
|
'stream-broken': { key: 'errorStreamBrokenBody' },
|
|
969
1088
|
'stream-ended': { key: 'errorStreamEndedBody' },
|
|
1089
|
+
// Feedback endpoints: these four are fully known Client-side, so an
|
|
1090
|
+
// English UI never shows the Host's Chinese line for them.
|
|
1091
|
+
'feedback-invalid': { key: 'errorFeedbackInvalid' },
|
|
1092
|
+
'feedback-unconfigured': { key: 'feedbackUnconfigured' },
|
|
1093
|
+
'feedback-too-fast': { key: 'feedbackTooFast' },
|
|
1094
|
+
'feedback-too-many': { key: 'feedbackTooMany' },
|
|
970
1095
|
}
|
|
971
1096
|
|
|
972
1097
|
/** One localized line for a wire error; unknown codes keep the Host text. */
|
|
@@ -1639,6 +1764,54 @@ window.__ModuleLoader__.load({
|
|
|
1639
1764
|
}
|
|
1640
1765
|
}
|
|
1641
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* Validate a feedback draft BEFORE posting it, so the Host's 400 never
|
|
1769
|
+
* comes back as a surprise. The bounds are the live Host ones (see
|
|
1770
|
+
* {@link FEEDBACK_DEFAULTS}); the CR/LF rule mirrors the Host's own
|
|
1771
|
+
* header-injection fence.
|
|
1772
|
+
* @param {{kind?: string, title?: string, body?: string, contact?: string}} draft
|
|
1773
|
+
* @param {{titleMin: number, titleMax: number, bodyMin: number, bodyMax: number, contactMax?: number}} [limits]
|
|
1774
|
+
* @returns {{ok: true}|{ok: false, key: string}}
|
|
1775
|
+
*/
|
|
1776
|
+
function validateFeedbackDraft(draft, limits) {
|
|
1777
|
+
const bound = limits ?? FEEDBACK_DEFAULTS
|
|
1778
|
+
const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
|
|
1779
|
+
const body = typeof draft?.body === 'string' ? draft.body.trim() : ''
|
|
1780
|
+
const chars = text => Array.from(text).length
|
|
1781
|
+
if (title.includes('\n') || title.includes('\r')) return { ok: false, key: 'feedbackNeedsTitle' }
|
|
1782
|
+
if (chars(title) < bound.titleMin || chars(title) > bound.titleMax) return { ok: false, key: 'feedbackNeedsTitle' }
|
|
1783
|
+
if (chars(body) < bound.bodyMin || chars(body) > bound.bodyMax) return { ok: false, key: 'feedbackNeedsBody' }
|
|
1784
|
+
const contact = typeof draft?.contact === 'string' ? draft.contact.trim() : ''
|
|
1785
|
+
if (bound.contactMax !== undefined && chars(contact) > bound.contactMax) return { ok: false, key: 'feedbackContactTooLong' }
|
|
1786
|
+
// Same pragmatic mailbox rule as the Host: the value becomes `Reply-To:`,
|
|
1787
|
+
// so anything that is not an address would be rejected by the relay.
|
|
1788
|
+
if (contact !== '' && !FEEDBACK_EMAIL_PATTERN.test(contact)) return { ok: false, key: 'feedbackContactInvalid' }
|
|
1789
|
+
return { ok: true }
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** One-line subject for the report (also used by the `mailto:` fallback). */
|
|
1793
|
+
function feedbackSubjectFor({ version, draft }) {
|
|
1794
|
+
const title = typeof draft?.title === 'string' ? draft.title.trim() : ''
|
|
1795
|
+
const kind = typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'
|
|
1796
|
+
return `[dsh-sidecard-ask ${version}] ${kind}: ${title === '' ? 'feedback' : title}`
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/** The report as plain text: what the copy button puts on the clipboard. */
|
|
1800
|
+
function feedbackPlainTextFor({ version, draft, userAgent }) {
|
|
1801
|
+
const contact = typeof draft?.contact === 'string' ? draft.contact.trim() : ''
|
|
1802
|
+
const body = typeof draft?.body === 'string' ? draft.body.trim() : ''
|
|
1803
|
+
return [
|
|
1804
|
+
`kind: ${typeof draft?.kind === 'string' && draft.kind !== '' ? draft.kind : 'other'}`,
|
|
1805
|
+
`title: ${typeof draft?.title === 'string' ? draft.title.trim() : ''}`,
|
|
1806
|
+
`contact: ${contact === '' ? '(not provided)' : contact}`,
|
|
1807
|
+
'',
|
|
1808
|
+
body,
|
|
1809
|
+
'',
|
|
1810
|
+
`plugin: ${version}`,
|
|
1811
|
+
`user agent: ${typeof userAgent === 'string' ? userAgent : ''}`,
|
|
1812
|
+
].join('\n')
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1642
1815
|
/**
|
|
1643
1816
|
* Validate a settings-page quick-action draft BEFORE posting it, so the
|
|
1644
1817
|
* Host's all-or-nothing refusal never comes back as a surprise. Also
|
|
@@ -2155,6 +2328,11 @@ body[data-ds-dark-theme] .dsa-quickbar .dsa-trigger-embedded{color:#0a1428}
|
|
|
2155
2328
|
.dsa-btn:hover{border-color:var(--dsw-alias-brand-primary, #4c8dff)}
|
|
2156
2329
|
.dsa-btn-primary{background:var(--dsw-alias-brand-primary, #4c8dff);border-color:var(--dsw-alias-brand-primary, #4c8dff);
|
|
2157
2330
|
color:#fff;font-weight:500}
|
|
2331
|
+
/* Same reason as the segmented chips and the quick-bar primary: the uiskin dark
|
|
2332
|
+
brand is a light blue (#5b9cf5) where white text measures 2.79:1. Dark ink
|
|
2333
|
+
lands at 6.57:1 and keeps the button reading as "filled". */
|
|
2334
|
+
html[data-ds-dark-theme] .dsa-btn-primary,
|
|
2335
|
+
body[data-ds-dark-theme] .dsa-btn-primary{color:#0a1428}
|
|
2158
2336
|
.dsa-btn:disabled{opacity:.55;cursor:default}
|
|
2159
2337
|
/* Icon-only variant (the floating layer's collapse/hide controls): square and
|
|
2160
2338
|
quiet so the card title bar stays one line and the title keeps the weight. */
|
|
@@ -2315,6 +2493,42 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
2315
2493
|
.dsa-hist-meta{font-size:11px;color:var(--dsw-alias-label-secondary, #777)}
|
|
2316
2494
|
.dsa-diag{display:flex;flex-direction:column;gap:2px;font-size:12px;
|
|
2317
2495
|
color:var(--dsw-alias-label-secondary, #555)}
|
|
2496
|
+
/* Feedback (settings page), v1.9.1 rebuild: one compact banner at the very top
|
|
2497
|
+
of the page, and a centered dialog for the form. The form used to be an
|
|
2498
|
+
always-visible inline panel here, which pushed every real setting below the
|
|
2499
|
+
fold; the SMTP transport form is gone from the page entirely — the Host reads
|
|
2500
|
+
it from the environment / user config, so there is nothing for an end user to
|
|
2501
|
+
fill in. The dialog is position:fixed with a high z-index because this
|
|
2502
|
+
section is rendered INSIDE the app's settings modal. */
|
|
2503
|
+
.dsa-feedback-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 14px;
|
|
2504
|
+
border-radius:12px;border:1px solid var(--dsw-alias-brand-primary, #4c8dff);
|
|
2505
|
+
background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.06))}
|
|
2506
|
+
.dsa-feedback-bar-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 240px}
|
|
2507
|
+
.dsa-feedback-bar-title{font-weight:600;color:var(--dsw-alias-label-primary, #111)}
|
|
2508
|
+
.dsa-feedback-bar-hint{font-size:12px;line-height:1.5;
|
|
2509
|
+
color:var(--dsw-alias-label-secondary, #666)}
|
|
2510
|
+
.dsa-feedback-bar > .dsa-btn{flex:0 0 auto;margin-left:auto}
|
|
2511
|
+
.dsa-feedback-backdrop{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;
|
|
2512
|
+
justify-content:center;padding:24px;background:rgba(0,0,0,.45)}
|
|
2513
|
+
.dsa-feedback-dialog{display:flex;flex-direction:column;gap:10px;width:min(560px, 100%);
|
|
2514
|
+
max-height:calc(100vh - 48px);overflow:auto;padding:16px 18px;border-radius:14px;
|
|
2515
|
+
border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.3));
|
|
2516
|
+
background:var(--dsw-alias-bg-overlay, rgba(127,127,127,.03));color:inherit;
|
|
2517
|
+
box-shadow:0 18px 48px rgba(0,0,0,.28)}
|
|
2518
|
+
.dsa-feedback-dialog-head{display:flex;align-items:center;gap:8px}
|
|
2519
|
+
.dsa-feedback-dialog-head > strong{flex:1;min-width:0;font-size:14px}
|
|
2520
|
+
.dsa-feedback-dialog-head > .dsa-feedback-close{flex:0 0 auto;width:28px;height:28px;
|
|
2521
|
+
padding:0;line-height:1;font-size:16px}
|
|
2522
|
+
.dsa-feedback-dialog-field{display:flex;flex-direction:column;gap:4px}
|
|
2523
|
+
.dsa-feedback-dialog-field > label{font-size:12px;
|
|
2524
|
+
color:var(--dsw-alias-label-secondary, #555)}
|
|
2525
|
+
.dsa-feedback-dialog-field > textarea{min-height:120px;resize:vertical}
|
|
2526
|
+
.dsa-feedback-dialog-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
2527
|
+
.dsa-feedback-dialog-hint{font-size:12px;line-height:1.5;
|
|
2528
|
+
color:var(--dsw-alias-label-secondary, #666)}
|
|
2529
|
+
.dsa-feedback-state{font-size:12px;color:var(--dsw-alias-label-secondary, #666)}
|
|
2530
|
+
.dsa-feedback-state[data-state="sent"]{color:var(--dsw-alias-state-success-primary, #1a7f37)}
|
|
2531
|
+
.dsa-feedback-state[data-state="error"]{color:var(--dsw-alias-state-error-primary, #c0392b)}
|
|
2318
2532
|
/* §4.5 legacy coexistence: an unmarked dsa-layer can only be the old
|
|
2319
2533
|
dsh-selection-followup ≤1.0.x build. When this build is the elected
|
|
2320
2534
|
leader it hides the old build's whole interaction surface so exactly
|
|
@@ -4328,6 +4542,142 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4328
4542
|
state.toast !== null ? h('div', { className: 'dsa-toast' }, state.toast) : null)
|
|
4329
4543
|
}
|
|
4330
4544
|
|
|
4545
|
+
/**
|
|
4546
|
+
* The feedback dialog (`role="dialog"`).
|
|
4547
|
+
*
|
|
4548
|
+
* Module scope on purpose: the settings page only mounts it while open, and
|
|
4549
|
+
* the contract test renders it directly — the test React shim has no working
|
|
4550
|
+
* setState, so a click-driven open cannot be exercised through the page.
|
|
4551
|
+
* Everything it needs arrives through props, so it is also the one surface
|
|
4552
|
+
* that can be checked without a Host.
|
|
4553
|
+
*/
|
|
4554
|
+
function FeedbackDialog(props) {
|
|
4555
|
+
const {
|
|
4556
|
+
t, limits, draft, onChange, status, error, ready, reason, hasRecipient,
|
|
4557
|
+
onClose, onSubmit, onMailto, onCopy,
|
|
4558
|
+
} = props
|
|
4559
|
+
const sending = status === 'sending'
|
|
4560
|
+
// Escape closes it: a modal without a keyboard exit is a trap.
|
|
4561
|
+
React.useEffect(() => {
|
|
4562
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
|
|
4563
|
+
return undefined
|
|
4564
|
+
}
|
|
4565
|
+
const onKey = event => { if (event !== null && event?.key === 'Escape') onClose() }
|
|
4566
|
+
document.addEventListener('keydown', onKey)
|
|
4567
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
4568
|
+
}, [onClose])
|
|
4569
|
+
|
|
4570
|
+
const field = (label, control) => h('div', { className: 'dsa-feedback-dialog-field' },
|
|
4571
|
+
h('label', null, label),
|
|
4572
|
+
control)
|
|
4573
|
+
|
|
4574
|
+
return h('div', {
|
|
4575
|
+
className: 'dsa-feedback-backdrop',
|
|
4576
|
+
'data-dsa-feedback-dialog': '',
|
|
4577
|
+
onClick: event => {
|
|
4578
|
+
// Only a click on the backdrop itself closes; clicks inside the card
|
|
4579
|
+
// bubble here and must not dismiss a half-written report.
|
|
4580
|
+
if (event !== null && event !== undefined && event.target === event.currentTarget) onClose()
|
|
4581
|
+
},
|
|
4582
|
+
},
|
|
4583
|
+
h('div', {
|
|
4584
|
+
className: 'dsa-feedback-dialog',
|
|
4585
|
+
role: 'dialog',
|
|
4586
|
+
'aria-modal': 'true',
|
|
4587
|
+
'aria-label': t('feedbackDialogTitle'),
|
|
4588
|
+
},
|
|
4589
|
+
h('div', { className: 'dsa-feedback-dialog-head' },
|
|
4590
|
+
h('strong', null, t('feedbackDialogTitle')),
|
|
4591
|
+
h('button', {
|
|
4592
|
+
type: 'button',
|
|
4593
|
+
className: 'dsa-btn dsa-feedback-close',
|
|
4594
|
+
'data-feedback-close': '',
|
|
4595
|
+
title: t('feedbackClose'),
|
|
4596
|
+
'aria-label': t('feedbackClose'),
|
|
4597
|
+
onClick: onClose,
|
|
4598
|
+
}, '×')),
|
|
4599
|
+
h('div', { className: 'dsa-feedback-dialog-field' },
|
|
4600
|
+
h('label', null, t('feedbackKind')),
|
|
4601
|
+
h('select', {
|
|
4602
|
+
className: 'dsa-input',
|
|
4603
|
+
'data-feedback-field': 'kind',
|
|
4604
|
+
value: draft.kind,
|
|
4605
|
+
onChange: event => onChange({ kind: event.target.value }),
|
|
4606
|
+
}, [
|
|
4607
|
+
['bug', t('feedbackKindBug')],
|
|
4608
|
+
['idea', t('feedbackKindIdea')],
|
|
4609
|
+
['question', t('feedbackKindQuestion')],
|
|
4610
|
+
['other', t('feedbackKindOther')],
|
|
4611
|
+
].map(([value, label]) => h('option', { key: value, value }, label)))),
|
|
4612
|
+
field(t('feedbackTitle'), h('input', {
|
|
4613
|
+
className: 'dsa-input',
|
|
4614
|
+
'data-feedback-field': 'title',
|
|
4615
|
+
placeholder: t('feedbackTitlePlaceholder'),
|
|
4616
|
+
maxLength: limits.titleMax,
|
|
4617
|
+
value: draft.title,
|
|
4618
|
+
onChange: event => onChange({ title: event.target.value }),
|
|
4619
|
+
})),
|
|
4620
|
+
field(t('feedbackBody'), h('textarea', {
|
|
4621
|
+
className: 'dsa-textarea',
|
|
4622
|
+
'data-feedback-field': 'body',
|
|
4623
|
+
placeholder: t('feedbackBodyPlaceholder'),
|
|
4624
|
+
maxLength: limits.bodyMax,
|
|
4625
|
+
value: draft.body,
|
|
4626
|
+
onChange: event => onChange({ body: event.target.value }),
|
|
4627
|
+
})),
|
|
4628
|
+
// The reporter's own address becomes the mail's `Reply-To:`, never its
|
|
4629
|
+
// `From:` — the relay authenticates as the owner's account and would
|
|
4630
|
+
// reject a foreign sender.
|
|
4631
|
+
field(t('feedbackContact'), h('input', {
|
|
4632
|
+
className: 'dsa-input',
|
|
4633
|
+
'data-feedback-field': 'contact',
|
|
4634
|
+
type: 'email',
|
|
4635
|
+
inputMode: 'email',
|
|
4636
|
+
autoComplete: 'email',
|
|
4637
|
+
placeholder: t('feedbackContactPlaceholder'),
|
|
4638
|
+
maxLength: limits.contactMax,
|
|
4639
|
+
value: draft.contact,
|
|
4640
|
+
onChange: event => onChange({ contact: event.target.value }),
|
|
4641
|
+
})),
|
|
4642
|
+
h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackContactHint')),
|
|
4643
|
+
h('span', { className: 'dsa-feedback-dialog-hint' }, t('feedbackEnvNote')),
|
|
4644
|
+
// Readiness: three states, not two. No recipient at all, recipient but no
|
|
4645
|
+
// usable transport (the mail-app path still works), fully ready. When the
|
|
4646
|
+
// Host explains why, print its sentence verbatim.
|
|
4647
|
+
ready === true
|
|
4648
|
+
? null
|
|
4649
|
+
: h('span', { className: 'dsa-feedback-dialog-hint', 'data-feedback-readiness': 'unready' },
|
|
4650
|
+
String(reason ?? '') !== ''
|
|
4651
|
+
? String(reason)
|
|
4652
|
+
: (hasRecipient === true ? t('feedbackMailtoOnly') : t('feedbackUnconfigured'))),
|
|
4653
|
+
h('div', { className: 'dsa-feedback-dialog-actions' },
|
|
4654
|
+
h('button', {
|
|
4655
|
+
type: 'button',
|
|
4656
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
4657
|
+
'data-feedback-submit': '',
|
|
4658
|
+
disabled: sending,
|
|
4659
|
+
title: ready === true ? undefined : t('feedbackUnconfigured'),
|
|
4660
|
+
onClick: onSubmit,
|
|
4661
|
+
}, sending ? t('feedbackSending') : t('feedbackSend')),
|
|
4662
|
+
h('button', {
|
|
4663
|
+
type: 'button',
|
|
4664
|
+
className: 'dsa-btn',
|
|
4665
|
+
'data-feedback-mailto': '',
|
|
4666
|
+
onClick: onMailto,
|
|
4667
|
+
}, t('feedbackMailto')),
|
|
4668
|
+
h('button', {
|
|
4669
|
+
type: 'button',
|
|
4670
|
+
className: 'dsa-btn',
|
|
4671
|
+
'data-feedback-copy': '',
|
|
4672
|
+
onClick: onCopy,
|
|
4673
|
+
}, t('feedbackCopy'))),
|
|
4674
|
+
status === 'sent'
|
|
4675
|
+
? h('span', { className: 'dsa-feedback-state', 'data-state': 'sent', role: 'status' }, t('feedbackSent'))
|
|
4676
|
+
: status === 'error'
|
|
4677
|
+
? h('span', { className: 'dsa-feedback-state', 'data-state': 'error', role: 'status' }, error)
|
|
4678
|
+
: null))
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4331
4681
|
/** The settings page (`settings.section`). */
|
|
4332
4682
|
function SettingsSection({ actions }) {
|
|
4333
4683
|
const state = useStoreState()
|
|
@@ -4335,6 +4685,24 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4335
4685
|
const [status, setStatus] = React.useState('idle')
|
|
4336
4686
|
const [diag, setDiag] = React.useState(null)
|
|
4337
4687
|
const [historyQuery, setHistoryQuery] = React.useState('')
|
|
4688
|
+
// ── feedback form ──────────────────────────────────────────────────
|
|
4689
|
+
// `idle` → typing; `sending` → one POST in flight (button locked);
|
|
4690
|
+
// `sent` → the draft was cleared; `error` → the draft is KEPT, so a
|
|
4691
|
+
// relay failure never eats what the user just wrote.
|
|
4692
|
+
// Closed by default: the entry must be the first thing on the page and
|
|
4693
|
+
// one click away, but an always-open form pushed every real setting below
|
|
4694
|
+
// the fold (the previous version did exactly that and read as clutter).
|
|
4695
|
+
const [feedbackOpen, setFeedbackOpen] = React.useState(false)
|
|
4696
|
+
const [feedbackDraft, setFeedbackDraft] = React.useState({ kind: 'bug', title: '', body: '', contact: '' })
|
|
4697
|
+
const [feedbackStatus, setFeedbackStatus] = React.useState('idle')
|
|
4698
|
+
const [feedbackError, setFeedbackError] = React.useState('')
|
|
4699
|
+
// Editing is write-only: the saved address is never sent back to this page
|
|
4700
|
+
// (the Host strips it from `/state`), so the form can only replace it.
|
|
4701
|
+
const feedbackInfo = state.capabilities?.feedback ?? null
|
|
4702
|
+
const feedbackSmtp = feedbackInfo?.smtp ?? null
|
|
4703
|
+
const feedbackLimits = feedbackInfo?.limits ?? {
|
|
4704
|
+
titleMin: 4, titleMax: 120, bodyMin: 10, bodyMax: 4000, contactMax: 120,
|
|
4705
|
+
}
|
|
4338
4706
|
const [notifyPerm, setNotifyPerm] = React.useState(
|
|
4339
4707
|
typeof notificationPermission === 'function' ? notificationPermission() : 'unsupported')
|
|
4340
4708
|
const config = state.config
|
|
@@ -4362,6 +4730,70 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4362
4730
|
control,
|
|
4363
4731
|
hint !== undefined ? h('span', { className: 'dsa-muted' }, hint) : null)
|
|
4364
4732
|
|
|
4733
|
+
// ── feedback actions ───────────────────────────────────────────────
|
|
4734
|
+
const feedbackUserAgent = typeof navigator === 'undefined' ? '' : String(navigator.userAgent ?? '')
|
|
4735
|
+
|
|
4736
|
+
const submitFeedback = async () => {
|
|
4737
|
+
if (feedbackStatus === 'sending') return
|
|
4738
|
+
const verdict = validateFeedbackDraft(feedbackDraft, feedbackLimits)
|
|
4739
|
+
if (verdict.ok !== true) {
|
|
4740
|
+
setFeedbackStatus('error')
|
|
4741
|
+
setFeedbackError(t(verdict.key, { n: feedbackLimits.contactMax ?? FEEDBACK_DEFAULTS.contactMax }))
|
|
4742
|
+
return
|
|
4743
|
+
}
|
|
4744
|
+
setFeedbackStatus('sending')
|
|
4745
|
+
setFeedbackError('')
|
|
4746
|
+
try {
|
|
4747
|
+
await postJson('feedback', {
|
|
4748
|
+
kind: feedbackDraft.kind,
|
|
4749
|
+
title: feedbackDraft.title.trim(),
|
|
4750
|
+
body: feedbackDraft.body.trim(),
|
|
4751
|
+
contact: feedbackDraft.contact.trim(),
|
|
4752
|
+
})
|
|
4753
|
+
setFeedbackStatus('sent')
|
|
4754
|
+
setFeedbackDraft({ kind: feedbackDraft.kind, title: '', body: '', contact: '' })
|
|
4755
|
+
} catch (error) {
|
|
4756
|
+
setFeedbackStatus('error')
|
|
4757
|
+
setFeedbackError(t('feedbackFailed', { message: localizeError(error) }))
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
/**
|
|
4762
|
+
* Fallback that needs no credentials: hand the draft to the mail app.
|
|
4763
|
+
*
|
|
4764
|
+
* The URL is built by the Host — deliberately, so the plaintext recipient
|
|
4765
|
+
* is never held or rendered by this page. If the Host has no recipient,
|
|
4766
|
+
* it answers 503 and the failure is shown like any other.
|
|
4767
|
+
*/
|
|
4768
|
+
const sendFeedbackByMailApp = async () => {
|
|
4769
|
+
try {
|
|
4770
|
+
const value = await postJson('feedback-mailto', {
|
|
4771
|
+
kind: feedbackDraft.kind,
|
|
4772
|
+
title: feedbackDraft.title.trim(),
|
|
4773
|
+
body: feedbackDraft.body.trim(),
|
|
4774
|
+
contact: feedbackDraft.contact.trim(),
|
|
4775
|
+
userAgent: feedbackUserAgent,
|
|
4776
|
+
})
|
|
4777
|
+
const url = typeof value?.url === 'string' ? value.url : ''
|
|
4778
|
+
if (url === '') throw new Error('no mailto url')
|
|
4779
|
+
if (typeof window !== 'undefined' && typeof window.open === 'function') window.open(url, '_self')
|
|
4780
|
+
else if (typeof location !== 'undefined') location.href = url
|
|
4781
|
+
} catch (error) {
|
|
4782
|
+
setFeedbackStatus('error')
|
|
4783
|
+
setFeedbackError(t('feedbackFailed', { message: localizeError(error) }))
|
|
4784
|
+
}
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
const copyFeedbackDraft = async () => {
|
|
4788
|
+
const text = feedbackPlainTextFor({
|
|
4789
|
+
version: state.version ?? CLIENT_VERSION,
|
|
4790
|
+
draft: feedbackDraft,
|
|
4791
|
+
userAgent: feedbackUserAgent,
|
|
4792
|
+
})
|
|
4793
|
+
const result = await copyText(text, null)
|
|
4794
|
+
toast(result === 'copied' ? t('feedbackCopied') : t('copyFailed'))
|
|
4795
|
+
}
|
|
4796
|
+
|
|
4365
4797
|
const select = (key, options) => h('select', {
|
|
4366
4798
|
className: 'dsa-input',
|
|
4367
4799
|
value: String(draft[key]),
|
|
@@ -4588,6 +5020,45 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4588
5020
|
? h('div', { className: 'dsa-family-notice', role: 'status' }, t('familyLegacyToast'))
|
|
4589
5021
|
: null)
|
|
4590
5022
|
|
|
5023
|
+
// ── feedback entry ─────────────────────────────────────────────────
|
|
5024
|
+
// One compact banner at the very top of the page, and a dialog for the
|
|
5025
|
+
// form. Nothing else about feedback lives on this page: the transport is
|
|
5026
|
+
// the Host's business (environment / user config), so an end user has
|
|
5027
|
+
// nothing to fill in before writing a report. The banner also works when
|
|
5028
|
+
// no SMTP endpoint is configured — the `mailto:` path needs nothing but a
|
|
5029
|
+
// recipient — so the dialog says that in one line instead of looking broken.
|
|
5030
|
+
const feedbackConfigured = feedbackInfo !== null && feedbackInfo.configured === true
|
|
5031
|
+
const feedbackBanner = h('div', { className: 'dsa-feedback-bar', 'data-dsa-feedback-section': '' },
|
|
5032
|
+
h('div', { className: 'dsa-feedback-bar-text' },
|
|
5033
|
+
h('div', { className: 'dsa-feedback-bar-title' }, t('setFeedback')),
|
|
5034
|
+
h('div', { className: 'dsa-feedback-bar-hint' }, t('setFeedbackHint'))),
|
|
5035
|
+
h('button', {
|
|
5036
|
+
type: 'button',
|
|
5037
|
+
className: 'dsa-btn dsa-btn-primary',
|
|
5038
|
+
'data-feedback-open': '',
|
|
5039
|
+
'aria-haspopup': 'dialog',
|
|
5040
|
+
'aria-expanded': feedbackOpen ? 'true' : 'false',
|
|
5041
|
+
onClick: () => setFeedbackOpen(true),
|
|
5042
|
+
}, t('feedbackOpen')))
|
|
5043
|
+
|
|
5044
|
+
const feedbackDialog = feedbackOpen !== true
|
|
5045
|
+
? null
|
|
5046
|
+
: h(FeedbackDialog, {
|
|
5047
|
+
t,
|
|
5048
|
+
limits: feedbackLimits,
|
|
5049
|
+
draft: feedbackDraft,
|
|
5050
|
+
onChange: patch => setFeedbackDraft({ ...feedbackDraft, ...patch }),
|
|
5051
|
+
status: feedbackStatus,
|
|
5052
|
+
error: feedbackError,
|
|
5053
|
+
ready: feedbackConfigured,
|
|
5054
|
+
reason: String(feedbackInfo?.reason ?? ''),
|
|
5055
|
+
hasRecipient: feedbackInfo?.hasRecipient === true,
|
|
5056
|
+
onClose: () => setFeedbackOpen(false),
|
|
5057
|
+
onSubmit: () => { void submitFeedback() },
|
|
5058
|
+
onMailto: sendFeedbackByMailApp,
|
|
5059
|
+
onCopy: () => { void copyFeedbackDraft() },
|
|
5060
|
+
})
|
|
5061
|
+
|
|
4591
5062
|
// NOT marked `data-dsa-rail-host`: the settings page is not a rail page,
|
|
4592
5063
|
// and marking it would let the floating layer dock over the settings panel.
|
|
4593
5064
|
return h('div', { className: 'dsa-settings', 'data-dsa-root': '' },
|
|
@@ -4595,6 +5066,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4595
5066
|
h('div', null,
|
|
4596
5067
|
h('div', { className: 'dsa-title' }, t('settingsTitle')),
|
|
4597
5068
|
h('div', { className: 'dsa-muted' }, t('settingsDesc'))),
|
|
5069
|
+
feedbackBanner,
|
|
4598
5070
|
familyBanner,
|
|
4599
5071
|
h('div', { className: 'dsa-grid' },
|
|
4600
5072
|
field(t('setTrigger'), select('trigger', [
|
|
@@ -4753,7 +5225,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
4753
5225
|
h('div', { key: 'sidecard' }, `${t('diagSidecard')}: ${describeSurfaces()}`),
|
|
4754
5226
|
h('div', { key: 'path' }, `${t('settingsTitle')} → ${diag.provenance?.persistedPath ?? ''}`),
|
|
4755
5227
|
])
|
|
4756
|
-
: null
|
|
5228
|
+
: null,
|
|
5229
|
+
feedbackDialog)
|
|
4757
5230
|
}
|
|
4758
5231
|
|
|
4759
5232
|
/** Session probe: an invisible entry in the composer tool row. */
|
|
@@ -5491,6 +5964,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
5491
5964
|
problems: value.problems ?? [],
|
|
5492
5965
|
provenance: value.provenance ?? null,
|
|
5493
5966
|
})
|
|
5967
|
+
// Readiness hints (the feedback card's "缺的只是发信凭据", the surface
|
|
5968
|
+
// list, → live in `capabilities`, which only `/state` reports. Without
|
|
5969
|
+
// this refresh the page kept telling the user the transport was
|
|
5970
|
+
// missing right after they had saved it.
|
|
5971
|
+
try {
|
|
5972
|
+
await refreshState()
|
|
5973
|
+
} catch {
|
|
5974
|
+
// The save itself succeeded; a failed re-read must not undo that.
|
|
5975
|
+
}
|
|
5494
5976
|
return value
|
|
5495
5977
|
},
|
|
5496
5978
|
async resetConfig() {
|
|
@@ -6271,6 +6753,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6271
6753
|
}, 'sidecard-ask: client activation')
|
|
6272
6754
|
},
|
|
6273
6755
|
|
|
6756
|
+
/**
|
|
6757
|
+
* Test seam: components that can be rendered on their own. The feedback
|
|
6758
|
+
* dialog lives here because the settings page mounts it only while open and
|
|
6759
|
+
* the test shim has no working setState, so a simulated click cannot reach
|
|
6760
|
+
* it.
|
|
6761
|
+
*/
|
|
6762
|
+
components: Object.freeze({ FeedbackDialog }),
|
|
6763
|
+
|
|
6274
6764
|
/**
|
|
6275
6765
|
* Test seam: pure helpers, frozen. The DSH client runtime only reads
|
|
6276
6766
|
* `inject`/`apply`; `test/*.mjs` loads this file with a synthetic
|
|
@@ -6294,6 +6784,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
|
|
|
6294
6784
|
quickActionsOf,
|
|
6295
6785
|
renderActionQuestion,
|
|
6296
6786
|
validateQuickActionsDraft,
|
|
6787
|
+
validateFeedbackDraft,
|
|
6788
|
+
feedbackSubjectFor,
|
|
6789
|
+
feedbackPlainTextFor,
|
|
6790
|
+
FEEDBACK_DEFAULTS,
|
|
6297
6791
|
shortcutFingerprint,
|
|
6298
6792
|
followUpChips,
|
|
6299
6793
|
formatElapsed,
|