@rezti/dsh-rez-suite 0.1.27 → 0.1.34

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 (44) hide show
  1. package/README.md +3 -1
  2. package/lib/client.d.ts +8 -1
  3. package/lib/client.js +393 -192
  4. package/lib/index.d.ts +4 -0
  5. package/lib/index.js +186 -13
  6. package/lib/style.css +47 -0
  7. package/package.json +17 -17
  8. package/src/boss/mount.ts +2 -0
  9. package/src/client/assets/rez-mark.png +0 -0
  10. package/src/client/brand-mark.ts +4 -0
  11. package/src/client/brand.module.css +51 -0
  12. package/src/client/brand.ts +135 -0
  13. package/src/client/index.ts +11 -0
  14. package/src/client/locales.ts +16 -2
  15. package/src/client/panel/AuditTab.tsx +20 -19
  16. package/src/client/panel/ConfigTab.tsx +59 -43
  17. package/src/client/panel/StatusTab.tsx +11 -10
  18. package/src/client/panel/WeixinTab.tsx +9 -8
  19. package/src/client/panel/helpers.ts +20 -5
  20. package/src/client/settings-card.tsx +48 -42
  21. package/src/client/upgrade-button.tsx +24 -12
  22. package/src/index.ts +21 -4
  23. package/src/mcp-host.ts +1 -1
  24. package/src/observe-mcp.ts +33 -3
  25. package/src/protocol.ts +2 -0
  26. package/src/qcc.ts +95 -0
  27. package/src/rooms.ts +65 -0
  28. package/src/store.ts +6 -0
  29. package/src/tools.ts +3 -2
  30. package/templates/boss/money/.agents/skills/boss-money/SKILL.md +1 -1
  31. package/templates/boss/money/AGENTS.md +3 -0
  32. package/templates/boss/ops/.agents/skills/boss-ops/SKILL.md +1 -1
  33. package/templates/boss/ops/AGENTS.md +3 -0
  34. package/templates/boss/product/.agents/skills/boss-product/SKILL.md +1 -1
  35. package/templates/boss/product/AGENTS.md +5 -0
  36. package/templates/staff/finance/.agents/skills/staff-finance/SKILL.md +1 -1
  37. package/templates/staff/finance/AGENTS.md +7 -0
  38. package/templates/staff/finance/MEMORY.md +1 -1
  39. package/templates/staff/hr/.agents/skills/staff-hr/SKILL.md +1 -1
  40. package/templates/staff/hr/AGENTS.md +7 -0
  41. package/templates/staff/hr/MEMORY.md +1 -1
  42. package/templates/staff/legal/.agents/skills/staff-legal/SKILL.md +1 -1
  43. package/templates/staff/legal/AGENTS.md +12 -2
  44. package/templates/staff/legal/MEMORY.md +1 -1
@@ -5,10 +5,11 @@
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { RezApi } from '../api.ts'
7
7
  import type { RezWeixinStatus } from '../../protocol.ts'
8
- import { errorMessage, tt } from './helpers.ts'
8
+ import { errorMessage, useT } from './helpers.ts'
9
9
  import css from './panel.module.css'
10
10
 
11
11
  export function WeixinTab({ api }: { api: RezApi }) {
12
+ const t = useT()
12
13
  const [status, setStatus] = useState<RezWeixinStatus | null>(null)
13
14
  const [error, setError] = useState('')
14
15
  const [code, setCode] = useState('')
@@ -68,32 +69,32 @@ export function WeixinTab({ api }: { api: RezApi }) {
68
69
 
69
70
  return (
70
71
  <div className={css.tabBody}>
71
- <p className={css.message}>{tt('weixin.intro')}</p>
72
+ <p className={css.message}>{t('weixin.intro')}</p>
72
73
  {error !== '' && <div className={css.error}>{error}</div>}
73
74
  {status !== null && <p className={css.message}>{status.hint}</p>}
74
75
  {status?.qrDataUrl !== undefined && (
75
- <img className={css.qr} src={status.qrDataUrl} alt={tt('weixin.qrAlt')} />
76
+ <img className={css.qr} src={status.qrDataUrl} alt={t('weixin.qrAlt')} />
76
77
  )}
77
78
  {status?.qrContent !== undefined && (
78
79
  <p className={css.message}><a href={status.qrContent} target="_blank" rel="noreferrer">{status.qrContent}</a></p>
79
80
  )}
80
81
  {status?.phase === 'need_verify' && (
81
82
  <div className={css.toolbar}>
82
- <input className={css.input} value={code} onChange={event => { setCode(event.target.value) }} placeholder={tt('weixin.verifyPlaceholder')} />
83
- <button type="button" className={css.button} disabled={busy} onClick={() => { void verify() }}>{tt('weixin.verify')}</button>
83
+ <input className={css.input} value={code} onChange={event => { setCode(event.target.value) }} placeholder={t('weixin.verifyPlaceholder')} />
84
+ <button type="button" className={css.button} disabled={busy} onClick={() => { void verify() }}>{t('weixin.verify')}</button>
84
85
  </div>
85
86
  )}
86
87
  <div className={css.toolbar}>
87
88
  <button type="button" className={css.button} disabled={busy} onClick={() => { void login() }}>
88
- {status?.loggedIn === true ? tt('weixin.reconnect') : tt('weixin.login')}
89
+ {status?.loggedIn === true ? t('weixin.reconnect') : t('weixin.login')}
89
90
  </button>
90
91
  {status?.loggedIn === true && (
91
- <button type="button" className={css.button} disabled={busy} onClick={() => { void logout() }}>{tt('weixin.logout')}</button>
92
+ <button type="button" className={css.button} disabled={busy} onClick={() => { void logout() }}>{t('weixin.logout')}</button>
92
93
  )}
93
94
  </div>
94
95
  {status !== null && (
95
96
  <p className={css.message}>
96
- {status.listening ? tt('weixin.listening') : status.loggedIn ? tt('weixin.loggedIn') : tt('weixin.loggedOut')}
97
+ {status.listening ? t('weixin.listening') : status.loggedIn ? t('weixin.loggedIn') : t('weixin.loggedOut')}
97
98
  </p>
98
99
  )}
99
100
  </div>
@@ -1,18 +1,33 @@
1
1
  /**
2
- * Shared panel helpers: active-language dictionary pick and error extraction.
2
+ * Official DSH locale seat for ReZ UI copy.
3
+ *
4
+ * Slot components receive `t` from `locale: 'dsh-rez-suite'`. Fallback copy
5
+ * is Chinese only — do not read `<html lang>`. The Host stores zh/en in
6
+ * `locale.preference`, which does not necessarily match the document language.
3
7
  */
4
8
 
5
- import { en, t, zh, type RezKey } from '../locales.ts'
9
+ import { createContext, useContext } from 'react'
10
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
11
+ import { t as fill, zh, type RezKey } from '../locales.ts'
6
12
 
7
13
  export type TranslateValues = Record<string, string | number>
14
+ export type RezTranslate = TranslateNS<'dsh-rez-suite'>
8
15
 
16
+ export const RezT = createContext<RezTranslate | null>(null)
17
+
18
+ /** Fallback for tests / code outside a slot. Prefer {@link useT}. */
9
19
  export function dictionary(): Record<string, string> {
10
- const lang = typeof document !== 'undefined' ? document.documentElement.lang : 'zh'
11
- return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
20
+ return { ...zh }
12
21
  }
13
22
 
14
23
  export function tt(key: RezKey, values?: TranslateValues): string {
15
- return t(dictionary(), key, values)
24
+ return fill(dictionary(), key, values)
25
+ }
26
+
27
+ export function useT(): RezTranslate {
28
+ const injected = useContext(RezT)
29
+ if (injected) return injected
30
+ return (key, params) => fill(dictionary(), key as RezKey, params as TranslateValues | undefined)
16
31
  }
17
32
 
18
33
  export function errorMessage(error: unknown): string {
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * ReZ settings pages: one accordion card in 设置 → 插件 → 插件配置,
3
3
  * plus a dedicated Plugins tab for the same content.
4
+ *
5
+ * `t` is the official locale seat (zh/en follows 设置 → 语言).
4
6
  */
5
7
 
6
8
  import { useMemo, useState } from 'react'
9
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
7
10
  import { RezApi } from './api.ts'
8
- import { tt } from './panel/helpers.ts'
11
+ import { RezT } from './panel/helpers.ts'
9
12
  import { AuditTab } from './panel/AuditTab.tsx'
10
13
  import { ConfigTab } from './panel/ConfigTab.tsx'
11
14
  import { StatusTab } from './panel/StatusTab.tsx'
@@ -14,55 +17,58 @@ import css from './panel/panel.module.css'
14
17
 
15
18
  export type RezTab = 'config' | 'weixin' | 'status' | 'audit'
16
19
 
17
- const TABS: ReadonlyArray<{ id: RezTab; label: () => string }> = [
18
- { id: 'config', label: () => tt('tab.config') },
19
- { id: 'weixin', label: () => tt('tab.weixin') },
20
- { id: 'status', label: () => tt('tab.status') },
21
- { id: 'audit', label: () => tt('tab.audit') },
22
- ]
23
-
24
- export function RezSettingsPage() {
20
+ export function RezSettingsPage({ t }: { t: TranslateNS<'dsh-rez-suite'> }) {
25
21
  const api = useMemo(() => new RezApi(), [])
26
22
  const [activeTab, setActiveTab] = useState<RezTab>('config')
23
+ const tabs: ReadonlyArray<{ id: RezTab; label: string }> = [
24
+ { id: 'config', label: t('tab.config') },
25
+ { id: 'weixin', label: t('tab.weixin') },
26
+ { id: 'status', label: t('tab.status') },
27
+ { id: 'audit', label: t('tab.audit') },
28
+ ]
27
29
  return (
28
- <div className={css.shell}>
29
- <div className={css.tabBar} role="tablist">
30
- {TABS.map(tab => (
31
- <button
32
- key={tab.id}
33
- type="button"
34
- role="tab"
35
- aria-selected={activeTab === tab.id}
36
- data-active={activeTab === tab.id ? '' : undefined}
37
- className={css.tab}
38
- onClick={() => { setActiveTab(tab.id) }}
39
- >
40
- {tab.label()}
41
- </button>
42
- ))}
30
+ <RezT.Provider value={t}>
31
+ <div className={css.shell}>
32
+ <div className={css.tabBar} role="tablist">
33
+ {tabs.map(tab => (
34
+ <button
35
+ key={tab.id}
36
+ type="button"
37
+ role="tab"
38
+ aria-selected={activeTab === tab.id}
39
+ data-active={activeTab === tab.id ? '' : undefined}
40
+ className={css.tab}
41
+ onClick={() => { setActiveTab(tab.id) }}
42
+ >
43
+ {tab.label}
44
+ </button>
45
+ ))}
46
+ </div>
47
+ {activeTab === 'config' && <ConfigTab api={api} />}
48
+ {activeTab === 'weixin' && <WeixinTab api={api} />}
49
+ {activeTab === 'status' && <StatusTab api={api} />}
50
+ {activeTab === 'audit' && <AuditTab api={api} />}
43
51
  </div>
44
- {activeTab === 'config' && <ConfigTab api={api} />}
45
- {activeTab === 'weixin' && <WeixinTab api={api} />}
46
- {activeTab === 'status' && <StatusTab api={api} />}
47
- {activeTab === 'audit' && <AuditTab api={api} />}
48
- </div>
52
+ </RezT.Provider>
49
53
  )
50
54
  }
51
55
 
52
- export function RezPluginCard() {
56
+ export function RezPluginCard({ t }: { t: TranslateNS<'dsh-rez-suite'> }) {
53
57
  const [open, setOpen] = useState(false)
54
58
  return (
55
- <li className={css.card + (open ? ' ' + css.cardOpen : '')}>
56
- <button type="button" className={css.header} onClick={() => { setOpen(!open) }}>
57
- <div className={css.headText}>
58
- <div className={css.name}>{tt('settings.title')}</div>
59
- <div className={css.description}>{tt('settings.description')}</div>
60
- </div>
61
- <svg className={css.chevron + (open ? ' ' + css.chevronOpen : '')} viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
62
- <path d="M3 5l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
63
- </svg>
64
- </button>
65
- {open ? <div className={css.body}><RezSettingsPage /></div> : null}
66
- </li>
59
+ <RezT.Provider value={t}>
60
+ <li className={css.card + (open ? ' ' + css.cardOpen : '')}>
61
+ <button type="button" className={css.header} onClick={() => { setOpen(!open) }}>
62
+ <div className={css.headText}>
63
+ <div className={css.name}>{t('settings.title')}</div>
64
+ <div className={css.description}>{t('settings.description')}</div>
65
+ </div>
66
+ <svg className={css.chevron + (open ? ' ' + css.chevronOpen : '')} viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
67
+ <path d="M3 5l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
68
+ </svg>
69
+ </button>
70
+ {open ? <div className={css.body}><RezSettingsPage t={t} /></div> : null}
71
+ </li>
72
+ </RezT.Provider>
67
73
  )
68
74
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Sidebar footer action: upgrade @rezti/dsh-rez-suite without a terminal.
3
3
  * Host code still needs a process respawn; the button does that after pnpm add.
4
+ *
5
+ * `t` is the official locale seat (zh/en follows 设置 → 语言).
4
6
  */
5
7
 
6
8
  import { useEffect, useMemo, useState } from 'react'
9
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
7
10
  import { RezApi } from './api.ts'
8
- import { tt } from './panel/helpers.ts'
9
11
  import css from './panel/panel.module.css'
10
12
  import type { RezUpdateStatus } from '../protocol.ts'
11
13
 
@@ -24,7 +26,11 @@ function UpgradeIcon() {
24
26
  )
25
27
  }
26
28
 
27
- async function waitUntilHostBack(api: RezApi, timeoutMs = 60_000): Promise<void> {
29
+ async function waitUntilHostBack(
30
+ api: RezApi,
31
+ timeoutMessage: string,
32
+ timeoutMs = 60_000,
33
+ ): Promise<void> {
28
34
  const deadline = Date.now() + timeoutMs
29
35
  await new Promise((resolve) => setTimeout(resolve, 800))
30
36
  while (Date.now() < deadline) {
@@ -35,10 +41,16 @@ async function waitUntilHostBack(api: RezApi, timeoutMs = 60_000): Promise<void>
35
41
  await new Promise((resolve) => setTimeout(resolve, 700))
36
42
  }
37
43
  }
38
- throw new Error(tt('upgrade.reloadTimeout'))
44
+ throw new Error(timeoutMessage)
39
45
  }
40
46
 
41
- export function UpgradeButton({ wide }: { wide: boolean }) {
47
+ export function UpgradeButton({
48
+ wide,
49
+ t,
50
+ }: {
51
+ wide: boolean
52
+ t: TranslateNS<'dsh-rez-suite'>
53
+ }) {
42
54
  const api = useMemo(() => new RezApi(), [])
43
55
  const [info, setInfo] = useState<RezUpdateStatus>()
44
56
  const [busy, setBusy] = useState(false)
@@ -55,10 +67,10 @@ export function UpgradeButton({ wide }: { wide: boolean }) {
55
67
  }, [api])
56
68
 
57
69
  const label = busy
58
- ? tt('upgrade.running')
70
+ ? t('upgrade.running')
59
71
  : info?.outdated === true
60
- ? tt('upgrade.label')
61
- : tt('upgrade.current')
72
+ ? t('upgrade.label')
73
+ : t('upgrade.current')
62
74
 
63
75
  const meta = note !== ''
64
76
  ? note
@@ -76,18 +88,18 @@ export function UpgradeButton({ wide }: { wide: boolean }) {
76
88
  const snapshot = info ?? await api.updateStatus()
77
89
  setInfo(snapshot)
78
90
  if (!snapshot.outdated) {
79
- setNote(tt('upgrade.already', { version: snapshot.installed }))
91
+ setNote(t('upgrade.already', { version: snapshot.installed }))
80
92
  return
81
93
  }
82
94
  const result = await api.applyUpdate()
83
95
  setInfo(result)
84
96
  if (result.restarting) {
85
- setNote(tt('upgrade.reloading'))
86
- await waitUntilHostBack(api)
97
+ setNote(t('upgrade.reloading'))
98
+ await waitUntilHostBack(api, t('upgrade.reloadTimeout'))
87
99
  window.location.reload()
88
100
  return
89
101
  }
90
- setNote(tt('upgrade.already', { version: result.installed }))
102
+ setNote(t('upgrade.already', { version: result.installed }))
91
103
  } catch (error) {
92
104
  setNote(error instanceof Error ? error.message : String(error))
93
105
  } finally {
@@ -101,7 +113,7 @@ export function UpgradeButton({ wide }: { wide: boolean }) {
101
113
  type="button"
102
114
  className={css.upgradeButton}
103
115
  data-outdated={info?.outdated === true ? '' : undefined}
104
- aria-label={tt('upgrade.aria')}
116
+ aria-label={t('upgrade.aria')}
105
117
  disabled={busy}
106
118
  onClick={() => { void onClick() }}
107
119
  >
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ 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
+ import { mountQccMcp, QCC_COMPANY_MCP_URL } from './qcc.ts'
34
+ import { qccRoomGuard } from './rooms.ts'
33
35
 
34
36
  export const name = 'rez-suite'
35
37
 
@@ -80,10 +82,14 @@ export const Config: z<RezConfig> = z.object({
80
82
  enabled: z.boolean().default(true),
81
83
  siteUrl: z.string().default(REZ_DEFAULT_CONNECTIONS.gsc.siteUrl),
82
84
  }).default(REZ_DEFAULT_CONNECTIONS.gsc),
85
+ qcc: z.object({
86
+ enabled: z.boolean().default(true),
87
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.qcc?.url ?? QCC_COMPANY_MCP_URL),
88
+ }).default(REZ_DEFAULT_CONNECTIONS.qcc ?? { enabled: true, url: QCC_COMPANY_MCP_URL }),
83
89
  tianyancha: z.object({
84
90
  enabled: z.boolean().default(true),
85
- url: z.string().default(REZ_DEFAULT_CONNECTIONS.tianyancha.url),
86
- }).default(REZ_DEFAULT_CONNECTIONS.tianyancha),
91
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.tianyancha?.url ?? 'https://mcp.tianyancha.com/mcp'),
92
+ }).default(REZ_DEFAULT_CONNECTIONS.tianyancha ?? { enabled: true, url: 'https://mcp.tianyancha.com/mcp' }),
87
93
  }).default(REZ_DEFAULT_CONNECTIONS),
88
94
  billing: z.object({
89
95
  inputCostPer1k: z.number().default(0.001),
@@ -101,6 +107,8 @@ export const REZ_GUIDANCE_CORE = [
101
107
  '个人微信(QClaw/ClawBot):打开 设置 → 插件 → ReZ-TI → 微信 扫码,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认。网页归档了再从微信说话会自动恢复到侧栏。发「重启对话」会新开一条可见会话并把旧会话归档。网页端必须开着。不必再跑命令、也不要调用微信 MCP 工具。',
102
108
  '意图用 rez_set_intent(erp/files/chat/home/auto)。审计用 rez_audit。',
103
109
  '涉及资金、对外发送、设备控制、生产写入时先征得用户批准。',
110
+ 'Match the user language: Chinese or English. Do not force 中文 when they write English.',
111
+ '制度、手册、出差/报销标准、素材路径:打开 Nextcloud Documents/Standards/INDEX.yaml,按 code 取当前 effective_from 最新且未被 replace 的条目,再按相对 uri 打开。引用带路径+生效日。找不到就说「标准文件未归档」,不要用 MEMORY 旧数字。素材文件夹用首次创建标题,不用会变的 Odoo/销售名。',
104
112
  ].join('')
105
113
 
106
114
  export function rezGuidanceFor(role: RezConfig['role']): string {
@@ -115,12 +123,13 @@ function normalize(input: Partial<RezConfig> | undefined): RezConfig {
115
123
  }
116
124
 
117
125
  function delegatedHost(ctx: Context, getConfig: () => RezConfig): McpHost {
118
- const snapshot = () => observeComposedServers({
126
+ const snapshot = (room?: string) => observeComposedServers({
119
127
  config: getConfig(),
120
128
  toolNames: listRegisteredToolNames(ctx.tools),
129
+ room,
121
130
  })
122
131
  return {
123
- status: () => snapshot(),
132
+ status: (room?: string) => snapshot(room),
124
133
  test: async () => testComposedServers({
125
134
  config: getConfig(),
126
135
  toolNames: listRegisteredToolNames(ctx.tools),
@@ -133,11 +142,16 @@ function delegatedHost(ctx: Context, getConfig: () => RezConfig): McpHost {
133
142
  export function apply(ctx: Context, config?: RezConfig): void {
134
143
  let current: () => RezConfig = () => normalize(config)
135
144
  const syncWorkspaces = mountRezWorkspaces(ctx, () => current().role)
145
+ const syncQcc = mountQccMcp(ctx)
136
146
  const syncTianyancha = mountTianyanchaMcp(ctx, () => current().role)
137
147
 
138
148
  const tokens = new TokenManager(joinTokenDb())
139
149
  const host = delegatedHost(ctx, () => current())
140
150
  ctx.effect(() => () => { tokens.close() }, 'dsh-rez-suite: ledger view')
151
+ const toolsHost = ctx.tools as { guard?: (guard: typeof qccRoomGuard) => () => void }
152
+ if (typeof toolsHost.guard === 'function') {
153
+ ctx.effect(() => toolsHost.guard!(qccRoomGuard), 'dsh-rez-suite: qcc room guard')
154
+ }
141
155
 
142
156
  const tools = [
143
157
  rezStatusTool(host, () => current()),
@@ -151,6 +165,7 @@ export function apply(ctx: Context, config?: RezConfig): void {
151
165
  saveConfig(value)
152
166
  current = () => value
153
167
  syncWorkspaces()
168
+ syncQcc()
154
169
  syncTianyancha()
155
170
  try {
156
171
  const settings = ctx.get('settings') as { update?: (namespace: unknown, value: unknown) => Promise<void> } | undefined
@@ -205,11 +220,13 @@ export function apply(ctx: Context, config?: RezConfig): void {
205
220
  current = source
206
221
  sync()
207
222
  syncWorkspaces()
223
+ syncQcc()
208
224
  syncTianyancha()
209
225
  },
210
226
  onChange: () => {
211
227
  sync()
212
228
  syncWorkspaces()
229
+ syncQcc()
213
230
  syncTianyancha()
214
231
  },
215
232
  })
package/src/mcp-host.ts CHANGED
@@ -368,7 +368,7 @@ export class McpHost {
368
368
  return results
369
369
  }
370
370
 
371
- status(): RezServerStatus[] {
371
+ status(_room?: string): RezServerStatus[] {
372
372
  const ids = new Set<string>([...this.enabledIds, ...this.connections.keys()])
373
373
  return [...ids].sort().map(id => {
374
374
  const connection = this.connections.get(id)
@@ -8,12 +8,14 @@
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, QCC_STATUS_ROOM_OFF } from './rooms.ts'
11
12
 
12
- export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc', 'tianyancha'] as const
13
+ export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc', 'qcc', 'tianyancha'] as const
13
14
 
14
15
  export type ComposedServer = (typeof COMPOSED_SERVERS)[number]
15
16
 
16
17
  export function mcpToolPrefix(server: string): string {
18
+ if (server === 'qcc') return 'mcp__qcc-'
17
19
  return 'mcp__' + server + '__'
18
20
  }
19
21
 
@@ -39,6 +41,8 @@ export function listRegisteredToolNames(tools: { schemas?: () => ReadonlyArray<{
39
41
  export interface ObserveInput {
40
42
  config: RezConfig
41
43
  toolNames: readonly string[]
44
+ /** Session cwd or folder name. Settings panel omits this. */
45
+ room?: string
42
46
  secretPresent?: (server: RezSystem) => boolean
43
47
  startError?: (server: RezSystem) => string | undefined
44
48
  }
@@ -101,11 +105,37 @@ export function observeServer(
101
105
  }
102
106
  }
103
107
 
104
- export function observeComposedServers(input: ObserveInput): RezServerStatus[] {
108
+ function composedServersFor(input: ObserveInput): ComposedServer[] {
105
109
  const servers = input.config.role === 'boss'
106
110
  ? COMPOSED_SERVERS
107
111
  : COMPOSED_SERVERS.filter((server) => server !== 'tianyancha')
108
- return servers.map(server => observeServer(server, input))
112
+ if (input.room !== undefined && !isLegalAssistantRoom(input.room)) {
113
+ return servers.filter((server) => server !== 'qcc')
114
+ }
115
+ return [...servers]
116
+ }
117
+
118
+ function observeQccOutsideLegalRoom(input: ObserveInput): RezServerStatus {
119
+ if (isEnabled(input.config, 'qcc') === false) {
120
+ return observeServer('qcc', input)
121
+ }
122
+ return {
123
+ server: 'qcc',
124
+ enabled: true,
125
+ state: 'disconnected',
126
+ toolCount: 0,
127
+ registeredTools: 0,
128
+ lastError: QCC_STATUS_ROOM_OFF,
129
+ }
130
+ }
131
+
132
+ export function observeComposedServers(input: ObserveInput): RezServerStatus[] {
133
+ return composedServersFor(input).map((server) => {
134
+ if (server === 'qcc' && !isLegalAssistantRoom(input.room)) {
135
+ return observeQccOutsideLegalRoom(input)
136
+ }
137
+ return observeServer(server, input)
138
+ })
109
139
  }
110
140
 
111
141
  export function testComposedServers(input: ObserveInput): RezTestResult[] {
package/src/protocol.ts CHANGED
@@ -48,6 +48,7 @@ export interface RezConnections {
48
48
  homeassistant: { enabled: boolean; url: string }
49
49
  tapd: { enabled: boolean; workspaceId: string; nickName: string }
50
50
  gsc: { enabled: boolean; siteUrl: string }
51
+ qcc: { enabled: boolean; url: string }
51
52
  tianyancha: { enabled: boolean; url: string }
52
53
  }
53
54
 
@@ -91,6 +92,7 @@ export interface RezPublicConfig {
91
92
  homeassistant: RezPublicConnection
92
93
  tapd: RezPublicConnection
93
94
  gsc: RezPublicConnection
95
+ qcc: RezPublicConnection
94
96
  tianyancha: RezPublicConnection
95
97
  }
96
98
  billing: { inputCostPer1k: number; outputCostPer1k: number; monthlyBudget: number }
package/src/qcc.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Qichacha official MCP for the 法务助理 workspace.
3
+ * Reuse https://agent.qcc.com/mcp/{company,risk}/stream via dsh-mcp-client.
4
+ * One API key starts two streamable-http servers (qcc-company, qcc-risk).
5
+ * Start for every suite role so 法务助理 can query. Execution is room-gated
6
+ * (`ctx.tools.guard` in rooms.ts). Do not dump into the global prompt.
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { loadRezConnections, startMcpWhenSecret } from '@rezti/dsh-rez-sso'
10
+
11
+ export const QCC_COMPANY_MCP_URL = 'https://agent.qcc.com/mcp/company/stream'
12
+ export const QCC_RISK_MCP_URL = 'https://agent.qcc.com/mcp/risk/stream'
13
+
14
+ export function bearerAuthorization(secret: string): string {
15
+ const trimmed = secret.trim()
16
+ if (/^bearer\s+/i.test(trimmed)) return 'Bearer ' + trimmed.replace(/^bearer\s+/i, '')
17
+ return 'Bearer ' + trimmed
18
+ }
19
+
20
+ /** Risk stream URL: swap /company/ for /risk/ when the company URL is official-shaped. */
21
+ export function qccRiskUrlFrom(companyUrl: string): string {
22
+ const trimmed = companyUrl.trim()
23
+ if (trimmed.includes('/company/')) return trimmed.replace('/company/', '/risk/')
24
+ return QCC_RISK_MCP_URL
25
+ }
26
+
27
+ function qccHeaders(secret: string): Record<string, string> {
28
+ return { Authorization: bearerAuthorization(secret) }
29
+ }
30
+
31
+ function qccCompanyUrl(): string {
32
+ const url = loadRezConnections().qcc?.url
33
+ return typeof url === 'string' && url.length > 0 ? url : QCC_COMPANY_MCP_URL
34
+ }
35
+
36
+ export function qccCompanyMcpConfig(secret: string): Record<string, unknown> {
37
+ return {
38
+ serverName: 'qcc-company',
39
+ transport: 'streamable-http',
40
+ url: qccCompanyUrl(),
41
+ headers: qccHeaders(secret),
42
+ failOnStartupError: false,
43
+ }
44
+ }
45
+
46
+ export function qccRiskMcpConfig(secret: string): Record<string, unknown> {
47
+ const companyUrl = qccCompanyUrl()
48
+ return {
49
+ serverName: 'qcc-risk',
50
+ transport: 'streamable-http',
51
+ url: qccRiskUrlFrom(companyUrl),
52
+ headers: qccHeaders(secret),
53
+ failOnStartupError: false,
54
+ }
55
+ }
56
+
57
+ /** Start company + risk clients whenever the Qichacha secret exists. */
58
+ export function mountQccMcp(
59
+ ctx: Context,
60
+ start: typeof startMcpWhenSecret = startMcpWhenSecret,
61
+ ): () => void {
62
+ let session: (() => void) | undefined
63
+ let pending = false
64
+
65
+ const ensure = (): void => {
66
+ void (async () => {
67
+ if (session !== undefined || pending) return
68
+ pending = true
69
+ try {
70
+ const disposeCompany = await start(ctx, {
71
+ system: 'qcc',
72
+ label: '企查查·企业',
73
+ config: qccCompanyMcpConfig,
74
+ })
75
+ const disposeRisk = await start(ctx, {
76
+ system: 'qcc',
77
+ label: '企查查·风险',
78
+ config: qccRiskMcpConfig,
79
+ })
80
+ session = () => {
81
+ disposeCompany()
82
+ disposeRisk()
83
+ }
84
+ } catch (error: unknown) {
85
+ const why = error instanceof Error ? error.message : String(error)
86
+ console.error('[dsh-rez-suite] qcc MCP start failed:', why)
87
+ } finally {
88
+ pending = false
89
+ }
90
+ })()
91
+ }
92
+
93
+ ensure()
94
+ return ensure
95
+ }
package/src/rooms.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Workspace room identity for execution-time MCP gates.
3
+ * Session cwd is the workspace folder (e.g. ~/.dsh/workspaces/法务助理).
4
+ */
5
+
6
+ export const LEGAL_ROOM_FOLDER = '法务助理'
7
+
8
+ export const QCC_ROOM_DENY = '企查查仅限法务助理房间使用,请切换到法务助理再查询。'
9
+
10
+ export const QCC_STATUS_ROOM_OFF = '房间未启用'
11
+
12
+ const ROOM_SEP = /[/\\]/
13
+
14
+ /** Last path segment of a workspace cwd or folder name. */
15
+ export function roomFolderFromPath(value: string | undefined): string | undefined {
16
+ if (typeof value !== 'string') return undefined
17
+ const trimmed = value.trim().replace(/[/\\]+$/, '')
18
+ if (trimmed.length === 0) return undefined
19
+ const parts = trimmed.split(ROOM_SEP).filter((part) => part.length > 0)
20
+ return parts.at(-1)
21
+ }
22
+
23
+ export function isLegalAssistantRoom(hint: string | undefined): boolean {
24
+ return roomFolderFromPath(hint) === LEGAL_ROOM_FOLDER
25
+ }
26
+
27
+ export function isQccToolName(name: string): boolean {
28
+ return name.startsWith('mcp__qcc-') || name.startsWith('mcp__qcc__')
29
+ }
30
+
31
+ /** Session cwd from a tool execution. Missing / unknown shapes → undefined. */
32
+ export function cwdFromExecution(execution: { agent?: unknown }): string | undefined {
33
+ const agent = execution.agent
34
+ if (typeof agent !== 'object' || agent === null) return undefined
35
+ const record = agent as Record<string, unknown>
36
+ const session = record.session
37
+ const header = typeof session === 'object' && session !== null
38
+ ? (session as Record<string, unknown>).header
39
+ : undefined
40
+ const candidates = [
41
+ typeof header === 'object' && header !== null ? (header as Record<string, unknown>).cwd : undefined,
42
+ typeof session === 'object' && session !== null ? (session as Record<string, unknown>).cwd : undefined,
43
+ record.cwd,
44
+ ]
45
+ for (const value of candidates) {
46
+ if (typeof value === 'string' && value.trim().length > 0) return value
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ /**
52
+ * Official `ctx.tools.guard` reason. Only Qichacha tools are gated.
53
+ * Must never throw: this guard is global and a throw would block WeChat turns.
54
+ * Missing cwd fails closed — not the legal room.
55
+ */
56
+ export function qccRoomGuard(execution: { name?: unknown; agent?: unknown }): string | undefined {
57
+ const name = typeof execution?.name === 'string' ? execution.name : ''
58
+ if (!isQccToolName(name)) return undefined
59
+ try {
60
+ if (isLegalAssistantRoom(cwdFromExecution(execution))) return undefined
61
+ return QCC_ROOM_DENY
62
+ } catch {
63
+ return QCC_ROOM_DENY
64
+ }
65
+ }
package/src/store.ts CHANGED
@@ -125,6 +125,12 @@ export function publicConfig(config: RezConfig): RezPublicConfig {
125
125
  secretConfigured: secretConfigured('gsc'),
126
126
  secretRef: REZ_CREDENTIAL_REFS.gsc,
127
127
  },
128
+ qcc: {
129
+ enabled: connections.qcc.enabled,
130
+ url: connections.qcc.url,
131
+ secretConfigured: secretConfigured('qcc'),
132
+ secretRef: REZ_CREDENTIAL_REFS.qcc,
133
+ },
128
134
  tianyancha: {
129
135
  enabled: connections.tianyancha.enabled,
130
136
  url: connections.tianyancha.url,