opencode-visual-cache 1.6.2 → 1.7.0-beta.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 (62) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +247 -247
  3. package/README_EN.md +247 -247
  4. package/dist/_version.d.ts +1 -1
  5. package/dist/_version.js +1 -1
  6. package/dist/core/color.d.ts +37 -0
  7. package/dist/core/color.js +108 -0
  8. package/dist/core/currency.d.ts +17 -0
  9. package/dist/core/currency.js +43 -0
  10. package/dist/core/estimate.d.ts +1 -0
  11. package/dist/core/estimate.js +40 -0
  12. package/dist/core/format.d.ts +15 -0
  13. package/dist/core/format.js +90 -0
  14. package/dist/core/index.d.ts +5 -0
  15. package/dist/core/index.js +5 -0
  16. package/dist/core/types.d.ts +17 -0
  17. package/dist/core/types.js +1 -0
  18. package/dist/index.js +12 -818
  19. package/dist/panel/TokenCachePanel.d.ts +10 -0
  20. package/dist/panel/TokenCachePanel.js +549 -0
  21. package/dist/panel/panel-api.d.ts +119 -0
  22. package/dist/panel/panel-api.js +1 -0
  23. package/dist/tui.js +223 -209
  24. package/dist/v2/commands.d.ts +10 -0
  25. package/dist/v2/commands.js +490 -0
  26. package/dist/v2/data.d.ts +27 -0
  27. package/dist/v2/data.js +103 -0
  28. package/dist/v2/index.d.ts +4 -0
  29. package/dist/v2/index.js +163 -0
  30. package/dist/v2/sidebar.d.ts +6 -0
  31. package/dist/v2/sidebar.js +36 -0
  32. package/dist/v2/status.d.ts +14 -0
  33. package/dist/v2/status.js +83 -0
  34. package/dist/v2/theme.d.ts +8 -0
  35. package/dist/v2/theme.js +16 -0
  36. package/dist/v2/types.d.ts +219 -0
  37. package/dist/v2/types.js +6 -0
  38. package/dist/v2/v2-panel-api.d.ts +8 -0
  39. package/dist/v2/v2-panel-api.js +176 -0
  40. package/dist/v2.js +2941 -0
  41. package/package.json +70 -67
  42. package/src/_version.ts +1 -1
  43. package/src/balance-providers.ts +153 -153
  44. package/src/core/color.ts +108 -0
  45. package/src/core/currency.ts +46 -0
  46. package/src/core/estimate.ts +36 -0
  47. package/src/core/format.ts +81 -0
  48. package/src/core/index.ts +5 -0
  49. package/src/core/types.ts +17 -0
  50. package/src/i18n.ts +380 -380
  51. package/src/index.tsx +1035 -2120
  52. package/src/panel/TokenCachePanel.tsx +776 -0
  53. package/src/panel/panel-api.ts +104 -0
  54. package/src/server.ts +10 -10
  55. package/src/v2/commands.ts +471 -0
  56. package/src/v2/data.ts +112 -0
  57. package/src/v2/index.tsx +184 -0
  58. package/src/v2/sidebar.tsx +69 -0
  59. package/src/v2/status.tsx +96 -0
  60. package/src/v2/theme.ts +19 -0
  61. package/src/v2/types.ts +159 -0
  62. package/src/v2/v2-panel-api.ts +177 -0
package/package.json CHANGED
@@ -1,67 +1,70 @@
1
- {
2
- "name": "opencode-visual-cache",
3
- "version": "1.6.2",
4
- "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
- "type": "module",
6
- "types": "dist/index.d.ts",
7
- "exports": {
8
- "./server": {
9
- "import": "./dist/server.js",
10
- "types": "./dist/server.d.ts"
11
- },
12
- "./tui": {
13
- "import": "./dist/tui.js",
14
- "config": {
15
- "enabled": true
16
- }
17
- }
18
- },
19
- "files": [
20
- "dist",
21
- "src",
22
- "install.mjs",
23
- "README.md",
24
- "README_EN.md"
25
- ],
26
- "bin": {
27
- "opencode-visual-cache": "install.mjs"
28
- },
29
- "scripts": {
30
- "build": "tsc && node build.tui.mjs",
31
- "build:tui": "node build.tui.mjs",
32
- "typecheck": "tsc --noEmit",
33
- "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"",
34
- "prepublishOnly": "tsc"
35
- },
36
- "keywords": [
37
- "opencode",
38
- "opencode-plugin",
39
- "tui",
40
- "token",
41
- "cache-hit-rate",
42
- "cost-tracking"
43
- ],
44
- "repository": {
45
- "type": "git",
46
- "url": "git+https://github.com/Hotakus/opencode-visual-cache.git"
47
- },
48
- "license": "MIT",
49
- "peerDependencies": {
50
- "@opencode-ai/plugin": ">=1.14.0",
51
- "@opencode-ai/sdk": ">=1.14.0",
52
- "@opentui/core": ">=0.2.0",
53
- "@opentui/solid": ">=0.2.0",
54
- "solid-js": ">=1.9.0"
55
- },
56
- "devDependencies": {
57
- "@opencode-ai/plugin": "^1.18.16",
58
- "@opencode-ai/sdk": "^1.18.16",
59
- "@opentui/core": "^0.5.1",
60
- "@opentui/solid": "^0.5.1",
61
- "@opentui/keymap": "^0.5.1",
62
- "esbuild": "^0.25.0",
63
- "esbuild-plugin-solid": "^0.5.0",
64
- "tsx": "^4.22.3",
65
- "typescript": "^5.8.0"
66
- }
67
- }
1
+ {
2
+ "name": "opencode-visual-cache",
3
+ "version": "1.7.0-beta.0",
4
+ "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
+ "type": "module",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ "./server": {
9
+ "import": "./dist/server.js",
10
+ "types": "./dist/server.d.ts"
11
+ },
12
+ "./tui": {
13
+ "import": "./dist/tui.js",
14
+ "config": {
15
+ "enabled": true
16
+ }
17
+ },
18
+ "./v2": {
19
+ "import": "./dist/v2.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "install.mjs",
26
+ "README.md",
27
+ "README_EN.md"
28
+ ],
29
+ "bin": {
30
+ "opencode-visual-cache": "install.mjs"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc && node build.tui.mjs",
34
+ "build:tui": "node build.tui.mjs",
35
+ "typecheck": "tsc --noEmit",
36
+ "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"",
37
+ "prepublishOnly": "tsc"
38
+ },
39
+ "keywords": [
40
+ "opencode",
41
+ "opencode-plugin",
42
+ "tui",
43
+ "token",
44
+ "cache-hit-rate",
45
+ "cost-tracking"
46
+ ],
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/Hotakus/opencode-visual-cache.git"
50
+ },
51
+ "license": "MIT",
52
+ "peerDependencies": {
53
+ "@opencode-ai/plugin": ">=1.14.0",
54
+ "@opencode-ai/sdk": ">=1.14.0",
55
+ "@opentui/core": ">=0.2.0",
56
+ "@opentui/solid": ">=0.2.0",
57
+ "solid-js": ">=1.9.0"
58
+ },
59
+ "devDependencies": {
60
+ "@opencode-ai/plugin": "^1.18.16",
61
+ "@opencode-ai/sdk": "^1.18.16",
62
+ "@opentui/core": "^0.5.1",
63
+ "@opentui/solid": "^0.5.1",
64
+ "@opentui/keymap": "^0.5.1",
65
+ "esbuild": "^0.25.0",
66
+ "esbuild-plugin-solid": "^0.5.0",
67
+ "tsx": "^4.22.3",
68
+ "typescript": "^5.8.0"
69
+ }
70
+ }
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.6.2";
2
+ export const PLUGIN_VERSION="1.7.0-beta.0";
@@ -1,153 +1,153 @@
1
- // ---------------------------------------------------------------------------
2
- // Balance providers — pluggable account-balance query adapters.
3
- // ---------------------------------------------------------------------------
4
-
5
- /** 归一化后的余额条目——显示层与具体 provider 解耦。 */
6
- export interface BalanceEntry {
7
- currency: string // 原生币种(CNY/USD…),复用现有汇率换算
8
- total: string // 余额字符串
9
- }
10
-
11
- /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */
12
- export class BalanceError extends Error {}
13
-
14
- /** 可插拔的余额 provider 适配器。 */
15
- export interface BalanceProvider {
16
- id: string // 唯一标识,同时用作 KV key 命名空间
17
- name: string // 显示名(专有名词,无需 i18n)
18
- keyPlaceholder?: string // key 输入框占位(如 "sk-...")
19
- fetchBalance(apiKey: string, signal?: AbortSignal): Promise<BalanceEntry[]>
20
- }
21
-
22
- const siliconflowProvider: BalanceProvider = {
23
- id: "siliconflow",
24
- name: "SiliconFlow",
25
- keyPlaceholder: "sk-...",
26
- async fetchBalance(apiKey, signal) {
27
- // 国内站 api.siliconflow.cn(CNY);国际站为 api.siliconflow.com(USD)
28
- const res = await fetch("https://api.siliconflow.cn/v1/user/info", {
29
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
30
- signal,
31
- })
32
- if (!res.ok) {
33
- if (res.status === 401) throw new BalanceError("401")
34
- if (res.status === 403) throw new BalanceError("403")
35
- throw new BalanceError(String(res.status))
36
- }
37
- const json = await res.json() as {
38
- status?: boolean
39
- data?: {
40
- balance?: string | number
41
- chargeBalance?: string | number
42
- totalBalance?: string | number
43
- }
44
- }
45
- // totalBalance 为总余额(含充值+赠送),缺失时回退 balance
46
- const total = json.data?.totalBalance ?? json.data?.balance
47
- if (typeof total === "undefined" || total === null) throw new BalanceError("EMPTY")
48
- return [{ currency: "CNY", total: String(total) }]
49
- },
50
- }
51
-
52
- const deepseekProvider: BalanceProvider = {
53
- id: "deepseek",
54
- name: "DeepSeek",
55
- keyPlaceholder: "sk-...",
56
- async fetchBalance(apiKey, signal) {
57
- const res = await fetch("https://api.deepseek.com/user/balance", {
58
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
59
- signal,
60
- })
61
- if (!res.ok) {
62
- if (res.status === 401) throw new BalanceError("401")
63
- if (res.status === 402 || res.status === 403) throw new BalanceError("403")
64
- throw new BalanceError(String(res.status))
65
- }
66
- const json = await res.json() as {
67
- is_available?: boolean
68
- balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[]
69
- }
70
- const infos = json.balance_infos ?? []
71
- if (infos.length === 0) throw new BalanceError("EMPTY")
72
- return infos.map((info) => ({
73
- currency: info.currency ?? "CNY",
74
- total: info.total_balance ?? "0",
75
- }))
76
- },
77
- }
78
-
79
- const openrouterProvider: BalanceProvider = {
80
- id: "openrouter",
81
- name: "OpenRouter",
82
- keyPlaceholder: "sk-or-...",
83
- async fetchBalance(apiKey, signal) {
84
- // 官方文档标注需 Management key,实测普通 API key 亦可查询账户余额
85
- const res = await fetch("https://openrouter.ai/api/v1/credits", {
86
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
87
- signal,
88
- })
89
- if (!res.ok) {
90
- if (res.status === 401 || res.status === 403) throw new BalanceError("403")
91
- throw new BalanceError(String(res.status))
92
- }
93
- const json = await res.json() as {
94
- data?: { total_credits?: number; total_usage?: number }
95
- }
96
- const credits = json.data?.total_credits
97
- const usage = json.data?.total_usage
98
- if (typeof credits !== "number" || typeof usage !== "number") throw new BalanceError("EMPTY")
99
- // 剩余额度 = 充值总额 - 已用
100
- return [{ currency: "USD", total: (credits - usage).toFixed(2) }]
101
- },
102
- }
103
-
104
- const moonshotProvider: BalanceProvider = {
105
- id: "moonshot",
106
- name: "Moonshot",
107
- keyPlaceholder: "sk-...",
108
- async fetchBalance(apiKey, signal) {
109
- // 国内站 api.moonshot.cn(CNY);国际站 api.moonshot.ai(USD)
110
- const res = await fetch("https://api.moonshot.cn/v1/users/me/balance", {
111
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
112
- signal,
113
- })
114
- if (!res.ok) {
115
- if (res.status === 401) throw new BalanceError("401")
116
- if (res.status === 403) throw new BalanceError("403")
117
- throw new BalanceError(String(res.status))
118
- }
119
- const json = await res.json() as {
120
- data?: { available_balance?: string | number }
121
- }
122
- const balance = json.data?.available_balance
123
- if (typeof balance === "undefined" || balance === null) throw new BalanceError("EMPTY")
124
- return [{ currency: "CNY", total: String(balance) }]
125
- },
126
- }
127
-
128
- /** 已注册的 provider 列表(按需追加新适配器)。 */
129
- export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider]
130
-
131
- /** 按 id 取 provider;未知 id 回退到第一个。 */
132
- export function getBalanceProvider(id: string): BalanceProvider {
133
- return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider
134
- }
135
-
136
- /**
137
- * 按 OpenCode providerID 匹配余额 provider。
138
- * 先精确匹配,再按前缀匹配(如 moonshotai-cn → moonshot);未命中返回 undefined。
139
- * 比较不区分大小写,容忍 providerID 的大小写变体。
140
- */
141
- export function matchBalanceProvider(providerId: string): BalanceProvider | undefined {
142
- const id = providerId.toLowerCase()
143
- const exact = balanceProviders.find((p) => p.id.toLowerCase() === id)
144
- if (exact) return exact
145
- return balanceProviders.find((p) => id.startsWith(p.id.toLowerCase()))
146
- }
147
-
148
- /** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */
149
- export function maskKey(k: string): string {
150
- if (!k) return ""
151
- if (k.length <= 10) return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 5))
152
- return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 10)) + k.slice(-5)
153
- }
1
+ // ---------------------------------------------------------------------------
2
+ // Balance providers — pluggable account-balance query adapters.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /** 归一化后的余额条目——显示层与具体 provider 解耦。 */
6
+ export interface BalanceEntry {
7
+ currency: string // 原生币种(CNY/USD…),复用现有汇率换算
8
+ total: string // 余额字符串
9
+ }
10
+
11
+ /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */
12
+ export class BalanceError extends Error {}
13
+
14
+ /** 可插拔的余额 provider 适配器。 */
15
+ export interface BalanceProvider {
16
+ id: string // 唯一标识,同时用作 KV key 命名空间
17
+ name: string // 显示名(专有名词,无需 i18n)
18
+ keyPlaceholder?: string // key 输入框占位(如 "sk-...")
19
+ fetchBalance(apiKey: string, signal?: AbortSignal): Promise<BalanceEntry[]>
20
+ }
21
+
22
+ const siliconflowProvider: BalanceProvider = {
23
+ id: "siliconflow",
24
+ name: "SiliconFlow",
25
+ keyPlaceholder: "sk-...",
26
+ async fetchBalance(apiKey, signal) {
27
+ // 国内站 api.siliconflow.cn(CNY);国际站为 api.siliconflow.com(USD)
28
+ const res = await fetch("https://api.siliconflow.cn/v1/user/info", {
29
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
30
+ signal,
31
+ })
32
+ if (!res.ok) {
33
+ if (res.status === 401) throw new BalanceError("401")
34
+ if (res.status === 403) throw new BalanceError("403")
35
+ throw new BalanceError(String(res.status))
36
+ }
37
+ const json = await res.json() as {
38
+ status?: boolean
39
+ data?: {
40
+ balance?: string | number
41
+ chargeBalance?: string | number
42
+ totalBalance?: string | number
43
+ }
44
+ }
45
+ // totalBalance 为总余额(含充值+赠送),缺失时回退 balance
46
+ const total = json.data?.totalBalance ?? json.data?.balance
47
+ if (typeof total === "undefined" || total === null) throw new BalanceError("EMPTY")
48
+ return [{ currency: "CNY", total: String(total) }]
49
+ },
50
+ }
51
+
52
+ const deepseekProvider: BalanceProvider = {
53
+ id: "deepseek",
54
+ name: "DeepSeek",
55
+ keyPlaceholder: "sk-...",
56
+ async fetchBalance(apiKey, signal) {
57
+ const res = await fetch("https://api.deepseek.com/user/balance", {
58
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
59
+ signal,
60
+ })
61
+ if (!res.ok) {
62
+ if (res.status === 401) throw new BalanceError("401")
63
+ if (res.status === 402 || res.status === 403) throw new BalanceError("403")
64
+ throw new BalanceError(String(res.status))
65
+ }
66
+ const json = await res.json() as {
67
+ is_available?: boolean
68
+ balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[]
69
+ }
70
+ const infos = json.balance_infos ?? []
71
+ if (infos.length === 0) throw new BalanceError("EMPTY")
72
+ return infos.map((info) => ({
73
+ currency: info.currency ?? "CNY",
74
+ total: info.total_balance ?? "0",
75
+ }))
76
+ },
77
+ }
78
+
79
+ const openrouterProvider: BalanceProvider = {
80
+ id: "openrouter",
81
+ name: "OpenRouter",
82
+ keyPlaceholder: "sk-or-...",
83
+ async fetchBalance(apiKey, signal) {
84
+ // 官方文档标注需 Management key,实测普通 API key 亦可查询账户余额
85
+ const res = await fetch("https://openrouter.ai/api/v1/credits", {
86
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
87
+ signal,
88
+ })
89
+ if (!res.ok) {
90
+ if (res.status === 401 || res.status === 403) throw new BalanceError("403")
91
+ throw new BalanceError(String(res.status))
92
+ }
93
+ const json = await res.json() as {
94
+ data?: { total_credits?: number; total_usage?: number }
95
+ }
96
+ const credits = json.data?.total_credits
97
+ const usage = json.data?.total_usage
98
+ if (typeof credits !== "number" || typeof usage !== "number") throw new BalanceError("EMPTY")
99
+ // 剩余额度 = 充值总额 - 已用
100
+ return [{ currency: "USD", total: (credits - usage).toFixed(2) }]
101
+ },
102
+ }
103
+
104
+ const moonshotProvider: BalanceProvider = {
105
+ id: "moonshot",
106
+ name: "Moonshot",
107
+ keyPlaceholder: "sk-...",
108
+ async fetchBalance(apiKey, signal) {
109
+ // 国内站 api.moonshot.cn(CNY);国际站 api.moonshot.ai(USD)
110
+ const res = await fetch("https://api.moonshot.cn/v1/users/me/balance", {
111
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
112
+ signal,
113
+ })
114
+ if (!res.ok) {
115
+ if (res.status === 401) throw new BalanceError("401")
116
+ if (res.status === 403) throw new BalanceError("403")
117
+ throw new BalanceError(String(res.status))
118
+ }
119
+ const json = await res.json() as {
120
+ data?: { available_balance?: string | number }
121
+ }
122
+ const balance = json.data?.available_balance
123
+ if (typeof balance === "undefined" || balance === null) throw new BalanceError("EMPTY")
124
+ return [{ currency: "CNY", total: String(balance) }]
125
+ },
126
+ }
127
+
128
+ /** 已注册的 provider 列表(按需追加新适配器)。 */
129
+ export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider]
130
+
131
+ /** 按 id 取 provider;未知 id 回退到第一个。 */
132
+ export function getBalanceProvider(id: string): BalanceProvider {
133
+ return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider
134
+ }
135
+
136
+ /**
137
+ * 按 OpenCode providerID 匹配余额 provider。
138
+ * 先精确匹配,再按前缀匹配(如 moonshotai-cn → moonshot);未命中返回 undefined。
139
+ * 比较不区分大小写,容忍 providerID 的大小写变体。
140
+ */
141
+ export function matchBalanceProvider(providerId: string): BalanceProvider | undefined {
142
+ const id = providerId.toLowerCase()
143
+ const exact = balanceProviders.find((p) => p.id.toLowerCase() === id)
144
+ if (exact) return exact
145
+ return balanceProviders.find((p) => id.startsWith(p.id.toLowerCase()))
146
+ }
147
+
148
+ /** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */
149
+ export function maskKey(k: string): string {
150
+ if (!k) return ""
151
+ if (k.length <= 10) return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 5))
152
+ return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 10)) + k.slice(-5)
153
+ }
@@ -0,0 +1,108 @@
1
+ /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
2
+ export function rgb(raw: unknown): { r: number; g: number; b: number } | null {
3
+ if (typeof raw === "string" && raw.startsWith("#")) {
4
+ const h = raw.slice(1)
5
+ return {
6
+ r: parseInt(h.slice(0, 2), 16),
7
+ g: parseInt(h.slice(2, 4), 16),
8
+ b: parseInt(h.slice(4, 6), 16),
9
+ }
10
+ }
11
+ if (raw && typeof raw === "object") {
12
+ const o = raw as Record<string, unknown>
13
+ if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
14
+ // RGBA channels may be 0-1 floats; detect and upscale.
15
+ const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
16
+ return {
17
+ r: Math.round(o.r * scale),
18
+ g: Math.round(o.g * scale),
19
+ b: Math.round(o.b * scale),
20
+ }
21
+ }
22
+ }
23
+ return null
24
+ }
25
+
26
+ /** HSL saturation of an RGB color (0–1). */
27
+ export function saturation(r: number, g: number, b: number): number {
28
+ const max = Math.max(r, g, b) / 255
29
+ const min = Math.min(r, g, b) / 255
30
+ const delta = max - min
31
+ if (delta === 0) return 0
32
+ const L = (max + min) / 2
33
+ return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
34
+ }
35
+
36
+ /**
37
+ * If the colour's saturation exceeds `maxSat`, pull it toward grey
38
+ * until saturation drops to maxSat. Returns a hex string.
39
+ */
40
+ export function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
41
+ const c = rgb(raw)
42
+ if (!c) return fallback
43
+ const sat = saturation(c.r, c.g, c.b)
44
+ if (sat <= maxSat) {
45
+ // already muted — return as hex
46
+ return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
47
+ }
48
+ /**
49
+ * Binary search for the optimal grey-mix ratio α (0…1).
50
+ *
51
+ * 12 iterations → 1/2^12 ≈ 1/4096 resolution. The downstream RGB
52
+ * channels are only 0–255 (8 bit), so 8 iterations (1/256) would
53
+ * technically suffice; 12 is intentionally over-budget — the extra
54
+ * precision costs almost nothing and guarantees the saturation probe
55
+ * converges to within a fraction of an 8‑bit step, eliminating
56
+ * colour banding in edge cases.
57
+ */
58
+ // Bt.601 luma (perceptual brightness used as the grey anchor)
59
+ const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
60
+ let lo = 0, hi = 1
61
+ for (let i = 0; i < 12; i++) {
62
+ const mid = (lo + hi) / 2
63
+ const nr = Math.round(c.r + (luma - c.r) * mid)
64
+ const ng = Math.round(c.g + (luma - c.g) * mid)
65
+ const nb = Math.round(c.b + (luma - c.b) * mid)
66
+ if (saturation(nr, ng, nb) > maxSat) lo = mid
67
+ else hi = mid
68
+ }
69
+ const nr = Math.round(c.r + (luma - c.r) * hi)
70
+ const ng = Math.round(c.g + (luma - c.g) * hi)
71
+ const nb = Math.round(c.b + (luma - c.b) * hi)
72
+ return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
73
+ }
74
+
75
+ /** Darken a hex colour by multiplying each channel by `factor` (0–1). */
76
+ export function dimColor(hex: string, factor = 0.5): string {
77
+ const c = rgb(hex)
78
+ if (!c) return hex
79
+ const r = Math.round(c.r * factor)
80
+ const g = Math.round(c.g * factor)
81
+ const b = Math.round(c.b * factor)
82
+ return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
83
+ }
84
+
85
+ // Morandi fallbacks — used when a theme colour cannot be resolved
86
+ export const FALLBACK = {
87
+ primary: "#8B9DAF",
88
+ text: "#C5C5BB",
89
+ muted: "#7A7A72",
90
+ success: "#9CAF8B",
91
+ warning: "#C5B88D",
92
+ error: "#B08A8A",
93
+ border: "#6B6B63",
94
+ } as const
95
+
96
+ /**
97
+ * Desaturation ceiling for the Morandi-style palette.
98
+ *
99
+ * Morandi colours float around 0.15–0.30 saturation in HSL space.
100
+ * 0.28 sits near the upper end of that range: it strips the aggressive
101
+ * punch from high-saturation themes (Dracula, Solarized …) while
102
+ * preserving enough colour identity that green / orange / red hit-rate
103
+ * coding stays distinguishable.
104
+ *
105
+ * Lower → more grey, harder to tell colours apart.
106
+ * Higher → bright themes bleed through and defeat the muted look.
107
+ */
108
+ export const MAX_SAT = 0.28
@@ -0,0 +1,46 @@
1
+ import type { BalanceEntry } from "../balance-providers"
2
+ import { formatBalanceAmount } from "./format"
3
+
4
+ export const CURRENCIES: Record<string, string> = {
5
+ USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
6
+ }
7
+ /** Approximate USD exchange rates — used as defaults when switching currency.
8
+ * Users can override via /cache-rate. Last updated 2026-05. */
9
+ export const DEFAULT_RATES: Record<string, number> = {
10
+ USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
11
+ }
12
+
13
+ /**
14
+ * 将余额从来源币种换算为目标币种。
15
+ * DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。
16
+ */
17
+ export function convertBalance(target: string, targetRate: number, amount: number, from: string): number {
18
+ if (from === target) return amount
19
+ const fromRate = DEFAULT_RATES[from] ?? 1
20
+ const usd = from === "USD" ? amount : amount / fromRate
21
+ return target === "USD" ? usd : usd * targetRate
22
+ }
23
+
24
+ /** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
25
+ export function balanceSymbol(currency: string): string {
26
+ const sym = CURRENCIES[currency]
27
+ return sym ?? currency + " "
28
+ }
29
+
30
+ /**
31
+ * 将余额列表格式化为单行文本。
32
+ * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
33
+ */
34
+ export function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
35
+ const native = pref ? list.find((x) => x.currency === pref) : undefined
36
+ if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
37
+ const base = list[0]
38
+ const baseAmt = parseFloat(base.total)
39
+ const converted = Number.isFinite(baseAmt)
40
+ ? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
41
+ : baseAmt
42
+ const shown = pref && base.currency !== pref
43
+ ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
44
+ : formatBalanceAmount(base.total)
45
+ return balanceSymbol(pref || base.currency) + shown
46
+ }
@@ -0,0 +1,36 @@
1
+ // ── token estimation ──
2
+ // Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
3
+ // chars per token) work well for natural language but systematically
4
+ // under-count tokens in JSON and source code where every punctuation mark
5
+ // tends to be its own token. Detect these cases and tighten the ratio.
6
+ // See: GPT-4 / Claude tokenizer behaviour with structured text.
7
+
8
+ export function estimateTokens(text: string): number {
9
+ if (!text || text.length === 0) return 0
10
+ let ascii = 0
11
+ let cjk = 0
12
+ for (const c of text) {
13
+ const code = c.codePointAt(0) ?? 0
14
+ if (code >= 0x4E00 && code <= 0x9FFF) cjk++ // CJK Unified
15
+ else if (code >= 0x3040 && code <= 0x30FF) cjk++ // Hiragana/Katakana
16
+ else if (code >= 0xAC00 && code <= 0xD7A3) cjk++ // Hangul
17
+ else if (code >= 0x1100 && code <= 0x11FF) cjk++ // Hangul Jamo
18
+ else if (code >= 0x2E80 && code <= 0x2EFF) cjk++ // CJK Radicals
19
+ else ascii++
20
+ }
21
+
22
+ // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
23
+ // ASCII chars/token for both JSON and source code — close to prose.
24
+ // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
25
+ // payloads, and systematically over-estimated token counts.
26
+ const trimmed = text.trimStart()
27
+ // Strip markdown code-fence prefix so that ```json … is detected as JSON
28
+ const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
29
+ const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
30
+ && /"[^"]+"\s*:/.test(text)
31
+ const codeLike = !jsonLike
32
+ && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
33
+
34
+ const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
35
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
36
+ }