@rezti/dsh-rez-suite 0.1.4 → 0.1.6

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.
@@ -5,6 +5,8 @@
5
5
  export const zh = {
6
6
  'entry.label': 'Rez',
7
7
  'entry.tooltip': 'Rez Suite 配置与审计面板',
8
+ 'settings.title': 'ReZ-TI',
9
+ 'settings.description': '公司 MCP(Odoo / Nextcloud / 企微 / HA)与个人微信扫码通道。',
8
10
  'panel.title': 'Rez Suite',
9
11
  'tab.config': '配置',
10
12
  'tab.weixin': '微信',
@@ -25,16 +27,23 @@ export const zh = {
25
27
  'config.env': '环境变量 (JSON)',
26
28
  'config.headers': 'Headers (JSON)',
27
29
  'config.jsonInvalid': 'JSON 格式错误:{error}',
30
+ 'config.mcpHint': '四个公司 MCP 在这里配。密钥写入 ~/.dsh/.credentials.yaml(REZ_*)。新填的密钥会立刻拉起连接;改 URL 后如果已经连上,需要重启 dsh web。',
31
+ 'config.secretConfigured': '已配置(留空则保持)',
32
+ 'config.secretMissing': '未配置',
28
33
  'config.odoo': 'Odoo',
29
- 'config.odoo.url': 'Odoo URL',
30
- 'config.odoo.apiKey': 'API Key',
34
+ 'config.odoo.url': 'Odoo MCP URL',
35
+ 'config.odoo.apiKey': 'API Token',
31
36
  'config.nextcloud': 'Nextcloud',
32
- 'config.nextcloud.url': 'Nextcloud URL',
37
+ 'config.nextcloud.url': 'Nextcloud 主机',
33
38
  'config.nextcloud.username': '用户名',
34
39
  'config.nextcloud.appPassword': 'App Password',
35
40
  'config.wecom': '企业微信',
36
41
  'config.wecom.botId': 'Bot ID',
37
42
  'config.wecom.secret': 'Secret',
43
+ 'config.wecom.webhook': '群机器人 Webhook',
44
+ 'config.ha': 'Home Assistant',
45
+ 'config.ha.url': 'HA MCP URL',
46
+ 'config.ha.token': '长期访问令牌',
38
47
  'config.fs': '本地文件 (fs-mcp-server)',
39
48
  'config.fs.root': '根目录',
40
49
  'config.sqlite': 'SQLite (sqlite-mcp-server)',
@@ -99,6 +108,8 @@ export type RezKey = keyof typeof zh
99
108
  export const en: Record<RezKey, string> = {
100
109
  'entry.label': 'Rez',
101
110
  'entry.tooltip': 'Rez Suite configuration and audit panel',
111
+ 'settings.title': 'ReZ-TI',
112
+ 'settings.description': 'Company MCP (Odoo / Nextcloud / WeCom / HA) and personal WeChat scan-and-chat.',
102
113
  'panel.title': 'Rez Suite',
103
114
  'tab.config': 'Config',
104
115
  'tab.weixin': 'WeChat',
@@ -118,16 +129,23 @@ export const en: Record<RezKey, string> = {
118
129
  'config.env': 'Env vars (JSON)',
119
130
  'config.headers': 'Headers (JSON)',
120
131
  'config.jsonInvalid': 'Invalid JSON: {error}',
132
+ 'config.mcpHint': 'Company MCP lives here. Secrets go to ~/.dsh/.credentials.yaml (REZ_*). A newly saved secret starts the connection immediately; URL changes after a live connection need a dsh web restart.',
133
+ 'config.secretConfigured': 'Configured (leave blank to keep)',
134
+ 'config.secretMissing': 'Not configured',
121
135
  'config.odoo': 'Odoo',
122
- 'config.odoo.url': 'Odoo URL',
123
- 'config.odoo.apiKey': 'API Key',
136
+ 'config.odoo.url': 'Odoo MCP URL',
137
+ 'config.odoo.apiKey': 'API token',
124
138
  'config.nextcloud': 'Nextcloud',
125
- 'config.nextcloud.url': 'Nextcloud URL',
139
+ 'config.nextcloud.url': 'Nextcloud host',
126
140
  'config.nextcloud.username': 'Username',
127
- 'config.nextcloud.appPassword': 'App Password',
141
+ 'config.nextcloud.appPassword': 'App password',
128
142
  'config.wecom': 'WeCom',
129
143
  'config.wecom.botId': 'Bot ID',
130
144
  'config.wecom.secret': 'Secret',
145
+ 'config.wecom.webhook': 'Group bot webhook',
146
+ 'config.ha': 'Home Assistant',
147
+ 'config.ha.url': 'HA MCP URL',
148
+ 'config.ha.token': 'Long-lived access token',
131
149
  'config.fs': 'Local files (fs-mcp-server)',
132
150
  'config.fs.root': 'Root directory',
133
151
  'config.sqlite': 'SQLite (sqlite-mcp-server)',
@@ -48,12 +48,12 @@ export function AuditTab({ api }: { api: RezApi }) {
48
48
  </div>
49
49
  {error !== '' && <div className={css.error}>{error}</div>}
50
50
  <div className={css.cards}>
51
- <div className={css.card}><div className={css.cardLabel}>{tt('audit.totalCalls')}</div><div className={css.cardValue}>{audit.totalCalls}</div></div>
52
- <div className={css.card}><div className={css.cardLabel}>{tt('audit.inputTokens')}</div><div className={css.cardValue}>{audit.totalInputTokens}</div></div>
53
- <div className={css.card}><div className={css.cardLabel}>{tt('audit.outputTokens')}</div><div className={css.cardValue}>{audit.totalOutputTokens}</div></div>
54
- <div className={css.card}><div className={css.cardLabel}>{tt('audit.totalCost')}</div><div className={css.cardValue}>{audit.totalCost.toFixed(4)}</div></div>
55
- <div className={css.card}><div className={css.cardLabel}>{tt('audit.monthCost')}</div><div className={css.cardValue}>{audit.monthCost.toFixed(4)}</div></div>
56
- <div className={css.card}><div className={css.cardLabel}>{tt('audit.budget')}</div><div className={css.cardValue}>{audit.monthlyBudget.toFixed(2)}</div></div>
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>
57
57
  </div>
58
58
  <div className={css.tableWrap}>
59
59
  <table className={css.table}>
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Config tab: role, billing rates, and a generic editor for every MCP server
3
- * in config.servers (stdio and streamable-http), with one-click tests.
2
+ * Config tab: company MCP connections (Odoo / Nextcloud / WeCom / HA),
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
- import type { RezMcpServerSpec, RezPublicConfig, RezRoleId, RezTestResult } from '../../protocol.ts'
8
+ import type { RezPublicConfig, RezPublicConnection, RezRoleId, RezTestResult } from '../../protocol.ts'
9
9
  import { errorMessage, tt } from './helpers.ts'
10
10
  import css from './panel.module.css'
11
11
 
@@ -16,6 +16,15 @@ const ROLES: Array<{ value: RezRoleId; label: string }> = [
16
16
  { value: 'operations', label: 'role.operations' },
17
17
  ]
18
18
 
19
+ type SecretDrafts = {
20
+ odoo: string
21
+ nextcloud: string
22
+ wechat: string
23
+ homeassistant: string
24
+ }
25
+
26
+ const EMPTY_SECRETS: SecretDrafts = { odoo: '', nextcloud: '', wechat: '', homeassistant: '' }
27
+
19
28
  function Field({ label, children }: { label: string; children: ReactNode }) {
20
29
  return (
21
30
  <div className={css.field}>
@@ -34,47 +43,88 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
34
43
  )
35
44
  }
36
45
 
37
- function parseRecord(text: string): Record<string, string> {
38
- const value: unknown = JSON.parse(text)
39
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a JSON object')
40
- const out: Record<string, string> = {}
41
- for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
42
- out[key] = String(item)
43
- }
44
- return out
46
+ function SecretField({
47
+ label,
48
+ configured,
49
+ value,
50
+ onChange,
51
+ }: {
52
+ label: string
53
+ configured: boolean
54
+ value: string
55
+ onChange: (value: string) => void
56
+ }) {
57
+ return (
58
+ <Field label={label}>
59
+ <input
60
+ className={css.input}
61
+ type="password"
62
+ autoComplete="off"
63
+ value={value}
64
+ placeholder={configured ? tt('config.secretConfigured') : tt('config.secretMissing')}
65
+ onChange={event => { onChange(event.target.value) }}
66
+ />
67
+ </Field>
68
+ )
69
+ }
70
+
71
+ function McpSection({
72
+ title,
73
+ row,
74
+ secret,
75
+ onEnabled,
76
+ onUrl,
77
+ onUsername,
78
+ onSecret,
79
+ urlLabel,
80
+ secretLabel,
81
+ usernameLabel,
82
+ }: {
83
+ title: string
84
+ row: RezPublicConnection
85
+ secret: string
86
+ onEnabled: (enabled: boolean) => void
87
+ onUrl?: (url: string) => void
88
+ onUsername?: (username: string) => void
89
+ onSecret: (secret: string) => void
90
+ urlLabel?: string
91
+ secretLabel: string
92
+ usernameLabel?: string
93
+ }) {
94
+ return (
95
+ <Section title={title}>
96
+ <Field label={tt('config.enabled')}>
97
+ <input className={css.checkbox} type="checkbox" checked={row.enabled} onChange={event => { onEnabled(event.target.checked) }} />
98
+ </Field>
99
+ {onUrl !== undefined && urlLabel !== undefined && (
100
+ <Field label={urlLabel}>
101
+ <input className={css.input} value={row.url ?? ''} onChange={event => { onUrl(event.target.value) }} />
102
+ </Field>
103
+ )}
104
+ {onUsername !== undefined && usernameLabel !== undefined && (
105
+ <Field label={usernameLabel}>
106
+ <input className={css.input} value={row.username ?? ''} onChange={event => { onUsername(event.target.value) }} />
107
+ </Field>
108
+ )}
109
+ <SecretField label={secretLabel} configured={row.secretConfigured} value={secret} onChange={onSecret} />
110
+ <p className={css.message}>{row.secretRef}</p>
111
+ </Section>
112
+ )
45
113
  }
46
114
 
47
115
  export function ConfigTab({ api }: { api: RezApi }) {
48
116
  const [config, setConfig] = useState<RezPublicConfig | null>(null)
117
+ const [secrets, setSecrets] = useState<SecretDrafts>(EMPTY_SECRETS)
49
118
  const [loading, setLoading] = useState(true)
50
119
  const [saving, setSaving] = useState(false)
51
120
  const [testing, setTesting] = useState(false)
52
121
  const [message, setMessage] = useState('')
53
122
  const [error, setError] = useState('')
54
123
  const [results, setResults] = useState<RezTestResult[]>([])
55
- const [argsText, setArgsText] = useState<Record<string, string>>({})
56
- const [envText, setEnvText] = useState<Record<string, string>>({})
57
- const [headerText, setHeaderText] = useState<Record<string, string>>({})
58
-
59
- const initTexts = (next: RezPublicConfig): void => {
60
- const args: Record<string, string> = {}
61
- const envs: Record<string, string> = {}
62
- const headers: Record<string, string> = {}
63
- for (const [id, server] of Object.entries(next.servers)) {
64
- args[id] = (server.args ?? []).join('\n')
65
- envs[id] = JSON.stringify(server.env ?? {}, null, 2)
66
- headers[id] = JSON.stringify(server.headers ?? {}, null, 2)
67
- }
68
- setArgsText(args)
69
- setEnvText(envs)
70
- setHeaderText(headers)
71
- }
72
124
 
73
125
  const load = async (): Promise<void> => {
74
126
  try {
75
- const next = await api.getConfig()
76
- setConfig(next)
77
- initTexts(next)
127
+ setConfig(await api.getConfig())
78
128
  } catch (err) {
79
129
  setError(errorMessage(err))
80
130
  } finally {
@@ -91,10 +141,10 @@ export function ConfigTab({ api }: { api: RezApi }) {
91
141
  setConfig(prev => prev === null ? prev : { ...prev, [key]: value })
92
142
  }
93
143
 
94
- const updateServer = (id: string, patch: Partial<RezMcpServerSpec>): void => {
144
+ const patchConnection = (id: keyof RezPublicConfig['connections'], patch: Partial<RezPublicConnection>): void => {
95
145
  setConfig(prev => prev === null ? prev : {
96
146
  ...prev,
97
- servers: { ...prev.servers, [id]: { ...prev.servers[id], ...patch } },
147
+ connections: { ...prev.connections, [id]: { ...prev.connections[id], ...patch } },
98
148
  })
99
149
  }
100
150
 
@@ -103,9 +153,20 @@ export function ConfigTab({ api }: { api: RezApi }) {
103
153
  setMessage('')
104
154
  setError('')
105
155
  try {
106
- const saved = await api.saveConfig(config)
156
+ const payload = {
157
+ enabled: config.enabled,
158
+ role: config.role,
159
+ billing: config.billing,
160
+ connections: {
161
+ odoo: { ...config.connections.odoo, secret: secrets.odoo || undefined },
162
+ nextcloud: { ...config.connections.nextcloud, secret: secrets.nextcloud || undefined },
163
+ wechat: { ...config.connections.wechat, secret: secrets.wechat || undefined },
164
+ homeassistant: { ...config.connections.homeassistant, secret: secrets.homeassistant || undefined },
165
+ },
166
+ }
167
+ const saved = await api.saveConfig(payload)
107
168
  setConfig(saved)
108
- initTexts(saved)
169
+ setSecrets(EMPTY_SECRETS)
109
170
  setMessage(tt('config.saved'))
110
171
  } catch (err) {
111
172
  setError(errorMessage(err))
@@ -129,27 +190,16 @@ export function ConfigTab({ api }: { api: RezApi }) {
129
190
  }
130
191
 
131
192
  const renderResult = (result: RezTestResult): string => {
132
- if (result.ok) return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
133
- return tt('config.testFail', { error: result.error ?? '' })
134
- }
135
-
136
- const commitArgs = (id: string): void => {
137
- updateServer(id, { args: argsText[id] === '' ? [] : argsText[id].split('\n').map(line => line.trim()).filter(Boolean) })
138
- }
139
-
140
- const commitRecord = (id: string, kind: 'env' | 'headers'): void => {
141
- const text = kind === 'env' ? envText[id] : headerText[id]
142
- try {
143
- const record = parseRecord(text ?? '{}')
144
- updateServer(id, kind === 'env' ? { env: record } : { headers: record })
145
- setError('')
146
- } catch (err) {
147
- setError(tt('config.jsonInvalid', { error: errorMessage(err) }))
193
+ if (result.ok) {
194
+ if (result.serverInfo !== undefined) return result.serverInfo
195
+ return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
148
196
  }
197
+ return tt('config.testFail', { error: result.error ?? '' })
149
198
  }
150
199
 
151
200
  return (
152
201
  <div className={css.tabBody}>
202
+ <p className={css.message}>{tt('config.mcpHint')}</p>
153
203
  <Section title={tt('config.role')}>
154
204
  <Field label={tt('config.role')}>
155
205
  <select className={css.select} value={config.role} onChange={event => { setTop('role', event.target.value as RezRoleId) }}>
@@ -161,41 +211,46 @@ export function ConfigTab({ api }: { api: RezApi }) {
161
211
  </Field>
162
212
  </Section>
163
213
 
164
- {Object.entries(config.servers).map(([id, server]) => (
165
- <Section key={id} title={id}>
166
- <Field label={tt('config.enabled')}>
167
- <input className={css.checkbox} type="checkbox" checked={server.enabled} onChange={event => { updateServer(id, { enabled: event.target.checked }) }} />
168
- </Field>
169
- <Field label={tt('config.transport')}>
170
- <select className={css.select} value={server.transport} onChange={event => { updateServer(id, { transport: event.target.value as RezMcpServerSpec['transport'] }) }}>
171
- <option value="stdio">stdio</option>
172
- <option value="streamable-http">streamable-http</option>
173
- </select>
174
- </Field>
175
- {server.transport === 'stdio' ? (
176
- <>
177
- <Field label={tt('config.command')}>
178
- <input className={css.input} value={server.command ?? ''} onChange={event => { updateServer(id, { command: event.target.value }) }} />
179
- </Field>
180
- <Field label={tt('config.args')}>
181
- <textarea className={css.input} rows={3} value={argsText[id] ?? ''} onChange={event => { setArgsText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitArgs(id) }} />
182
- </Field>
183
- <Field label={tt('config.env')}>
184
- <textarea className={css.input} rows={4} value={envText[id] ?? '{}'} onChange={event => { setEnvText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'env') }} />
185
- </Field>
186
- </>
187
- ) : (
188
- <>
189
- <Field label={tt('config.url')}>
190
- <input className={css.input} value={server.url ?? ''} onChange={event => { updateServer(id, { url: event.target.value }) }} />
191
- </Field>
192
- <Field label={tt('config.headers')}>
193
- <textarea className={css.input} rows={4} value={headerText[id] ?? '{}'} onChange={event => { setHeaderText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'headers') }} />
194
- </Field>
195
- </>
196
- )}
197
- </Section>
198
- ))}
214
+ <McpSection
215
+ title={tt('config.odoo')}
216
+ row={config.connections.odoo}
217
+ secret={secrets.odoo}
218
+ onEnabled={enabled => { patchConnection('odoo', { enabled }) }}
219
+ onUrl={url => { patchConnection('odoo', { url }) }}
220
+ onSecret={value => { setSecrets(prev => ({ ...prev, odoo: value })) }}
221
+ urlLabel={tt('config.odoo.url')}
222
+ secretLabel={tt('config.odoo.apiKey')}
223
+ />
224
+ <McpSection
225
+ title={tt('config.nextcloud')}
226
+ row={config.connections.nextcloud}
227
+ secret={secrets.nextcloud}
228
+ onEnabled={enabled => { patchConnection('nextcloud', { enabled }) }}
229
+ onUrl={url => { patchConnection('nextcloud', { url }) }}
230
+ onUsername={username => { patchConnection('nextcloud', { username }) }}
231
+ onSecret={value => { setSecrets(prev => ({ ...prev, nextcloud: value })) }}
232
+ urlLabel={tt('config.nextcloud.url')}
233
+ usernameLabel={tt('config.nextcloud.username')}
234
+ secretLabel={tt('config.nextcloud.appPassword')}
235
+ />
236
+ <McpSection
237
+ title={tt('config.wecom')}
238
+ row={config.connections.wechat}
239
+ secret={secrets.wechat}
240
+ onEnabled={enabled => { patchConnection('wechat', { enabled }) }}
241
+ onSecret={value => { setSecrets(prev => ({ ...prev, wechat: value })) }}
242
+ secretLabel={tt('config.wecom.webhook')}
243
+ />
244
+ <McpSection
245
+ title={tt('config.ha')}
246
+ row={config.connections.homeassistant}
247
+ secret={secrets.homeassistant}
248
+ onEnabled={enabled => { patchConnection('homeassistant', { enabled }) }}
249
+ onUrl={url => { patchConnection('homeassistant', { url }) }}
250
+ onSecret={value => { setSecrets(prev => ({ ...prev, homeassistant: value })) }}
251
+ urlLabel={tt('config.ha.url')}
252
+ secretLabel={tt('config.ha.token')}
253
+ />
199
254
 
200
255
  <Section title={tt('config.billing')}>
201
256
  <Field label={tt('config.billing.input')}>