dsh-my-observability 0.3.2 → 0.3.4

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 CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  本文件记录 dsh-my-observability 的所有版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.3.4] - 2026-09-18
6
+
7
+ ### 变更
8
+
9
+ - fix(my-observability): 去掉仅内部使用的 4 个导出(knip 死代码门禁)
10
+ - docs(guard,observability,mermaid-render): 设置页面板真实截图 + README 效果图
11
+ - fix(my-observability): 拆分设置视图至尺寸门禁内
12
+ - feat(my-observability): 设置页配置面板(aiReview / aiTimeoutMs 可视化编辑)
13
+
14
+ ## [0.3.3] - 2026-09-18
15
+
16
+ ### 变更
17
+
18
+ - fix(my-observability): 去掉仅内部使用的 4 个导出(knip 死代码门禁)
19
+ - docs(guard,observability,mermaid-render): 设置页面板真实截图 + README 效果图
20
+ - fix(my-observability): 拆分设置视图至尺寸门禁内
21
+ - feat(my-observability): 设置页配置面板(aiReview / aiTimeoutMs 可视化编辑)
22
+
5
23
  ## [0.3.2] - 2026-09-17
6
24
 
7
25
  ### 变更
package/README.md CHANGED
@@ -5,6 +5,8 @@
5
5
  <div align="center">
6
6
  <img alt="轨迹回放面板:侧边栏时间轴展示 agent 行为(状态/模型流/工具调用)" src="https://unpkg.com/dsh-my-observability/assets/replay-panel.png" width="640" />
7
7
  <img alt="Git 工具面板:仓库状态 / 差异 / 提交前审查 / 类型化提交" src="https://unpkg.com/dsh-my-observability/assets/git-panel.png" width="640" />
8
+ <br/>
9
+ <img alt="设置页配置面板(设置 → 插件 → 可观测性):AI 审查增强开关 + AI 审查超时" src="https://unpkg.com/dsh-my-observability/assets/settings-panel.png" width="640" />
8
10
  </div>
9
11
 
10
12
  **dsh-my-observability**:DSH 可观测性 + Git 工程工具插件——**事件审计**记录 agent 的每一次行为(状态变化 / 模型流 / 工具调用与结果),**轨迹回放面板**按时间轴回放会话轨迹;**结构化 Git 工具**提供 Conventional Commits 类型化提交;**增量 diff 审查**在提交前用规则引擎(可选 AI 增强)检查调试残留、密钥泄露等问题。
@@ -38,6 +40,8 @@ server 端改动需重启 `dsh web`;client 端改动硬刷新浏览器(Cmd/C
38
40
  | `aiReview` | 增量 diff 审查启用 AI 增强(默认 `true`;agents 服务不可用时自动降级) |
39
41
  | `aiTimeoutMs` | AI 审查超时(毫秒,默认 60000) |
40
42
 
43
+ `aiReview` 与 `aiTimeoutMs` 可在 **设置 → 插件 → 可观测性** 里可视化编辑:保存即写回 profile 层 `cordis.patch.yml` 的插件行(行 id `observability`,其余手写键原样保留),DSH 热重载后立即生效,无需重启。其余字段(`aiProvider` / `aiModel` / `aiCwd` / `resourceIntervalMs` 等)仍按上表在 `cordis.patch.yml` 手写;非法值不会写坏配置(`aiReview` 非布尔忽略,`aiTimeoutMs` 非正/非有限回退 60000)。
44
+
41
45
  ## 限制与说明
42
46
 
43
47
  - **Git 工具作用于本机仓库**:仓库路径由你在面板输入(localStorage 记住),仅本机可访问(loopback 围栏)。
Binary file
package/lib/client.js CHANGED
@@ -37,8 +37,7 @@ window.__ModuleLoader__.load({
37
37
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
38
38
  const { createElement, useEffect, useState } = require('react')
39
39
 
40
- // 原生页签身份(issue #187 批 1):id/kind = 迁移前 better-sidebar 的 tab id
41
- // (每个面板一个类型,id 全局唯一且是席位的 key),order = 迁移前的指南页顺序。
40
+ // 原生页签身份(issue #187 批 1):id/kind 沿用迁移前 better-sidebar 的 tab id(每面板一个类型,id 全局唯一且是席位 key),order 为指南页顺序。
42
41
  const REPLAY_TAB_ID = 'dsh-my-observability:replay'
43
42
  const GIT_TAB_ID = 'dsh-my-observability:git'
44
43
  const REPLAY_TAB_ORDER = 40
@@ -194,6 +193,23 @@ const strings = {
194
193
  loading: () => (isZh() ? '加载中…' : 'Loading…'),
195
194
  emptyDiff: () => (isZh() ? '(空)' : '(empty)'),
196
195
  noRepo: () => (isZh() ? '请输入仓库路径' : 'Enter a repo path'),
196
+ // 设置页(设置 → 插件 → 可观测性,issue #383)
197
+ settingsTitle: () => (isZh() ? '可观测性' : 'Observability'),
198
+ settingsSectionTitle: () => (isZh() ? 'AI 审查' : 'AI review'),
199
+ settingsAiReviewLabel: () => (isZh() ? 'AI 审查增强' : 'AI review enhancement'),
200
+ settingsAiReviewHint: () => isZh()
201
+ ? '增量 diff 审查调用 AI agent 补充规则引擎结论(agents 服务不可用或超时自动降级为纯规则)'
202
+ : 'Let an AI agent augment the rule-engine review of the incremental diff (degrades to rules only when the agents service is unavailable)',
203
+ settingsAiTimeoutLabel: () => (isZh() ? 'AI 审查超时(ms)' : 'AI review timeout (ms)'),
204
+ settingsAiTimeoutHint: () => isZh()
205
+ ? '单次 AI 审查的最长等待;非正数或非有限值在保存时回退为 60000'
206
+ : 'Max wait per AI review; non-positive or non-finite values fall back to 60000 on save',
207
+ settingsSave: () => (isZh() ? '保存' : 'Save'),
208
+ settingsSaved: () => (isZh() ? '已保存并生效' : 'Saved and applied'),
209
+ settingsSaveFailed: () => (isZh() ? '保存失败' : 'Save failed'),
210
+ settingsLoadFailedHint: () => isZh()
211
+ ? '无法读取 /observability/api/config:请确认服务端插件已加载(改过 server 端后需重启 dsh web)'
212
+ : 'Cannot read /observability/api/config: make sure the host half is loaded (restart dsh web after host-side changes)',
197
213
  };
198
214
 
199
215
  // ── shared icons (inline, stroke=currentColor, matching better-sidebar) ──
@@ -1632,6 +1648,167 @@ function GitPanel() {
1632
1648
  }), createElement(DiffView, { diff }), createElement(ReviewReport, { report }), createElement(CommitForm, { repo, onCommitted }));
1633
1649
  }
1634
1650
 
1651
+ "use strict";
1652
+ // ── 设置页签(issue #383):设置 → 插件 → 可观测性 ────────────────────
1653
+ // 只暴露 README 已记录的 aiReview / aiTimeoutMs 两项;其余键(aiProvider /
1654
+ // aiModel / aiCwd / resourceIntervalMs / resourceLimits)继续由用户在
1655
+ // cordis.patch.yml 手写(服务端写回时会合并保留原条目已存在的键)。
1656
+ //
1657
+ // 保存 → PUT /observability/api/config → 写回 profile patch 文件;DSH 的
1658
+ // watchUserPatches 热重载 patch 后重新 apply(保存即生效)。保存成功后面板
1659
+ // 只更新本地状态:这两项在服务端生效,前端无缓存需要同步。
1660
+ //
1661
+ // 片段文件:无 import/export,共享 factory 作用域(apiJson / strings /
1662
+ // createElement 由 replay.js / i18n.js / 模板提供)。
1663
+ const SETTINGS_API = '/observability/api/config';
1664
+ /** 设置页样式:只用自己的类名前缀 + 宿主语义 token(随 effect 注入/卸载)。 */
1665
+ const SETTINGS_STYLES = `
1666
+ .dsh-my-observability-settings{display:flex;flex-direction:column;gap:10px;padding:12px}
1667
+ .dsh-my-observability-settings-section{display:flex;flex-direction:column;gap:8px}
1668
+ .dsh-my-observability-settings-section-title{font:var(--dsw-font-xs-strong-13);color:var(--dsw-alias-label-secondary)}
1669
+ .dsh-my-observability-settings-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
1670
+ .dsh-my-observability-settings-info{display:flex;flex-direction:column;gap:2px;min-width:0}
1671
+ .dsh-my-observability-settings-label{font:var(--dsw-font-xs-strong-13);color:var(--dsw-alias-label-primary)}
1672
+ .dsh-my-observability-settings-hint{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-tertiary);line-height:1.5}
1673
+ .dsh-my-observability-settings-toggle{flex:none;width:34px;height:20px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb, var(--dsw-alias-label-tertiary) 30%, transparent);position:relative;cursor:pointer;transition:background var(--ds-transition-duration-slow) var(--ds-ease-in-out),border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out)}
1674
+ .dsh-my-observability-settings-toggle[data-on="true"]{background:var(--dsw-alias-state-success-primary);border-color:transparent}
1675
+ .dsh-my-observability-settings-toggle::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-label-primary);transition:transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),background var(--ds-transition-duration-slow) var(--ds-ease-in-out)}
1676
+ .dsh-my-observability-settings-toggle[data-on="true"]::after{transform:translateX(12px);background:var(--dsw-alias-label-primary-foreground)}
1677
+ .dsh-my-observability-settings-input{flex:none;width:120px;height:28px;padding:0 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:var(--dsw-font-xxs-12)}
1678
+ .dsh-my-observability-settings-input:focus{outline:none;border-color:var(--dsw-alias-interactive-primary)}
1679
+ .dsh-my-observability-settings-actions{display:flex;align-items:center;gap:8px}
1680
+ .dsh-my-observability-settings-btn{height:28px;padding:0 14px;border-radius:6px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-interactive-bg);color:var(--dsw-alias-label-primary);font:var(--dsw-font-xxs-12)}
1681
+ .dsh-my-observability-settings-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
1682
+ .dsh-my-observability-settings-status{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-tertiary)}
1683
+ .dsh-my-observability-settings-saved{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-state-success-primary)}
1684
+ .dsh-my-observability-settings-error{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-state-error-primary)}
1685
+ `;
1686
+ /** 开关行(布尔配置项)。 */
1687
+ function SettingsToggleRow({ label, hint, on, onChange, }) {
1688
+ return createElement('div', { className: 'dsh-my-observability-settings-row' }, createElement('div', { className: 'dsh-my-observability-settings-info' }, createElement('div', { className: 'dsh-my-observability-settings-label' }, label), createElement('div', { className: 'dsh-my-observability-settings-hint' }, hint)), createElement('div', {
1689
+ className: 'dsh-my-observability-settings-toggle',
1690
+ 'data-on': String(on),
1691
+ role: 'switch',
1692
+ 'aria-checked': String(on),
1693
+ onClick: () => onChange(!on),
1694
+ }));
1695
+ }
1696
+ /** 数字输入行(AI 审查超时 ms)。 */
1697
+ function SettingsNumberRow({ label, hint, value, onChange, }) {
1698
+ return createElement('div', { className: 'dsh-my-observability-settings-row' }, createElement('div', { className: 'dsh-my-observability-settings-info' }, createElement('div', { className: 'dsh-my-observability-settings-label' }, label), createElement('div', { className: 'dsh-my-observability-settings-hint' }, hint)), createElement('input', {
1699
+ className: 'dsh-my-observability-settings-input',
1700
+ type: 'number',
1701
+ min: '1',
1702
+ step: '1000',
1703
+ value: String(value),
1704
+ onChange: (e) => onChange(Number(e.target.value)),
1705
+ }));
1706
+ }
1707
+ /** 保存配置:PUT 配置端点;失败只显示失败提示(绝不误报已保存)。 */
1708
+ function saveSettings(draft, setSaved, setSaveError) {
1709
+ setSaved(false);
1710
+ setSaveError(false);
1711
+ apiJson(SETTINGS_API, {
1712
+ method: 'PUT',
1713
+ headers: { 'content-type': 'application/json' },
1714
+ body: JSON.stringify(draft),
1715
+ })
1716
+ .then(() => setSaved(true))
1717
+ .catch(() => setSaveError(true));
1718
+ }
1719
+ /** 配置加载失败视图(读不到配置时不显示表单,避免用默认值覆盖真实配置)。 */
1720
+ function SettingsLoadError({ onRetry }) {
1721
+ return createElement('div', { className: 'dsh-my-observability-settings' }, createElement('div', { className: 'dsh-my-observability-settings-error' }, strings.loadError()), createElement('div', { className: 'dsh-my-observability-settings-status' }, strings.settingsLoadFailedHint()), createElement('div', { className: 'dsh-my-observability-settings-actions' }, createElement('button', { className: 'dsh-my-observability-settings-btn', onClick: onRetry }, strings.retry())));
1722
+ }
1723
+ /** 加载态(配置未返回前的占位,避免先渲染出默认值表单再被回填覆盖)。 */
1724
+ function SettingsLoading() {
1725
+ return createElement('div', { className: 'dsh-my-observability-settings' }, createElement('div', { className: 'dsh-my-observability-settings-status' }, strings.loading()));
1726
+ }
1727
+ /** 配置区块:区块标题 + 两个配置行(AI 审查开关 / 超时)。 */
1728
+ function SettingsConfigSection({ draft, patch, }) {
1729
+ return createElement('div', { className: 'dsh-my-observability-settings-section' }, createElement('div', { className: 'dsh-my-observability-settings-section-title' }, strings.settingsSectionTitle()), createElement(SettingsToggleRow, {
1730
+ label: strings.settingsAiReviewLabel(),
1731
+ hint: strings.settingsAiReviewHint(),
1732
+ on: draft.aiReview !== false,
1733
+ onChange: (value) => patch('aiReview', value),
1734
+ }), createElement(SettingsNumberRow, {
1735
+ label: strings.settingsAiTimeoutLabel(),
1736
+ hint: strings.settingsAiTimeoutHint(),
1737
+ value: draft.aiTimeoutMs,
1738
+ onChange: (value) => patch('aiTimeoutMs', value),
1739
+ }));
1740
+ }
1741
+ /** 保存区块:保存按钮 + 成功/失败提示(互斥,保存失败绝不显示"已保存")。 */
1742
+ function SettingsActions({ save, saved, saveError, }) {
1743
+ return createElement('div', { className: 'dsh-my-observability-settings-actions' }, createElement('button', { className: 'dsh-my-observability-settings-btn', onClick: save }, strings.settingsSave()), saved ? createElement('span', { className: 'dsh-my-observability-settings-saved' }, strings.settingsSaved()) : null, saveError
1744
+ ? createElement('span', { className: 'dsh-my-observability-settings-error' }, strings.settingsSaveFailed())
1745
+ : null);
1746
+ }
1747
+ /** 设置页视图:GET 回填 → 编辑 → 保存(各区块拆成子组件,控制单函数长度)。 */
1748
+ function ObservabilitySettingsView() {
1749
+ const [draft, setDraft] = useState(null);
1750
+ const [loading, setLoading] = useState(true);
1751
+ const [failed, setFailed] = useState(false);
1752
+ const [saved, setSaved] = useState(false);
1753
+ const [saveError, setSaveError] = useState(false);
1754
+ const load = () => {
1755
+ setLoading(true);
1756
+ setSaved(false);
1757
+ setSaveError(false);
1758
+ apiJson(SETTINGS_API)
1759
+ .then((value) => {
1760
+ setDraft(value);
1761
+ setLoading(false);
1762
+ })
1763
+ .catch(() => {
1764
+ setLoading(false);
1765
+ setFailed(true);
1766
+ });
1767
+ };
1768
+ useEffect(() => {
1769
+ load();
1770
+ }, []);
1771
+ if (loading)
1772
+ return createElement(SettingsLoading, null);
1773
+ if (draft === null)
1774
+ return createElement(SettingsLoadError, { onRetry: load });
1775
+ const patch = (key, value) => setDraft({ ...draft, [key]: value });
1776
+ return createElement('div', { className: 'dsh-my-observability-settings' }, createElement(SettingsConfigSection, { draft, patch }), createElement(SettingsActions, {
1777
+ save: () => saveSettings(draft, setSaved, setSaveError),
1778
+ saved,
1779
+ saveError,
1780
+ }));
1781
+ }
1782
+ /** 附加设置页签:slots 服务缺失(精简上下文 / 宿主未提供)时静默跳过。 */
1783
+ function attachObservabilitySettingsTab(ctx) {
1784
+ // strict=false:首屏时 slots 提供者 fiber 可能尚未 active,strict 模式的
1785
+ // ctx.get 返回 undefined → 页签静默消失(要等 HMR 才出现)。取到实例即可,
1786
+ // 注册本身由 slots.inject 等待槽位声明。
1787
+ const slots = typeof ctx.get === 'function' ? ctx.get('slots', false) : undefined;
1788
+ if (slots === undefined || slots === null)
1789
+ return;
1790
+ ctx.effect(() => {
1791
+ if (typeof document === 'undefined' || typeof document.head === 'undefined')
1792
+ return () => { };
1793
+ const style = document.createElement('style');
1794
+ style.setAttribute('data-dsh-my-observability-settings', 'styles');
1795
+ style.textContent = SETTINGS_STYLES;
1796
+ document.head.appendChild(style);
1797
+ return () => {
1798
+ if (style.parentNode !== null)
1799
+ style.parentNode.removeChild(style);
1800
+ };
1801
+ }, 'dsh-my-observability: settings styles');
1802
+ ctx.effect(() => slots.inject('settings.plugins.tab', () => slots.register({
1803
+ name: 'settings.plugins.tab',
1804
+ // id 必须全局唯一(list 型槽位的 tab key):复用别人的 id 会顶掉
1805
+ // 对方那一格(宿主契约:fresh id 追加在已有条目旁)。
1806
+ id: 'dsh-my-observability-settings',
1807
+ order: 92,
1808
+ label: () => strings.settingsTitle(),
1809
+ }, ObservabilitySettingsView)), 'dsh-my-observability: settings tab registration');
1810
+ }
1811
+
1635
1812
  "use strict";
1636
1813
  // ── 样式(DSH 语义 token,随 activation 注入 / teardown 卸载)──────
1637
1814
  // 前缀 dsh-my-observability-(issue #54:与 dsh-my-guard 前缀分离,消除跨插件类名冲突)。
@@ -1810,8 +1987,10 @@ function injectStyles() {
1810
1987
  // 样式先注入:不依赖任何服务(服务缺失/时序未就绪也不影响面板配色)。
1811
1988
  ctx.effect(() => injectStyles(), 'dsh-my-observability: styles')
1812
1989
 
1813
- const tabs = ctx.sidebarRightTabs
1814
- const slots = ctx.slots
1990
+ // 设置页签(issue #383):只依赖 slots(strict=false 读取),与侧边栏面板所需的 sidebarRightTabs 无关,故先于判空注册。
1991
+ attachObservabilitySettingsTab(ctx)
1992
+
1993
+ const { sidebarRightTabs: tabs, slots } = ctx
1815
1994
  // 判空必须同时覆盖 null 与 undefined(typeof null 是 object,会骗过 === undefined)。
1816
1995
  if (tabs == null || slots == null) return
1817
1996
 
package/lib/client.src.js CHANGED
@@ -37,8 +37,7 @@ window.__ModuleLoader__.load({
37
37
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
38
38
  const { createElement, useEffect, useState } = require('react')
39
39
 
40
- // 原生页签身份(issue #187 批 1):id/kind = 迁移前 better-sidebar 的 tab id
41
- // (每个面板一个类型,id 全局唯一且是席位的 key),order = 迁移前的指南页顺序。
40
+ // 原生页签身份(issue #187 批 1):id/kind 沿用迁移前 better-sidebar 的 tab id(每面板一个类型,id 全局唯一且是席位 key),order 为指南页顺序。
42
41
  const REPLAY_TAB_ID = 'dsh-my-observability:replay'
43
42
  const GIT_TAB_ID = 'dsh-my-observability:git'
44
43
  const REPLAY_TAB_ORDER = 40
@@ -53,6 +52,7 @@ window.__ModuleLoader__.load({
53
52
  /*__PART_RESOURCE__*/
54
53
  /*__PART_REPLAY_EXT__*/
55
54
  /*__PART_GIT__*/
55
+ /*__PART_SETTINGS__*/
56
56
  /*__PART_STYLES__*/
57
57
 
58
58
  // ── 插件体:样式注入 + 两个原生页签注册 ─────────────────────────────
@@ -73,8 +73,10 @@ window.__ModuleLoader__.load({
73
73
  // 样式先注入:不依赖任何服务(服务缺失/时序未就绪也不影响面板配色)。
74
74
  ctx.effect(() => injectStyles(), 'dsh-my-observability: styles')
75
75
 
76
- const tabs = ctx.sidebarRightTabs
77
- const slots = ctx.slots
76
+ // 设置页签(issue #383):只依赖 slots(strict=false 读取),与侧边栏面板所需的 sidebarRightTabs 无关,故先于判空注册。
77
+ attachObservabilitySettingsTab(ctx)
78
+
79
+ const { sidebarRightTabs: tabs, slots } = ctx
78
80
  // 判空必须同时覆盖 null 与 undefined(typeof null 是 object,会骗过 === undefined)。
79
81
  if (tabs == null || slots == null) return
80
82
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * dsh-my-observability — 设置页配置端点(GET/PUT /observability/api/config,issue #383)。
3
+ *
4
+ * 设置 → 插件 → 可观测性 面板只暴露 README 已记录的 `aiReview` /
5
+ * `aiTimeoutMs` 两项;其余字段(aiProvider / aiModel / aiCwd /
6
+ * resourceIntervalMs / resourceLimits)继续由用户在 cordis.patch.yml 手写,
7
+ * 设置页保存不得连带覆盖它们。
8
+ *
9
+ * 写回复用 dsh-shared 的配置持久化原语(currentProfile / patchFileOf /
10
+ * writePatchConfig),并**先合并该条目已有键**:writePatchConfig 的语义是
11
+ * 「删除同 id 旧条目 → 追加新条目」,只写两项会把用户手写的其余配置一起删掉
12
+ * (数据破坏,issue #383 验收项「不写坏配置」)。
13
+ */
14
+ import { readFile } from 'node:fs/promises';
15
+ import { currentProfile, extractConfig, patchFileOf, readJsonBody, writeJson, writePatchConfig } from 'dsh-shared';
16
+ /** aiTimeoutMs 默认值(与 src/index.ts buildOptions 的兜底共用同一口径)。 */
17
+ const DEFAULT_AI_TIMEOUT_MS = 60000;
18
+ /**
19
+ * 写回行 id:必须与 plugins/dsh-my-observability/cordis.patch.yml 里的插件行
20
+ * id 一致——loader 按行 id 匹配配置,id 不符会新增孤儿行、原行配置不变。
21
+ */
22
+ const CONFIG_ROW_ID = 'observability';
23
+ /** aiTimeoutMs 规整:非数字 / 非有限 / 非正 → 回退默认(绝不把 0/NaN 写进 patch)。
24
+ * 导出供 src/index.ts 的 buildOptions 复用,保证「默认值口径」只有一处定义。
25
+ * (其余仅本文件内部使用的符号不导出:knip 会把无外部引用的导出判为 dead code。) */
26
+ export function normalizeTimeout(value) {
27
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_AI_TIMEOUT_MS;
28
+ }
29
+ /** 当前生效配置(GET 的 value,也是设置页表单的回填源)。 */
30
+ function configValueOf(options) {
31
+ return { aiReview: options.aiReview !== false, aiTimeoutMs: normalizeTimeout(options.aiTimeoutMs) };
32
+ }
33
+ /**
34
+ * 校验 + 规整 PUT payload:非对象 → undefined(调用方回 400)。
35
+ *
36
+ * 非法**字段**只忽略该字段、不整单拒绝:设置页一次提交两项,因一个字段非法
37
+ * 而丢掉另一项合法修改,用户会误以为保存无效(issue #383 验收项「非法值回退
38
+ * 默认」);aiTimeoutMs 非法时回退默认而非保留旧值,与 README 记录的默认口径
39
+ * 一致。
40
+ */
41
+ function normalizeConfigPatch(payload, current) {
42
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
43
+ return undefined;
44
+ const raw = payload;
45
+ const next = { aiReview: current.aiReview, aiTimeoutMs: current.aiTimeoutMs };
46
+ if (typeof raw.aiReview === 'boolean')
47
+ next.aiReview = raw.aiReview;
48
+ if (raw.aiTimeoutMs !== undefined)
49
+ next.aiTimeoutMs = normalizeTimeout(raw.aiTimeoutMs);
50
+ return next;
51
+ }
52
+ /** 读取 patch 文件中该行已有的 config 块(文件不存在 / 解析失败 → 空对象)。 */
53
+ async function readExistingConfig(file) {
54
+ try {
55
+ return extractConfig(await readFile(file, 'utf8'), CONFIG_ROW_ID) ?? {};
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ /** 写回 profile 层 patch 文件:合并已有键后整体重写(保留用户手写的其他配置)。 */
62
+ export async function persistSettingsConfig(config) {
63
+ const file = patchFileOf(currentProfile());
64
+ const existing = await readExistingConfig(file);
65
+ await writePatchConfig(file, CONFIG_ROW_ID, { ...existing, ...config });
66
+ }
67
+ /**
68
+ * 分派配置端点:GET 返回当前生效值;PUT 校验 → 写回 + 即时生效。
69
+ * 未匹配(含其他动词)返回 false,交由调用方回 404(保持既有路由契约)。
70
+ */
71
+ export async function dispatchConfigRoutes(method, request, response, options, onChange) {
72
+ if (method !== 'config')
73
+ return false;
74
+ if (request.method === 'GET') {
75
+ writeJson(response, 200, { ok: true, value: configValueOf(options) });
76
+ return true;
77
+ }
78
+ if (request.method !== 'PUT')
79
+ return false;
80
+ const next = normalizeConfigPatch(await readJsonBody(request), configValueOf(options));
81
+ if (next === undefined) {
82
+ writeJson(response, 400, { ok: false, error: { message: 'invalid config' } });
83
+ return true;
84
+ }
85
+ await onChange(next);
86
+ writeJson(response, 200, { ok: true, value: next });
87
+ return true;
88
+ }
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { createStore } from './store.js';
23
23
  import { attachAuditListeners } from './audit.js';
24
24
  import { registerObservabilityRoutes } from './routes.js';
25
+ import { persistSettingsConfig, normalizeTimeout } from './config-routes.js';
25
26
  import { createResourceMonitor } from './resource-monitor.js';
26
27
  export const name = 'dsh-my-observability';
27
28
  export const inject = ['webServer'];
@@ -32,9 +33,26 @@ function buildOptions(config) {
32
33
  aiModel: config?.aiModel,
33
34
  aiCwd: config?.aiCwd,
34
35
  aiReview: config?.aiReview !== false,
35
- aiTimeoutMs: Number.isFinite(config?.aiTimeoutMs) && config?.aiTimeoutMs > 0
36
- ? config?.aiTimeoutMs
37
- : 60000,
36
+ // 与设置页保存共用同一默认值口径(config-routes.normalizeTimeout)。
37
+ aiTimeoutMs: normalizeTimeout(config?.aiTimeoutMs),
38
+ };
39
+ }
40
+ /**
41
+ * 设置页保存回调(issue #383):**先写回 profile patch 文件、再更新内存**——
42
+ * 写盘失败时保持内存旧值(内存与磁盘不劈叉),并把失败如实抛给路由层;
43
+ * DSH 的 watchUserPatches 会热重载 patch 文件,保存即生效。
44
+ */
45
+ function settingsChangeHandler(ctx, options) {
46
+ return async (next) => {
47
+ try {
48
+ await persistSettingsConfig(next);
49
+ }
50
+ catch (error) {
51
+ ctx.logger.warn(`[dsh-my-observability] 配置保存失败(操作=config/save,原因=${error instanceof Error ? error.message : String(error)})`);
52
+ throw error;
53
+ }
54
+ Object.assign(options, next);
55
+ ctx.logger.info?.(`[dsh-my-observability] 设置已保存(aiReview=${next.aiReview}, aiTimeoutMs=${next.aiTimeoutMs})`);
38
56
  };
39
57
  }
40
58
  export function apply(ctx, config) {
@@ -59,8 +77,8 @@ export function apply(ctx, config) {
59
77
  },
60
78
  });
61
79
  monitor.start();
62
- // ── 路由(查询 / git 工具 / diff 审查 / 资源)───────────────────────
63
- registerObservabilityRoutes(ctx, store, monitor, options);
80
+ // ── 路由(查询 / git 工具 / diff 审查 / 资源 / 设置页配置)──────────
81
+ registerObservabilityRoutes(ctx, store, monitor, options, settingsChangeHandler(ctx, options));
64
82
  // ── 卸载冲刷:清防抖定时器 + 立即落盘 + 停采样 ──────────────────────
65
83
  ctx.effect(() => {
66
84
  monitor.stop();
package/lib/routes.js CHANGED
@@ -9,14 +9,16 @@
9
9
  * - GET /git/diff?repo=&staged= — 差异文本
10
10
  * - POST /git/commit — 类型化提交(Conventional Commits)
11
11
  * - POST /review — 增量 diff 审查(规则引擎 + 可选 AI)
12
+ * - GET/PUT /config — 设置页配置读写(aiReview / aiTimeoutMs)
12
13
  */
13
14
  import { isTrustedApiRequest, readJsonBody, writeJson, writeError } from 'dsh-shared';
15
+ import { dispatchConfigRoutes } from './config-routes.js';
14
16
  import { gitStatus, gitDiff, gitCommit } from './git.js';
15
17
  import { parseDiff } from './diff.js';
16
18
  import { reviewRules } from './review.js';
17
19
  import { runAiReview } from './ai.js';
18
20
  /** 注册 /observability/api 路由(effect 持有 disposer)。 */
19
- export function registerObservabilityRoutes(ctx, store, monitor, options) {
21
+ export function registerObservabilityRoutes(ctx, store, monitor, options, onConfigChange) {
20
22
  const webRuntime = ctx.get ? ctx.get('webRuntime') : undefined;
21
23
  const trustedHosts = webRuntime !== undefined && webRuntime !== null && Array.isArray(webRuntime.trustedHosts)
22
24
  ? webRuntime.trustedHosts
@@ -25,11 +27,11 @@ export function registerObservabilityRoutes(ctx, store, monitor, options) {
25
27
  ctx.effect(() => ctx.webServer.register({
26
28
  kind: 'prefix',
27
29
  path: '/observability/api',
28
- handler: apiHandler(ctx, fence, store, monitor, options),
30
+ handler: apiHandler(ctx, fence, store, monitor, options, onConfigChange),
29
31
  }), 'dsh-my-observability: /observability/api routes');
30
32
  }
31
33
  /** 统一 handler:fence → 方法分派 → 404/错误兜底。 */
32
- function apiHandler(ctx, fence, store, monitor, options) {
34
+ function apiHandler(ctx, fence, store, monitor, options, onConfigChange) {
33
35
  return async (request, response) => {
34
36
  if (!fence(request)) {
35
37
  writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
@@ -43,7 +45,7 @@ function apiHandler(ctx, fence, store, monitor, options) {
43
45
  // 状态(曾经的 CI flaky:固定 40ms sleep 赌 readFile 跑完,慢机器上
44
46
  // 查询读到 0 条事件 → 0 !== 1)。whenReady 就绪后立即 resolve,无延迟。
45
47
  await store.whenReady();
46
- const handled = await dispatchMethod(method, request, response, url, ctx, store, monitor, options);
48
+ const handled = await dispatchMethod(method, request, response, url, ctx, store, monitor, options, onConfigChange);
47
49
  if (!handled) {
48
50
  writeJson(response, 404, {
49
51
  ok: false,
@@ -61,11 +63,12 @@ function isMethod(method, request, name, verb) {
61
63
  return method === name && request.method === verb;
62
64
  }
63
65
  /** 按 method 分派到具体 handler;未识别返回 false(调用方回 404)。 */
64
- async function dispatchMethod(method, request, response, url, ctx, store, monitor, options) {
65
- const handled = await dispatchCore(method, request, response, url, ctx, store, monitor, options);
66
- if (handled)
66
+ async function dispatchMethod(method, request, response, url, ctx, store, monitor, options, onConfigChange) {
67
+ if (await dispatchCore(method, request, response, url, ctx, store, monitor, options))
67
68
  return true;
68
- return dispatchExtended(method, request, response, url, ctx, store);
69
+ if (dispatchExtended(method, request, response, url, ctx, store))
70
+ return true;
71
+ return dispatchConfigRoutes(method, request, response, options, onConfigChange);
69
72
  }
70
73
  /** 核心路由(复杂度 ≤10)。 */
71
74
  async function dispatchCore(method, request, response, url, ctx, store, monitor, options) {
@@ -275,13 +278,8 @@ async function aiOutcome(ctx, payload, options, diffText, report) {
275
278
  });
276
279
  }
277
280
  // ── HTTP helpers ───────────────────────────────────────────────────────────
278
- function queryOf(url, name) {
279
- return url.searchParams.get(name) ?? '';
280
- }
281
- function repoOf(url) {
282
- const repo = url.searchParams.get('repo');
283
- return typeof repo === 'string' ? repo : '';
284
- }
281
+ const queryOf = (url, name) => url.searchParams.get(name) ?? '';
282
+ const repoOf = (url) => url.searchParams.get('repo') ?? '';
285
283
  function limitOf(url) {
286
284
  const raw = url.searchParams.get('limit');
287
285
  const parsed = raw === null ? 0 : Number(raw);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-my-observability",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "DSH 可观测性 + Git 工程工具插件:会话轨迹回放(时间轴)、事件审计(agent 行为记录)、结构化 Git 提交(Conventional Commits)、增量 diff 审查(提交前审查)。DSH web plugin: trajectory replay timeline, event audit log, structured git commits, pre-commit incremental diff review.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",