dsh-totonai 0.0.0-stage → 0.1.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.
package/README.md CHANGED
@@ -1,3 +1,114 @@
1
- # Temporary Holding Version
1
+ # dsh-totonai
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 ToTonAI AI 中转站接进 DeepSeek Harness 的官方插件:账号登录、80+ 主流模型直连、站内扫码充值开通会员。
4
+
5
+ > 支持 **GPT / ChatGPT / Claude / Gemini / DeepSeek / 豆包 / Grok / GLM** 等 80+ 主流模型。
6
+ > 在 DSH 插件市场搜索栏输入 `GPT`、`Claude`、`DeepSeek`、`Gemini` 或 `ToTonAI` 都能搜到本插件。
7
+
8
+ - **账号制,不接触上游 Key**:在 DSH **设置 → ToTonAI** 面板里用账号密码登录(或填插件配置),插件自动登录换令牌,你的上游 API Key 永远不会出现在插件包或对话里。
9
+ - **完整复制站点体系**:与 www.toton.pro 相同的模型目录、会员档位与计费口径。
10
+ - **图形面板**:设置在 DSH 设置页里,登录/注册、会员状态与剩余额度、选档位出收款二维码、"我已付款"申报,都不用跳网页。
11
+ - **站内充值**:对话里说"帮我充值",模型生成收款码订单,扫码按金额付款并备注用户名,付款后一句话完成申报,站长核对后开通。
12
+
13
+ ## 功能
14
+
15
+ | 能力 | 说明 |
16
+ | --- | --- |
17
+ | 图形面板 | 设置 → ToTonAI:登录 / 注册、会员状态与剩余额度、选档位与月年卡出收款码、"我已付款"申报、刷新状态 |
18
+ | 模型接入 | OpenAI / Anthropic / Google / DeepSeek / 豆包 / Grok / GLM 等 80+ 文本模型 |
19
+ | 账号注册 | `totonai_register` 在对话里直接注册站点账号,不用打开网页 |
20
+ | 账号登录 | 配置账号密码后自动换取令牌,令牌 7 天有效,过期自动静默续期 |
21
+ | 自动重试 | 令牌失效(401)时自动重新登录并重试本次请求 |
22
+ | 充值开通 | `totonai_recharge` 生成收款码订单(价格实时读取站点配置)、`totonai_pay_claim` 提交付款申报、`totonai_pay_status` 查询会员是否已开通 |
23
+ | 会员档位 | entry=Go(27 元/月,307 元/年,500 万 token/月)、pro=Plus(77 元/月,847 元/年,2500 万)、ultimate=Pro Max(177 元/月,2077 元/年,5000 万 + 10 张生图/日) |
24
+
25
+ ## 安装
26
+
27
+ ### 从模型广场安装
28
+
29
+ 在 DSH 模型广场搜索 `ToTonAI` 或 `totonai`,一键安装后重启 DeepSeek Harness。
30
+
31
+ ### 本地安装(开发/自用)
32
+
33
+ ```bash
34
+ # 在插件目录打包
35
+ npm pack
36
+
37
+ # 在 DSH 里安装本地包
38
+ dsh plugin --profile web add ./dsh-totonai-0.1.0.tgz
39
+ ```
40
+
41
+ ## 配置
42
+
43
+ 装好之后**不用改配置**,直接进 DSH **设置 → ToTonAI** 面板:
44
+
45
+ 1. 没有账号 → 点"注册新账号",填用户名(3-32 位字母/数字/下划线)和密码(≥6 位),注册成功自动登录。
46
+ 2. 有账号 → 直接登录。
47
+ 3. 登录后面板显示用户名、当前档位、总额度 / 已用 / 剩余 token。
48
+ 4. 想开通或续费 → 选 Go / Plus / Pro Max、月卡或年卡、微信或支付宝,点"生成收款码",面板里直接显示二维码图片。
49
+ 5. 扫码付对应金额,**备注里写你的用户名**,回来点"我已付款";站长核对后开通,再点"刷新状态"即可看到档位变化。
50
+
51
+ 账号密码只保存在本机 `~/.dsh-totonai/creds.json`(权限 600),不写进任何日志或对话内容。
52
+
53
+ 也可以继续用插件配置(可选,优先级高于面板):
54
+
55
+ | 字段 | 说明 |
56
+ | --- | --- |
57
+ | 站点地址 baseURL | 默认 `https://www.toton.pro`,一般不用改 |
58
+ | 账号 username | 留空即可,面板登录时会自动写入 |
59
+ | 密码 password | 留空即可 |
60
+ | 请求超时 | 非流式请求超时秒数,默认 180 |
61
+
62
+ 配置完成后重启,在模型选择器里即可看到 `ToTonAI` 提供商的全部模型。
63
+
64
+ ## 使用
65
+
66
+ ### 还没有账号?
67
+
68
+ 两种方式,选一个:
69
+
70
+ - **面板注册(推荐)**:设置 → ToTonAI → "注册新账号",填用户名密码,注册完自动登录,马上能用。
71
+ - **对话注册**:说"帮我注册一个 ToTonAI 账号,用户名 demo_01,密码 123456",模型调用 `totonai_register` 完成注册,再去面板登录即可。
72
+
73
+ ### 日常使用
74
+
75
+ 1. 新会话选择任意 ToTonAI 模型(如 `deepseek/deepseek-v4-pro` 或 `openai/gpt-5.4`)。
76
+ 2. 正常对话。
77
+ 3. 需要开通会员时,直接说:
78
+
79
+ > 帮我充值 Plus 月卡
80
+
81
+ 模型会调用 `totonai_recharge` 返回金额、订单号和收款码链接;按金额付款,备注写上你的用户名,回来说"已付款",模型调用 `totonai_pay_claim` 把申报提交给站长。站长核对到账后开通,一般 5 分钟内生效;之后说"查一下会员状态",模型读取你的账号确认档位与剩余额度。
82
+
83
+ > 说明:站点使用个人收款码,没有自动回调,开通由站长人工确认,这是当前设计的正常流程,不是故障。
84
+
85
+ ## 会员档位
86
+
87
+ | 档位 | 显示名 | 月卡 | 年卡 | 权益 |
88
+ | --- | --- | --- | --- | --- |
89
+ | `entry` | Go | ¥27 | ¥307 | 500 万 token/月 |
90
+ | `pro` | Plus | ¥77 | ¥847 | 2500 万 token/月 |
91
+ | `ultimate` | Pro Max | ¥177 | ¥2077 | 5000 万 token/月 + 10 张生图/日 |
92
+
93
+ ## 兼容性
94
+
95
+ - DeepSeek Harness `>=0.2.0-rc.1 <0.3.0`(在 0.2.1-rc.3 验证)
96
+ - Node.js `>=22`
97
+
98
+ ## 说明
99
+
100
+ - 生图模型暂不接入 DSH 对话流,请继续在 ToTonAI 网页端使用生图功能。
101
+ - 令牌仅保存在进程内,重启后自动重新登录;账号密码只写在本机 `~/.dsh-totonai/creds.json`,不写进任何日志或对话内容。
102
+ - 插件仅与你的 ToTonAI 站点通信,所有请求都带 DeepSeek Harness 归属标识。
103
+
104
+ ## 开发
105
+
106
+ ```bash
107
+ npm install
108
+ npm run build # tsc 编译到 lib/
109
+ node test/smoke.mjs # 冒烟测试(流映射/消息转换/401 重试/充值工具)
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,309 @@
1
+ /* global window, document, fetch, navigator */
2
+ // plugin/client: DSH 设置页 → ToTonAI 分区(登录/注册、会员状态、充值二维码、我已付款)
3
+ // 手写单文件 bundle,无构建链,由 dsh web 客户端的 ModuleLoader 注入。
4
+ // 硬约束:任何异常只 console.warn,界面挂掉也不能影响 dsh 设置页本身。
5
+ window.__ModuleLoader__.load({
6
+ id: 'totonai',
7
+ factory: (require) => {
8
+ const module = { exports: {} }
9
+ const React = require('react')
10
+ const useState = React.useState
11
+ const useEffect = React.useEffect
12
+ const h = React.createElement
13
+
14
+ const isZh = () => {
15
+ const l = (document.documentElement.lang || navigator.language || 'zh').toLowerCase()
16
+ return l.startsWith('zh')
17
+ }
18
+ const L = (zh, en) => (isZh() ? zh : en)
19
+
20
+ async function call(action, payload) {
21
+ const r = await fetch('/api-totonai/panel', {
22
+ method: 'POST',
23
+ credentials: 'same-origin',
24
+ headers: { 'content-type': 'application/json' },
25
+ body: JSON.stringify(Object.assign({ action }, payload || {})),
26
+ })
27
+ const j = await r.json().catch(() => ({ ok: false, msg: '接口返回异常' }))
28
+ return j && typeof j === 'object' ? j : { ok: false, msg: '接口返回异常' }
29
+ }
30
+
31
+ const C = {
32
+ text: 'var(--dsw-alias-label-primary, inherit)',
33
+ sub: 'var(--dsw-alias-label-secondary, #888)',
34
+ faint: 'var(--dsw-alias-label-tertiary, #999)',
35
+ border: 'var(--dsw-alias-border-l3, rgba(127,127,127,.25))',
36
+ layer: 'var(--dsw-alias-bg-layer-1, transparent)',
37
+ ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
38
+ warn: 'var(--dsw-alias-state-warn-primary, #b7791f)',
39
+ err: 'var(--dsw-alias-state-error-primary, #cf222e)',
40
+ }
41
+ const S = {
42
+ wrap: { margin: '0 0 28px', maxWidth: 720 },
43
+ h: { fontSize: 15, fontWeight: 600, margin: '0 0 4px', color: C.text },
44
+ note: { fontSize: 12, color: C.sub, margin: '0 0 14px', lineHeight: 1.6 },
45
+ card: { border: '1px solid ' + C.border, borderRadius: 10, background: C.layer, padding: '14px 16px', marginBottom: 12 },
46
+ row: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
47
+ label: { fontSize: 12, color: C.sub, marginBottom: 4, display: 'block' },
48
+ input: { width: '100%', boxSizing: 'border-box', padding: '8px 10px', fontSize: 13, borderRadius: 8, border: '1px solid ' + C.border, background: 'transparent', color: C.text, outline: 'none' },
49
+ btn: { padding: '8px 14px', fontSize: 13, borderRadius: 8, border: '1px solid ' + C.border, background: 'transparent', color: C.text, cursor: 'pointer' },
50
+ btnPrimary: { padding: '8px 14px', fontSize: 13, borderRadius: 8, border: '1px solid ' + C.text, background: C.text, color: 'var(--dsw-alias-bg-base, #fff)', cursor: 'pointer' },
51
+ qr: { display: 'block', width: 176, height: 176, margin: '12px auto', objectFit: 'contain', borderRadius: 8 },
52
+ kv: { fontSize: 12, color: C.sub, lineHeight: 1.9 },
53
+ }
54
+
55
+ function tok(n) {
56
+ const v = Number(n || 0)
57
+ if (v >= 10000) return (v / 10000).toFixed(0) + ' 万'
58
+ return String(v)
59
+ }
60
+
61
+ function Notice(props) {
62
+ if (!props.text) return null
63
+ const color = props.kind === 'error' ? C.err : props.kind === 'ok' ? C.ok : C.warn
64
+ return h('div', { style: { fontSize: 12, lineHeight: 1.6, marginTop: 8, color: color } }, props.text)
65
+ }
66
+
67
+ function AuthForm(props) {
68
+ const tabState = useState('login')
69
+ const tab = tabState[0]
70
+ const setTab = tabState[1]
71
+ const uState = useState('')
72
+ const username = uState[0]
73
+ const setUsername = uState[1]
74
+ const pState = useState('')
75
+ const password = pState[0]
76
+ const setPassword = pState[1]
77
+ const bState = useState(false)
78
+ const busy = bState[0]
79
+ const setBusy = bState[1]
80
+ const eState = useState('')
81
+ const err = eState[0]
82
+ const setErr = eState[1]
83
+
84
+ const submit = async () => {
85
+ if (busy) return
86
+ setErr('')
87
+ setBusy(true)
88
+ try {
89
+ const r = await call(tab, { username: String(username).trim(), password: password })
90
+ if (!r.ok) {
91
+ setErr(r.msg || L('操作失败', 'Failed'))
92
+ return
93
+ }
94
+ props.onDone(r)
95
+ } catch (e) {
96
+ setErr(String((e && e.message) || e))
97
+ } finally {
98
+ setBusy(false)
99
+ }
100
+ }
101
+
102
+ return h('div', { style: S.card }, [
103
+ h('div', { key: 'tabs', style: S.row }, [
104
+ h('button', { key: 'a', type: 'button', style: tab === 'login' ? S.btnPrimary : S.btn, onClick: () => setTab('login') }, L('登录', 'Sign in')),
105
+ h('button', { key: 'b', type: 'button', style: tab === 'register' ? S.btnPrimary : S.btn, onClick: () => setTab('register') }, L('注册新账号', 'Create account')),
106
+ ]),
107
+ h('div', { key: 'u', style: { marginTop: 12 } }, [
108
+ h('label', { key: 'l', style: S.label }, L('ToTonAI 账号', 'ToTonAI account')),
109
+ h('input', { key: 'i', style: S.input, value: username, placeholder: L('3-32 位字母/数字/下划线', '3-32 letters/digits'), onChange: (e) => setUsername(e.target.value), autoComplete: 'username' }),
110
+ ]),
111
+ h('div', { key: 'p', style: { marginTop: 10 } }, [
112
+ h('label', { key: 'l', style: S.label }, L('密码', 'Password')),
113
+ h('input', { key: 'i', style: S.input, type: 'password', value: password, placeholder: L('至少 6 位', 'min 6 characters'), onChange: (e) => setPassword(e.target.value), autoComplete: 'current-password' }),
114
+ ]),
115
+ h('div', { key: 'act', style: { marginTop: 14 } }, [
116
+ h('button', { key: 's', type: 'button', style: busy ? Object.assign({}, S.btnPrimary, { opacity: 0.6 }) : S.btnPrimary, disabled: busy, onClick: submit }, busy ? L('处理中…', 'Working…') : tab === 'login' ? L('登录', 'Sign in') : L('注册并登录', 'Create & sign in')),
117
+ ]),
118
+ h(Notice, { key: 'err', text: err, kind: 'error' }),
119
+ h('div', { key: 'tip', style: { fontSize: 12, color: C.faint, marginTop: 10, lineHeight: 1.6 } }, L('账号密码只保存在这台电脑(~/.dsh-totonai/creds.json),不上传、不写进对话。', 'Credentials stay on this machine only (~/.dsh-totonai/creds.json).')),
120
+ ])
121
+ }
122
+
123
+ function AccountCard(props) {
124
+ const d = props.data || {}
125
+ const refreshingState = useState(false)
126
+ const refreshing = refreshingState[0]
127
+ const setRefreshing = refreshingState[1]
128
+ return h('div', { style: S.card }, [
129
+ h('div', { key: 'hd', style: S.row }, [
130
+ h('div', { key: 'u', style: { fontSize: 13, fontWeight: 600, color: C.text } }, d.username || ''),
131
+ h('div', { key: 'lv', style: { fontSize: 12, padding: '2px 8px', borderRadius: 999, border: '1px solid ' + C.border, color: d.active ? C.ok : C.warn } }, d.active ? d.displayName || d.level : L('未开通', 'Inactive')),
132
+ ]),
133
+ d.active
134
+ ? h('div', { key: 'kv', style: Object.assign({}, S.kv, { marginTop: 8 }) }, [
135
+ h('div', { key: 'a' }, L('总额度 ', 'Quota ') + tok(d.tokenQuota) + ' token'),
136
+ h('div', { key: 'b' }, L('已用 ', 'Used ') + tok(d.usedToken) + ' token · ' + L('剩余 ', 'Left ') + tok(d.remainToken) + ' token'),
137
+ ])
138
+ : h('div', { key: 'kv', style: Object.assign({}, S.kv, { marginTop: 8 }) }, L('当前是免费档,充值后即可调用模型。', 'Free tier: recharge to unlock models.')),
139
+ h('div', { key: 'act', style: { marginTop: 10, display: 'flex', gap: 8 } }, [
140
+ h('button', { key: 'r', type: 'button', style: S.btn, disabled: refreshing, onClick: async () => { setRefreshing(true); try { await props.onRefresh() } finally { setRefreshing(false) } } }, refreshing ? L('刷新中…', 'Refreshing…') : L('刷新状态', 'Refresh')),
141
+ h('button', { key: 'o', type: 'button', style: S.btn, onClick: props.onLogout }, L('退出登录', 'Sign out')),
142
+ ]),
143
+ ])
144
+ }
145
+
146
+ function RechargeCard(props) {
147
+ const data = props.data || {}
148
+ const packages = props.quote || {}
149
+ const pickState = useState({ level: 'pro', period: 'month', pay_type: 'wechat' })
150
+ const pick = pickState[0]
151
+ const setPick = pickState[1]
152
+ const orderState = useState(null)
153
+ const order = orderState[0]
154
+ const setOrder = orderState[1]
155
+ const claimState = useState({ busy: false, msg: '', kind: 'warn' })
156
+ const claim = claimState[0]
157
+ const setClaim = claimState[1]
158
+
159
+ const levels = ['entry', 'pro', 'ultimate']
160
+ const cur = packages[pick.level] || {}
161
+ const amount = pick.period === 'year' ? Number(cur.year_price || 0) : Number(cur.month_price || 0)
162
+ const qrUrl = (data.baseURL || '') + '/qrcode/' + pick.pay_type
163
+
164
+ const start = () => {
165
+ const no = 'TT' + Date.now().toString(36).toUpperCase() + Math.random().toString(36).slice(2, 6).toUpperCase()
166
+ setOrder({
167
+ out_trade_no: no,
168
+ amount: amount,
169
+ level: pick.level,
170
+ period: pick.period,
171
+ display_name: cur.display_name || pick.level,
172
+ pay_type: pick.pay_type,
173
+ })
174
+ setClaim({ busy: false, msg: '', kind: 'warn' })
175
+ }
176
+
177
+ const claimPay = async () => {
178
+ if (!order || claim.busy) return
179
+ setClaim({ busy: true, msg: '', kind: 'warn' })
180
+ try {
181
+ const r = await call('claim', order)
182
+ setClaim({ busy: false, msg: r.ok ? L('已提交核对,等站长开通(一般 5 分钟内)。', 'Submitted. The owner will activate it, usually within 5 minutes.') : r.msg || L('提交失败', 'Failed'), kind: r.ok ? 'ok' : 'error' })
183
+ } catch (e) {
184
+ setClaim({ busy: false, msg: String((e && e.message) || e), kind: 'error' })
185
+ }
186
+ }
187
+
188
+ return h('div', { style: S.card }, [
189
+ h('div', { key: 'h', style: { fontSize: 13, fontWeight: 600, marginBottom: 10, color: C.text } }, L('开通 / 续费会员', 'Membership')),
190
+ h('div', { key: 'lv', style: Object.assign({}, S.row, { flexWrap: 'wrap' }) }, levels.map((k) => {
191
+ const p = packages[k] || {}
192
+ const on = pick.level === k
193
+ return h('div', {
194
+ key: k,
195
+ role: 'button',
196
+ tabIndex: 0,
197
+ style: Object.assign({ flex: '1 1 150px', padding: '10px 12px', borderRadius: 10, cursor: 'pointer', border: '1px solid ' + (on ? C.text : C.border), background: on ? 'rgba(127,127,127,.10)' : 'transparent', color: C.text }, {}),
198
+ onClick: () => setPick(Object.assign({}, pick, { level: k })),
199
+ }, [
200
+ h('div', { key: 'n', style: { fontSize: 13, fontWeight: 600 } }, p.display_name || k),
201
+ h('div', { key: 'p', style: { fontSize: 12, color: C.sub, marginTop: 4 } }, '¥' + (p.month_price || 0) + L(' / 月', ' / mo') + ' · ' + tok(p.token_quota) + ' token'),
202
+ ])
203
+ })),
204
+ h('div', { key: 'per', style: Object.assign({}, S.row, { marginTop: 10 }) }, [
205
+ h('button', { key: 'm', type: 'button', style: pick.period === 'month' ? S.btnPrimary : S.btn, onClick: () => setPick(Object.assign({}, pick, { period: 'month' })) }, L('月卡', 'Monthly')),
206
+ h('button', { key: 'y', type: 'button', style: pick.period === 'year' ? S.btnPrimary : S.btn, onClick: () => setPick(Object.assign({}, pick, { period: 'year' })) }, L('年卡', 'Yearly')),
207
+ h('button', { key: 'w', type: 'button', style: pick.pay_type === 'wechat' ? S.btnPrimary : S.btn, onClick: () => setPick(Object.assign({}, pick, { pay_type: 'wechat' })) }, L('微信', 'WeChat')),
208
+ h('button', { key: 'a', type: 'button', style: pick.pay_type === 'alipay' ? S.btnPrimary : S.btn, onClick: () => setPick(Object.assign({}, pick, { pay_type: 'alipay' })) }, L('支付宝', 'Alipay')),
209
+ ]),
210
+ order
211
+ ? h('div', { key: 'ord', style: { marginTop: 12 } }, [
212
+ h('div', { key: 'amt', style: { fontSize: 13, color: C.text } }, L('应付 ', 'Amount ') + '¥' + order.amount + ' · ' + (order.display_name || '') + ' · ' + L('订单号 ', 'Order ') + order.out_trade_no),
213
+ h('img', { key: 'qr', src: qrUrl, alt: L('收款码', 'QR code'), style: S.qr }),
214
+ h('div', { key: 'tip', style: { fontSize: 12, color: C.warn, lineHeight: 1.7 } }, L('付款备注务必填写你的用户名:', 'Put your username in the payment note: ') + (data.username || '')),
215
+ h('div', { key: 'act', style: { marginTop: 10, display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
216
+ h('button', { key: 'c', type: 'button', style: claim.busy ? Object.assign({}, S.btnPrimary, { opacity: 0.6 }) : S.btnPrimary, disabled: claim.busy, onClick: claimPay }, claim.busy ? L('提交中…', 'Submitting…') : L('我已付款', 'I have paid')),
217
+ h('button', { key: 'r', type: 'button', style: S.btn, onClick: props.onRefresh }, L('刷新状态', 'Refresh')),
218
+ ]),
219
+ h(Notice, { key: 'm', text: claim.msg, kind: claim.kind }),
220
+ ])
221
+ : h('div', { key: 'go', style: { marginTop: 12, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } }, [
222
+ h('button', { key: 'b', type: 'button', style: S.btnPrimary, disabled: !(amount > 0), onClick: start }, (L('生成收款码,应付 ', 'Pay ') + '¥' + amount)),
223
+ h('div', { key: 't', style: { fontSize: 12, color: C.faint } }, L('个人收款码,付款后由站长核对开通', 'Manual activation after payment')),
224
+ ]),
225
+ h('div', { key: 'note', style: { fontSize: 12, color: C.faint, marginTop: 12, lineHeight: 1.7 } }, L('价格实时读取站点配置。个人收款码没有自动回调,开通由站长人工确认,这不是故障。', 'Prices come from the site config. Personal QR codes have no callback; activation is manual by design.')),
226
+ ])
227
+ }
228
+
229
+ function SettingsSection() {
230
+ const st = useState({ loading: true, loggedIn: false })
231
+ const state = st[0]
232
+ const setState = st[1]
233
+ const q = useState(null)
234
+ const quote = q[0]
235
+ const setQuote = q[1]
236
+ const msgState = useState('')
237
+ const msg = msgState[0]
238
+ const setMsg = msgState[1]
239
+
240
+ const loadStatus = async () => {
241
+ setMsg('')
242
+ try {
243
+ const r = await call('status')
244
+ if (!r.ok) {
245
+ setState({ loading: false, loggedIn: false })
246
+ setMsg(r.msg || L('读取状态失败', 'Failed to load'))
247
+ return
248
+ }
249
+ setState(Object.assign({ loading: false }, r))
250
+ } catch (e) {
251
+ setState({ loading: false, loggedIn: false })
252
+ setMsg(String((e && e.message) || e))
253
+ }
254
+ }
255
+
256
+ const loadQuote = async () => {
257
+ try {
258
+ const r = await call('quote')
259
+ if (r.ok) setQuote(r.packages || null)
260
+ } catch (e) {
261
+ // 价格读取失败时前端用空表,充值区按钮会禁用
262
+ }
263
+ }
264
+
265
+ useEffect(() => {
266
+ void loadStatus()
267
+ void loadQuote()
268
+ }, [])
269
+
270
+ if (state.loading) {
271
+ return h('div', { style: S.wrap }, h('div', { style: S.note }, L('加载中…', 'Loading…')))
272
+ }
273
+
274
+ const header = [
275
+ h('div', { key: 'h', style: S.h }, 'ToTonAI'),
276
+ h('div', { key: 'n', style: S.note }, L('账号登录、会员状态与充值都在这里完成;模型在会话的模型选择器里使用。', 'Account, membership and recharge all live here. Models are picked in the chat composer.')),
277
+ ]
278
+
279
+ if (!state.loggedIn) {
280
+ return h('div', { style: S.wrap }, header.concat([
281
+ h(AuthForm, { key: 'form', onDone: async () => { await loadStatus() } }),
282
+ h(Notice, { key: 'm', text: msg, kind: 'error' }),
283
+ ]))
284
+ }
285
+
286
+ return h('div', { style: S.wrap }, header.concat([
287
+ h(AccountCard, { key: 'acct', data: state, onRefresh: loadStatus, onLogout: async () => { await call('logout'); await loadStatus() } }),
288
+ h(RechargeCard, { key: 'pay', data: state, quote: quote, onRefresh: loadStatus }),
289
+ h(Notice, { key: 'm', text: msg, kind: 'error' }),
290
+ ]))
291
+ }
292
+
293
+ const name = 'llm-totonai'
294
+ // 用到 slots 服务必须先声明,否则 ctx.slots 读不到
295
+ const inject = ['slots']
296
+ function apply(ctx) {
297
+ try {
298
+ ctx.slots.inject('settings.section', () =>
299
+ ctx.slots.register({ name: 'settings.section', id: 'totonai', order: 30, label: () => 'ToTonAI' }, SettingsSection),
300
+ )
301
+ } catch (error) {
302
+ console.warn('[totonai] 设置页注册失败', error)
303
+ }
304
+ }
305
+
306
+ module.exports = { name, inject, apply }
307
+ return module.exports
308
+ },
309
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: llm-totonai
3
+ name: dsh-totonai
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="96" fill="#0f0f0f"/>
3
+ <g fill="none" stroke="#ffffff" stroke-width="26" stroke-linecap="round">
4
+ <circle cx="196" cy="180" r="34"/>
5
+ <circle cx="340" cy="180" r="34"/>
6
+ <circle cx="196" cy="330" r="34"/>
7
+ <circle cx="268" cy="255" r="34"/>
8
+ <circle cx="340" cy="330" r="34"/>
9
+ <path d="M226 196 L240 225 M312 196 L298 225 M226 314 L240 285 M312 314 L298 285 M268 281 L268 252"/>
10
+ </g>
11
+ <text x="256" y="452" text-anchor="middle" font-family="Arial, sans-serif" font-size="44" font-weight="bold" fill="#ffffff" letter-spacing="6">ToTonAI</text>
12
+ </svg>
@@ -0,0 +1,20 @@
1
+ import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
2
+ import type { TotonAiConfig } from './config.js';
3
+ import type { TokenStore } from './token.js';
4
+ /**
5
+ * ToTonAI 中转站适配器。
6
+ *
7
+ * 请求映射:DSH 的对话消息 → OpenAI Chat Completions 流式请求;
8
+ * 响应映射:SSE 事件流 → DSH 的 StreamChunk 块协议。
9
+ * 鉴权统一使用 /login 换取的 Bearer 令牌,401 时自动重登重试一次。
10
+ */
11
+ export declare class TotonAiAdapter extends LlmAdapter {
12
+ private readonly config;
13
+ private readonly tokens;
14
+ constructor(config: TotonAiConfig, tokens: TokenStore);
15
+ providerInfo(provider: string): LlmProviderInfo;
16
+ listModels(): Promise<LlmModelInfo[]>;
17
+ resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
18
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
19
+ private requestStream;
20
+ }