@telosmaylx/dsh-session-notify 0.1.8 → 0.1.9

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/lib/client.js CHANGED
@@ -45,13 +45,13 @@ window.__ModuleLoader__.load({
45
45
  title: '会话完成提醒', subtitle: '完成推送的语言与文案模板(保存后刷新生效)',
46
46
  preset: '预设', choosePreset: '选择预设…', language: '语言',
47
47
  fields: { completed: '完成', error: '出错', aborted: '中止', blocked: '阻塞', 'max-tokens': '输出上限' },
48
- titleLabel: '推送标题', titleHint: '默认「任务已完成」;插入 {title} 使用会话标题',
48
+ titleLabel: '推送标题', titleHint: '所有原因共用;留空时按原因用默认标题', titleTplLabel: '按原因定制标题(可选)', titleTplOpen: '展开', titleTplClose: '收起', ttPlaceholders: { completed: '任务已完成', error: '任务出错', aborted: '任务已中止', blocked: '任务被阻塞', 'max-tokens': '任务达到输出上限' },
49
49
  kindLabels: { completed: '会话已完成', error: '会话出错', aborted: '会话已中止', blocked: '会话被阻塞', 'max-tokens': '会话达到输出上限' },
50
50
  tokens: { title: '会话标题', label: '完成标签', duration: '用时', usage: '消耗', error: '错误', cache: '缓存命中', tps: '速度 TPS' },
51
51
  samples: { title: '示例会话', label: '会话已完成', duration: '12 秒', usage: '1,240 输入 / 3,560 输出', error: '连接超时', cache: '96.5%', tps: '92 tok/s' },
52
52
  presets: { custom: '自定义预设', unnamed: '未命名预设', default: '默认', playful: '轻松风格 🎉', formal: '正式简洁', detail: '详细报告' },
53
53
  placeholder: '自定义文案:点「+ 插入信息」,或直接输入文字',
54
- insertInfo: '+ 插入信息', insertTitle: '在光标处插入信息', preview: '预览:',
54
+ insertInfo: '+ 插入信息', insertTitle: '在光标处插入信息', sendTest: '发送', sendTestTitle: '发送测试通知(当前模板渲染)', preview: '预览:',
55
55
  skipSubagents: '跳过子代理会话',
56
56
  pushMode: '推送方式', pushDual: '系统通知 + 页内提示', pushSystem: '仅系统通知', pushToast: '仅页内提示',
57
57
  discard: '放弃', save: '保存', saving: '保存中…', saved: '已保存 ✓ · 刷新页面后生效', saveFailed: '保存失败:', refresh: '点击刷新', reset: '重置', resetDone: '已重置为默认值 · 刷新页面后生效',
@@ -65,13 +65,13 @@ window.__ModuleLoader__.load({
65
65
  title: '會話完成提醒', subtitle: '完成推送的語言與文案模板(儲存後重新整理生效)',
66
66
  preset: '預設', choosePreset: '選擇預設…', language: '語言',
67
67
  fields: { completed: '完成', error: '出錯', aborted: '中止', blocked: '阻塞', 'max-tokens': '輸出上限' },
68
- titleLabel: '推送標題', titleHint: '預設「任務已完成」;插入 {title} 使用會話標題',
68
+ titleLabel: '推送標題', titleHint: '所有原因共用;留空時按原因用預設標題', titleTplLabel: '按原因自訂標題(選填)', titleTplOpen: '展開', titleTplClose: '收起', ttPlaceholders: { completed: '任務已完成', error: '任務出錯', aborted: '任務已中止', blocked: '任務被阻塞', 'max-tokens': '任務達到輸出上限' },
69
69
  kindLabels: { completed: '會話已完成', error: '會話出錯', aborted: '會話已中止', blocked: '會話被阻塞', 'max-tokens': '會話達到輸出上限' },
70
70
  tokens: { title: '會話標題', label: '完成標籤', duration: '用時', usage: '消耗', error: '錯誤', cache: '快取命中', tps: '速度 TPS' },
71
71
  samples: { title: '範例會話', label: '會話已完成', duration: '12 秒', usage: '1,240 輸入 / 3,560 輸出', error: '連線逾時', cache: '96.5%', tps: '92 tok/s' },
72
72
  presets: { custom: '自訂預設', unnamed: '未命名預設', default: '預設', playful: '輕鬆風格 🎉', formal: '正式簡潔', detail: '詳細報告' },
73
73
  placeholder: '自訂文案:點「+ 插入資訊」,或直接輸入文字',
74
- insertInfo: '+ 插入資訊', insertTitle: '在游標處插入資訊', preview: '預覽:',
74
+ insertInfo: '+ 插入資訊', insertTitle: '在游標處插入資訊', sendTest: '傳送', sendTestTitle: '傳送測試通知(目前範本渲染)', preview: '預覽:',
75
75
  skipSubagents: '跳過子代理會話',
76
76
  pushMode: '推送方式', pushDual: '系統通知 + 頁內提示', pushSystem: '僅系統通知', pushToast: '僅頁內提示',
77
77
  discard: '放棄', save: '儲存', saving: '儲存中…', saved: '已儲存 ✓ · 重新整理頁面後生效', saveFailed: '儲存失敗:', refresh: '點擊重新整理', reset: '重置', resetDone: '已重置為預設值 · 重新整理頁面後生效',
@@ -85,13 +85,13 @@ window.__ModuleLoader__.load({
85
85
  title: 'Session completion alert', subtitle: 'Language & message templates for completion pushes (refresh after save)',
86
86
  preset: 'Preset', choosePreset: 'Choose preset…', language: 'Language',
87
87
  fields: { completed: 'Completed', error: 'Error', aborted: 'Aborted', blocked: 'Blocked', 'max-tokens': 'Token cap' },
88
- titleLabel: 'Push title', titleHint: 'Defaults to "Task completed"; insert {title} for the session title',
88
+ titleLabel: 'Push title', titleHint: 'Shared by all reasons; leave empty for per-reason defaults', titleTplLabel: 'Per-reason titles (optional)', titleTplOpen: 'Expand', titleTplClose: 'Collapse', ttPlaceholders: { completed: 'Task completed', error: 'Task failed', aborted: 'Task aborted', blocked: 'Task blocked', 'max-tokens': 'Task hit the output cap' },
89
89
  kindLabels: { completed: 'Session completed', error: 'Session failed', aborted: 'Session aborted', blocked: 'Session blocked', 'max-tokens': 'Session hit the output-token cap' },
90
90
  tokens: { title: 'Session title', label: 'Label', duration: 'Duration', usage: 'Usage', error: 'Error', cache: 'Cache hit', tps: 'Speed TPS' },
91
91
  samples: { title: 'Example session', label: 'Session completed', duration: '12s', usage: '1,240 in / 3,560 out', error: 'connection timeout', cache: '96.5%', tps: '92 tok/s' },
92
92
  presets: { custom: 'Custom preset', unnamed: 'Untitled preset', default: 'Default', playful: 'Playful 🎉', formal: 'Formal', detail: 'Detailed report' },
93
93
  placeholder: 'Custom text: use “+ Insert info”, or type directly',
94
- insertInfo: '+ Insert info', insertTitle: 'Insert info at cursor', preview: 'Preview: ',
94
+ insertInfo: '+ Insert info', insertTitle: 'Insert info at cursor', sendTest: 'Send', sendTestTitle: 'Send test notification (rendered from current template)', preview: 'Preview: ',
95
95
  skipSubagents: 'Skip subagent sessions',
96
96
  pushMode: 'Push channel', pushDual: 'System + in-page', pushSystem: 'System only', pushToast: 'In-page only',
97
97
  discard: 'Discard', save: 'Save', saving: 'Saving…', saved: 'Saved ✓ · refresh page to apply', saveFailed: 'Save failed: ', refresh: 'Refresh', reset: 'Reset', resetDone: 'Reset to defaults ✓ · refresh page to apply',
@@ -105,13 +105,13 @@ window.__ModuleLoader__.load({
105
105
  title: 'セッション完了通知', subtitle: '完了プッシュの言語とメッセージテンプレート(保存後に再読み込みで反映)',
106
106
  preset: 'プリセット', choosePreset: 'プリセットを選択…', language: '言語',
107
107
  fields: { completed: '完了', error: 'エラー', aborted: '中止', blocked: 'ブロック', 'max-tokens': 'トークン上限' },
108
- titleLabel: 'プッシュタイトル', titleHint: 'デフォルトは「タスク完了」;{title} でセッションタイトルを使用',
108
+ titleLabel: 'プッシュタイトル', titleHint: '全理由共通;空欄なら理由ごとのデフォルトを使用', titleTplLabel: '理由ごとのタイトル(任意)', titleTplOpen: '展開', titleTplClose: '折りたたむ', ttPlaceholders: { completed: 'タスク完了', error: 'タスクエラー', aborted: 'タスク中止', blocked: 'タスクがブロックされました', 'max-tokens': '出力上限に到達' },
109
109
  kindLabels: { completed: 'セッション完了', error: 'セッションエラー', aborted: 'セッション中止', blocked: 'セッションがブロックされました', 'max-tokens': '出力トークン上限に到達' },
110
110
  tokens: { title: 'セッションタイトル', label: '完了ラベル', duration: '所要時間', usage: '消費', error: 'エラー', cache: 'キャッシュヒット', tps: '速度 TPS' },
111
111
  samples: { title: 'サンプルセッション', label: 'セッション完了', duration: '12 秒', usage: '1,240 入力 / 3,560 出力', error: '接続タイムアウト', cache: '96.5%', tps: '92 tok/s' },
112
112
  presets: { custom: 'カスタムプリセット', unnamed: '未命名プリセット', default: 'デフォルト', playful: 'カジュアル 🎉', formal: 'フォーマル', detail: '詳細レポート' },
113
113
  placeholder: 'カスタム文面:「+ 情報を挿入」または直接入力',
114
- insertInfo: '+ 情報を挿入', insertTitle: 'カーソル位置に情報を挿入', preview: 'プレビュー:',
114
+ insertInfo: '+ 情報を挿入', insertTitle: 'カーソル位置に情報を挿入', sendTest: '送信', sendTestTitle: 'テスト通知を送信(現在のテンプレートで描画)', preview: 'プレビュー:',
115
115
  skipSubagents: 'サブエージェントセッションをスキップ',
116
116
  pushMode: '通知チャネル', pushDual: 'システム+ページ内', pushSystem: 'システムのみ', pushToast: 'ページ内のみ',
117
117
  discard: '破棄', save: '保存', saving: '保存中…', saved: '保存しました ✓ · ページを再読み込み', saveFailed: '保存失敗:', refresh: '再読み込み', reset: 'リセット', resetDone: 'デフォルトに戻しました ✓ · ページを再読み込み',
@@ -125,13 +125,13 @@ window.__ModuleLoader__.load({
125
125
  title: '세션 완료 알림', subtitle: '완료 푸시의 언어 및 메시지 템플릿(저장 후 새로고침 시 적용)',
126
126
  preset: '프리셋', choosePreset: '프리셋 선택…', language: '언어',
127
127
  fields: { completed: '완료', error: '오류', aborted: '중단', blocked: '차단', 'max-tokens': '토큰 한도' },
128
- titleLabel: '푸시 제목', titleHint: '기본값은 「작업 완료」;{title} 세션 제목 사용',
128
+ titleLabel: '푸시 제목', titleHint: '모든 이유 공통;비우면 이유별 기본값 사용', titleTplLabel: '이유별 제목 (선택)', titleTplOpen: '펼치기', titleTplClose: '접기', ttPlaceholders: { completed: '작업 완료', error: '작업 오류', aborted: '작업 중단됨', blocked: '작업 차단됨', 'max-tokens': '출력 한도 도달' },
129
129
  kindLabels: { completed: '세션 완료', error: '세션 오류', aborted: '세션 중단됨', blocked: '세션 차단됨', 'max-tokens': '출력 토큰 한도 도달' },
130
130
  tokens: { title: '세션 제목', label: '완료 라벨', duration: '소요 시간', usage: '소모', error: '오류', cache: '캐시 히트', tps: '속도 TPS' },
131
131
  samples: { title: '예시 세션', label: '세션 완료', duration: '12초', usage: '1,240 입력 / 3,560 출력', error: '연결 시간 초과', cache: '96.5%', tps: '92 tok/s' },
132
132
  presets: { custom: '사용자 지정 프리셋', unnamed: '이름 없는 프리셋', default: '기본', playful: '캐주얼 🎉', formal: '정식', detail: '상세 보고' },
133
133
  placeholder: '사용자 지정 문구: "+ 정보 삽입" 또는 직접 입력',
134
- insertInfo: '+ 정보 삽입', insertTitle: '커서 위치에 정보 삽입', preview: '미리보기: ',
134
+ insertInfo: '+ 정보 삽입', insertTitle: '커서 위치에 정보 삽입', sendTest: '보내기', sendTestTitle: '테스트 알림 보내기 (현재 템플릿으로 렌더링)', preview: '미리보기: ',
135
135
  skipSubagents: '하위 에이전트 세션 건너뛰기',
136
136
  pushMode: '알림 채널', pushDual: '시스템+페이지 내', pushSystem: '시스템만', pushToast: '페이지 내만',
137
137
  discard: '취소', save: '저장', saving: '저장 중…', saved: '저장됨 ✓ · 새로고침 후 적용', saveFailed: '저장 실패: ', refresh: '새로고침', reset: '초기화', resetDone: '기본값으로 초기화됨 ✓ · 새로고침 후 적용',
@@ -195,10 +195,10 @@ window.__ModuleLoader__.load({
195
195
  var cwdLine = workspaceLine(s)
196
196
  var start = Date.now()
197
197
  function attempt() {
198
- var notice = readNoticeAny(id)
199
- if (notice || Date.now() - start > 6000) {
198
+ var proj = readNoticeAny(id)
199
+ if (proj || Date.now() - start > 6000) {
200
200
  retryTimer = null
201
- notifyUser(pushTitle(name, notice ? '' : ' 已完成'), notice || (cwdLine ? cwdLine + ' · ' : '') + '详情见会话内系统消息', pushModeOf())
201
+ notifyUser(pushTitle(name, proj ? proj.title : '', proj ? '' : ' 已完成'), (proj && proj.text) || (cwdLine ? cwdLine + ' · ' : '') + '详情见会话内系统消息', pushModeOf())
202
202
  return
203
203
  }
204
204
  retryTimer = setTimeout(attempt, 400)
@@ -217,8 +217,15 @@ window.__ModuleLoader__.load({
217
217
  return 'dual'
218
218
  }
219
219
 
220
- /** 推送标题:默认「任务已完成」;用户模板可用 {title} 引用会话标题;无通知时附带完成标记。 */
221
- function pushTitle(name, suffix) {
220
+ /**
221
+ * 推送标题:优先级——
222
+ * 1. 宿主投影渲染好的该原因标题(projTitle,含 {title} 替换);
223
+ * 2. 用户全局 titleTemplate({title} = 会话标题);
224
+ * 3. 默认「任务已完成」。
225
+ * 无通知正文时(降级)附带完成标记 suffix。
226
+ */
227
+ function pushTitle(name, projTitle, suffix) {
228
+ if (typeof projTitle === 'string' && projTitle.trim() !== '') return projTitle.trim() + (suffix || '')
222
229
  var tpl = '任务已完成'
223
230
  try {
224
231
  if (notifyScope) {
@@ -237,14 +244,26 @@ window.__ModuleLoader__.load({
237
244
  * 2. 会话事件窗口里的 notice 节点(正在看的会话,落盘后立刻可用);
238
245
  * 3. 无 → 降级(工作区信息)。
239
246
  */
247
+ /**
248
+ * 读取完成通知:返回 { text, title } | null。
249
+ * 优先宿主投影(新格式对象 {kind,text,title};兼容旧版字符串值);
250
+ * 投影缺失时从会话事件窗口读 notice 节点(此时无该原因标题,title 为空串 → 走模板/默认)。
251
+ */
240
252
  function readNoticeAny(id) {
241
253
  try {
242
254
  var snap = list.getSnapshot()
243
255
  var cur = snap && snap.byId && snap.byId[id]
244
256
  var pv = cur && cur.projectionValues && cur.projectionValues[NOTICE_PROJECTION_KEY]
245
- if (typeof pv === 'string' && pv.trim() !== '') return pv
257
+ if (pv != null) {
258
+ if (typeof pv === 'string' && pv.trim() !== '') return { text: pv, title: '' }
259
+ if (typeof pv === 'object' && pv !== null) {
260
+ var txt = typeof pv.text === 'string' ? pv.text : ''
261
+ if (txt.trim() !== '') return { text: txt, title: typeof pv.title === 'string' ? pv.title : '' }
262
+ }
263
+ }
246
264
  } catch (e) { /* 投影缺失 → 走窗口路径 */ }
247
- return readNotice(id)
265
+ var fallback = readNotice(id)
266
+ return fallback ? { text: fallback, title: '' } : null
248
267
  }
249
268
 
250
269
  /** 从会话事件窗口找宿主写入的最新 notice 全文(kind=context + form=notice)。 */
@@ -505,13 +524,14 @@ window.__ModuleLoader__.load({
505
524
 
506
525
  /** 系统预设:仅保留「默认」(作为基线,其余以自定义预设承载)。 */
507
526
  var PRESETS = [
508
- { id: 'default', labelKey: 'default', language: 'zh', titleTemplate: '任务已完成', templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' } },
527
+ { id: 'default', labelKey: 'default', language: 'zh', titleTemplate: '', titleTemplates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' }, templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' } },
509
528
  ]
510
529
 
511
- /** 重置目标:与宿主 DEFAULT_SETTINGS 一致(设置面板可编辑字段子集)。 */
530
+ /** 重置目标:与宿主 DEFAULT_SETTINGS 一致(设置面板可编辑字段子集;语言保留当前值)。 */
512
531
  var DEFAULT_FLAT = {
513
532
  language: 'zh',
514
- titleTemplate: '任务已完成',
533
+ titleTemplate: '',
534
+ titleTemplates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
515
535
  pushMode: 'dual',
516
536
  templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
517
537
  }
@@ -569,6 +589,7 @@ window.__ModuleLoader__.load({
569
589
  if (String(cur.titleTemplate ?? '') !== String(p.titleTemplate ?? '')) return false
570
590
  for (var j = 0; j < REASON_FIELDS.length; j++) {
571
591
  if (templateOf(cur, REASON_FIELDS[j]) !== String(p.templates[REASON_FIELDS[j]] ?? '').trim()) return false
592
+ if (titleTplOf(cur, REASON_FIELDS[j]) !== String((p.titleTemplates && p.titleTemplates[REASON_FIELDS[j]]) ?? '').trim()) return false
572
593
  }
573
594
  return true
574
595
  }
@@ -599,6 +620,11 @@ window.__ModuleLoader__.load({
599
620
  return String(cur && cur['tpl-' + field] !== undefined ? cur['tpl-' + field] : (tpl[field] ?? ''))
600
621
  .replace(/\{label\}/g, '').trim()
601
622
  }
623
+ /** 按原因定制标题:草稿键 tt-<field> 优先,其次已保存的 titleTemplates[<field>]。 */
624
+ function titleTplOf(cur, field) {
625
+ var tt = (cur && cur.titleTemplates) || {}
626
+ return String(cur && cur['tt-' + field] !== undefined ? cur['tt-' + field] : (tt[field] ?? '')).trim()
627
+ }
602
628
  /** 扁平快照(用于 dirty 对比与模板打包)。 */
603
629
  function flatOf(cur) {
604
630
  var out = {
@@ -608,6 +634,7 @@ window.__ModuleLoader__.load({
608
634
  pushMode: ['dual', 'system', 'toast'].indexOf(cur.pushMode) >= 0 ? cur.pushMode : 'dual',
609
635
  }
610
636
  for (var i = 0; i < REASON_FIELDS.length; i++) out['tpl-' + REASON_FIELDS[i]] = templateOf(cur, REASON_FIELDS[i])
637
+ for (var j = 0; j < REASON_FIELDS.length; j++) out['tt-' + REASON_FIELDS[j]] = titleTplOf(cur, REASON_FIELDS[j])
611
638
  return out
612
639
  }
613
640
  function sameFlat(a, b) {
@@ -758,6 +785,9 @@ window.__ModuleLoader__.load({
758
785
  function syncFromDom() {
759
786
  var tpl = domToTemplate()
760
787
  lastTpl.current = tpl
788
+ // 占位提示只随内容变化:有输入 → 隐藏;清空 → 恢复(hint 可编辑内容或 placeholder)
789
+ if (tpl !== '') clearPlaceholder()
790
+ else rebuild('')
761
791
  propsRef.current.onChange(tpl)
762
792
  }
763
793
 
@@ -841,9 +871,8 @@ window.__ModuleLoader__.load({
841
871
  contentEditable: true,
842
872
  suppressContentEditableWarning: true,
843
873
  onInput: syncFromDom,
844
- onMouseDown: function () { clearPlaceholder() },
874
+ onMouseDown: function () { /* 不清理占位:点击聚焦不消失,输入内容才隐藏 */ },
845
875
  onKeyDown: function (e) {
846
- clearPlaceholder()
847
876
  if (e.key === 'Enter') { e.preventDefault(); return }
848
877
  // Ctrl/Cmd+A:手动扩选“全部子节点(含胶囊)”——浏览器全选天然跳过
849
878
  // contenteditable=false 的胶囊,导致全选删除后胶囊残留
@@ -881,18 +910,25 @@ window.__ModuleLoader__.load({
881
910
  })
882
911
  }
883
912
 
884
- /** 内置默认内容的提示串(空模板时直接展示真实默认效果:文字 + 信息标签)。 */
913
+ /** 各语言「会话」前缀(用于默认提示中在标签内插入会话标题标签)。 */
914
+ var SESSION_PREFIX = { zh: '会话', 'zh-tw': '會話', en: 'Session ', ja: 'セッション', ko: '세션 ' }
915
+
916
+ /** 内置默认内容的提示串(空模板时直接展示真实默认效果:会话「会话标题」+ 信息标签)。 */
885
917
  function defaultHintOf(field, langCode) {
886
918
  var lang = LANG_IDS.indexOf(langCode) >= 0 ? langCode : 'zh'
887
919
  var tt = tOf(lang)
888
920
  var label = (tt.kindLabels && tt.kindLabels[field]) || tt.samples.label
889
921
  var d = '{duration}'
890
922
  var u = '{usage}'
891
- if (lang === 'en') return label + ' (took ' + d + ', used ' + u + ').'
892
- if (lang === 'ja') return label + '(所要 ' + d + '、消費 ' + u + ')。'
893
- if (lang === 'ko') return label + '(소요 ' + d + ', 소모 ' + u + ')。'
894
- if (lang === 'zh-tw') return label + '(用時 ' + d + ',消耗 ' + u + ')。'
895
- return label + '(用时 ' + d + ',消耗 ' + u + ')。'
923
+ var prefix = SESSION_PREFIX[lang] || '会话'
924
+ var rest = String(label).slice(prefix.length) // 剥掉「会话」前缀,保留原因后缀(已完成/中止/…)
925
+ var titleTag = lang === 'en' ? '"{title}"' : '「{title}」'
926
+ var head = prefix + titleTag + ((lang === 'en' || lang === 'ko') ? ' ' : '') + rest
927
+ if (lang === 'en') return head + ' (took ' + d + ', used ' + u + ').'
928
+ if (lang === 'ja') return head + '(所要 ' + d + '、消費 ' + u + ')。'
929
+ if (lang === 'ko') return head + '(소요 ' + d + ', 소모 ' + u + ')。'
930
+ if (lang === 'zh-tw') return head + '(用時 ' + d + ',消耗 ' + u + ')。'
931
+ return head + '(用时 ' + d + ',消耗 ' + u + ')。'
896
932
  }
897
933
 
898
934
  return function SettingsCard() {
@@ -936,6 +972,9 @@ window.__ModuleLoader__.load({
936
972
  var permTickState = useState(0) // 通知权限状态刷新计数(请求授权后 setPermTick(+1) 重渲染)
937
973
  var permTick = permTickState[0]
938
974
  var setPermTick = permTickState[1]
975
+ var titleTplOpenState = useState(false) // 「按原因定制标题」折叠区展开状态
976
+ var titleTplOpen = titleTplOpenState[0]
977
+ var setTitleTplOpen = titleTplOpenState[1]
939
978
 
940
979
  /** 用户手势内请求通知权限(Chromium 忽略非手势的自动请求,必须由点击触发);授权结果经重渲染刷新状态区。 */
941
980
  function requestPermissionNow() {
@@ -1025,6 +1064,10 @@ window.__ModuleLoader__.load({
1025
1064
  setCustomPresets(next)
1026
1065
  saveCustomPresets(next)
1027
1066
  setActivePreset(null)
1067
+ // 删除的是当前使用的预设 → 表单恢复默认预设内容(presetIdOf 自动匹配「默认」)
1068
+ if (shownEntry.id === (activePreset && activePreset.id)) {
1069
+ setDraft(Object.assign({}, DEFAULT_FLAT))
1070
+ }
1028
1071
  setNote(t.customDeleted)
1029
1072
  log('custom preset deleted')
1030
1073
  }
@@ -1032,15 +1075,18 @@ window.__ModuleLoader__.load({
1032
1075
  function save() {
1033
1076
  var flat = flatOf(cur)
1034
1077
  var templates = {}
1078
+ var titleTemplates = {}
1035
1079
  for (var i = 0; i < REASON_FIELDS.length; i++) {
1036
1080
  var k = REASON_FIELDS[i]
1037
1081
  templates[k] = flat['tpl-' + k]
1082
+ titleTemplates[k] = flat['tt-' + k]
1038
1083
  }
1039
1084
  setNote(t.saving)
1040
1085
  var tasks = [
1041
1086
  scope.set('language', flat.language),
1042
1087
  scope.set('templates', templates),
1043
1088
  scope.set('titleTemplate', flat.titleTemplate),
1089
+ scope.set('titleTemplates', titleTemplates),
1044
1090
  scope.set('pushMode', flat.pushMode),
1045
1091
  ]
1046
1092
  Promise.all(tasks).then(function () {
@@ -1054,7 +1100,7 @@ window.__ModuleLoader__.load({
1054
1100
  var found = null
1055
1101
  if (shownCustom && shownEntry) {
1056
1102
  targetId = shownEntry.id
1057
- entry = Object.assign({}, shownEntry, { language: lang, titleTemplate: flat.titleTemplate, templates: templates })
1103
+ entry = Object.assign({}, shownEntry, { language: lang, titleTemplate: flat.titleTemplate, titleTemplates: titleTemplates, templates: templates })
1058
1104
  list = customPresets.map(function (cp) {
1059
1105
  if (cp.id === targetId) { found = Object.assign({}, cp, entry); return found }
1060
1106
  return cp
@@ -1063,7 +1109,7 @@ window.__ModuleLoader__.load({
1063
1109
  } else {
1064
1110
  var n = nextUnnamedNumber(customPresets)
1065
1111
  targetId = n === 1 ? 'unnamed' : 'unnamed-' + n
1066
- entry = { id: targetId, name: null, auto: true, language: lang, titleTemplate: flat.titleTemplate, templates: templates }
1112
+ entry = { id: targetId, name: null, auto: true, language: lang, titleTemplate: flat.titleTemplate, titleTemplates: titleTemplates, templates: templates }
1067
1113
  list = customPresets.concat([entry])
1068
1114
  found = entry
1069
1115
  }
@@ -1080,21 +1126,46 @@ window.__ModuleLoader__.load({
1080
1126
  })
1081
1127
  }
1082
1128
 
1083
- /** 一键重置:把设置还原为默认值(与宿主 DEFAULT_SETTINGS 一致)并立即保存。 */
1129
+ /** 发送测试通知:用当前模板(空模板用默认文案)渲染该原因的通知;标题按原因优先级。 */
1130
+ function sendTestNotice(field) {
1131
+ try {
1132
+ var samples = t.samples
1133
+ var titleTpl = titleTplOf(cur, field) || String(cur.titleTemplate || '').trim() || t.ttPlaceholders[field]
1134
+ var title = String(titleTpl).replaceAll('{title}', samples.title).trim() || samples.title
1135
+ var tpl = templateOf(cur, field)
1136
+ var bodyTpl = tpl !== '' ? tpl : defaultHintOf(field, cur.language)
1137
+ var body = String(bodyTpl)
1138
+ .replace(/\{label\}/g, '')
1139
+ .replaceAll('{title}', samples.title)
1140
+ .replaceAll('{duration}', samples.duration)
1141
+ .replaceAll('{usage}', samples.usage)
1142
+ .replaceAll('{error}', samples.error)
1143
+ .replaceAll('{cache}', samples.cache)
1144
+ .replaceAll('{tps}', samples.tps)
1145
+ notifyUser(title, body, ['dual', 'system', 'toast'].indexOf(cur.pushMode) >= 0 ? cur.pushMode : 'dual')
1146
+ log('test notice sent: ' + field)
1147
+ } catch (e) {
1148
+ log('test notice failed: ' + (e && e.message ? e.message : e))
1149
+ }
1150
+ }
1151
+
1152
+ /** 一键重置:除语言外全部还原为默认值(语言保留当前选择)并立即保存。 */
1084
1153
  function resetAll() {
1085
1154
  var def = DEFAULT_FLAT
1155
+ var lang = LANG_IDS.indexOf(cur.language) >= 0 ? cur.language : 'zh' // 重置不改变语言
1086
1156
  setNote(t.saving)
1087
1157
  var tasks = [
1088
- scope.set('language', def.language),
1158
+ scope.set('language', lang),
1089
1159
  scope.set('templates', def.templates),
1090
1160
  scope.set('titleTemplate', def.titleTemplate),
1161
+ scope.set('titleTemplates', def.titleTemplates),
1091
1162
  scope.set('pushMode', def.pushMode),
1092
1163
  ]
1093
1164
  Promise.all(tasks).then(function () {
1094
1165
  setDraft(null)
1095
1166
  setNote(t.resetDone)
1096
1167
  setJustSaved(true)
1097
- log('settings reset to defaults')
1168
+ log('settings reset to defaults (language kept: ' + lang + ')')
1098
1169
  }).catch(function (e) {
1099
1170
  setNote(t.saveFailed + (e && e.message ? e.message : e))
1100
1171
  log('settings reset failed: ' + (e && e.message ? e.message : e))
@@ -1205,13 +1276,37 @@ window.__ModuleLoader__.load({
1205
1276
  var handle = editorRefs.current.title
1206
1277
  if (handle) handle.insertAtCaret('title')
1207
1278
  else setVal('titleTemplate', String(cur.titleTemplate ?? '') + '{title}')
1208
- setMenuFor(null)
1279
+ // 插入后保持菜单展开:方便连续插入多个标签,由用户自行折叠
1209
1280
  },
1210
1281
  style: { appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px', borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)' },
1211
1282
  }, '+ ' + t.tokens.title)
1212
1283
  )
1213
1284
  : null,
1214
1285
  ),
1286
+ // 按原因定制标题(折叠区,避免界面臃肿):留空 = 用上方全局模板或语言默认标题
1287
+ h('div', { key: 'title-tpl', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER } },
1288
+ h('button', {
1289
+ type: 'button',
1290
+ onClick: function () { setTitleTplOpen(!titleTplOpen) },
1291
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', appearance: 'none', background: 'none', border: 'none', cursor: 'pointer', padding: '0', font: 'inherit', textAlign: 'left' },
1292
+ },
1293
+ h('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.titleTplLabel),
1294
+ h('span', { style: { fontSize: '12px', color: SUBTLE } }, titleTplOpen ? t.titleTplClose : t.titleTplOpen),
1295
+ ),
1296
+ titleTplOpen
1297
+ ? REASON_FIELDS.map(function (field) {
1298
+ return h('div', { key: 'tt-' + field, style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1299
+ h('span', { style: { width: '76px', flex: 'none', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))' } }, t.fields[field]),
1300
+ h('input', {
1301
+ value: titleTplOf(cur, field),
1302
+ placeholder: t.ttPlaceholders[field],
1303
+ onChange: function (e) { setVal('tt-' + field, e.target.value) },
1304
+ style: Object.assign({}, SELECT_STYLE, { flex: 1, fontSize: '13px' }),
1305
+ }),
1306
+ )
1307
+ })
1308
+ : null,
1309
+ ),
1215
1310
  ]
1216
1311
  // 模板(forEach:每条回调独立绑定字段,避免 var 闭包串写)
1217
1312
  // 每栏 = 标签行(左文案 + 右「+插入信息」)+ ChipEditor(文字+内联胶囊,光标处插入)
@@ -1225,18 +1320,30 @@ window.__ModuleLoader__.load({
1225
1320
  key: 'tpl-' + field,
1226
1321
  style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '12px 0', borderTop: DIVIDER },
1227
1322
  },
1228
- h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1323
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' } },
1229
1324
  h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, t.fields[field]),
1230
- h('button', {
1231
- type: 'button',
1232
- title: t.insertTitle,
1233
- onClick: function () { setMenuFor(menuFor === field ? null : field) },
1234
- style: {
1235
- appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px',
1236
- borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1237
- background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
1238
- },
1239
- }, t.insertInfo),
1325
+ h('div', { style: { display: 'flex', gap: '6px' } },
1326
+ h('button', {
1327
+ type: 'button',
1328
+ title: t.sendTestTitle,
1329
+ onClick: function () { sendTestNotice(field) },
1330
+ style: {
1331
+ appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px',
1332
+ borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1333
+ background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
1334
+ },
1335
+ }, t.sendTest),
1336
+ h('button', {
1337
+ type: 'button',
1338
+ title: t.insertTitle,
1339
+ onClick: function () { setMenuFor(menuFor === field ? null : field) },
1340
+ style: {
1341
+ appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '3px 10px',
1342
+ borderRadius: '999px', border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
1343
+ background: 'none', color: 'var(--dsw-alias-label-secondary, inherit)',
1344
+ },
1345
+ }, t.insertInfo),
1346
+ ),
1240
1347
  ),
1241
1348
  h(ChipEditor, {
1242
1349
  template: tplValue,
@@ -1263,7 +1370,7 @@ window.__ModuleLoader__.load({
1263
1370
  var handle = editorRefs.current[field]
1264
1371
  if (handle) handle.insertAtCaret(k)
1265
1372
  else setVal('tpl-' + field, templateOf(cur, field) + '{' + k + '}')
1266
- setMenuFor(null)
1373
+ // 插入后保持菜单展开:方便连续插入多个标签,由用户自行折叠
1267
1374
  },
1268
1375
  style: {
1269
1376
  appearance: 'none', font: 'inherit', fontSize: '12px', cursor: 'pointer', padding: '4px 10px',
package/lib/core.js CHANGED
@@ -25,57 +25,96 @@ function tableFor(language) {
25
25
  return LABELS[language] || LABELS.zh
26
26
  }
27
27
 
28
+ /**
29
+ * 各语言默认推送标题(按结束原因差异化)。
30
+ * 优先级:titleTemplates[kind] > titleTemplate(全局模板)> 本表。
31
+ */
32
+ const DEFAULT_TITLES = {
33
+ zh: { completed: '任务已完成', aborted: '任务已中止', blocked: '任务被阻塞', error: '任务出错', 'max-tokens': '任务达到输出上限' },
34
+ 'zh-tw': { completed: '任務已完成', aborted: '任務已中止', blocked: '任務被阻塞', error: '任務出錯', 'max-tokens': '任務達到輸出上限' },
35
+ en: { completed: 'Task completed', aborted: 'Task aborted', blocked: 'Task blocked', error: 'Task failed', 'max-tokens': 'Task hit the output cap' },
36
+ ja: { completed: 'タスク完了', aborted: 'タスク中止', blocked: 'タスクがブロックされました', error: 'タスクエラー', 'max-tokens': '出力上限に到達' },
37
+ ko: { completed: '작업 완료', aborted: '작업 중단됨', blocked: '작업 차단됨', error: '작업 오류', 'max-tokens': '출력 한도 도달' },
38
+ }
39
+
40
+ /**
41
+ * 渲染某原因的实际推送标题:
42
+ * titleTemplates[kind](按原因定制)> titleTemplate(全局模板)> DEFAULT_TITLES[lang][kind]。
43
+ * 模板支持 {title} 占位符(会话标题,缺失为空);渲染后为空时回退默认标题。
44
+ * @param {string} kind
45
+ * @param {{ language?: string; titleTemplate?: string; titleTemplates?: Record<string, string> }} opts
46
+ * @param {string} [titleValue]
47
+ * @returns {string}
48
+ */
49
+ export function renderTitle(kind, opts = {}, titleValue = '') {
50
+ const language = LABELS[opts.language] ? opts.language : 'zh'
51
+ const per = (opts.titleTemplates && typeof opts.titleTemplates[kind] === 'string' && opts.titleTemplates[kind].trim() !== '') ? opts.titleTemplates[kind] : ''
52
+ const global = typeof opts.titleTemplate === 'string' && opts.titleTemplate.trim() !== '' ? opts.titleTemplate : ''
53
+ const fallback = (DEFAULT_TITLES[language] && DEFAULT_TITLES[language][kind]) || DEFAULT_TITLES.zh[kind] || '任务已完成'
54
+ const tpl = per || global || fallback
55
+ const out = String(tpl).replaceAll('{title}', titleValue).trim()
56
+ return out || fallback
57
+ }
58
+
28
59
  /**
29
60
  * 各语言默认正文句式(templates 留空时的回退文案)。
30
61
  * 按 reason 差异化表达,避免清一色「(用时 X,消耗 Y)」:
31
62
  * - completed:括号紧凑式;aborted/blocked:句号拆句;error:错误详情前置;
32
63
  * - max-tokens:句号拆句 + 建议;generic:兜底。
33
- * 占位符:{label} 标签、{errBlock} 错误详情块(含分隔符,无错误为空)、{dur} 用时、{use} 消耗。
64
+ * 标签内嵌会话标题:{titleBlock}(带语言引号;无标题时为空块,自动退回「会话已完成」)。
65
+ * 其他占位符:{errBlock} 错误详情块(含分隔符,无错误为空)、{dur} 用时、{use} 消耗。
34
66
  * 仅当用时与消耗都有数据时启用;数据不全回退到 buildNotice 的 parts 拼接逻辑(自动省略缺失项)。
35
67
  */
36
68
  const DEFAULT_PHRASES = {
37
69
  zh: {
38
- completed: '{label}(用时 {dur},消耗 {use})。',
39
- aborted: '{label}。用时 {dur},消耗 {use}。',
40
- blocked: '{label}。用时 {dur},消耗 {use}。',
41
- error: '{label}{errBlock}(用时 {dur},消耗 {use})。',
42
- 'max-tokens': '{label}。用时 {dur},消耗 {use},建议拆分任务后重试。',
43
- generic: '{label}。用时 {dur},消耗 {use}。',
70
+ completed: '会话{titleBlock}已完成(用时 {dur},消耗 {use})。',
71
+ aborted: '会话{titleBlock}已中止。用时 {dur},消耗 {use}。',
72
+ blocked: '会话{titleBlock}被阻塞。用时 {dur},消耗 {use}。',
73
+ error: '会话{titleBlock}出错{errBlock}(用时 {dur},消耗 {use})。',
74
+ 'max-tokens': '会话{titleBlock}达到输出上限。用时 {dur},消耗 {use},建议拆分任务后重试。',
75
+ generic: '会话{titleBlock}已结束。用时 {dur},消耗 {use}。',
44
76
  },
45
77
  'zh-tw': {
46
- completed: '{label}(用時 {dur},消耗 {use})。',
47
- aborted: '{label}。用時 {dur},消耗 {use}。',
48
- blocked: '{label}。用時 {dur},消耗 {use}。',
49
- error: '{label}{errBlock}(用時 {dur},消耗 {use})。',
50
- 'max-tokens': '{label}。用時 {dur},消耗 {use},建議拆分任務後重試。',
51
- generic: '{label}。用時 {dur},消耗 {use}。',
78
+ completed: '會話{titleBlock}已完成(用時 {dur},消耗 {use})。',
79
+ aborted: '會話{titleBlock}已中止。用時 {dur},消耗 {use}。',
80
+ blocked: '會話{titleBlock}被阻塞。用時 {dur},消耗 {use}。',
81
+ error: '會話{titleBlock}出錯{errBlock}(用時 {dur},消耗 {use})。',
82
+ 'max-tokens': '會話{titleBlock}達到輸出上限。用時 {dur},消耗 {use},建議拆分任務後重試。',
83
+ generic: '會話{titleBlock}已結束。用時 {dur},消耗 {use}。',
52
84
  },
53
85
  en: {
54
- completed: '{label} (took {dur}, used {use}).',
55
- aborted: '{label}. Took {dur}, used {use}.',
56
- blocked: '{label}. Took {dur}, used {use}.',
57
- error: '{label}{errBlock} (took {dur}, used {use}).',
58
- 'max-tokens': '{label}. Took {dur}, used {use} — consider splitting the task.',
59
- generic: '{label}. Took {dur}, used {use}.',
86
+ completed: 'Session {titleBlock} completed (took {dur}, used {use}).',
87
+ aborted: 'Session {titleBlock} aborted. Took {dur}, used {use}.',
88
+ blocked: 'Session {titleBlock} blocked. Took {dur}, used {use}.',
89
+ error: 'Session {titleBlock} failed{errBlock} (took {dur}, used {use}).',
90
+ 'max-tokens': 'Session {titleBlock} hit the output-token cap. Took {dur}, used {use} — consider splitting the task.',
91
+ generic: 'Session {titleBlock} ended. Took {dur}, used {use}.',
60
92
  },
61
93
  ja: {
62
- completed: '{label}(所要 {dur}、消費 {use})。',
63
- aborted: '{label}。所要 {dur}、消費 {use}。',
64
- blocked: '{label}。所要 {dur}、消費 {use}。',
65
- error: '{label}{errBlock}(所要 {dur}、消費 {use})。',
66
- 'max-tokens': '{label}。所要 {dur}、消費 {use}。タスクの分割をおすすめします。',
67
- generic: '{label}。所要 {dur}、消費 {use}。',
94
+ completed: 'セッション{titleBlock}完了(所要 {dur}、消費 {use})。',
95
+ aborted: 'セッション{titleBlock}中止。所要 {dur}、消費 {use}。',
96
+ blocked: 'セッション{titleBlock}がブロックされました。所要 {dur}、消費 {use}。',
97
+ error: 'セッション{titleBlock}エラー{errBlock}(所要 {dur}、消費 {use})。',
98
+ 'max-tokens': 'セッション{titleBlock}が出力トークン上限に到達。所要 {dur}、消費 {use}。タスクの分割をおすすめします。',
99
+ generic: 'セッション{titleBlock}終了。所要 {dur}、消費 {use}。',
68
100
  },
69
101
  ko: {
70
- completed: '{label}(소요 {dur}, 소모 {use})。',
71
- aborted: '{label}. 소요 {dur}, 소모 {use}.',
72
- blocked: '{label}. 소요 {dur}, 소모 {use}.',
73
- error: '{label}{errBlock}(소요 {dur}, 소모 {use}).',
74
- 'max-tokens': '{label}. 소요 {dur}, 소모 {use} — 작업 분할을 권장합니다.',
75
- generic: '{label}. 소요 {dur}, 소모 {use}.',
102
+ completed: '세션{titleBlock} 완료(소요 {dur}, 소모 {use})。',
103
+ aborted: '세션{titleBlock} 중단됨. 소요 {dur}, 소모 {use}.',
104
+ blocked: '세션{titleBlock} 차단됨. 소요 {dur}, 소모 {use}.',
105
+ error: '세션{titleBlock} 오류{errBlock}(소요 {dur}, 소모 {use}).',
106
+ 'max-tokens': '세션{titleBlock} 출력 한도 도달. 소요 {dur}, 소모 {use} — 작업 분할을 권장합니다.',
107
+ generic: '세션{titleBlock} 종료. 소요 {dur}, 소모 {use}.',
76
108
  },
77
109
  }
78
110
 
111
+ /** 会话标题块:带语言引号(zh/zh-tw/ja/ko「」,en ");无标题返回空块。 */
112
+ function titleValueBlock(language, titleValue) {
113
+ const v = typeof titleValue === 'string' ? titleValue.trim() : ''
114
+ if (!v) return ''
115
+ return language === 'en' ? `"${v}"` : `「${v}」`
116
+ }
117
+
79
118
  /**
80
119
  * 按语言取 reason 标签。
81
120
  * @param {string} kind
@@ -297,8 +336,10 @@ export function buildNotice(kind, reasonData, { startedAt, endedAt, usage = null
297
336
  } else if (durationLine && usageLine && phraseTpl) {
298
337
  // 默认句式(用时+消耗都有时启用,按 reason 差异化表达)
299
338
  const errBlock = errText ? L.sep + errText : ''
339
+ const titleBlock = titleValueBlock(language, titleValue)
300
340
  text = phraseTpl
301
341
  .replaceAll('{label}', label)
342
+ .replaceAll('{titleBlock}', titleBlock)
302
343
  .replaceAll('{errBlock}', errBlock)
303
344
  .replaceAll('{dur}', durationLine)
304
345
  .replaceAll('{use}', usageLine)