dsh-dfy-pet 2.6.1

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.
@@ -0,0 +1,3 @@
1
+ - insert: # 向配置树插入插件条目
2
+ - id: deepseek-pet # 条目 id,删除或禁用时按它定位
3
+ name: 'dsh-dfy-pet' # 已发布的包名,npm 安装后由此解析
package/icon.png ADDED
Binary file
package/index.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Host half of the DeepSeek pet bundle.
3
+ *
4
+ * The pet itself is drawn by the Client module (`client.js`). This half adds
5
+ * the authenticated OpenCode Go monitoring routes: the Client calls
6
+ * `api/deepseek-pet/opencode/check` and `.../history`, and all network and
7
+ * filesystem work happens here so the browser never needs CORS access or a
8
+ * writable local directory.
9
+ */
10
+ import { performCheck, readHistory, readStatus, KEY_REF } from './monitor.js'; // 监控实现与凭据引用
11
+
12
+ export const inject = ['connection', 'credentials']; // 认证路由与只读凭据服务
13
+
14
+ const CHECK_PATH = '/api/deepseek-pet/opencode/check'; // 检查路由绝对路径
15
+ const HISTORY_PATH = '/api/deepseek-pet/opencode/history'; // 历史路由绝对路径
16
+ const STATUS_PATH = '/api/deepseek-pet/opencode/status'; // 当前状态路由绝对路径
17
+ const MAX_KEY_LENGTH = 4096; // 限制请求体里的 Key 长度
18
+
19
+ function jsonResponse(body, status = 200) { // 统一的 JSON 响应
20
+ return new Response(JSON.stringify(body), { // 序列化响应体
21
+ status, // HTTP 状态
22
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, // 禁止缓存
23
+ }); // 返回响应
24
+ } // 结束 JSON 响应
25
+
26
+ function errorResponse(error, status) { // 统一的错误响应
27
+ return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, status); // 返回错误文本
28
+ } // 结束错误响应
29
+
30
+ export function apply(ctx) { // 注册监控路由
31
+ const credentials = ctx.credentials; // 凭据服务
32
+ const routes = ctx.connection.fetch; // Host 认证路由注册表
33
+ let queue = Promise.resolve(); // 串行化检查,避免并发写快照
34
+ const enqueue = (task) => { // 把任务接入串行队列
35
+ const run = queue.then(task, task); // 前一个任务结束后执行
36
+ queue = run.then(() => undefined, () => undefined); // 吞掉错误以保持队列
37
+ return run; // 返回本次结果
38
+ }; // 结束队列包装
39
+ const resolveKey = async () => { // 读取已保存的 OpenCode Key
40
+ const resolved = await credentials.resolve(KEY_REF); // 从凭据服务读取
41
+ return typeof resolved?.value === 'string' ? resolved.value : ''; // 返回非空字符串
42
+ }; // 结束 Key 读取
43
+ const storeKey = async (value) => { // 保存非空 Key
44
+ const trimmed = typeof value === 'string' ? value.trim() : ''; // 去掉两侧空格
45
+ if (trimmed.length > 0) await credentials.set(KEY_REF, trimmed); // 写入只读凭据
46
+ }; // 结束 Key 保存
47
+ const readBody = async (request) => { // 读取 JSON 请求体
48
+ if (request.method !== 'POST') return {}; // 非 POST 视为空
49
+ try { // 解析请求体
50
+ const body = await request.json(); // 读取 JSON
51
+ return body !== null && typeof body === 'object' ? body : {}; // 只接受对象
52
+ } catch (error) { // 空或非法 JSON
53
+ return {}; // 按空对象处理
54
+ } // 结束异常
55
+ }; // 结束请求体读取
56
+
57
+ routes.register({ // 检查路由
58
+ path: CHECK_PATH, // 绝对注册路径
59
+ methods: ['POST'], // 只接受 POST
60
+ requestBody: 'buffered', // 缓冲读取请求体
61
+ fetch: async (request) => { // 处理一次检查
62
+ try { // 统一捕获失败
63
+ const body = await readBody(request); // 读取请求体
64
+ const provided = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''; // 提取请求中的 Key
65
+ if (provided.length > MAX_KEY_LENGTH) return errorResponse(new Error('API Key 过长'), 400); // 拒绝超长 Key
66
+ await storeKey(provided); // 保存非空 Key
67
+ const apiKey = provided.length > 0 ? provided : await resolveKey(); // 本次使用的 Key
68
+ const result = await enqueue(() => performCheck({ apiKey, signal: request.signal })); // 串行执行检查
69
+ return jsonResponse({ ...result, hasKey: apiKey.length > 0 }); // 返回结果与 Key 状态
70
+ } catch (error) { // 检查失败
71
+ return errorResponse(error, 502); // 以 502 返回原因
72
+ } // 结束异常
73
+ }, // 结束检查处理器
74
+ }); // 结束检查路由
75
+
76
+ routes.register({ // 历史路由
77
+ path: HISTORY_PATH, // 绝对注册路径
78
+ methods: ['GET'], // 只读
79
+ requestBody: 'buffered', // 与注册协议保持一致
80
+ fetch: async (request) => { // 处理历史读取
81
+ try { // 统一捕获失败
82
+ const url = new URL(request.url); // 解析查询参数
83
+ const rawLimit = Number(url.searchParams.get('limit') ?? '20'); // 默认 20 条
84
+ const limit = Number.isFinite(rawLimit) ? rawLimit : 20; // 非法值回退
85
+ const checks = await readHistory(limit); // 读取最近记录
86
+ const apiKey = await resolveKey(); // 查询 Key 状态
87
+ return jsonResponse({ ok: true, hasKey: apiKey.length > 0, checks }); // 返回历史
88
+ } catch (error) { // 读取失败
89
+ return errorResponse(error, 500); // 以 500 返回原因
90
+ } // 结束异常
91
+ }, // 结束历史处理器
92
+ }); // 结束历史路由
93
+
94
+ routes.register({ // 当前状态路由
95
+ path: STATUS_PATH, // 绝对注册路径
96
+ methods: ['GET'], // 只读
97
+ requestBody: 'buffered', // 与注册协议保持一致
98
+ fetch: async (request) => { // 处理状态读取
99
+ try { // 统一捕获失败
100
+ const status = await readStatus(); // 读取最近快照
101
+ const apiKey = await resolveKey(); // 查询 Key 状态
102
+ return jsonResponse({ ok: true, hasKey: apiKey.length > 0, status }); // 返回状态
103
+ } catch (error) { // 读取失败
104
+ return errorResponse(error, 500); // 以 500 返回原因
105
+ } // 结束异常
106
+ }, // 结束状态处理器
107
+ }); // 结束状态路由
108
+ } // 结束 apply
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DeepSeek Whale Girl",
4
+ "description": "A chibi DeepSeek whale-girl desktop pet in the bottom-right corner: animated idle, waving and jumping, tap to talk, drag to move. Right-click to configure an OpenAI/Anthropic-compatible AI relay as an llm-pi-ai provider. Community fan art by YunYueSama."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DeepSeek 大肥鱼娘",
4
+ "description": "右下角的 DeepSeek 鲸鱼娘 Q 版桌宠:会呼吸、挥手、蹦跳,点一下会说话,还能拖着走。右键可把 OpenAI / Anthropic 兼容的 AI 中转站添加为 Harness 的 llm-pi-ai 供应商。社区二创形象,画师 YunYueSama。"
5
+ }
6
+ }
package/monitor.js ADDED
@@ -0,0 +1,400 @@
1
+ import { promises as fs } from 'node:fs'; // 文件系统操作
2
+ import path from 'node:path'; // 路径拼接
3
+ import os from 'node:os'; // 用户主目录
4
+
5
+ export const MODELS_URL = 'https://opencode.ai/zen/go/v1/models'; // OpenCode Go 实时模型端点
6
+ export const DOCS_URL = 'https://opencode.ai/docs/go/'; // 定价与月度配额文档
7
+ export const SNAPSHOT_VERSION = 1; // 快照格式版本
8
+ export const MAX_SNAPSHOTS = 50; // 本地最多保留的检查次数
9
+ export const KEY_REF = 'OPENCODE_GO_API_KEY'; // 凭据引用名
10
+ export const DEFAULT_PLANS = [ // 文档中的三档套餐
11
+ { key: 'go', label: 'Go' }, // 基础套餐
12
+ { key: 'goPlus', label: 'Go Plus' }, // 更高额度套餐
13
+ { key: 'goTeams', label: 'Go Teams' }, // 团队套餐
14
+ ]; // 结束套餐定义
15
+
16
+ export function snapshotDir() { // 返回快照目录
17
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.trim().length > 0 // 优先使用 DSH_HOME
18
+ ? process.env.DSH_HOME.trim() // 使用配置的主目录
19
+ : path.join(os.homedir(), '.dsh'); // 回退到 ~/.dsh
20
+ return path.join(home, 'opencode-go-models-snapshots'); // 需求指定的目录名
21
+ } // 结束目录解析
22
+
23
+ function combineSignals(signals) { // 合并多个 AbortSignal
24
+ const valid = signals.filter(Boolean); // 去掉空信号
25
+ if (valid.length === 0) return undefined; // 没有信号
26
+ if (valid.length === 1) return valid[0]; // 单个信号直接返回
27
+ if (typeof AbortSignal.any === 'function') return AbortSignal.any(valid); // 原生合并
28
+ const controller = new AbortController(); // 兜底控制器
29
+ const abort = () => controller.abort(); // 中止函数
30
+ for (const signal of valid) { // 遍历信号
31
+ if (signal.aborted) { abort(); break; } // 已中止则立即中止
32
+ signal.addEventListener('abort', abort, { once: true }); // 监听中止
33
+ } // 结束遍历
34
+ return controller.signal; // 返回合并信号
35
+ } // 结束信号合并
36
+
37
+ export async function fetchWithRetry(url, options = {}, policy = {}) { // 带超时与重试的 fetch
38
+ const timeoutMs = policy.timeoutMs ?? 15000; // 单次超时
39
+ const retries = policy.retries ?? 2; // 可重试次数
40
+ let lastError; // 最后一次错误
41
+ for (let attempt = 0; attempt <= retries; attempt += 1) { // 逐次尝试
42
+ const controller = new AbortController(); // 超时控制器
43
+ const timer = setTimeout(() => controller.abort(), timeoutMs); // 到点中止
44
+ const signal = combineSignals([options.signal, controller.signal]); // 合并调用方信号
45
+ try { // 发起请求
46
+ const response = await fetch(url, { ...options, signal }); // 执行请求
47
+ if (response.status >= 500 || response.status === 429) { // 可重试状态
48
+ lastError = new Error(`HTTP ${response.status}`); // 记录错误
49
+ await response.body?.cancel(); // 释放响应体
50
+ } else { // 非可重试状态
51
+ return response; // 直接返回响应
52
+ } // 结束状态判断
53
+ } catch (error) { // 网络或超时错误
54
+ lastError = error; // 记录错误
55
+ } finally { // 清理定时器
56
+ clearTimeout(timer); // 清除超时
57
+ } // 结束清理
58
+ if (attempt < retries) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1))); // 退避后重试
59
+ } // 结束重试循环
60
+ throw lastError ?? new Error('请求失败'); // 抛出最后一次错误
61
+ } // 结束 fetchWithRetry
62
+
63
+ function numberOrNull(value) { // 只接受有限数字
64
+ return typeof value === 'number' && Number.isFinite(value) ? value : null; // 非数字返回 null
65
+ } // 结束数字校验
66
+
67
+ export function stableStringify(value) { // 稳定序列化,避免键顺序造成假变动
68
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; // 数组递归
69
+ if (value !== null && typeof value === 'object') { // 对象按 key 排序
70
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; // 返回排序后的对象
71
+ } // 结束对象分支
72
+ return JSON.stringify(value); // 基本类型直接序列化
73
+ } // 结束稳定序列化
74
+
75
+ export function normalizeModelName(value) { // 生成用于匹配的名称键
76
+ return String(value ?? '') // 统一成字符串
77
+ .toLowerCase() // 忽略大小写
78
+ .replace(/\([^)]*\)/g, ' ') // 去掉括号里的档位与折扣说明
79
+ .replace(/&/g, ' and ') // 处理 & 符号
80
+ .replace(/[^a-z0-9]+/g, ' ') // 非字母数字转空格
81
+ .replace(/\s+/g, ' ') // 合并空格
82
+ .trim(); // 去掉首尾空格
83
+ } // 结束名称归一化
84
+
85
+ function normalizeApiModel(item) { // 把 API 条目转成内部模型
86
+ const id = typeof item?.id === 'string' ? item.id.trim() : ''; // 读取模型 id
87
+ const name = typeof item?.name === 'string' && item.name.trim().length > 0 ? item.name.trim() : id; // 读取显示名
88
+ const cost = item?.cost?.text; // 读取文本计费
89
+ const input = numberOrNull(cost?.input?.price); // 输入单价
90
+ const output = numberOrNull(cost?.output?.price); // 输出单价
91
+ const cacheRead = numberOrNull(cost?.input?.cache?.read); // 缓存读单价
92
+ const cacheWrite = numberOrNull(cost?.input?.cache?.write); // 缓存写单价
93
+ const context = numberOrNull(item?.limit?.context); // 上下文窗口
94
+ const maxOutput = numberOrNull(item?.limit?.output); // 最大输出
95
+ return { id, name, input, output, cacheRead, cacheWrite, context, maxOutput, costDigest: stableStringify(item?.cost ?? null), pricingSource: input !== null || output !== null ? 'api' : null }; // 返回内部记录
96
+ } // 结束 API 模型转换
97
+
98
+ export async function fetchModels(apiKey, signal) { // 读取实时模型列表
99
+ const headers = { 'user-agent': 'deepseek-harness-opencode-go-monitor/1.0', accept: 'application/json' }; // 标识本客户端
100
+ if (typeof apiKey === 'string' && apiKey.length > 0) headers.authorization = `Bearer ${apiKey}`; // 有 Key 时携带
101
+ const response = await fetchWithRetry(MODELS_URL, { headers, signal }, { timeoutMs: 15000, retries: 2 }); // 请求模型端点
102
+ if (response.status === 401 || response.status === 403) throw new Error(`认证失败(HTTP ${response.status}),请检查 OpenCode API Key`); // 认证提示
103
+ if (!response.ok) throw new Error(`模型端点返回 HTTP ${response.status}`); // 其他状态错误
104
+ const payload = await response.json(); // 解析 JSON
105
+ const list = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : []; // 兼容多种形状
106
+ if (list.length === 0) throw new Error('模型端点没有返回任何模型'); // 空列表视为失败
107
+ return list.map(normalizeApiModel).filter((model) => model.id.length > 0); // 规范化后返回
108
+ } // 结束模型抓取
109
+
110
+ function stripHtml(html) { // 把表格单元格转成纯文本
111
+ return String(html ?? '') // 统一成字符串
112
+ .replace(/<[^>]*>/g, ' ') // 去掉标签
113
+ .replace(/&nbsp;/gi, ' ') // 不换行空格
114
+ .replace(/&amp;/gi, '&') // 转义 &
115
+ .replace(/&lt;/gi, '<') // 转义 <
116
+ .replace(/&gt;/gi, '>') // 转义 >
117
+ .replace(/&quot;/gi, '"') // 转义双引号
118
+ .replace(/&#0*39;|&#x0*27;/gi, "'") // 转义单引号
119
+ .replace(/&mdash;/gi, '—') // 破折号
120
+ .replace(/\s+/g, ' ') // 合并空白
121
+ .trim(); // 去掉首尾空格
122
+ } // 结束文本清洗
123
+
124
+ export function parseMoney(text) { // 解析价格或配额
125
+ const normalized = String(text ?? '').trim(); // 统一成字符串
126
+ if (normalized.length === 0 || normalized === '-') return null; // 空值与占位
127
+ if (/^free$/i.test(normalized)) return 0; // Free 记为 0
128
+ if (/unlimited/i.test(normalized)) return 'unlimited'; // 无限配额
129
+ const matches = [...normalized.matchAll(/\$([0-9]+(?:\.[0-9]+)?)/g)]; // 找出所有美元金额
130
+ if (matches.length === 0) return null; // 没有金额
131
+ return Number(matches[matches.length - 1][1]); // 取最后一个金额(折扣后的现价)
132
+ } // 结束金额解析
133
+
134
+ function parseRequests(value) { // 解析 5h 请求估算
135
+ const normalized = String(value ?? '').replace(/,/g, '').trim(); // 去逗号与空白
136
+ if (/unlimited|∞/i.test(normalized)) return 'unlimited'; // 不限量
137
+ if (normalized.length === 0 || /^(?:-|—|n\/a)$/i.test(normalized)) return null; // 空值
138
+ const number = Number(normalized.replace(/[^0-9.]/g, '')); // 提取数字
139
+ return Number.isFinite(number) ? number : null; // 返回数值
140
+ } // 结束请求解析
141
+
142
+ export function parsePricingTables(html) { // 解析文档页的配额与 5h 请求估算
143
+ const tables = [...String(html ?? '').matchAll(/<table[\s\S]*?<\/table>/gi)].map((match) => match[0]); // 拆出所有表格
144
+ const quotaByPlan = []; // 各套餐配额行
145
+ const requestByPlan = []; // 各套餐 5h 请求行
146
+ const warnings = []; // 解析提示
147
+ let quotaTables = 0; // 配额表数量
148
+ let requestTables = 0; // 请求表数量
149
+ const cellsOf = (row) => [...row.matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((match) => stripHtml(match[1])); // 拆出单元格
150
+ for (const table of tables) { // 逐个表格
151
+ const rows = [...table.matchAll(/<tr[\s\S]*?<\/tr>/gi)].map((match) => match[0]); // 拆出行
152
+ if (rows.length < 2) continue; // 跳过空表
153
+ const header = cellsOf(rows[0]); // 表头
154
+ const isQuota = header.some((cell) => /^monthly limit$/i.test(cell)); // 配额表
155
+ const isRequests = header.some((cell) => /requests per 5 hours/i.test(cell)); // 5h 请求表
156
+ if (!isQuota && !isRequests) continue; // 只看目标表
157
+ const index = isQuota ? quotaTables : requestTables; // 当前套餐序号
158
+ const plan = DEFAULT_PLANS[index] ?? { key: `plan${index}`, label: `Plan ${index + 1}` }; // 按顺序识别套餐
159
+ const byName = new Map(); // 名称到行
160
+ for (const row of rows.slice(1)) { // 逐行解析
161
+ const cells = cellsOf(row); // 单元格
162
+ if (isQuota) { // 配额行
163
+ if (cells.length < 6) continue; // 列数不足
164
+ const name = cells[0]; // 模型名
165
+ if (name.length === 0) continue; // 跳过空名
166
+ const record = { // 文档配额行
167
+ name, // 原始名称
168
+ input: parseMoney(cells[1]), // 输入价
169
+ output: parseMoney(cells[2]), // 输出价
170
+ cacheRead: parseMoney(cells[3]), // 缓存读
171
+ cacheWrite: parseMoney(cells[4]), // 缓存写
172
+ monthlyLimit: parseMoney(cells[cells.length - 1]), // 月度配额
173
+ }; // 结束配额行
174
+ const key = normalizeModelName(name); // 归一化名称
175
+ const previous = byName.get(key); // 已有同模型行
176
+ if (previous === undefined) byName.set(key, record); // 首次出现
177
+ else if (previous.monthlyLimit === null && record.monthlyLimit !== null) byName.set(key, record); // 用有配额的行覆盖
178
+ } else { // 请求行
179
+ if (cells.length < 2) continue; // 列数不足
180
+ const name = cells[0]; // 模型名
181
+ if (name.length === 0) continue; // 跳过空名
182
+ const record = { name, fiveHour: parseRequests(cells[1]) }; // 只取 5h 列
183
+ const key = normalizeModelName(name); // 归一化名称
184
+ const previous = byName.get(key); // 已有同模型行
185
+ if (previous === undefined) byName.set(key, record); // 首次出现
186
+ else if (previous.fiveHour === null && record.fiveHour !== null) byName.set(key, record); // 用有数值的行覆盖
187
+ } // 结束行类型
188
+ } // 结束行循环
189
+ if (isQuota) { quotaByPlan[index] = [...byName.values()]; quotaTables += 1; } // 保存配额表
190
+ else { requestByPlan[index] = [...byName.values()]; requestTables += 1; } // 保存请求表
191
+ } // 结束表格循环
192
+ const plans = DEFAULT_PLANS.map((plan, index) => ({ key: plan.key, label: plan.label, rows: quotaByPlan[index] ?? [], requests: requestByPlan[index] ?? [] })); // 统一三档套餐
193
+ if (quotaTables === 0) warnings.push('定价文档中没有找到配额表,配额将沿用上次快照或为空'); // 结构变化提示
194
+ if (requestTables === 0) warnings.push('定价文档中没有找到 5h 请求估算表,5h 请求将沿用上次快照或为空'); // 请求表提示
195
+ return { plans, warnings }; // 返回解析结果
196
+ } // 结束文档解析
197
+
198
+ export async function fetchPricingDocs(signal) { // 抓取并解析定价文档
199
+ const response = await fetchWithRetry(DOCS_URL, { headers: { 'user-agent': 'deepseek-harness-opencode-go-monitor/1.0', accept: 'text/html' }, signal }, { timeoutMs: 20000, retries: 1 }); // 请求文档
200
+ if (!response.ok) throw new Error(`定价文档返回 HTTP ${response.status}`); // 状态错误
201
+ const html = await response.text(); // 读取 HTML
202
+ return { ...parsePricingTables(html), url: response.url || DOCS_URL }; // 返回解析结果与最终地址
203
+ } // 结束文档抓取
204
+
205
+ export function mergeModelData(apiModels, docs, previousModels = []) { // 合并 API 与文档数据
206
+ const docPlans = docs?.plans ?? []; // 文档套餐
207
+ const plansForMerge = docPlans.length > 0 ? docPlans : DEFAULT_PLANS.map((plan) => ({ ...plan, rows: [], requests: [] })); // 文档失败时仍保留三档
208
+ const docRows = new Map(); // 归一化名称到文档行
209
+ for (const plan of docPlans) { // 遍历文档套餐
210
+ for (const row of plan.rows) { // 遍历行
211
+ const key = normalizeModelName(row.name); // 归一化
212
+ const existing = docRows.get(key); // 现有行
213
+ if (existing === undefined) docRows.set(key, row); // 首次
214
+ else if (existing.monthlyLimit === null && row.monthlyLimit !== null) docRows.set(key, row); // 补配额
215
+ } // 结束行遍历
216
+ } // 结束套餐遍历
217
+ const previousById = new Map(previousModels.map((model) => [model.id, model])); // 上次快照
218
+ return apiModels.map((model) => { // 逐模型合并
219
+ const normalizedName = normalizeModelName(model.name); // 名称键
220
+ const normalizedId = normalizeModelName(model.id); // id 键
221
+ const row = docRows.get(normalizedName) ?? docRows.get(normalizedId); // 文档行
222
+ const previous = previousById.get(model.id); // 上次记录
223
+ const quotas = {}; // 三档配额
224
+ const requests5h = {}; // 三档 5h 请求
225
+ let matchedDocs = false; // 是否命中文档
226
+ let matchedRequests = false; // 是否命中请求表
227
+ for (const plan of plansForMerge) { // 每个套餐
228
+ const targetName = normalizeModelName(row?.name ?? model.name); // 文档中的目标名称
229
+ const planRow = plan.rows.find((candidate) => normalizeModelName(candidate.name) === targetName); // 套餐内匹配
230
+ if (planRow?.monthlyLimit !== undefined) matchedDocs = true; // 命中文档
231
+ quotas[plan.key] = planRow?.monthlyLimit ?? previous?.quotas?.[plan.key] ?? null; // 文档优先,其次上次
232
+ const requestRow = (plan.requests ?? []).find((candidate) => { const key = normalizeModelName(candidate.name); return key === normalizedName || key === normalizedId; }); // 套餐内请求匹配
233
+ if (requestRow?.fiveHour !== undefined && requestRow.fiveHour !== null) matchedRequests = true; // 命中请求表
234
+ requests5h[plan.key] = requestRow?.fiveHour ?? previous?.requests5h?.[plan.key] ?? null; // 请求优先,其次上次
235
+ } // 结束套餐循环
236
+ return { // 合并后的模型
237
+ ...model, // API 字段
238
+ input: model.input ?? row?.input ?? null, // 价格回退文档
239
+ output: model.output ?? row?.output ?? null, // 输出价回退文档
240
+ cacheRead: model.cacheRead ?? row?.cacheRead ?? null, // 缓存读回退
241
+ cacheWrite: model.cacheWrite ?? row?.cacheWrite ?? null, // 缓存写回退
242
+ pricingSource: model.pricingSource ?? (row ? 'docs' : null), // 价格来源
243
+ quotas, // 三档配额
244
+ requests5h, // 三档 5h 请求
245
+ quotaSource: matchedDocs ? 'docs' : previous ? 'previous' : null, // 配额来源
246
+ requestSource: matchedRequests ? 'docs' : previous ? 'previous' : null, // 请求来源
247
+ }; // 结束合并
248
+ }); // 结束模型映射
249
+ } // 结束数据合并
250
+
251
+ function comparePrice(changes, field, from, to) { // 比较价格字段
252
+ if (typeof from === 'number' && typeof to === 'number' && from !== to) { // 两边都有值且不同
253
+ changes.push({ kind: 'price', field, from, to }); // 记录价格变动
254
+ } // 结束比较
255
+ } // 结束价格比较
256
+
257
+ export function diffSnapshots(previous, next) { // 对比两次快照
258
+ const nextById = new Map(next.models.map((model) => [model.id, model])); // 本次模型
259
+ const previousById = new Map((previous?.models ?? []).map((model) => [model.id, model])); // 上次模型
260
+ if (!previous) return { baseline: true, added: [], removed: [], changed: [], counts: { added: 0, removed: 0, changed: 0, total: next.models.length } }; // 首次建立基线
261
+ const added = next.models.filter((model) => !previousById.has(model.id)).map((model) => ({ id: model.id, name: model.name })); // 新增模型
262
+ const removed = (previous.models ?? []).filter((model) => !nextById.has(model.id)).map((model) => ({ id: model.id, name: model.name })); // 移除模型
263
+ const changed = []; // 变动模型
264
+ for (const model of next.models) { // 逐模型比较
265
+ const before = previousById.get(model.id); // 上次记录
266
+ if (!before) continue; // 新增已单独处理
267
+ const changes = []; // 字段变动
268
+ comparePrice(changes, 'input', before.input, model.input); // 输入价
269
+ comparePrice(changes, 'output', before.output, model.output); // 输出价
270
+ comparePrice(changes, 'cacheRead', before.cacheRead, model.cacheRead); // 缓存读
271
+ comparePrice(changes, 'cacheWrite', before.cacheWrite, model.cacheWrite); // 缓存写
272
+ if (typeof before.costDigest === 'string' && typeof model.costDigest === 'string' && before.costDigest !== model.costDigest && !changes.some((change) => change.kind === 'price')) { // 计费规则变化
273
+ changes.push({ kind: 'price', field: 'cost', from: null, to: null }); // 记录规则变化
274
+ } // 结束规则比较
275
+ for (const plan of DEFAULT_PLANS) { // 三档配额
276
+ const from = before.quotas?.[plan.key] ?? null; // 上次配额
277
+ const to = model.quotas?.[plan.key] ?? null; // 本次配额
278
+ if (from !== to) changes.push({ kind: 'quota', field: plan.key, from, to }); // 记录配额变动
279
+ } // 结束配额比较
280
+ for (const plan of DEFAULT_PLANS) { // 三档 5h 请求
281
+ const from = before.requests5h?.[plan.key] ?? null; // 上次请求
282
+ const to = model.requests5h?.[plan.key] ?? null; // 本次请求
283
+ if (from !== null && to !== null && from !== to) changes.push({ kind: 'request', field: plan.key, from, to }); // 记录请求变动
284
+ } // 结束请求比较
285
+ if (changes.length > 0) changed.push({ id: model.id, name: model.name, changes }); // 保存变动
286
+ } // 结束模型比较
287
+ return { baseline: false, added, removed, changed, counts: { added: added.length, removed: removed.length, changed: changed.length, total: next.models.length } }; // 返回差异
288
+ } // 结束快照对比
289
+
290
+ export function makeSnapshot(models, docs, diff, warnings, now = new Date()) { // 组装快照
291
+ return { // 快照结构
292
+ version: SNAPSHOT_VERSION, // 格式版本
293
+ checkedAt: now.toISOString(), // 检查时间
294
+ source: { models: MODELS_URL, docs: docs?.url ?? DOCS_URL, plans: (docs?.plans ?? []).map((plan) => plan.label) }, // 数据来源
295
+ baseline: diff.baseline, // 是否基线
296
+ counts: diff.counts, // 数量摘要
297
+ diff: { added: diff.added, removed: diff.removed, changed: diff.changed }, // 差异详情
298
+ models, // 完整模型快照
299
+ warnings, // 警告
300
+ }; // 结束快照
301
+ } // 结束快照组装
302
+
303
+ export async function listSnapshotFiles(dir = snapshotDir()) { // 列出快照文件
304
+ try { // 读取目录
305
+ const entries = await fs.readdir(dir); // 文件列表
306
+ return entries.filter((name) => /^snapshot-.*\.json$/.test(name)).sort().reverse(); // 按时间倒序
307
+ } catch (error) { // 目录不存在
308
+ if (error?.code === 'ENOENT') return []; // 视为空
309
+ throw error; // 其他错误继续抛出
310
+ } // 结束异常
311
+ } // 结束列目录
312
+
313
+ export async function readSnapshots(dir = snapshotDir(), limit = MAX_SNAPSHOTS) { // 读取最近快照
314
+ const files = await listSnapshotFiles(dir); // 文件列表
315
+ const snapshots = []; // 结果
316
+ for (const file of files.slice(0, Math.max(limit, 1))) { // 限制数量
317
+ try { // 读取单个文件
318
+ const raw = await fs.readFile(path.join(dir, file), 'utf8'); // 读取文本
319
+ const parsed = JSON.parse(raw); // 解析 JSON
320
+ if (parsed && parsed.version === SNAPSHOT_VERSION && Array.isArray(parsed.models)) { // 校验版本
321
+ snapshots.push({ file, ...parsed }); // 保存
322
+ } // 结束校验
323
+ } catch (error) { // 损坏文件
324
+ /* 跳过损坏文件 */ // 不阻断历史
325
+ } // 结束异常
326
+ } // 结束循环
327
+ return snapshots; // 返回快照
328
+ } // 结束读取
329
+
330
+ export async function pruneSnapshots(dir = snapshotDir(), keep = MAX_SNAPSHOTS) { // 修剪旧快照
331
+ const files = await listSnapshotFiles(dir); // 文件列表
332
+ for (const file of files.slice(keep)) { // 超出保留数
333
+ await fs.rm(path.join(dir, file), { force: true }); // 删除
334
+ } // 结束循环
335
+ } // 结束修剪
336
+
337
+ export async function writeSnapshot(snapshot, dir = snapshotDir()) { // 原子写入快照
338
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 }); // 创建目录
339
+ const stamp = snapshot.checkedAt.replace(/[:.]/g, '-'); // 文件名安全时间戳
340
+ const file = `snapshot-${stamp}.json`; // 目标文件名
341
+ const temp = path.join(dir, `.${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`); // 临时文件
342
+ try { // 写入临时文件
343
+ await fs.writeFile(temp, JSON.stringify(snapshot, null, 2), { mode: 0o600 }); // 写入内容
344
+ await fs.rename(temp, path.join(dir, file)); // 同目录原子替换
345
+ } catch (error) { // 写入失败
346
+ await fs.rm(temp, { force: true }); // 清理临时文件
347
+ throw error; // 继续抛出
348
+ } // 结束异常
349
+ await pruneSnapshots(dir); // 修剪旧文件
350
+ return file; // 返回文件名
351
+ } // 结束写入
352
+
353
+ export async function readHistory(limit = 20, dir = snapshotDir()) { // 读取历史差异
354
+ const clamped = Math.min(Math.max(Math.trunc(limit) || 20, 1), MAX_SNAPSHOTS); // 限制数量
355
+ const snapshots = await readSnapshots(dir, clamped); // 最近快照
356
+ return snapshots.map((snapshot) => ({ // 映射展示字段
357
+ checkedAt: snapshot.checkedAt, // 检查时间
358
+ baseline: snapshot.baseline === true, // 是否基线
359
+ counts: snapshot.counts ?? { added: 0, removed: 0, changed: 0, total: 0 }, // 数量摘要
360
+ added: snapshot.diff?.added ?? [], // 新增
361
+ removed: snapshot.diff?.removed ?? [], // 移除
362
+ changed: snapshot.diff?.changed ?? [], // 变动
363
+ warnings: snapshot.warnings ?? [], // 警告
364
+ })); // 结束映射
365
+ } // 结束历史读取
366
+
367
+ export async function readStatus(dir = snapshotDir()) { // 读取最近一次状态
368
+ const snapshots = await readSnapshots(dir, 1); // 最近快照
369
+ const snapshot = snapshots[0] ?? null; // 第一条
370
+ if (!snapshot) return null; // 没有快照
371
+ return { checkedAt: snapshot.checkedAt, baseline: snapshot.baseline === true, counts: snapshot.counts ?? { added: 0, removed: 0, changed: 0, total: 0 }, warnings: snapshot.warnings ?? [], models: snapshot.models ?? [] }; // 返回状态
372
+ } // 结束状态读取
373
+
374
+ export async function performCheck(options = {}) { // 执行一次检查
375
+ const dir = options.dir ?? snapshotDir(); // 快照目录
376
+ const now = options.now instanceof Date ? options.now : new Date(); // 检查时间
377
+ const previousSnapshots = await readSnapshots(dir, 1); // 最近一次快照
378
+ const previous = previousSnapshots[0] ?? null; // 上次快照
379
+ const warnings = []; // 警告
380
+ const apiModels = await fetchModels(options.apiKey ?? '', options.signal); // 拉取实时模型
381
+ let docs = null; // 文档结果
382
+ try { // 文档失败不阻断
383
+ docs = await fetchPricingDocs(options.signal); // 抓取文档
384
+ warnings.push(...(docs.warnings ?? [])); // 收集文档提示
385
+ } catch (error) { // 文档抓取失败
386
+ warnings.push(`定价文档抓取失败,配额与 5h 请求沿用上次快照:${error instanceof Error ? error.message : String(error)}`); // 记录提示
387
+ } // 结束文档容错
388
+ const merged = mergeModelData(apiModels, docs, previous?.models ?? []); // 合并数据
389
+ if (docs && docs.plans.length > 0 && merged.every((model) => Object.values(model.quotas ?? {}).every((value) => value === null))) { // 配额全部未命中
390
+ warnings.push('文档已抓取,但没有匹配到任何模型的配额'); // 匹配失败提示
391
+ } // 结束匹配检查
392
+ if (docs && docs.plans.length > 0 && merged.every((model) => Object.values(model.requests5h ?? {}).every((value) => value === null))) { // 请求全部未命中
393
+ warnings.push('文档已抓取,但没有匹配到任何模型的 5h 请求估算'); // 请求匹配失败提示
394
+ } // 结束请求检查
395
+ const provisional = makeSnapshot(merged, docs, { baseline: false, added: [], removed: [], changed: [], counts: { added: 0, removed: 0, changed: 0, total: merged.length } }, warnings, now); // 临时快照
396
+ const diff = diffSnapshots(previous, provisional); // 计算差异
397
+ const snapshot = { ...provisional, baseline: diff.baseline, counts: diff.counts, diff: { added: diff.added, removed: diff.removed, changed: diff.changed } }; // 最终快照
398
+ const file = await writeSnapshot(snapshot, dir); // 原子写入
399
+ return { ok: true, checkedAt: snapshot.checkedAt, baseline: diff.baseline, counts: diff.counts, added: diff.added, removed: diff.removed, changed: diff.changed, warnings: snapshot.warnings, models: snapshot.models, file, plans: (docs?.plans ?? []).map((plan) => plan.label) }; // 返回检查结果与当前模型
400
+ } // 结束检查
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "dsh-dfy-pet",
3
+ "version": "2.6.1",
4
+ "description": "A chibi DeepSeek whale-girl desktop pet with right-click AI relay provider configuration and an OpenCode Go model/pricing/quota monitor.",
5
+ "license": "Apache-2.0",
6
+ "keywords": [
7
+ "deepseek-harness",
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "desktop-pet",
11
+ "opencode-go",
12
+ "client-plugin"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/huishanjiang/dsh-DFY-pet.git"
17
+ },
18
+ "homepage": "https://github.com/huishanjiang/dsh-DFY-pet#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/huishanjiang/dsh-DFY-pet/issues"
21
+ },
22
+ "engines": {
23
+ "node": ">=18",
24
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
25
+ },
26
+ "type": "module",
27
+ "main": "./index.js",
28
+ "exports": {
29
+ ".": "./index.js",
30
+ "./client": "./client.js",
31
+ "./locale/*.json": "./locale/*.json",
32
+ "./icon": "./icon.png",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "index.js",
37
+ "monitor.js",
38
+ "client.js",
39
+ "cordis.patch.yml",
40
+ "locale/*.json",
41
+ "icon.png",
42
+ "assets/pet-sheet.webp",
43
+ "LICENSE",
44
+ "NOTICE.md",
45
+ "README.md"
46
+ ],
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ },
51
+ "client": {
52
+ "platform": "web",
53
+ "immediately": true,
54
+ "inject": [
55
+ "@deepseek-ai/dsh-client-locale"
56
+ ]
57
+ }
58
+ },
59
+ "peerDependencies": {
60
+ "react": "^18.2.0",
61
+ "@deepseek-ai/dsh-credentials": ">=0.2.1-rc.2 <0.3.0"
62
+ }
63
+ }