bhg-helper 1.0.0

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 (53) hide show
  1. package/README.md +78 -0
  2. package/api/app.ts +53 -0
  3. package/api/index.ts +9 -0
  4. package/api/lib/logger.ts +65 -0
  5. package/api/lib/paths.ts +27 -0
  6. package/api/lib/providers.ts +66 -0
  7. package/api/lib/repository.ts +153 -0
  8. package/api/lib/types.ts +43 -0
  9. package/api/relay/config.ts +76 -0
  10. package/api/relay/protocol.ts +393 -0
  11. package/api/relay/server.ts +283 -0
  12. package/api/routes/backups.ts +73 -0
  13. package/api/routes/config.ts +197 -0
  14. package/api/routes/install.ts +158 -0
  15. package/api/routes/logs.ts +20 -0
  16. package/api/routes/providers.ts +13 -0
  17. package/api/routes/relay.ts +106 -0
  18. package/api/server.ts +40 -0
  19. package/cli/cli.js +454 -0
  20. package/dist/assets/index-BjvGHrGe.js +156 -0
  21. package/dist/assets/index-CQrGCyBr.css +1 -0
  22. package/dist/favicon.svg +4 -0
  23. package/dist/index.html +20 -0
  24. package/index.html +19 -0
  25. package/nodemon.json +10 -0
  26. package/package.json +82 -0
  27. package/postcss.config.js +10 -0
  28. package/scripts/install.bat +32 -0
  29. package/scripts/start.bat +46 -0
  30. package/scripts/start.ps1 +45 -0
  31. package/src/App.tsx +73 -0
  32. package/src/assets/react.svg +1 -0
  33. package/src/components/ConsolePanel.tsx +44 -0
  34. package/src/components/Empty.tsx +8 -0
  35. package/src/components/ErrorBoundary.tsx +54 -0
  36. package/src/components/Layout.tsx +17 -0
  37. package/src/components/Page.tsx +130 -0
  38. package/src/components/Sidebar.tsx +56 -0
  39. package/src/hooks/useTheme.ts +29 -0
  40. package/src/index.css +1350 -0
  41. package/src/lib/api.ts +120 -0
  42. package/src/lib/store.ts +166 -0
  43. package/src/lib/types.ts +117 -0
  44. package/src/lib/utils.ts +6 -0
  45. package/src/main.tsx +10 -0
  46. package/src/pages/ConsolePage.tsx +48 -0
  47. package/src/pages/Dashboard.tsx +101 -0
  48. package/src/pages/Install.tsx +195 -0
  49. package/src/pages/Relay.tsx +409 -0
  50. package/src/vite-env.d.ts +1 -0
  51. package/tailwind.config.js +13 -0
  52. package/tsconfig.json +40 -0
  53. package/vite.config.ts +28 -0
package/src/lib/api.ts ADDED
@@ -0,0 +1,120 @@
1
+ // BHG-helper API client
2
+ import type {
3
+ BackupInfo,
4
+ ConfigPayload,
5
+ Credentials,
6
+ BridgeConfig,
7
+ Settings,
8
+ ProviderPreset,
9
+ LogEntry,
10
+ } from './types'
11
+
12
+ const BASE = '/api'
13
+
14
+ async function request<T>(path: string, init?: RequestInit): Promise<T> {
15
+ const res = await fetch(BASE + path, {
16
+ headers: { 'Content-Type': 'application/json' },
17
+ ...init,
18
+ })
19
+ const json = await res.json().catch(() => ({ ok: false, error: 'invalid JSON' }))
20
+ if (!res.ok || !json.ok) {
21
+ throw new Error(json.error || `HTTP ${res.status}`)
22
+ }
23
+ return json.data as T
24
+ }
25
+
26
+ export interface RelayStatus {
27
+ running: boolean
28
+ info: { port: number; running: boolean; baseUrl: string; modelMap: Record<string, string>; deepseekApiKey: string } | null
29
+ config: {
30
+ port: number
31
+ deepseekBaseUrl: string
32
+ spoofProvider: string
33
+ verbose: boolean
34
+ deepseekApiKey: string
35
+ modelMap: Record<string, string>
36
+ configPath?: string
37
+ apiKeyConfigured?: boolean
38
+ }
39
+ }
40
+
41
+ export const api = {
42
+ getConfig: () => request<ConfigPayload>('/config'),
43
+
44
+ saveBridge: (bridge: BridgeConfig) =>
45
+ request<{ ok: true; backup: string | null; size: number }>('/config/bridge', {
46
+ method: 'PUT',
47
+ body: JSON.stringify(bridge),
48
+ }),
49
+
50
+ saveCredentials: (credentials: Credentials) =>
51
+ request<{ ok: true; backup: string | null; size: number }>('/config/credentials', {
52
+ method: 'PUT',
53
+ body: JSON.stringify(credentials),
54
+ }),
55
+
56
+ saveSettings: (settings: Settings) =>
57
+ request<{ ok: true; backup: string | null; size: number }>('/config/settings', {
58
+ method: 'PUT',
59
+ body: JSON.stringify(settings),
60
+ }),
61
+
62
+ listBackups: () => request<BackupInfo[]>('/backups'),
63
+
64
+ createBackup: (scope: 'bridge' | 'credentials' | 'settings') =>
65
+ request<{ filename: string }>('/backups', {
66
+ method: 'POST',
67
+ body: JSON.stringify({ scope }),
68
+ }),
69
+
70
+ restoreBackup: (filename: string) =>
71
+ request<{ scope: string }>('/backups/restore', {
72
+ method: 'POST',
73
+ body: JSON.stringify({ filename }),
74
+ }),
75
+
76
+ deleteBackup: (filename: string) =>
77
+ request<{ ok: true }>(`/backups/${encodeURIComponent(filename)}`, {
78
+ method: 'DELETE',
79
+ }),
80
+
81
+ listProviderPresets: () => request<ProviderPreset[]>('/providers/presets'),
82
+
83
+ resetCatalog: () =>
84
+ request<{ ok: true; backup: string | null; size: number; modelCount: number; switchedActiveFrom: string | null }>(
85
+ '/config/reset-catalog',
86
+ { method: 'POST' }
87
+ ),
88
+
89
+ listLogs: () => request<LogEntry[]>('/logs'),
90
+
91
+ // Relay
92
+ getRelayStatus: () => request<RelayStatus>('/relay/status'),
93
+ startRelay: () => request<{ ok: true }>('/relay/start', { method: 'POST' }),
94
+ stopRelay: () => request<{ ok: true }>('/relay/stop', { method: 'POST' }),
95
+ restartRelay: () => request<{ ok: true }>('/relay/restart', { method: 'POST' }),
96
+ updateRelayConfig: (patch: Record<string, unknown>) =>
97
+ request<{ port: number; deepseekBaseUrl: string }>('/relay/config', {
98
+ method: 'PUT',
99
+ body: JSON.stringify(patch),
100
+ }),
101
+
102
+
103
+ // Install
104
+ getInstallStatus: () =>
105
+ request<{
106
+ projectRoot: string
107
+ hasNodeModules: boolean
108
+ hasPackageJson: boolean
109
+ needsInstall: boolean
110
+ isRunning: boolean
111
+ logCount: number
112
+ node: { ok: boolean; version?: string; error?: string }
113
+ npm: { ok: boolean; version?: string; error?: string }
114
+ }>('/install/status'),
115
+ runInstall: () =>
116
+ request<{ started: boolean; pid?: number }>('/install/run', { method: 'POST' }),
117
+ cancelInstall: () =>
118
+ request<{ killed: boolean }>('/install/cancel', { method: 'POST' }),
119
+ }
120
+
@@ -0,0 +1,166 @@
1
+ // BHG-helper — zustand store
2
+ import { create } from 'zustand'
3
+ import { api } from './api'
4
+ import type {
5
+ BackupInfo,
6
+ BridgeConfig,
7
+ ConfigPayload,
8
+ Credentials,
9
+ LogEntry,
10
+ ProviderPreset,
11
+ Settings,
12
+ } from './types'
13
+
14
+ interface CockpitState {
15
+ // data
16
+ config: ConfigPayload | null
17
+ presets: ProviderPreset[]
18
+ logs: LogEntry[]
19
+ // ui
20
+ loading: boolean
21
+ saving: boolean
22
+ lastSavedAt: string | null
23
+ errorMessage: string | null
24
+
25
+ // actions
26
+ loadAll: () => Promise<void>
27
+ saveBridge: (bridge: BridgeConfig) => Promise<void>
28
+ saveCredentials: (c: Credentials) => Promise<void>
29
+ saveSettings: (s: Settings) => Promise<void>
30
+ setActiveModel: (id: string) => Promise<void>
31
+ refreshBackups: () => Promise<void>
32
+ createBackup: (scope: 'bridge' | 'credentials' | 'settings') => Promise<void>
33
+ restoreBackup: (filename: string) => Promise<void>
34
+ deleteBackup: (filename: string) => Promise<void>
35
+ appendLog: (entry: LogEntry) => void
36
+ clearError: () => void
37
+ }
38
+
39
+ export const useStore = create<CockpitState>((set, get) => ({
40
+ config: null,
41
+ presets: [],
42
+ logs: [],
43
+ loading: false,
44
+ saving: false,
45
+ lastSavedAt: null,
46
+ errorMessage: null,
47
+
48
+ loadAll: async () => {
49
+ set({ loading: true, errorMessage: null })
50
+ try {
51
+ const [config, presets, logs] = await Promise.all([
52
+ api.getConfig(),
53
+ api.listProviderPresets(),
54
+ api.listLogs(),
55
+ ])
56
+ set({ config, presets, logs, loading: false })
57
+ } catch (err) {
58
+ const msg = err instanceof Error ? err.message : String(err)
59
+ set({ loading: false, errorMessage: msg })
60
+ }
61
+ },
62
+
63
+ saveBridge: async (bridge) => {
64
+ set({ saving: true, errorMessage: null })
65
+ try {
66
+ await api.saveBridge(bridge)
67
+ const config = await api.getConfig()
68
+ set({ config, saving: false, lastSavedAt: new Date().toISOString() })
69
+ } catch (err) {
70
+ const msg = err instanceof Error ? err.message : String(err)
71
+ set({ saving: false, errorMessage: msg })
72
+ }
73
+ },
74
+
75
+ saveCredentials: async (credentials) => {
76
+ set({ saving: true, errorMessage: null })
77
+ try {
78
+ await api.saveCredentials(credentials)
79
+ const config = await api.getConfig()
80
+ set({ config, saving: false, lastSavedAt: new Date().toISOString() })
81
+ } catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err)
83
+ set({ saving: false, errorMessage: msg })
84
+ }
85
+ },
86
+
87
+ saveSettings: async (settings) => {
88
+ set({ saving: true, errorMessage: null })
89
+ try {
90
+ await api.saveSettings(settings)
91
+ const config = await api.getConfig()
92
+ set({ config, saving: false, lastSavedAt: new Date().toISOString() })
93
+ } catch (err) {
94
+ const msg = err instanceof Error ? err.message : String(err)
95
+ set({ saving: false, errorMessage: msg })
96
+ }
97
+ },
98
+
99
+ setActiveModel: async (id) => {
100
+ const cur = get().config?.settings
101
+ if (!cur) return
102
+ const next: Settings = { ...cur, model: id }
103
+ await get().saveSettings(next)
104
+ },
105
+
106
+ refreshBackups: async () => {
107
+ try {
108
+ const backups = await api.listBackups()
109
+ const config = get().config
110
+ if (config) set({ config: { ...config, backups } })
111
+ } catch {
112
+ // ignore
113
+ }
114
+ },
115
+
116
+ createBackup: async (scope) => {
117
+ try {
118
+ await api.createBackup(scope)
119
+ await get().refreshBackups()
120
+ } catch (err) {
121
+ const msg = err instanceof Error ? err.message : String(err)
122
+ set({ errorMessage: msg })
123
+ }
124
+ },
125
+
126
+ restoreBackup: async (filename) => {
127
+ try {
128
+ await api.restoreBackup(filename)
129
+ await get().loadAll()
130
+ } catch (err) {
131
+ const msg = err instanceof Error ? err.message : String(err)
132
+ set({ errorMessage: msg })
133
+ }
134
+ },
135
+
136
+ deleteBackup: async (filename) => {
137
+ try {
138
+ await api.deleteBackup(filename)
139
+ await get().refreshBackups()
140
+ } catch (err) {
141
+ const msg = err instanceof Error ? err.message : String(err)
142
+ set({ errorMessage: msg })
143
+ }
144
+ },
145
+
146
+ appendLog: (entry) => set((s) => ({ logs: [...s.logs, entry].slice(-300) })),
147
+ clearError: () => set({ errorMessage: null }),
148
+ }))
149
+
150
+ // SSE log stream subscriber
151
+ export function startLogStream(): () => void {
152
+ const es = new EventSource('/api/logs/stream')
153
+ const handler = (e: MessageEvent) => {
154
+ try {
155
+ const data = JSON.parse(e.data) as LogEntry
156
+ useStore.getState().appendLog(data)
157
+ } catch {
158
+ // ignore
159
+ }
160
+ }
161
+ es.onmessage = handler
162
+ es.onerror = () => {
163
+ // silent — server will retry
164
+ }
165
+ return () => es.close()
166
+ }
@@ -0,0 +1,117 @@
1
+ // BHG-helper — TypeScript types
2
+
3
+ export interface ModelEntry {
4
+ id: string
5
+ label: string
6
+ tier: 'large' | 'middle' | 'small'
7
+ provider: string
8
+ context_window: number
9
+ default_max_tokens: number
10
+ max_tokens: number
11
+ pricing: {
12
+ currency: string
13
+ quota_type: string
14
+ pricing_group: string
15
+ input_per_1m_tokens: number
16
+ output_per_1m_tokens: number
17
+ cache_read_per_1m_tokens: number
18
+ cache_write_per_1m_tokens: number
19
+ }
20
+ description: string
21
+ protocol: 'anthropic' | 'openai'
22
+ enabled: boolean
23
+ available: boolean
24
+ }
25
+
26
+ export interface BridgeModelCatalogCache {
27
+ models: ModelEntry[]
28
+ defaults: { large: string; middle: string; small: string }
29
+ pricing_version?: string
30
+ pricing_group?: string
31
+ }
32
+
33
+ export interface BridgeConfig {
34
+ bridgeModelCatalogCache?: BridgeModelCatalogCache
35
+ additionalModelOptionsCache?: Array<{
36
+ value: string
37
+ label: string
38
+ description: string
39
+ descriptionForModel: string
40
+ }>
41
+ oauthAccount?: null | {
42
+ accountUuid: string
43
+ organizationUuid: string
44
+ hasExtraUsageEnabled?: boolean
45
+ billingType?: string
46
+ subscriptionCreatedAt?: string
47
+ displayName?: string
48
+ }
49
+ [key: string]: unknown
50
+ }
51
+
52
+ export interface Credentials {
53
+ claudeAiOauth?: {
54
+ accessToken: string
55
+ newApiApiKey: string
56
+ refreshToken: string
57
+ expiresAt: number
58
+ scopes: string[]
59
+ subscriptionType: string
60
+ rateLimitTier: string
61
+ newApiSessionUserId: number
62
+ newApiSessionCookie: string
63
+ }
64
+ [key: string]: unknown
65
+ }
66
+
67
+ export interface Settings {
68
+ env?: Record<string, string>
69
+ includeCoAuthoredBy?: boolean
70
+ model: string
71
+ skipDangerousModePermissionPrompt?: boolean
72
+ hasCompletedOnboarding?: boolean
73
+ language?: string
74
+ [key: string]: unknown
75
+ }
76
+
77
+ export interface BackupInfo {
78
+ filename: string
79
+ scope: 'bridge' | 'credentials' | 'settings' | 'unknown'
80
+ size: number
81
+ createdAt: string
82
+ }
83
+
84
+ export interface ConfigPayload {
85
+ bhgHelperDir: string
86
+ files: {
87
+ bridge: string
88
+ credentials: string
89
+ settings: string
90
+ }
91
+ bridge: BridgeConfig | null
92
+ credentials: Credentials | null
93
+ settings: Settings | null
94
+ backups: BackupInfo[]
95
+ }
96
+
97
+ export interface ProviderPreset {
98
+ id: string
99
+ name: string
100
+ protocol: 'anthropic' | 'openai'
101
+ baseUrl?: string
102
+ notes: string
103
+ models: Array<{
104
+ id: string
105
+ label: string
106
+ tier: 'large' | 'middle' | 'small'
107
+ description: string
108
+ }>
109
+ }
110
+
111
+ export interface LogEntry {
112
+ id: number
113
+ ts: string
114
+ level: 'INFO' | 'OK' | 'WARN' | 'ERR'
115
+ scope: string
116
+ message: string
117
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
@@ -0,0 +1,48 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { useStore } from '../lib/store'
3
+ import Page, { Panel } from '../components/Page'
4
+ import { Trash2 } from 'lucide-react'
5
+
6
+ export default function ConsolePage() {
7
+ const logs = useStore((s) => s.logs)
8
+ const bodyRef = useRef<HTMLDivElement | null>(null)
9
+
10
+ useEffect(() => {
11
+ if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight
12
+ }, [logs.length])
13
+
14
+ return (
15
+ <Page title="事件日志" code="06" subtitle="所有文件读写 / API 调用 / 备份事件实时回放">
16
+ <Panel title={`EVENT STREAM · ${logs.length} 条`}>
17
+ <div
18
+ ref={bodyRef}
19
+ style={{
20
+ maxHeight: 'calc(100vh - 280px)',
21
+ overflowY: 'auto',
22
+ background: 'var(--bg-0)',
23
+ border: '1px solid var(--line-2)',
24
+ padding: 'var(--sp-2)',
25
+ fontFamily: 'var(--font-mono)',
26
+ fontSize: 11,
27
+ }}
28
+ >
29
+ {logs.length === 0 ? (
30
+ <div className="empty">
31
+ <div className="empty-title">— 还没有事件 —</div>
32
+ <div>所有操作会实时显示在此处</div>
33
+ </div>
34
+ ) : (
35
+ logs.map((l) => (
36
+ <div key={l.id} className={'log-row ' + l.level}>
37
+ <span className="log-ts">{l.ts}</span>
38
+ <span className={'log-level ' + l.level}>{l.level}</span>
39
+ <span className="log-scope">{l.scope}</span>
40
+ <span className="log-msg">{l.message}</span>
41
+ </div>
42
+ ))
43
+ )}
44
+ </div>
45
+ </Panel>
46
+ </Page>
47
+ )
48
+ }
@@ -0,0 +1,101 @@
1
+ import { useStore } from '../lib/store'
2
+ import Page, { Panel, StatusCard } from '../components/Page'
3
+ import { RefreshCw, Server, ExternalLink, Terminal } from 'lucide-react'
4
+ import { api } from '../lib/api'
5
+ import { useEffect, useState } from 'react'
6
+
7
+ export default function Dashboard() {
8
+ const loadAll = useStore((s) => s.loadAll)
9
+ const [relay, setRelay] = useState<{ running: boolean; hasKey: boolean; models: string[] } | null>(null)
10
+
11
+ async function refresh() {
12
+ try {
13
+ const s = await api.getRelayStatus()
14
+ const m = await fetch('http://127.0.0.1:8787/v1/models').then(r => r.json()).catch(() => null)
15
+ setRelay({
16
+ running: s.running,
17
+ hasKey: !!(s.info?.deepseekApiKey || s.config?.deepseekApiKey),
18
+ models: m?.data?.map((d: { id: string }) => d.id) ?? [],
19
+ })
20
+ } catch {
21
+ setRelay({ running: false, hasKey: false, models: [] })
22
+ }
23
+ }
24
+
25
+ useEffect(() => {
26
+ void refresh()
27
+ const t = setInterval(refresh, 5000)
28
+ return () => clearInterval(t)
29
+ }, [])
30
+
31
+ return (
32
+ <Page
33
+ title="主控台"
34
+ code="01"
35
+ subtitle="Claude Code → DeepSeek 本地中转"
36
+ actions={
37
+ <button className="btn" onClick={() => { void loadAll(); void refresh() }}>
38
+ <RefreshCw size={14} /> 刷新
39
+ </button>
40
+ }
41
+ >
42
+ <div className="panel-grid cols-3" style={{ marginBottom: 'var(--sp-5)' }}>
43
+ <StatusCard
44
+ tone={relay?.running ? 'green' : 'amber'}
45
+ label="中转服务"
46
+ value={relay?.running ? 'RUNNING :8787' : 'STOPPED'}
47
+ meta={relay?.running ? 'claude.exe 可用' : '去「中转服务」启动'}
48
+ />
49
+ <StatusCard
50
+ tone={relay?.hasKey ? 'green' : 'amber'}
51
+ label="DeepSeek API Key"
52
+ value={relay?.hasKey ? '已配置' : '未配置'}
53
+ meta={relay?.hasKey ? '中转可调用' : '需要填入 Key'}
54
+ />
55
+ <StatusCard
56
+ tone={(relay?.models?.length ?? 0) > 0 ? 'green' : 'amber'}
57
+ label="可用模型"
58
+ value={`${relay?.models?.length ?? 0} 个`}
59
+ meta={relay?.models?.[0] || '中转未启'}
60
+ />
61
+ </div>
62
+
63
+ <Panel title="HOW IT WORKS · 工作原理">
64
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, alignItems: 'center', padding: 'var(--sp-3) 0' }}>
65
+ <Block title="claude.exe" sub="官方 CLI" body="读 ~/.claude/settings.json" />
66
+ <Block title="中转 :8787" sub="本地服务" body="Anthropic → OpenAI 翻译" highlight />
67
+ <Block title="DeepSeek" sub="api.deepseek.com" body="真实模型调用" />
68
+ </div>
69
+ </Panel>
70
+
71
+ <Panel title="QUICK START · 3 步">
72
+ <ol style={{ margin: 0, paddingLeft: 20, lineHeight: 1.9, fontSize: 13, color: 'var(--ink-1)' }}>
73
+ <li>去 <a href="https://platform.deepseek.com" target="_blank" rel="noreferrer" style={{ color: 'var(--accent-green)' }}>platform.deepseek.com <ExternalLink size={11} /></a> 申请 API Key</li>
74
+ <li>进 <a href="#/relay" style={{ color: 'var(--accent-green)' }}>「中转服务」</a> 填 Key → 保存 → 启动</li>
75
+ <li>新开终端跑 <code style={{ background: 'var(--bg-2)', padding: '2px 6px', borderRadius: 3, fontSize: 12 }}>claude</code></li>
76
+ </ol>
77
+ </Panel>
78
+
79
+ <div style={{ marginTop: 'var(--sp-3)', color: 'var(--ink-3)', fontSize: 11, display: 'flex', gap: 12, alignItems: 'center', fontFamily: 'var(--font-mono)' }}>
80
+ <Terminal size={12} />
81
+ 端口: Vite 5173 · API 3001 · 中转 8787 · 全部仅本机监听
82
+ </div>
83
+ </Page>
84
+ )
85
+ }
86
+
87
+ function Block({ title, sub, body, highlight }: { title: string; sub: string; body: string; highlight?: boolean }) {
88
+ return (
89
+ <div style={{
90
+ padding: 'var(--sp-3)',
91
+ background: highlight ? 'rgba(16, 185, 129, 0.08)' : 'var(--bg-1)',
92
+ border: `1px solid ${highlight ? 'rgba(16, 185, 129, 0.3)' : 'var(--line-1)'}`,
93
+ borderRadius: 8,
94
+ textAlign: 'center',
95
+ }}>
96
+ <div style={{ fontSize: 14, fontWeight: 600, color: highlight ? 'var(--accent-green)' : 'var(--ink-0)' }}>{title}</div>
97
+ <div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>{sub}</div>
98
+ <div style={{ fontSize: 11, color: 'var(--ink-2)', marginTop: 6, fontFamily: 'var(--font-mono)' }}>{body}</div>
99
+ </div>
100
+ )
101
+ }