@rezti/dsh-rez-suite 0.1.45 → 0.1.46

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +9 -10
  3. package/README.zh.md +7 -3
  4. package/cordis.patch.yml +3 -1
  5. package/lib/client.d.ts +8 -2
  6. package/lib/client.js +175 -22
  7. package/lib/index.d.ts +4 -0
  8. package/lib/index.js +269 -16
  9. package/lib/style.css +43 -0
  10. package/package.json +7 -5
  11. package/src/boss/mount.ts +44 -4
  12. package/src/boss/register.ts +2 -0
  13. package/src/boss/seed.ts +29 -1
  14. package/src/changelog.ts +85 -0
  15. package/src/client/locales.ts +16 -4
  16. package/src/client/panel/ChangelogTab.tsx +57 -0
  17. package/src/client/panel/ConfigTab.tsx +16 -9
  18. package/src/client/panel/panel.module.css +43 -0
  19. package/src/client/settings-card.tsx +4 -1
  20. package/src/esphome-lab.ts +181 -0
  21. package/src/index.ts +17 -4
  22. package/src/observe-mcp.ts +12 -6
  23. package/src/presets.ts +11 -5
  24. package/src/protocol.ts +2 -0
  25. package/src/rooms.ts +29 -5
  26. package/src/store.ts +6 -0
  27. package/src/tools.ts +41 -0
  28. package/templates/boss/ops/AGENTS.md +1 -0
  29. package/templates/boss/product/AGENTS.md +5 -2
  30. package/templates/staff/design/.agents/skills/ops-design/SKILL.md +6 -0
  31. package/templates/staff/design/AGENTS.md +71 -0
  32. package/templates/staff/design/BOOTSTRAP.md +23 -0
  33. package/templates/staff/design/IDENTITY.md +8 -0
  34. package/templates/staff/design/MEMORY.md +9 -0
  35. package/templates/staff/design/PRIORITIES.md +3 -0
  36. package/templates/staff/design/SOUL.md +6 -0
  37. package/templates/staff/design/USER.md +8 -0
  38. package/templates/staff/hr/.agents/skills/staff-hr/SKILL.md +1 -1
  39. package/templates/staff/hr/.agents/skills/staff-onboard-keys/SKILL.md +99 -0
  40. package/templates/staff/hr/AGENTS.md +10 -0
  41. package/templates/staff/lab/.agents/skills/engineer-lab/SKILL.md +6 -0
  42. package/templates/staff/lab/AGENTS.md +34 -0
  43. package/templates/staff/lab/BOOTSTRAP.md +23 -0
  44. package/templates/staff/lab/IDENTITY.md +8 -0
  45. package/templates/staff/lab/MEMORY.md +9 -0
  46. package/templates/staff/lab/PRIORITIES.md +3 -0
  47. package/templates/staff/lab/SOUL.md +5 -0
  48. package/templates/staff/lab/USER.md +7 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * User-facing suite notes. Keep in sync with CHANGELOG.md (npm / git).
3
+ * The settings tab renders this; do not put release copy only in markdown.
4
+ */
5
+
6
+ export interface ChangelogCopy {
7
+ zh: string
8
+ en: string
9
+ }
10
+
11
+ export interface ChangelogSection {
12
+ title: ChangelogCopy
13
+ items: ChangelogCopy[]
14
+ }
15
+
16
+ export interface ChangelogRelease {
17
+ version: string
18
+ date: string
19
+ sections: ChangelogSection[]
20
+ }
21
+
22
+ export const SUITE_CHANGELOG: readonly ChangelogRelease[] = [
23
+ {
24
+ version: '0.1.46',
25
+ date: '2026-08-19',
26
+ sections: [
27
+ {
28
+ title: { zh: '员工会碰到的', en: 'What you will see' },
29
+ items: [
30
+ {
31
+ zh: '回形针上传 PDF:说明书 / 规格书走输入栏回形针,先入 Marketing/Inbox,对应 TAPD 改成测试中并转凯文;凯文说审批了哪份,再进 Marketing/Publish。不要用微信发 PDF。',
32
+ en: 'Paperclip PDF uploads: manuals go to Marketing/Inbox first. TAPD moves to 测试中 and Kelvin; after he names the file, it lands in Marketing/Publish. Do not send PDFs over WeChat.',
33
+ },
34
+ {
35
+ zh: '运营多了「设计」房间:拍摄原片 Comfy 只修一次,shot / clean 先入 Inbox;凯文终审后同时写入 Marketing/RAW。',
36
+ en: 'Operations gets a Design room. Retouch shots once in Comfy; shot/clean stay in Inbox until Kelvin dual-saves them to Marketing/RAW.',
37
+ },
38
+ {
39
+ zh: '品牌 / VI 走 Marketing/Inbox/Brand/,终审后写入 Marketing/RAW/Brand/,不要写进某个 SKU。',
40
+ en: 'Brand / VI files go Marketing/Inbox/Brand/, then Marketing/RAW/Brand/ after approval — not into a SKU folder.',
41
+ },
42
+ {
43
+ zh: '工程师多了「实验室」:对话里给 YAML,立刻拿到下载链接。还在编是说明文件;成功是 firmware.bin;失败是带日志的 README。不要在对话里等 bin,不要默认 OTA。',
44
+ en: 'Engineers get a Lab room. Submit YAML and get a download link immediately. Compiling shows a README; success is firmware.bin; failure includes the log. Do not wait for the bin in chat. Do not OTA by default.',
45
+ },
46
+ {
47
+ zh: '人事助理可引导新人领本机安装包、自己申请 API Key。',
48
+ en: 'HR assistant can walk new staff through the desktop installer and API key request.',
49
+ },
50
+ {
51
+ zh: '设置页暂时不显示企业微信配置栏(已填过的 webhook 不会被擦掉)。',
52
+ en: 'WeCom settings are hidden for now. An existing webhook is left as-is.',
53
+ },
54
+ {
55
+ zh: '运营仍可改 TAPD 状态、写评论、往 Inbox 传文件;不能删 TAPD / Nextcloud。',
56
+ en: 'Operations can still update TAPD and write Inbox files, but cannot delete TAPD or Nextcloud items.',
57
+ },
58
+ ],
59
+ },
60
+ {
61
+ title: { zh: '安装与审批机', en: 'Installers and approver machines' },
62
+ items: [
63
+ {
64
+ zh: '员工 Mac zip / Windows exe、审批人 Docker:每次启动拉钉死的 0.1.46,不要 latest。',
65
+ en: 'Staff Mac zip / Windows exe and approver Docker pin suite 0.1.46. Do not use latest.',
66
+ },
67
+ {
68
+ zh: '安装包只是启动壳,第一次会从外网拉 Node 和 dsh。',
69
+ en: 'The zip/exe is a launcher. The first run downloads Node and dsh.',
70
+ },
71
+ {
72
+ zh: '回形针依赖还在 GitHub。装 suite 要能访问 GitHub;公司机禁外网会失败。',
73
+ en: 'The paperclip plugin still comes from GitHub. Installing suite needs GitHub access.',
74
+ },
75
+ ],
76
+ },
77
+ ],
78
+ },
79
+ ]
80
+
81
+ export function latestSuiteChangelog(): ChangelogRelease {
82
+ const latest = SUITE_CHANGELOG[0]
83
+ if (latest === undefined) throw new Error('SUITE_CHANGELOG is empty')
84
+ return latest
85
+ }
@@ -6,15 +6,16 @@ export const zh = {
6
6
  'entry.label': 'Rez',
7
7
  'entry.tooltip': 'Rez Suite 配置与审计面板',
8
8
  'settings.title': 'ReZ-TI',
9
- 'settings.description': '公司 MCP(Odoo / Nextcloud / 企微 / HA / TAPD / GSC)与个人微信扫码通道。',
9
+ 'settings.description': '公司 MCP(Odoo / Nextcloud / HA / TAPD / GSC)。企业微信栏暂隐。个人微信在「微信」页签扫码。',
10
10
  'panel.title': 'Rez Suite',
11
11
  'tab.config': '配置',
12
12
  'tab.weixin': '微信',
13
13
  'tab.status': '状态',
14
14
  'tab.audit': '用量',
15
+ 'tab.changelog': '更新日志',
15
16
  // config
16
17
  'config.role': '角色',
17
- 'config.roleHint': '选身份后左侧自动出现工作区。人事/财务/法务助理全员可见;搞钱/产品/运营仅「老板」。',
18
+ 'config.roleHint': '选身份后左侧自动出现工作区。人事/财务/法务助理全员可见;设计房间仅「运营」;实验室仅「工程师」;搞钱/产品/运营仅「老板」。',
18
19
  'role.all': '全部',
19
20
  'role.boss': '老板',
20
21
  'role.engineer': '工程师',
@@ -58,6 +59,9 @@ export const zh = {
58
59
  'config.tianyancha': '天眼查(法务助理 · 仅老板)',
59
60
  'config.tianyancha.url': '天眼查 MCP URL',
60
61
  'config.tianyancha.token': 'OpenAPI Token',
62
+ 'config.esphome': 'ESPHome 实验室',
63
+ 'config.esphome.url': 'MCP URL',
64
+ 'config.esphome.token': '实验室 Token',
61
65
  'config.fs': '本地文件 (fs-mcp-server)',
62
66
  'config.fs.root': '根目录',
63
67
  'config.sqlite': 'SQLite (sqlite-mcp-server)',
@@ -102,6 +106,8 @@ export const zh = {
102
106
  'audit.reset': '清空账本',
103
107
  'audit.resetConfirm': '确定清空全部用量记录吗?',
104
108
  'audit.empty': '暂无记录',
109
+ 'changelog.current': '当前 {version}',
110
+ 'changelog.upgrade': '升级:',
105
111
  // common
106
112
  'common.close': '关闭',
107
113
  'common.loading': '加载中…',
@@ -136,14 +142,15 @@ export const en: Record<RezKey, string> = {
136
142
  'entry.label': 'Rez',
137
143
  'entry.tooltip': 'Rez Suite configuration and audit panel',
138
144
  'settings.title': 'ReZ-TI',
139
- 'settings.description': 'Company MCP (Odoo / Nextcloud / WeCom / HA / TAPD / GSC) and personal WeChat scan-and-chat.',
145
+ 'settings.description': 'Company MCP (Odoo / Nextcloud / HA / TAPD / GSC). WeCom settings are hidden for now. Personal WeChat is the WeChat tab.',
140
146
  'panel.title': 'Rez Suite',
141
147
  'tab.config': 'Config',
142
148
  'tab.weixin': 'WeChat',
143
149
  'tab.status': 'Status',
144
150
  'tab.audit': 'Usage',
151
+ 'tab.changelog': 'Changelog',
145
152
  'config.role': 'Role',
146
- 'config.roleHint': 'Identity registers workspaces in the sidebar. Staff assistants for every role; 搞钱 / 产品 / 运营 for Boss only.',
153
+ 'config.roleHint': 'Identity registers workspaces in the sidebar. Staff assistants for every role; 设计 for Operations only; 实验室 for Engineer only; 搞钱 / 产品 / 运营 for Boss only.',
147
154
  'role.all': 'All',
148
155
  'role.boss': 'Boss',
149
156
  'role.engineer': 'Engineer',
@@ -187,6 +194,9 @@ export const en: Record<RezKey, string> = {
187
194
  'config.tianyancha': 'Tianyancha (Legal assistant, boss only)',
188
195
  'config.tianyancha.url': 'Tianyancha MCP URL',
189
196
  'config.tianyancha.token': 'OpenAPI token',
197
+ 'config.esphome': 'ESPHome lab',
198
+ 'config.esphome.url': 'MCP URL',
199
+ 'config.esphome.token': 'Lab token',
190
200
  'config.fs': 'Local files (fs-mcp-server)',
191
201
  'config.fs.root': 'Root directory',
192
202
  'config.sqlite': 'SQLite (sqlite-mcp-server)',
@@ -229,6 +239,8 @@ export const en: Record<RezKey, string> = {
229
239
  'audit.reset': 'Clear ledger',
230
240
  'audit.resetConfirm': 'Clear all usage records?',
231
241
  'audit.empty': 'No records',
242
+ 'changelog.current': 'Now {version}',
243
+ 'changelog.upgrade': 'Upgrade:',
232
244
  'common.close': 'Close',
233
245
  'common.loading': 'Loading…',
234
246
  'common.error': 'Error: {error}',
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Settings tab: what this suite version changed. Copy lives in src/changelog.ts.
3
+ */
4
+
5
+ import { useEffect, useState } from 'react'
6
+ import type { RezApi } from '../api.ts'
7
+ import { SUITE_CHANGELOG, latestSuiteChangelog } from '../../changelog.ts'
8
+ import { zh } from '../locales.ts'
9
+ import { useT } from './helpers.ts'
10
+ import css from './panel.module.css'
11
+
12
+ export function ChangelogTab({ api }: { api: RezApi }) {
13
+ const t = useT()
14
+ const lang = t('tab.config') === zh['tab.config'] ? 'zh' : 'en'
15
+ const latest = latestSuiteChangelog()
16
+ const [installed, setInstalled] = useState('')
17
+
18
+ useEffect(() => {
19
+ let cancelled = false
20
+ void api.updateStatus().then((status) => {
21
+ if (!cancelled) setInstalled(status.installed)
22
+ }).catch(() => {
23
+ if (!cancelled) setInstalled(latest.version)
24
+ })
25
+ return () => { cancelled = true }
26
+ }, [api, latest.version])
27
+
28
+ return (
29
+ <div className={css.tabBody}>
30
+ <p className={css.message}>
31
+ {t('changelog.current', { version: installed || latest.version })}
32
+ {' · '}
33
+ {t('changelog.upgrade')}
34
+ {' '}
35
+ <code className={css.changelogCode}>dsh plugin --profile web add @rezti/dsh-rez-suite@{latest.version}</code>
36
+ </p>
37
+ {SUITE_CHANGELOG.map((release) => (
38
+ <section key={release.version} className={css.section}>
39
+ <h3 className={css.sectionTitle}>
40
+ {release.version}
41
+ <span className={css.changelogDate}>{release.date}</span>
42
+ </h3>
43
+ {release.sections.map((section) => (
44
+ <div key={section.title[lang]} className={css.changelogBlock}>
45
+ <div className={css.changelogHeading}>{section.title[lang]}</div>
46
+ <ul className={css.changelogList}>
47
+ {section.items.map((item) => (
48
+ <li key={item[lang]}>{item[lang]}</li>
49
+ ))}
50
+ </ul>
51
+ </div>
52
+ ))}
53
+ </section>
54
+ ))}
55
+ </div>
56
+ )
57
+ }
@@ -26,9 +26,10 @@ type SecretDrafts = {
26
26
  gsc: string
27
27
  qcc: string
28
28
  tianyancha: string
29
+ esphome: string
29
30
  }
30
31
 
31
- const EMPTY_SECRETS: SecretDrafts = { odoo: '', nextcloud: '', wechat: '', homeassistant: '', tapd: '', gsc: '', qcc: '', tianyancha: '' }
32
+ const EMPTY_SECRETS: SecretDrafts = { odoo: '', nextcloud: '', wechat: '', homeassistant: '', tapd: '', gsc: '', qcc: '', tianyancha: '', esphome: '' }
32
33
 
33
34
  function Field({ label, children }: { label: string; children: ReactNode }) {
34
35
  return (
@@ -177,6 +178,7 @@ export function ConfigTab({ api }: { api: RezApi }) {
177
178
  gsc: { ...config.connections.gsc, secret: secrets.gsc || undefined },
178
179
  qcc: { ...config.connections.qcc, secret: secrets.qcc || undefined },
179
180
  tianyancha: { ...config.connections.tianyancha, secret: secrets.tianyancha || undefined },
181
+ esphome: { ...config.connections.esphome, secret: secrets.esphome || undefined },
180
182
  },
181
183
  }
182
184
  const saved = await api.saveConfig(payload)
@@ -249,14 +251,7 @@ export function ConfigTab({ api }: { api: RezApi }) {
249
251
  usernameLabel={t('config.nextcloud.username')}
250
252
  secretLabel={t('config.nextcloud.appPassword')}
251
253
  />
252
- <McpSection
253
- title={t('config.wecom')}
254
- row={config.connections.wechat}
255
- secret={secrets.wechat}
256
- onEnabled={enabled => { patchConnection('wechat', { enabled }) }}
257
- onSecret={value => { setSecrets(prev => ({ ...prev, wechat: value })) }}
258
- secretLabel={t('config.wecom.webhook')}
259
- />
254
+ {/* WeCom (企业微信) 设置栏暂隐:安装用法未定,TAPD 1154488930001001224。保存时仍回写已有 wechat 配置,不擦掉。 */}
260
255
  <McpSection
261
256
  title={t('config.ha')}
262
257
  row={config.connections.homeassistant}
@@ -320,6 +315,18 @@ export function ConfigTab({ api }: { api: RezApi }) {
320
315
  secretLabel={t('config.tianyancha.token')}
321
316
  />
322
317
  )}
318
+ {(config.role === 'engineer' || config.role === 'boss') && (
319
+ <McpSection
320
+ title={t('config.esphome')}
321
+ row={config.connections.esphome}
322
+ secret={secrets.esphome}
323
+ onEnabled={enabled => { patchConnection('esphome', { enabled }) }}
324
+ onUrl={url => { patchConnection('esphome', { url }) }}
325
+ onSecret={value => { setSecrets(prev => ({ ...prev, esphome: value })) }}
326
+ urlLabel={t('config.esphome.url')}
327
+ secretLabel={t('config.esphome.token')}
328
+ />
329
+ )}
323
330
 
324
331
  <Section title={t('config.billing')}>
325
332
  <Field label={t('config.billing.input')}>
@@ -376,3 +376,46 @@
376
376
  background: var(--dsw-alias-brand-primary);
377
377
  flex: none;
378
378
  }
379
+
380
+ .changelogDate {
381
+ margin-left: 8px;
382
+ font-weight: 500;
383
+ font-variant-numeric: tabular-nums;
384
+ color: var(--dsw-alias-label-tertiary);
385
+ }
386
+
387
+ .changelogBlock {
388
+ display: flex;
389
+ flex-direction: column;
390
+ gap: 6px;
391
+ }
392
+
393
+ .changelogHeading {
394
+ font-size: 12.5px;
395
+ font-weight: 600;
396
+ color: var(--dsw-alias-label-secondary);
397
+ }
398
+
399
+ .changelogList {
400
+ margin: 0;
401
+ padding-left: 18px;
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: 6px;
405
+ font-size: 13px;
406
+ line-height: 1.55;
407
+ color: var(--dsw-alias-label-primary);
408
+ }
409
+
410
+ .changelogList li + li {
411
+ margin: 0;
412
+ }
413
+
414
+ .changelogCode {
415
+ font-size: 12px;
416
+ padding: 1px 6px;
417
+ border-radius: 6px;
418
+ background: var(--dsw-alias-bg-layer-3);
419
+ border: 1px solid var(--dsw-alias-border-l2);
420
+ word-break: break-all;
421
+ }
@@ -10,12 +10,13 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
10
10
  import { RezApi } from './api.ts'
11
11
  import { RezT } from './panel/helpers.ts'
12
12
  import { AuditTab } from './panel/AuditTab.tsx'
13
+ import { ChangelogTab } from './panel/ChangelogTab.tsx'
13
14
  import { ConfigTab } from './panel/ConfigTab.tsx'
14
15
  import { StatusTab } from './panel/StatusTab.tsx'
15
16
  import { WeixinTab } from './panel/WeixinTab.tsx'
16
17
  import css from './panel/panel.module.css'
17
18
 
18
- export type RezTab = 'config' | 'weixin' | 'status' | 'audit'
19
+ export type RezTab = 'config' | 'weixin' | 'status' | 'audit' | 'changelog'
19
20
 
20
21
  export function RezSettingsPage({ t }: { t: TranslateNS<'dsh-rez-suite'> }) {
21
22
  const api = useMemo(() => new RezApi(), [])
@@ -25,6 +26,7 @@ export function RezSettingsPage({ t }: { t: TranslateNS<'dsh-rez-suite'> }) {
25
26
  { id: 'weixin', label: t('tab.weixin') },
26
27
  { id: 'status', label: t('tab.status') },
27
28
  { id: 'audit', label: t('tab.audit') },
29
+ { id: 'changelog', label: t('tab.changelog') },
28
30
  ]
29
31
  return (
30
32
  <RezT.Provider value={t}>
@@ -48,6 +50,7 @@ export function RezSettingsPage({ t }: { t: TranslateNS<'dsh-rez-suite'> }) {
48
50
  {activeTab === 'weixin' && <WeixinTab api={api} />}
49
51
  {activeTab === 'status' && <StatusTab api={api} />}
50
52
  {activeTab === 'audit' && <AuditTab api={api} />}
53
+ {activeTab === 'changelog' && <ChangelogTab api={api} />}
51
54
  </div>
52
55
  </RezT.Provider>
53
56
  )
@@ -0,0 +1,181 @@
1
+ /**
2
+ * ESPHome lab: official dsh-mcp-client → lab MCP, plus HTTP fallback.
3
+ * Lab server runs official `esphome compile`. Never puts firmware bytes
4
+ * into the model context; the tool returns a click-later download link.
5
+ */
6
+
7
+ import { loadRezConnections, readManagedCredential, REZ_CREDENTIAL_REFS, startMcpWhenSecret } from '@rezti/dsh-rez-sso'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+
10
+ export const ESPHOME_MCP_URL = 'http://127.0.0.1:8100/mcp'
11
+ /** POST /v1/compile 只交 YAML、立刻回链接。真机 `esphome compile` 在后台,最长见实验室 `ESPHOME_COMPILE_TIMEOUT`(默认 20 分钟)。 */
12
+ export const ESPHOME_COMPILE_TIMEOUT_MS = 120 * 1000
13
+
14
+ export interface EsphomeCompileResult {
15
+ jobId: string
16
+ device: string
17
+ status: string
18
+ download: string
19
+ direct?: string
20
+ nextcloud?: string
21
+ size?: number
22
+ sha256?: string
23
+ logTail?: string
24
+ error?: string
25
+ }
26
+
27
+ export function esphomeLabUrl(): string {
28
+ const url = loadRezConnections().esphome?.url
29
+ return typeof url === 'string' && url.length > 0 ? url.replace(/\/+$/, '') : ESPHOME_MCP_URL
30
+ }
31
+
32
+ /** MCP is :8100/mcp; click-later HTTP lives on :8099. */
33
+ export function esphomeHttpOrigin(mcpUrl: string = esphomeLabUrl()): string {
34
+ try {
35
+ const url = new URL(mcpUrl)
36
+ if (url.port === '8100') url.port = '8099'
37
+ return url.origin
38
+ } catch {
39
+ return 'http://127.0.0.1:8099'
40
+ }
41
+ }
42
+
43
+ function labToken(): string | undefined {
44
+ const env = process.env.REZ_ESPHOME_TOKEN
45
+ if (typeof env === 'string' && env.length > 0) return env
46
+ return readManagedCredential(REZ_CREDENTIAL_REFS.esphome)
47
+ }
48
+
49
+ export function esphomeLabConfigured(): boolean {
50
+ const connections = loadRezConnections().esphome
51
+ if (connections?.enabled === false) return false
52
+ return labToken() !== undefined
53
+ }
54
+
55
+ export function bearerAuthorization(secret: string): string {
56
+ const trimmed = secret.trim()
57
+ if (/^bearer\s+/i.test(trimmed)) return 'Bearer ' + trimmed.replace(/^bearer\s+/i, '')
58
+ return 'Bearer ' + trimmed
59
+ }
60
+
61
+ export function esphomeMcpConfig(secret: string): Record<string, unknown> {
62
+ return {
63
+ serverName: 'esphome',
64
+ transport: 'streamable-http',
65
+ url: esphomeLabUrl(),
66
+ headers: { Authorization: bearerAuthorization(secret) },
67
+ failOnStartupError: false,
68
+ toolCallTimeoutMs: ESPHOME_COMPILE_TIMEOUT_MS,
69
+ }
70
+ }
71
+
72
+ interface CompileJson {
73
+ ok?: boolean
74
+ id?: string
75
+ device?: string
76
+ status?: string
77
+ download?: string
78
+ direct?: string
79
+ nextcloud?: string
80
+ size?: number
81
+ sha256?: string
82
+ log_tail?: string
83
+ error?: string
84
+ }
85
+
86
+ /** Submit YAML; return the click-later link. Does not fetch firmware bytes. */
87
+ export async function startEsphomeCompile(input: {
88
+ yaml: string
89
+ fetchImpl?: typeof fetch
90
+ }): Promise<EsphomeCompileResult> {
91
+ const token = labToken()
92
+ if (token === undefined) {
93
+ throw new Error('未配置 REZ_ESPHOME_TOKEN。设置 → 插件 → ReZ-TI → ESPHome 实验室 填 Token。')
94
+ }
95
+ const fetchImpl = input.fetchImpl ?? fetch
96
+ const controller = new AbortController()
97
+ const timer = setTimeout(() => { controller.abort() }, ESPHOME_COMPILE_TIMEOUT_MS)
98
+ try {
99
+ const response = await fetchImpl(esphomeHttpOrigin() + '/v1/compile', {
100
+ method: 'POST',
101
+ headers: {
102
+ authorization: bearerAuthorization(token),
103
+ 'content-type': 'application/yaml',
104
+ },
105
+ body: input.yaml,
106
+ signal: controller.signal,
107
+ })
108
+ const compiled = await response.json() as CompileJson
109
+ if (!response.ok || compiled.ok !== true || typeof compiled.id !== 'string') {
110
+ throw new Error(compiled.error ?? ('compile HTTP ' + response.status))
111
+ }
112
+ const download = compiled.download
113
+ ?? compiled.nextcloud
114
+ ?? compiled.direct
115
+ ?? (esphomeHttpOrigin() + '/v1/jobs/' + compiled.id + '/download')
116
+ return {
117
+ jobId: compiled.id,
118
+ device: compiled.device ?? 'device',
119
+ status: compiled.status ?? 'compiling',
120
+ download,
121
+ direct: compiled.direct,
122
+ nextcloud: compiled.nextcloud,
123
+ size: compiled.size,
124
+ sha256: compiled.sha256,
125
+ logTail: compiled.log_tail,
126
+ error: compiled.error,
127
+ }
128
+ } finally {
129
+ clearTimeout(timer)
130
+ }
131
+ }
132
+
133
+ /** @deprecated use startEsphomeCompile */
134
+ export const compileEsphomeFirmware = startEsphomeCompile
135
+
136
+ /** Start for every identity; execution is room-gated to 实验室. */
137
+ export function mountEsphomeMcp(
138
+ ctx: Context,
139
+ start: typeof startMcpWhenSecret = startMcpWhenSecret,
140
+ ): () => void {
141
+ let session: (() => void) | undefined
142
+ let pending = false
143
+ let retryTimer: ReturnType<typeof setTimeout> | undefined
144
+
145
+ const hasRezSso = (): boolean => {
146
+ const host = ctx as Context & { get?: (name: string) => unknown }
147
+ if (typeof host.get !== 'function') return true
148
+ return host.get('rezSso') !== undefined
149
+ }
150
+
151
+ const ensure = (): void => {
152
+ void (async () => {
153
+ if (session !== undefined || pending) return
154
+ if (!hasRezSso()) {
155
+ if (retryTimer === undefined) {
156
+ retryTimer = setTimeout(() => {
157
+ retryTimer = undefined
158
+ ensure()
159
+ }, 300)
160
+ }
161
+ return
162
+ }
163
+ pending = true
164
+ try {
165
+ session = await start(ctx, {
166
+ system: 'esphome',
167
+ label: 'ESPHome 实验室',
168
+ config: esphomeMcpConfig,
169
+ })
170
+ } catch (error: unknown) {
171
+ const why = error instanceof Error ? error.message : String(error)
172
+ console.error('[dsh-rez-suite] esphome MCP start failed:', why)
173
+ } finally {
174
+ pending = false
175
+ }
176
+ })()
177
+ }
178
+
179
+ ensure()
180
+ return ensure
181
+ }
package/src/index.ts CHANGED
@@ -25,13 +25,14 @@ import {
25
25
  import { makeRoutes } from './routes.ts'
26
26
  import { defaultConfig, mergeConfig, saveConfig } from './store.ts'
27
27
  import { TokenManager } from './token-manager.ts'
28
- import { rezConfigTool, rezStatusTool } from './tools.ts'
28
+ import { rezConfigTool, rezEsphomeCompileTool, rezStatusTool } from './tools.ts'
29
29
  import type { RezConfig } from './protocol.ts'
30
30
  import { workspaceGuidanceFor, mountRezWorkspaces } from './boss/mount.ts'
31
31
  import { liveSelfUpdate } from './self-update.ts'
32
32
  import { mountTianyanchaMcp } from './tianyancha.ts'
33
33
  import { mountQccMcp, QCC_COMPANY_MCP_URL } from './qcc.ts'
34
- import { qccRoomGuard } from './rooms.ts'
34
+ import { mountEsphomeMcp } from './esphome-lab.ts'
35
+ import { suiteRoomGuard } from './rooms.ts'
35
36
 
36
37
  export const name = 'rez-suite'
37
38
 
@@ -90,6 +91,10 @@ export const Config: z<RezConfig> = z.object({
90
91
  enabled: z.boolean().default(true),
91
92
  url: z.string().default(REZ_DEFAULT_CONNECTIONS.tianyancha?.url ?? 'https://mcp.tianyancha.com/mcp'),
92
93
  }).default(REZ_DEFAULT_CONNECTIONS.tianyancha ?? { enabled: true, url: 'https://mcp.tianyancha.com/mcp' }),
94
+ esphome: z.object({
95
+ enabled: z.boolean().default(true),
96
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.esphome?.url ?? 'http://127.0.0.1:8100/mcp'),
97
+ }).default(REZ_DEFAULT_CONNECTIONS.esphome ?? { enabled: true, url: 'http://127.0.0.1:8100/mcp' }),
93
98
  }).default(REZ_DEFAULT_CONNECTIONS),
94
99
  billing: z.object({
95
100
  inputCostPer1k: z.number().default(0.001),
@@ -109,6 +114,9 @@ export const REZ_GUIDANCE_CORE = [
109
114
  '涉及资金、对外发送、设备控制、生产写入时先征得用户批准。',
110
115
  'Match the user language: Chinese or English. Do not force 中文 when they write English.',
111
116
  '制度、手册、出差/报销标准、素材路径:打开 Nextcloud Documents/Standards/INDEX.yaml,按 code 取当前 effective_from 最新且未被 replace 的条目,再按相对 uri 打开。引用带路径+生效日。找不到就说「标准文件未归档」,不要用 MEMORY 旧数字。素材文件夹用首次创建标题,不用会变的 Odoo/销售名。',
117
+ '同事交说明书/规格书:用输入栏回形针上传原 PDF(不要拖进对话——官方只收图;也不要用微信发文件)。不要直接写 Marketing/Publish。先找到该产品对应 TAPD 需求(Wiki 写死的说明书/规格书 TAPD ID;没有则按首次创建标题搜「产品使用说明书」或「技术手册」),把 PDF 放到 Marketing/Inbox/{分类}/{首次创建标题}/manual/ 或 datasheet/(zh.pdf 或 en.pdf),评论该需求、状态改测试中(status_3)、处理人改凯文智能家居。规划中先转到开发中再测试中;已实现要先退回开发中。凯文说审批了哪份(产品、说明书或规格书、中英)之后,再把 Inbox 文件写入 Marketing/Publish 对应路径,TAPD 改已实现。拒绝则改已拒绝、不写 Publish。Wiki 缺 NC RAW、NC Publish、Git products/{slug}/models 与 motion 就说「Wiki 未归档」。这不是一件发布;不要拷下游、不要上渠道。',
118
+ '同事交拍摄原片:对话里交图即可。先调官方 Comfy MCP 只修一次,得到高精度干净原片;Harness 不做推理,不要自写 WebSocket。这不是图生场景图也不是图生白底图。修完后员工必须声明是什么图(white_bg/scene/label/gallery/packaging,写进 TAPD 评论);未声明不准进审批。不要直接写 Marketing/RAW。两种都先放到 Marketing/Inbox/{分类}/{首次创建标题}/photo/shot/ 与 photo/clean/。对应 TAPD(Wiki 写死的拍摄图 TAPD ID;没有则按首次创建标题搜「产品拍摄」;还没有则评论 1154488930001001218)改测试中、处理人改凯文智能家居。凯文说审批了哪份之后,shot 与 clean 同时写入 Marketing/RAW 对应路径,TAPD 改已实现。拒绝不写 RAW。不要写 Publish、不要上渠道。现有 photo/ 根目录或产品根目录样板不强制搬家。',
119
+ '同事交品牌规范或公司 VI:这是公司级,写 Marketing/Brand/,不要写进某个产品 SKU 目录。规范 PDF 和 psd/ai 源文件用回形针;VI 图在对话里交,才调官方 Comfy MCP 只修一次。声明类型(guideline 或 logo/wordmark/icon/color/font/template)后,两种先入 Marketing/Inbox/Brand/guideline/ 或 vi/ 的 shot 与 clean。评论 1154488930001001220,改测试中、处理人凯文智能家居。凯文说审批了哪份之后,shot 与 clean 同时写入 Marketing/RAW/Brand 对应路径。拒绝不写 RAW。不要写 Publish。根上 VI/ 与 MKT/VI 是历史盘,不搬家、不再当正式路径。不要把规范抄进 Standards 正文。',
112
120
  ].join('')
113
121
 
114
122
  export function rezGuidanceFor(role: RezConfig['role']): string {
@@ -144,18 +152,20 @@ export function apply(ctx: Context, config?: RezConfig): void {
144
152
  const syncWorkspaces = mountRezWorkspaces(ctx, () => current().role)
145
153
  const syncQcc = mountQccMcp(ctx)
146
154
  const syncTianyancha = mountTianyanchaMcp(ctx, () => current().role)
155
+ const syncEsphome = mountEsphomeMcp(ctx)
147
156
 
148
157
  const tokens = new TokenManager(joinTokenDb())
149
158
  const host = delegatedHost(ctx, () => current())
150
159
  ctx.effect(() => () => { tokens.close() }, 'dsh-rez-suite: ledger view')
151
- const toolsHost = ctx.tools as { guard?: (guard: typeof qccRoomGuard) => () => void }
160
+ const toolsHost = ctx.tools as { guard?: (guard: typeof suiteRoomGuard) => () => void }
152
161
  if (typeof toolsHost.guard === 'function') {
153
- ctx.effect(() => toolsHost.guard!(qccRoomGuard), 'dsh-rez-suite: qcc room guard')
162
+ ctx.effect(() => toolsHost.guard!(suiteRoomGuard), 'dsh-rez-suite: room guard')
154
163
  }
155
164
 
156
165
  const tools = [
157
166
  rezStatusTool(host, () => current()),
158
167
  rezConfigTool(() => current()),
168
+ rezEsphomeCompileTool(),
159
169
  ]
160
170
 
161
171
  const routes = makeRoutes({
@@ -167,6 +177,7 @@ export function apply(ctx: Context, config?: RezConfig): void {
167
177
  syncWorkspaces()
168
178
  syncQcc()
169
179
  syncTianyancha()
180
+ syncEsphome()
170
181
  try {
171
182
  const settings = ctx.get('settings') as { update?: (namespace: unknown, value: unknown) => Promise<void> } | undefined
172
183
  if (settings?.update !== undefined) await settings.update(REZ_SETTINGS_NAMESPACE, value)
@@ -222,12 +233,14 @@ export function apply(ctx: Context, config?: RezConfig): void {
222
233
  syncWorkspaces()
223
234
  syncQcc()
224
235
  syncTianyancha()
236
+ syncEsphome()
225
237
  },
226
238
  onChange: () => {
227
239
  sync()
228
240
  syncWorkspaces()
229
241
  syncQcc()
230
242
  syncTianyancha()
243
+ syncEsphome()
231
244
  },
232
245
  })
233
246
 
@@ -8,12 +8,15 @@
8
8
 
9
9
  import { lastMcpStartError, REZ_CREDENTIAL_REFS, secretConfigured, type RezSystem } from '@rezti/dsh-rez-sso'
10
10
  import type { RezConfig, RezServerStatus, RezTestResult } from './protocol.ts'
11
- import { isLegalAssistantRoom, isQccToolName, QCC_STATUS_ROOM_OFF } from './rooms.ts'
11
+ import { isLabRoom, isLegalAssistantRoom, isQccToolName, QCC_STATUS_ROOM_OFF } from './rooms.ts'
12
12
 
13
- export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc', 'qcc', 'tianyancha'] as const
13
+ export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc', 'qcc', 'tianyancha', 'esphome'] as const
14
14
 
15
15
  export type ComposedServer = (typeof COMPOSED_SERVERS)[number]
16
16
 
17
+ /** Front panel hides these until install/docs are ready. Backend MCP can still start if a secret exists. TAPD 1154488930001001224. */
18
+ export const FRONT_HIDDEN_SERVERS: ReadonlySet<ComposedServer> = new Set(['wechat'])
19
+
17
20
  export function mcpToolPrefix(server: string): string {
18
21
  if (server === 'qcc') return 'mcp__qcc-'
19
22
  return 'mcp__' + server + '__'
@@ -109,13 +112,16 @@ export function observeServer(
109
112
  }
110
113
 
111
114
  function composedServersFor(input: ObserveInput): ComposedServer[] {
112
- const servers = input.config.role === 'boss'
113
- ? COMPOSED_SERVERS
115
+ let servers: ComposedServer[] = input.config.role === 'boss'
116
+ ? [...COMPOSED_SERVERS]
114
117
  : COMPOSED_SERVERS.filter((server) => server !== 'tianyancha')
115
118
  if (input.room !== undefined && !isLegalAssistantRoom(input.room)) {
116
- return servers.filter((server) => server !== 'qcc')
119
+ servers = servers.filter((server) => server !== 'qcc')
120
+ }
121
+ if (input.room !== undefined && !isLabRoom(input.room)) {
122
+ servers = servers.filter((server) => server !== 'esphome')
117
123
  }
118
- return [...servers]
124
+ return servers.filter((server) => !FRONT_HIDDEN_SERVERS.has(server))
119
125
  }
120
126
 
121
127
  function observeQccOutsideLegalRoom(input: ObserveInput): RezServerStatus {