@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.
- package/README.md +3 -1
- package/lib/client.d.ts +8 -1
- package/lib/client.js +393 -192
- package/lib/index.d.ts +4 -0
- package/lib/index.js +186 -13
- package/lib/style.css +47 -0
- package/package.json +17 -17
- package/src/boss/mount.ts +2 -0
- package/src/client/assets/rez-mark.png +0 -0
- package/src/client/brand-mark.ts +4 -0
- package/src/client/brand.module.css +51 -0
- package/src/client/brand.ts +135 -0
- package/src/client/index.ts +11 -0
- package/src/client/locales.ts +16 -2
- package/src/client/panel/AuditTab.tsx +20 -19
- package/src/client/panel/ConfigTab.tsx +59 -43
- package/src/client/panel/StatusTab.tsx +11 -10
- package/src/client/panel/WeixinTab.tsx +9 -8
- package/src/client/panel/helpers.ts +20 -5
- package/src/client/settings-card.tsx +48 -42
- package/src/client/upgrade-button.tsx +24 -12
- package/src/index.ts +21 -4
- package/src/mcp-host.ts +1 -1
- package/src/observe-mcp.ts +33 -3
- package/src/protocol.ts +2 -0
- package/src/qcc.ts +95 -0
- package/src/rooms.ts +65 -0
- package/src/store.ts +6 -0
- package/src/tools.ts +3 -2
- package/templates/boss/money/.agents/skills/boss-money/SKILL.md +1 -1
- package/templates/boss/money/AGENTS.md +3 -0
- package/templates/boss/ops/.agents/skills/boss-ops/SKILL.md +1 -1
- package/templates/boss/ops/AGENTS.md +3 -0
- package/templates/boss/product/.agents/skills/boss-product/SKILL.md +1 -1
- package/templates/boss/product/AGENTS.md +5 -0
- package/templates/staff/finance/.agents/skills/staff-finance/SKILL.md +1 -1
- package/templates/staff/finance/AGENTS.md +7 -0
- package/templates/staff/finance/MEMORY.md +1 -1
- package/templates/staff/hr/.agents/skills/staff-hr/SKILL.md +1 -1
- package/templates/staff/hr/AGENTS.md +7 -0
- package/templates/staff/hr/MEMORY.md +1 -1
- package/templates/staff/legal/.agents/skills/staff-legal/SKILL.md +1 -1
- package/templates/staff/legal/AGENTS.md +12 -2
- 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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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',
|
package/src/client/locales.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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}>{
|
|
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() }}>{
|
|
47
|
-
<button type="button" className={css.button + ' ' + css.dangerButton} onClick={() => { void reset() }}>{
|
|
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}>{
|
|
52
|
-
<div className={css.stat}><div className={css.cardLabel}>{
|
|
53
|
-
<div className={css.stat}><div className={css.cardLabel}>{
|
|
54
|
-
<div className={css.stat}><div className={css.cardLabel}>{
|
|
55
|
-
<div className={css.stat}><div className={css.cardLabel}>{
|
|
56
|
-
<div className={css.stat}><div className={css.cardLabel}>{
|
|
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>{
|
|
63
|
-
<th>{
|
|
64
|
-
<th>{
|
|
65
|
-
<th>{
|
|
66
|
-
<th>{
|
|
67
|
-
<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}>{
|
|
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}>{
|
|
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,
|
|
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 ?
|
|
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={
|
|
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}>{
|
|
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(
|
|
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
|
|
210
|
+
return t('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
|
|
206
211
|
}
|
|
207
|
-
return
|
|
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}>{
|
|
213
|
-
<Section title={
|
|
214
|
-
<Field label={
|
|
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}>{
|
|
221
|
+
{ROLES.map(role => <option key={role.value} value={role.value}>{t(role.label as never)}</option>)}
|
|
217
222
|
</select>
|
|
218
223
|
</Field>
|
|
219
|
-
<
|
|
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={
|
|
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={
|
|
232
|
-
secretLabel={
|
|
237
|
+
urlLabel={t('config.odoo.url')}
|
|
238
|
+
secretLabel={t('config.odoo.apiKey')}
|
|
233
239
|
/>
|
|
234
240
|
<McpSection
|
|
235
|
-
title={
|
|
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={
|
|
243
|
-
usernameLabel={
|
|
244
|
-
secretLabel={
|
|
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={
|
|
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={
|
|
258
|
+
secretLabel={t('config.wecom.webhook')}
|
|
253
259
|
/>
|
|
254
260
|
<McpSection
|
|
255
|
-
title={
|
|
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={
|
|
262
|
-
secretLabel={
|
|
267
|
+
urlLabel={t('config.ha.url')}
|
|
268
|
+
secretLabel={t('config.ha.token')}
|
|
263
269
|
/>
|
|
264
270
|
<McpSection
|
|
265
|
-
title={
|
|
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={
|
|
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={
|
|
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={
|
|
286
|
+
secretLabel={t('config.tapd.token')}
|
|
281
287
|
/>
|
|
282
288
|
<McpSection
|
|
283
|
-
title={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
304
|
-
secretLabel={
|
|
319
|
+
urlLabel={t('config.tianyancha.url')}
|
|
320
|
+
secretLabel={t('config.tianyancha.token')}
|
|
305
321
|
/>
|
|
306
322
|
)}
|
|
307
323
|
|
|
308
|
-
<Section title={
|
|
309
|
-
<Field label={
|
|
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={
|
|
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={
|
|
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() }}>{
|
|
322
|
-
<button type="button" className={css.button} disabled={testing} onClick={() => { void test() }}>{testing ?
|
|
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}>{
|
|
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,
|
|
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}>{
|
|
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}>{
|
|
37
|
-
<button type="button" className={css.button} onClick={() => { void load() }}>{
|
|
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}>{
|
|
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>{
|
|
46
|
-
<th>{
|
|
47
|
-
<th>{
|
|
48
|
-
<th>{
|
|
49
|
-
<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>
|