@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
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Official DSH has no brand slot: ui-sidebar paints BrandWordmark, the empty
3
+ * hero paints FishLogo + a fixed headline. Replacing the whole sidebar or
4
+ * the center column would drop their inner seats. This overlay swaps only
5
+ * those two SVGs and the known headline strings.
6
+ */
7
+
8
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
9
+ import { REZ_MARK_DATA_URL } from './brand-mark.ts'
10
+ import css from './brand.module.css'
11
+
12
+ /** Official BrandWordmark viewBox (182:24 wordmark). */
13
+ export const WORDMARK_VIEWBOX = '0 0 182 24'
14
+ /** Official FishLogo viewBox. */
15
+ export const FISH_VIEWBOX = '0 0 23.16 17.04'
16
+ /** Official empty-hero headlines we replace. */
17
+ export const OFFICIAL_HEADLINES = ['探索未至之境', 'Into the Unknown'] as const
18
+
19
+ function normViewBox(value: string | null): string {
20
+ return (value ?? '').trim().replace(/\s+/g, ' ')
21
+ }
22
+
23
+ function alreadyBranded(node: Node): boolean {
24
+ const el = node instanceof Element ? node : node.parentElement
25
+ return el?.closest('[data-rez-brand]') != null
26
+ }
27
+
28
+ function markImg(kind: 'wordmark' | 'icon', className: string): HTMLImageElement {
29
+ const img = document.createElement('img')
30
+ img.src = REZ_MARK_DATA_URL
31
+ img.alt = ''
32
+ img.draggable = false
33
+ img.className = className
34
+ img.setAttribute('data-rez-brand', kind)
35
+ return img
36
+ }
37
+
38
+ function paintWordmark(svg: SVGSVGElement, t: TranslateNS<'dsh-rez-suite'>): void {
39
+ const parent = svg.parentNode
40
+ if (parent === null || alreadyBranded(svg)) return
41
+ const row = document.createElement('span')
42
+ row.className = css.wordmark
43
+ row.setAttribute('data-rez-brand', 'wordmark')
44
+ row.setAttribute('aria-label', t('brand.wordmarkAria'))
45
+ const name = document.createElement('span')
46
+ name.className = css.name
47
+ name.textContent = t('brand.name')
48
+ const badge = document.createElement('span')
49
+ badge.className = css.badge
50
+ badge.textContent = t('brand.badge')
51
+ row.append(markImg('wordmark', css.mark), name, badge)
52
+ parent.replaceChild(row, svg)
53
+ }
54
+
55
+ function paintFish(svg: SVGSVGElement): void {
56
+ const parent = svg.parentNode
57
+ if (parent === null || alreadyBranded(svg)) return
58
+ const wrap = document.createElement('span')
59
+ wrap.setAttribute('data-rez-brand', 'icon')
60
+ wrap.style.display = 'inline-flex'
61
+ wrap.style.alignItems = 'center'
62
+ wrap.style.justifyContent = 'center'
63
+ const width = svg.getAttribute('width')
64
+ const height = svg.getAttribute('height')
65
+ if (width) wrap.style.width = /px$/.test(width) ? width : `${width}px`
66
+ if (height) wrap.style.height = /px$/.test(height) ? height : `${height}px`
67
+ wrap.append(markImg('icon', css.markOnly))
68
+ parent.replaceChild(wrap, svg)
69
+ }
70
+
71
+ function paintHeadline(el: Element, t: TranslateNS<'dsh-rez-suite'>): void {
72
+ el.setAttribute('data-rez-brand', 'headline')
73
+ el.textContent = t('brand.headline')
74
+ }
75
+
76
+ function refreshCopy(root: ParentNode, t: TranslateNS<'dsh-rez-suite'>): void {
77
+ for (const row of root.querySelectorAll('[data-rez-brand="wordmark"]')) {
78
+ const name = row.querySelector(`.${css.name}`)
79
+ const badge = row.querySelector(`.${css.badge}`)
80
+ if (name) name.textContent = t('brand.name')
81
+ if (badge) badge.textContent = t('brand.badge')
82
+ row.setAttribute('aria-label', t('brand.wordmarkAria'))
83
+ }
84
+ for (const el of root.querySelectorAll('[data-rez-brand="headline"]')) {
85
+ el.textContent = t('brand.headline')
86
+ }
87
+ }
88
+
89
+ export function paintBrand(root: ParentNode, t: TranslateNS<'dsh-rez-suite'>): void {
90
+ refreshCopy(root, t)
91
+ const svgs = root.querySelectorAll('svg[aria-hidden="true"]')
92
+ for (const node of svgs) {
93
+ if (!(node instanceof SVGSVGElement)) continue
94
+ const box = normViewBox(node.getAttribute('viewBox'))
95
+ if (box === WORDMARK_VIEWBOX) paintWordmark(node, t)
96
+ else if (box === FISH_VIEWBOX) paintFish(node)
97
+ }
98
+ const doc = root instanceof Document ? root : root.ownerDocument
99
+ if (doc === null) return
100
+ const walker = doc.createTreeWalker(root as Node, NodeFilter.SHOW_TEXT)
101
+ const headlines: Text[] = []
102
+ while (walker.nextNode()) {
103
+ const text = walker.currentNode as Text
104
+ const value = text.data.trim()
105
+ if ((OFFICIAL_HEADLINES as readonly string[]).includes(value)) headlines.push(text)
106
+ }
107
+ for (const text of headlines) {
108
+ const host = text.parentElement
109
+ if (host) paintHeadline(host, t)
110
+ }
111
+ }
112
+
113
+ export function mountRezBrand(t: TranslateNS<'dsh-rez-suite'>): () => void {
114
+ if (typeof document === 'undefined') return () => undefined
115
+ let frame = 0
116
+ const run = (): void => {
117
+ frame = 0
118
+ paintBrand(document, t)
119
+ }
120
+ const schedule = (): void => {
121
+ if (frame !== 0) return
122
+ frame = requestAnimationFrame(run)
123
+ }
124
+ run()
125
+ const observer = new MutationObserver(schedule)
126
+ observer.observe(document.documentElement, {
127
+ childList: true,
128
+ subtree: true,
129
+ characterData: true,
130
+ })
131
+ return () => {
132
+ observer.disconnect()
133
+ if (frame !== 0) cancelAnimationFrame(frame)
134
+ }
135
+ }
@@ -9,6 +9,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
9
9
  import type {} from '@deepseek-ai/dsh-client-locale/client'
10
10
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
11
11
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
12
+ import { mountRezBrand, paintBrand } from './brand.ts'
12
13
  import { en, zh, type RezKey } from './locales.ts'
13
14
  import { RezPluginCard, RezSettingsPage } from './settings-card.tsx'
14
15
  import { UpgradeButton } from './upgrade-button.tsx'
@@ -45,6 +46,16 @@ export type { RezKey } from './locales.ts'
45
46
  export function apply(ctx: ClientContext): void {
46
47
  const t = ctx.locale.bind(NS)
47
48
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-rez-suite: dictionaries')
49
+ ctx.effect(() => {
50
+ const stop = mountRezBrand(t)
51
+ const unsub = ctx.locale.subscribe(() => {
52
+ if (typeof document !== 'undefined') paintBrand(document, t)
53
+ })
54
+ return () => {
55
+ unsub()
56
+ stop()
57
+ }
58
+ }, 'dsh-rez-suite: brand')
48
59
 
49
60
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
50
61
  name: 'settings.plugin.item',
@@ -52,6 +52,9 @@ export const zh = {
52
52
  'config.gsc': 'Google Search Console',
53
53
  'config.gsc.siteUrl': '站点 URL',
54
54
  'config.gsc.key': '服务账号 JSON 或密钥文件路径',
55
+ 'config.qcc': '企查查(法务助理)',
56
+ 'config.qcc.url': '企查查企业 MCP URL',
57
+ 'config.qcc.token': 'API Key',
55
58
  'config.tianyancha': '天眼查(法务助理 · 仅老板)',
56
59
  'config.tianyancha.url': '天眼查 MCP URL',
57
60
  'config.tianyancha.token': 'OpenAPI Token',
@@ -71,7 +74,7 @@ export const zh = {
71
74
  'config.testOk': '成功({tools} 个工具, {latency}ms)',
72
75
  'config.testFail': '失败:{error}',
73
76
  // status
74
- 'status.hint': '绿标表示本机已注册 mcp__<系统>__* 工具。远端 MCP 通了但这里仍红,说明 dsh-mcp-client 还没挂上。',
77
+ 'status.hint': '绿标表示本机已注册 mcp__<系统>__* 工具。远端 MCP 通了但这里仍红,说明 dsh-mcp-client 还没挂上。企查查只在法务助理房间可用;其它房间不显示或显示「房间未启用」。',
75
78
  'status.total': '当前角色 {role},共注册 {count} 个 MCP 工具',
76
79
  'status.col.server': 'Server',
77
80
  'status.col.state': '状态',
@@ -121,6 +124,10 @@ export const zh = {
121
124
  'upgrade.reloading': '正在重载',
122
125
  'upgrade.already': '已是 {version}',
123
126
  'upgrade.reloadTimeout': '升级完成,但网页还没重新连上。请刷新这一页。',
127
+ 'brand.name': '雷泽智能',
128
+ 'brand.badge': '定制版',
129
+ 'brand.headline': '雷泽智能定制版',
130
+ 'brand.wordmarkAria': '雷泽智能定制版',
124
131
  } as const
125
132
 
126
133
  export type RezKey = keyof typeof zh
@@ -174,6 +181,9 @@ export const en: Record<RezKey, string> = {
174
181
  'config.gsc': 'Google Search Console',
175
182
  'config.gsc.siteUrl': 'Site URL',
176
183
  'config.gsc.key': 'Service-account JSON or key file path',
184
+ 'config.qcc': 'Qichacha (Legal assistant)',
185
+ 'config.qcc.url': 'Qichacha company MCP URL',
186
+ 'config.qcc.token': 'API key',
177
187
  'config.tianyancha': 'Tianyancha (Legal assistant, boss only)',
178
188
  'config.tianyancha.url': 'Tianyancha MCP URL',
179
189
  'config.tianyancha.token': 'OpenAPI token',
@@ -192,7 +202,7 @@ export const en: Record<RezKey, string> = {
192
202
  'config.testResult': '{server}: {status}',
193
203
  'config.testOk': 'OK ({tools} tools, {latency}ms)',
194
204
  'config.testFail': 'Failed: {error}',
195
- 'status.hint': 'Green means this host registered mcp__<system>__* tools. A live remote MCP with a red row means dsh-mcp-client has not attached yet.',
205
+ 'status.hint': 'Green means this host registered mcp__<system>__* tools. A live remote MCP with a red row means dsh-mcp-client has not attached yet. Qichacha is only for the Legal assistant room; other rooms hide the row or show “room not enabled”.',
196
206
  'status.total': 'Role {role}, {count} MCP tools registered',
197
207
  'status.col.server': 'Server',
198
208
  'status.col.state': 'State',
@@ -240,6 +250,10 @@ export const en: Record<RezKey, string> = {
240
250
  'upgrade.reloading': 'Reloading',
241
251
  'upgrade.already': 'Already {version}',
242
252
  'upgrade.reloadTimeout': 'Update finished, but the page did not reconnect. Refresh this tab.',
253
+ 'brand.name': 'ReZ-TI',
254
+ 'brand.badge': 'Custom',
255
+ 'brand.headline': 'ReZ-TI Custom Edition',
256
+ 'brand.wordmarkAria': 'ReZ-TI Custom Edition',
243
257
  }
244
258
 
245
259
  /** Minimal {name} interpolator shared by the panel helpers. */
@@ -5,10 +5,11 @@
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { RezApi } from '../api.ts'
7
7
  import type { RezAuditResponse } 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 AuditTab({ api }: { api: RezApi }) {
12
+ const t = useT()
12
13
  const [audit, setAudit] = useState<RezAuditResponse | null>(null)
13
14
  const [error, setError] = useState('')
14
15
  const [loading, setLoading] = useState(true)
@@ -28,7 +29,7 @@ export function AuditTab({ api }: { api: RezApi }) {
28
29
  useEffect(() => { void load() }, [])
29
30
 
30
31
  const reset = async (): Promise<void> => {
31
- if (!window.confirm(tt('audit.resetConfirm'))) return
32
+ if (!window.confirm(t('audit.resetConfirm'))) return
32
33
  try {
33
34
  await api.resetAudit()
34
35
  await load()
@@ -37,38 +38,38 @@ export function AuditTab({ api }: { api: RezApi }) {
37
38
  }
38
39
  }
39
40
 
40
- if (loading) return <div className={css.message}>{tt('common.loading')}</div>
41
+ if (loading) return <div className={css.message}>{t('common.loading')}</div>
41
42
  if (audit === null) return <div className={css.error}>{error}</div>
42
43
 
43
44
  return (
44
45
  <div className={css.tabBody}>
45
46
  <div className={css.toolbar}>
46
- <button type="button" className={css.button} onClick={() => { void load() }}>{tt('common.refresh')}</button>
47
- <button type="button" className={css.button + ' ' + css.dangerButton} onClick={() => { void reset() }}>{tt('audit.reset')}</button>
47
+ <button type="button" className={css.button} onClick={() => { void load() }}>{t('common.refresh')}</button>
48
+ <button type="button" className={css.button + ' ' + css.dangerButton} onClick={() => { void reset() }}>{t('audit.reset')}</button>
48
49
  </div>
49
50
  {error !== '' && <div className={css.error}>{error}</div>}
50
51
  <div className={css.cards}>
51
- <div className={css.stat}><div className={css.cardLabel}>{tt('audit.totalCalls')}</div><div className={css.cardValue}>{audit.totalCalls}</div></div>
52
- <div className={css.stat}><div className={css.cardLabel}>{tt('audit.inputTokens')}</div><div className={css.cardValue}>{audit.totalInputTokens}</div></div>
53
- <div className={css.stat}><div className={css.cardLabel}>{tt('audit.outputTokens')}</div><div className={css.cardValue}>{audit.totalOutputTokens}</div></div>
54
- <div className={css.stat}><div className={css.cardLabel}>{tt('audit.totalCost')}</div><div className={css.cardValue}>{audit.totalCost.toFixed(4)}</div></div>
55
- <div className={css.stat}><div className={css.cardLabel}>{tt('audit.monthCost')}</div><div className={css.cardValue}>{audit.monthCost.toFixed(4)}</div></div>
56
- <div className={css.stat}><div className={css.cardLabel}>{tt('audit.budget')}</div><div className={css.cardValue}>{audit.monthlyBudget.toFixed(2)}</div></div>
52
+ <div className={css.stat}><div className={css.cardLabel}>{t('audit.totalCalls')}</div><div className={css.cardValue}>{audit.totalCalls}</div></div>
53
+ <div className={css.stat}><div className={css.cardLabel}>{t('audit.inputTokens')}</div><div className={css.cardValue}>{audit.totalInputTokens}</div></div>
54
+ <div className={css.stat}><div className={css.cardLabel}>{t('audit.outputTokens')}</div><div className={css.cardValue}>{audit.totalOutputTokens}</div></div>
55
+ <div className={css.stat}><div className={css.cardLabel}>{t('audit.totalCost')}</div><div className={css.cardValue}>{audit.totalCost.toFixed(4)}</div></div>
56
+ <div className={css.stat}><div className={css.cardLabel}>{t('audit.monthCost')}</div><div className={css.cardValue}>{audit.monthCost.toFixed(4)}</div></div>
57
+ <div className={css.stat}><div className={css.cardLabel}>{t('audit.budget')}</div><div className={css.cardValue}>{audit.monthlyBudget.toFixed(2)}</div></div>
57
58
  </div>
58
59
  <div className={css.tableWrap}>
59
60
  <table className={css.table}>
60
61
  <thead>
61
62
  <tr>
62
- <th>{tt('audit.col.time')}</th>
63
- <th>{tt('audit.col.server')}</th>
64
- <th>{tt('audit.col.tool')}</th>
65
- <th>{tt('audit.col.tokens')}</th>
66
- <th>{tt('audit.col.cost')}</th>
67
- <th>{tt('audit.col.status')}</th>
63
+ <th>{t('audit.col.time')}</th>
64
+ <th>{t('audit.col.server')}</th>
65
+ <th>{t('audit.col.tool')}</th>
66
+ <th>{t('audit.col.tokens')}</th>
67
+ <th>{t('audit.col.cost')}</th>
68
+ <th>{t('audit.col.status')}</th>
68
69
  </tr>
69
70
  </thead>
70
71
  <tbody>
71
- {audit.recent.length === 0 && <tr><td colSpan={6}>{tt('audit.empty')}</td></tr>}
72
+ {audit.recent.length === 0 && <tr><td colSpan={6}>{t('audit.empty')}</td></tr>}
72
73
  {audit.recent.map(row => (
73
74
  <tr key={row.id}>
74
75
  <td>{new Date(row.ts).toLocaleString()}</td>
@@ -76,7 +77,7 @@ export function AuditTab({ api }: { api: RezApi }) {
76
77
  <td>{row.tool}</td>
77
78
  <td>{row.inputTokens + row.outputTokens}</td>
78
79
  <td>{row.cost.toFixed(4)}</td>
79
- <td>{row.ok ? <span className={css.badge + ' ' + css.badgeOk}>{tt('audit.ok')}</span> : <span className={css.badge + ' ' + css.badgeFail}>{tt('audit.fail')}</span>}</td>
80
+ <td>{row.ok ? <span className={css.badge + ' ' + css.badgeOk}>{t('audit.ok')}</span> : <span className={css.badge + ' ' + css.badgeFail}>{t('audit.fail')}</span>}</td>
80
81
  </tr>
81
82
  ))}
82
83
  </tbody>
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Config tab: company MCP connections (Odoo / Nextcloud / WeCom / HA / TAPD / GSC),
2
+ * Config tab: company MCP connections (Odoo / Nextcloud / WeCom / HA / TAPD / GSC / QCC),
3
3
  * role, and billing. Secrets are write-only and land in credentials.yaml.
4
4
  */
5
5
 
6
6
  import { useEffect, useState, type ReactNode } from 'react'
7
7
  import type { RezApi } from '../api.ts'
8
8
  import type { RezPublicConfig, RezPublicConnection, RezRoleId, RezTestResult } from '../../protocol.ts'
9
- import { errorMessage, tt } from './helpers.ts'
9
+ import { errorMessage, useT } from './helpers.ts'
10
10
  import css from './panel.module.css'
11
11
 
12
12
  const ROLES: Array<{ value: RezRoleId; label: string }> = [
@@ -24,10 +24,11 @@ type SecretDrafts = {
24
24
  homeassistant: string
25
25
  tapd: string
26
26
  gsc: string
27
+ qcc: string
27
28
  tianyancha: string
28
29
  }
29
30
 
30
- const EMPTY_SECRETS: SecretDrafts = { odoo: '', nextcloud: '', wechat: '', homeassistant: '', tapd: '', gsc: '', tianyancha: '' }
31
+ const EMPTY_SECRETS: SecretDrafts = { odoo: '', nextcloud: '', wechat: '', homeassistant: '', tapd: '', gsc: '', qcc: '', tianyancha: '' }
31
32
 
32
33
  function Field({ label, children }: { label: string; children: ReactNode }) {
33
34
  return (
@@ -58,6 +59,7 @@ function SecretField({
58
59
  value: string
59
60
  onChange: (value: string) => void
60
61
  }) {
62
+ const t = useT()
61
63
  return (
62
64
  <Field label={label}>
63
65
  <input
@@ -65,7 +67,7 @@ function SecretField({
65
67
  type="password"
66
68
  autoComplete="off"
67
69
  value={value}
68
- placeholder={configured ? tt('config.secretConfigured') : tt('config.secretMissing')}
70
+ placeholder={configured ? t('config.secretConfigured') : t('config.secretMissing')}
69
71
  onChange={event => { onChange(event.target.value) }}
70
72
  />
71
73
  </Field>
@@ -97,9 +99,10 @@ function McpSection({
97
99
  secretLabel: string
98
100
  usernameLabel?: string
99
101
  }) {
102
+ const t = useT()
100
103
  return (
101
104
  <Section title={title}>
102
- <Field label={tt('config.enabled')}>
105
+ <Field label={t('config.enabled')}>
103
106
  <input className={css.checkbox} type="checkbox" checked={row.enabled} onChange={event => { onEnabled(event.target.checked) }} />
104
107
  </Field>
105
108
  {onUrl !== undefined && urlLabel !== undefined && (
@@ -120,6 +123,7 @@ function McpSection({
120
123
  }
121
124
 
122
125
  export function ConfigTab({ api }: { api: RezApi }) {
126
+ const t = useT()
123
127
  const [config, setConfig] = useState<RezPublicConfig | null>(null)
124
128
  const [secrets, setSecrets] = useState<SecretDrafts>(EMPTY_SECRETS)
125
129
  const [loading, setLoading] = useState(true)
@@ -141,7 +145,7 @@ export function ConfigTab({ api }: { api: RezApi }) {
141
145
 
142
146
  useEffect(() => { void load() }, [])
143
147
 
144
- if (loading) return <div className={css.message}>{tt('common.loading')}</div>
148
+ if (loading) return <div className={css.message}>{t('common.loading')}</div>
145
149
  if (config === null) return <div className={css.error}>{error}</div>
146
150
 
147
151
  const setTop = (key: 'enabled' | 'role', value: boolean | RezRoleId): void => {
@@ -171,13 +175,14 @@ export function ConfigTab({ api }: { api: RezApi }) {
171
175
  homeassistant: { ...config.connections.homeassistant, secret: secrets.homeassistant || undefined },
172
176
  tapd: { ...config.connections.tapd, secret: secrets.tapd || undefined },
173
177
  gsc: { ...config.connections.gsc, secret: secrets.gsc || undefined },
178
+ qcc: { ...config.connections.qcc, secret: secrets.qcc || undefined },
174
179
  tianyancha: { ...config.connections.tianyancha, secret: secrets.tianyancha || undefined },
175
180
  },
176
181
  }
177
182
  const saved = await api.saveConfig(payload)
178
183
  setConfig(saved)
179
184
  setSecrets(EMPTY_SECRETS)
180
- setMessage(tt('config.saved'))
185
+ setMessage(t('config.saved'))
181
186
  } catch (err) {
182
187
  setError(errorMessage(err))
183
188
  } finally {
@@ -202,129 +207,140 @@ export function ConfigTab({ api }: { api: RezApi }) {
202
207
  const renderResult = (result: RezTestResult): string => {
203
208
  if (result.ok) {
204
209
  if (result.serverInfo !== undefined) return result.serverInfo
205
- return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
210
+ return t('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
206
211
  }
207
- return tt('config.testFail', { error: result.error ?? '' })
212
+ return t('config.testFail', { error: result.error ?? '' })
208
213
  }
209
214
 
210
215
  return (
211
216
  <div className={css.tabBody}>
212
- <p className={css.message}>{tt('config.mcpHint')}</p>
213
- <Section title={tt('config.role')}>
214
- <Field label={tt('config.role')}>
217
+ <p className={css.message}>{t('config.mcpHint')}</p>
218
+ <Section title={t('config.role')}>
219
+ <Field label={t('config.role')}>
215
220
  <select className={css.select} value={config.role} onChange={event => { setTop('role', event.target.value as RezRoleId) }}>
216
- {ROLES.map(role => <option key={role.value} value={role.value}>{tt(role.label as never)}</option>)}
221
+ {ROLES.map(role => <option key={role.value} value={role.value}>{t(role.label as never)}</option>)}
217
222
  </select>
218
223
  </Field>
219
- <Field label={tt('config.enabled')}>
224
+ <p className={css.message}>{t('config.roleHint')}</p>
225
+ <Field label={t('config.enabled')}>
220
226
  <input className={css.checkbox} type="checkbox" checked={config.enabled} onChange={event => { setTop('enabled', event.target.checked) }} />
221
227
  </Field>
222
228
  </Section>
223
229
 
224
230
  <McpSection
225
- title={tt('config.odoo')}
231
+ title={t('config.odoo')}
226
232
  row={config.connections.odoo}
227
233
  secret={secrets.odoo}
228
234
  onEnabled={enabled => { patchConnection('odoo', { enabled }) }}
229
235
  onUrl={url => { patchConnection('odoo', { url }) }}
230
236
  onSecret={value => { setSecrets(prev => ({ ...prev, odoo: value })) }}
231
- urlLabel={tt('config.odoo.url')}
232
- secretLabel={tt('config.odoo.apiKey')}
237
+ urlLabel={t('config.odoo.url')}
238
+ secretLabel={t('config.odoo.apiKey')}
233
239
  />
234
240
  <McpSection
235
- title={tt('config.nextcloud')}
241
+ title={t('config.nextcloud')}
236
242
  row={config.connections.nextcloud}
237
243
  secret={secrets.nextcloud}
238
244
  onEnabled={enabled => { patchConnection('nextcloud', { enabled }) }}
239
245
  onUrl={url => { patchConnection('nextcloud', { url }) }}
240
246
  onUsername={username => { patchConnection('nextcloud', { username }) }}
241
247
  onSecret={value => { setSecrets(prev => ({ ...prev, nextcloud: value })) }}
242
- urlLabel={tt('config.nextcloud.url')}
243
- usernameLabel={tt('config.nextcloud.username')}
244
- secretLabel={tt('config.nextcloud.appPassword')}
248
+ urlLabel={t('config.nextcloud.url')}
249
+ usernameLabel={t('config.nextcloud.username')}
250
+ secretLabel={t('config.nextcloud.appPassword')}
245
251
  />
246
252
  <McpSection
247
- title={tt('config.wecom')}
253
+ title={t('config.wecom')}
248
254
  row={config.connections.wechat}
249
255
  secret={secrets.wechat}
250
256
  onEnabled={enabled => { patchConnection('wechat', { enabled }) }}
251
257
  onSecret={value => { setSecrets(prev => ({ ...prev, wechat: value })) }}
252
- secretLabel={tt('config.wecom.webhook')}
258
+ secretLabel={t('config.wecom.webhook')}
253
259
  />
254
260
  <McpSection
255
- title={tt('config.ha')}
261
+ title={t('config.ha')}
256
262
  row={config.connections.homeassistant}
257
263
  secret={secrets.homeassistant}
258
264
  onEnabled={enabled => { patchConnection('homeassistant', { enabled }) }}
259
265
  onUrl={url => { patchConnection('homeassistant', { url }) }}
260
266
  onSecret={value => { setSecrets(prev => ({ ...prev, homeassistant: value })) }}
261
- urlLabel={tt('config.ha.url')}
262
- secretLabel={tt('config.ha.token')}
267
+ urlLabel={t('config.ha.url')}
268
+ secretLabel={t('config.ha.token')}
263
269
  />
264
270
  <McpSection
265
- title={tt('config.tapd')}
271
+ title={t('config.tapd')}
266
272
  row={config.connections.tapd}
267
273
  secret={secrets.tapd}
268
274
  onEnabled={enabled => { patchConnection('tapd', { enabled }) }}
269
275
  extra={(
270
276
  <>
271
- <Field label={tt('config.tapd.workspaceId')}>
277
+ <Field label={t('config.tapd.workspaceId')}>
272
278
  <input className={css.input} value={config.connections.tapd.workspaceId ?? ''} onChange={event => { patchConnection('tapd', { workspaceId: event.target.value }) }} />
273
279
  </Field>
274
- <Field label={tt('config.tapd.nickName')}>
280
+ <Field label={t('config.tapd.nickName')}>
275
281
  <input className={css.input} value={config.connections.tapd.nickName ?? ''} onChange={event => { patchConnection('tapd', { nickName: event.target.value }) }} />
276
282
  </Field>
277
283
  </>
278
284
  )}
279
285
  onSecret={value => { setSecrets(prev => ({ ...prev, tapd: value })) }}
280
- secretLabel={tt('config.tapd.token')}
286
+ secretLabel={t('config.tapd.token')}
281
287
  />
282
288
  <McpSection
283
- title={tt('config.gsc')}
289
+ title={t('config.gsc')}
284
290
  row={config.connections.gsc}
285
291
  secret={secrets.gsc}
286
292
  onEnabled={enabled => { patchConnection('gsc', { enabled }) }}
287
293
  extra={(
288
- <Field label={tt('config.gsc.siteUrl')}>
294
+ <Field label={t('config.gsc.siteUrl')}>
289
295
  <input className={css.input} value={config.connections.gsc.siteUrl ?? ''} onChange={event => { patchConnection('gsc', { siteUrl: event.target.value }) }} />
290
296
  </Field>
291
297
  )}
292
298
  onSecret={value => { setSecrets(prev => ({ ...prev, gsc: value })) }}
293
- secretLabel={tt('config.gsc.key')}
299
+ secretLabel={t('config.gsc.key')}
300
+ />
301
+ <McpSection
302
+ title={t('config.qcc')}
303
+ row={config.connections.qcc}
304
+ secret={secrets.qcc}
305
+ onEnabled={enabled => { patchConnection('qcc', { enabled }) }}
306
+ onUrl={url => { patchConnection('qcc', { url }) }}
307
+ onSecret={value => { setSecrets(prev => ({ ...prev, qcc: value })) }}
308
+ urlLabel={t('config.qcc.url')}
309
+ secretLabel={t('config.qcc.token')}
294
310
  />
295
311
  {config.role === 'boss' && (
296
312
  <McpSection
297
- title={tt('config.tianyancha')}
313
+ title={t('config.tianyancha')}
298
314
  row={config.connections.tianyancha}
299
315
  secret={secrets.tianyancha}
300
316
  onEnabled={enabled => { patchConnection('tianyancha', { enabled }) }}
301
317
  onUrl={url => { patchConnection('tianyancha', { url }) }}
302
318
  onSecret={value => { setSecrets(prev => ({ ...prev, tianyancha: value })) }}
303
- urlLabel={tt('config.tianyancha.url')}
304
- secretLabel={tt('config.tianyancha.token')}
319
+ urlLabel={t('config.tianyancha.url')}
320
+ secretLabel={t('config.tianyancha.token')}
305
321
  />
306
322
  )}
307
323
 
308
- <Section title={tt('config.billing')}>
309
- <Field label={tt('config.billing.input')}>
324
+ <Section title={t('config.billing')}>
325
+ <Field label={t('config.billing.input')}>
310
326
  <input className={css.input} type="number" step="0.0001" value={config.billing.inputCostPer1k} onChange={event => { setConfig(prev => prev === null ? prev : { ...prev, billing: { ...prev.billing, inputCostPer1k: Number(event.target.value) } }) }} />
311
327
  </Field>
312
- <Field label={tt('config.billing.output')}>
328
+ <Field label={t('config.billing.output')}>
313
329
  <input className={css.input} type="number" step="0.0001" value={config.billing.outputCostPer1k} onChange={event => { setConfig(prev => prev === null ? prev : { ...prev, billing: { ...prev.billing, outputCostPer1k: Number(event.target.value) } }) }} />
314
330
  </Field>
315
- <Field label={tt('config.billing.budget')}>
331
+ <Field label={t('config.billing.budget')}>
316
332
  <input className={css.input} type="number" step="0.01" value={config.billing.monthlyBudget} onChange={event => { setConfig(prev => prev === null ? prev : { ...prev, billing: { ...prev.billing, monthlyBudget: Number(event.target.value) } }) }} />
317
333
  </Field>
318
334
  </Section>
319
335
 
320
336
  <div className={css.toolbar}>
321
- <button type="button" className={css.button + ' ' + css.primaryButton} disabled={saving} onClick={() => { void save() }}>{tt('config.save')}</button>
322
- <button type="button" className={css.button} disabled={testing} onClick={() => { void test() }}>{testing ? tt('config.testing') : tt('config.test')}</button>
337
+ <button type="button" className={css.button + ' ' + css.primaryButton} disabled={saving} onClick={() => { void save() }}>{t('config.save')}</button>
338
+ <button type="button" className={css.button} disabled={testing} onClick={() => { void test() }}>{testing ? t('config.testing') : t('config.test')}</button>
323
339
  </div>
324
340
  {message !== '' && <div className={css.message}>{message}</div>}
325
341
  {error !== '' && <div className={css.error}>{error}</div>}
326
342
  {results.map(result => (
327
- <div key={result.server} className={result.ok ? css.message : css.error}>{tt('config.testResult', { server: result.server, status: renderResult(result) })}</div>
343
+ <div key={result.server} className={result.ok ? css.message : css.error}>{t('config.testResult', { server: result.server, status: renderResult(result) })}</div>
328
344
  ))}
329
345
  </div>
330
346
  )
@@ -5,10 +5,11 @@
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { RezApi } from '../api.ts'
7
7
  import type { RezStatusResponse } 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 StatusTab({ api }: { api: RezApi }) {
12
+ const t = useT()
12
13
  const [status, setStatus] = useState<RezStatusResponse | null>(null)
13
14
  const [error, setError] = useState('')
14
15
  const [loading, setLoading] = useState(true)
@@ -27,26 +28,26 @@ export function StatusTab({ api }: { api: RezApi }) {
27
28
 
28
29
  useEffect(() => { void load() }, [])
29
30
 
30
- if (loading) return <div className={css.message}>{tt('common.loading')}</div>
31
+ if (loading) return <div className={css.message}>{t('common.loading')}</div>
31
32
  if (status === null) return <div className={css.error}>{error}</div>
32
33
 
33
34
  return (
34
35
  <div className={css.tabBody}>
35
36
  <div className={css.toolbar}>
36
- <span className={css.message}>{tt('status.total', { role: status.role, count: status.totalRegisteredTools })}</span>
37
- <button type="button" className={css.button} onClick={() => { void load() }}>{tt('common.refresh')}</button>
37
+ <span className={css.message}>{t('status.total', { role: status.role, count: status.totalRegisteredTools })}</span>
38
+ <button type="button" className={css.button} onClick={() => { void load() }}>{t('common.refresh')}</button>
38
39
  </div>
39
- <p className={css.message}>{tt('status.hint')}</p>
40
+ <p className={css.message}>{t('status.hint')}</p>
40
41
  {error !== '' && <div className={css.error}>{error}</div>}
41
42
  <div className={css.tableWrap}>
42
43
  <table className={css.table}>
43
44
  <thead>
44
45
  <tr>
45
- <th>{tt('status.col.server')}</th>
46
- <th>{tt('status.col.state')}</th>
47
- <th>{tt('status.col.tools')}</th>
48
- <th>{tt('status.col.registered')}</th>
49
- <th>{tt('status.col.error')}</th>
46
+ <th>{t('status.col.server')}</th>
47
+ <th>{t('status.col.state')}</th>
48
+ <th>{t('status.col.tools')}</th>
49
+ <th>{t('status.col.registered')}</th>
50
+ <th>{t('status.col.error')}</th>
50
51
  </tr>
51
52
  </thead>
52
53
  <tbody>