Skip to content

Commit 0bd1cfa

Browse files
committed
Release v2.13.0
feat: full i18n (zh/en), Feishu webhook alerts, alert indicators on monitor cards
1 parent c2b443c commit 0bd1cfa

41 files changed

Lines changed: 2726 additions & 650 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [2.13.0] - 2026-05-11
8+
9+
### Added
10+
- Full i18n support with Chinese/English language switcher (react-i18next)
11+
- Feishu webhook alert notifications for monitor
12+
- Per-target alert enable/disable toggle
13+
- Status change detection: new failure, repeated failure (configurable interval), recovery
14+
- DB-persisted alert state (survives restarts)
15+
- Optional webhook signature verification
16+
- Configurable notification language (en/zh, default en)
17+
- Alert bell indicator on monitor model cards (color-coded by health status)
18+
19+
### Changed
20+
- All hardcoded UI strings replaced with i18n translation keys
21+
- Monitor settings modal now includes alert configuration section (webhook URL, secret, language, reminder interval)
22+
723
## [2.12.1] - 2026-04-28
824

925
### Fixed

backend/src/providers/adapter.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export class DynamicProvider extends BaseLLMProvider {
149149
const inputTokens = data.usage?.prompt_tokens || 0;
150150
const completionTokens = data.usage?.completion_tokens || 0;
151151
const reasoningTokens = data.usage?.completion_tokens_details?.reasoning_tokens || 0;
152+
const cacheReadTokens = data.usage?.prompt_tokens_details?.cached_tokens || 0;
152153

153154
return {
154155
text: data.choices?.[0]?.message?.content || '',
@@ -160,6 +161,7 @@ export class DynamicProvider extends BaseLLMProvider {
160161
firstTokenLatency: 0, // Non-streaming: no TTFT available
161162
estimatedCost: 0,
162163
model: this.modelName,
164+
...(cacheReadTokens > 0 && { cacheReadTokens }),
163165
};
164166
}
165167

@@ -240,6 +242,7 @@ export class DynamicProvider extends BaseLLMProvider {
240242
const inputTokens = usageData?.prompt_tokens || 0;
241243
const completionTokens = usageData?.completion_tokens || 0;
242244
const reasoningTokens = usageData?.completion_tokens_details?.reasoning_tokens || 0;
245+
const cacheReadTokens = usageData?.prompt_tokens_details?.cached_tokens || 0;
243246

244247
return {
245248
text: '',
@@ -250,6 +253,7 @@ export class DynamicProvider extends BaseLLMProvider {
250253
responseTime,
251254
firstTokenLatency,
252255
estimatedCost: 0,
256+
...(cacheReadTokens > 0 && { cacheReadTokens }),
253257
model: this.modelName,
254258
};
255259
}
@@ -291,6 +295,8 @@ export class DynamicProvider extends BaseLLMProvider {
291295
const responseTime = Date.now() - startTime;
292296
const inputTokens = data.usage?.input_tokens || 0;
293297
const outputTokens = data.usage?.output_tokens || 0;
298+
const cacheCreationTokens = data.usage?.cache_creation_input_tokens || 0;
299+
const cacheReadTokens = data.usage?.cache_read_input_tokens || 0;
294300

295301
return {
296302
text: data.content?.[0]?.text || '',
@@ -302,6 +308,8 @@ export class DynamicProvider extends BaseLLMProvider {
302308
firstTokenLatency: 0, // Non-streaming: no TTFT available
303309
estimatedCost: 0,
304310
model: this.modelName,
311+
...(cacheCreationTokens > 0 && { cacheCreationTokens }),
312+
...(cacheReadTokens > 0 && { cacheReadTokens }),
305313
};
306314
}
307315

@@ -342,6 +350,8 @@ export class DynamicProvider extends BaseLLMProvider {
342350
let buffer = '';
343351
let inputTokens = 0;
344352
let outputTokens = 0;
353+
let cacheCreationTokens = 0;
354+
let cacheReadTokens = 0;
345355

346356
while (true) {
347357
const { done, value } = await reader.read();
@@ -362,6 +372,8 @@ export class DynamicProvider extends BaseLLMProvider {
362372
}
363373
if (parsed.type === 'message_start') {
364374
inputTokens = parsed.message?.usage?.input_tokens || 0;
375+
cacheCreationTokens = parsed.message?.usage?.cache_creation_input_tokens || 0;
376+
cacheReadTokens = parsed.message?.usage?.cache_read_input_tokens || 0;
365377
}
366378
if (parsed.type === 'message_delta') {
367379
outputTokens = parsed.usage?.output_tokens || outputTokens;
@@ -388,6 +400,8 @@ export class DynamicProvider extends BaseLLMProvider {
388400
firstTokenLatency: firstTokenTime ? firstTokenTime - startTime : 0,
389401
estimatedCost: 0,
390402
model: this.modelName,
403+
...(cacheCreationTokens > 0 && { cacheCreationTokens }),
404+
...(cacheReadTokens > 0 && { cacheReadTokens }),
391405
};
392406
}
393407

backend/src/routes/monitor.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ router.put('/config', validate(MonitorConfigSchema), (req: Request, res: Respons
4040
const updated: MonitorGlobalConfig = {
4141
defaultIntervalMinutes: interval,
4242
healthThresholds: ht,
43+
alertWebhookUrl: typeof body.alertWebhookUrl === 'string' ? body.alertWebhookUrl : current.alertWebhookUrl || '',
44+
alertReminderMinutes:
45+
typeof body.alertReminderMinutes === 'number'
46+
? Math.max(5, Math.min(1440, body.alertReminderMinutes))
47+
: current.alertReminderMinutes,
48+
alertWebhookSecret:
49+
typeof body.alertWebhookSecret === 'string' ? body.alertWebhookSecret : current.alertWebhookSecret || '',
50+
alertLanguage:
51+
body.alertLanguage === 'zh' || body.alertLanguage === 'en' ? body.alertLanguage : current.alertLanguage || 'en',
4352
};
4453
monitorConfigStore.setConfig(updated);
4554
res.json({ success: true, config: monitorConfigStore.getConfig() });

backend/src/routes/playground.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ async function streamOpenAI(
499499
inputTokens: usageData?.prompt_tokens || 0,
500500
outputTokens: usageData?.completion_tokens || 0,
501501
reasoningTokens: usageData?.completion_tokens_details?.reasoning_tokens || 0,
502+
cacheReadTokens: usageData?.prompt_tokens_details?.cached_tokens || 0,
502503
},
503504
resultOut,
504505
);
@@ -565,6 +566,8 @@ async function streamAnthropic(
565566
let reasoningText = '';
566567
let inputTokens = 0;
567568
let outputTokens = 0;
569+
let cacheCreationTokens = 0;
570+
let cacheReadTokens = 0;
568571

569572
try {
570573
while (true) {
@@ -585,6 +588,8 @@ async function streamAnthropic(
585588

586589
if (parsed.type === 'message_start') {
587590
inputTokens = parsed.message?.usage?.input_tokens || 0;
591+
cacheCreationTokens = parsed.message?.usage?.cache_creation_input_tokens || 0;
592+
cacheReadTokens = parsed.message?.usage?.cache_read_input_tokens || 0;
588593
}
589594
if (parsed.type === 'content_block_delta') {
590595
const deltaType = parsed.delta?.type;
@@ -633,6 +638,8 @@ async function streamAnthropic(
633638
inputTokens,
634639
outputTokens,
635640
reasoningTokens: reasoningText.length > 0 ? Math.ceil(reasoningText.length / 4) : 0,
641+
cacheCreationTokens,
642+
cacheReadTokens,
636643
},
637644
resultOut,
638645
);
@@ -813,16 +820,22 @@ function emitDone(
813820
modelName: string,
814821
fullText: string,
815822
reasoningText: string,
816-
tokens: { inputTokens: number; outputTokens: number; reasoningTokens: number },
823+
tokens: {
824+
inputTokens: number;
825+
outputTokens: number;
826+
reasoningTokens: number;
827+
cacheCreationTokens?: number;
828+
cacheReadTokens?: number;
829+
},
817830
resultOut?: StreamResult,
818831
) {
819832
if (isAborted()) return;
820833
const responseTime = Date.now() - startTime;
821834
const firstTokenLatency = firstTokenTime ? firstTokenTime - startTime : 0;
822-
const { inputTokens, outputTokens, reasoningTokens } = tokens;
835+
const { inputTokens, outputTokens, reasoningTokens, cacheCreationTokens, cacheReadTokens } = tokens;
823836
const tokensPerSecond = outputTokens > 0 ? Math.round((outputTokens / responseTime) * 1000) : 0;
824837

825-
const doneEvent = {
838+
const doneEvent: Record<string, any> = {
826839
type: 'done',
827840
text: fullText,
828841
reasoningText,
@@ -835,6 +848,8 @@ function emitDone(
835848
tokensPerSecond,
836849
model: modelName,
837850
};
851+
if (cacheCreationTokens) doneEvent.cacheCreationTokens = cacheCreationTokens;
852+
if (cacheReadTokens) doneEvent.cacheReadTokens = cacheReadTokens;
838853
sendEvent(doneEvent);
839854

840855
if (resultOut) {
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import crypto from 'crypto';
2+
import { getDb } from './database';
3+
import { monitorConfigStore, MonitorTarget } from './monitorConfigStore';
4+
import { HealthStatus } from './monitorStore';
5+
6+
type AlertType = 'down' | 'reminder' | 'recovery';
7+
8+
interface AlertMetrics {
9+
latencyMs: number;
10+
ttftMs: number;
11+
outputTokens: number;
12+
errorMessage?: string;
13+
}
14+
15+
/** Get the previous health status for a target (skip the just-inserted ping) */
16+
function getPreviousStatus(providerId: string, modelName: string): HealthStatus | null {
17+
const db = getDb();
18+
const row = db
19+
.prepare(
20+
`SELECT health_status FROM monitor_pings
21+
WHERE provider_id = ? AND model_name = ?
22+
ORDER BY checked_at DESC LIMIT 1 OFFSET 1`,
23+
)
24+
.get(providerId, modelName) as { health_status: HealthStatus } | undefined;
25+
return row?.health_status ?? null;
26+
}
27+
28+
/** Determine if an alert should be sent */
29+
function shouldSendAlert(
30+
target: MonitorTarget,
31+
currentStatus: HealthStatus,
32+
reminderMinutes: number,
33+
): { send: boolean; type: AlertType } | null {
34+
const previousStatus = getPreviousStatus(target.providerId, target.modelName);
35+
36+
if (!previousStatus) return null;
37+
38+
const wasDown = previousStatus === 'down';
39+
const isDown = currentStatus === 'down' || currentStatus === 'very_slow';
40+
41+
if (wasDown && !isDown) {
42+
return { send: true, type: 'recovery' };
43+
}
44+
45+
if (!wasDown && isDown) {
46+
return { send: true, type: 'down' };
47+
}
48+
49+
if (isDown) {
50+
const lastAlertAt = target.lastAlertAt;
51+
if (!lastAlertAt) {
52+
return { send: true, type: 'reminder' };
53+
}
54+
const elapsed = Date.now() - new Date(lastAlertAt).getTime();
55+
if (elapsed >= reminderMinutes * 60 * 1000) {
56+
return { send: true, type: 'reminder' };
57+
}
58+
}
59+
60+
return null;
61+
}
62+
63+
/** Generate Feishu webhook signature — appends timestamp & sign as URL params */
64+
function buildSignedUrl(webhookUrl: string, secret: string): string {
65+
const timestamp = Math.floor(Date.now() / 1000).toString();
66+
const stringToSign = `${timestamp}\n${secret}`;
67+
const hmac = crypto.createHmac('sha256', stringToSign);
68+
hmac.update('');
69+
const sign = hmac.digest('base64');
70+
const sep = webhookUrl.includes('?') ? '&' : '?';
71+
return `${webhookUrl}${sep}timestamp=${timestamp}&sign=${encodeURIComponent(sign)}`;
72+
}
73+
74+
/** Localized alert content */
75+
function getAlertContent(lang: 'en' | 'zh', type: AlertType, target: MonitorTarget, metrics: AlertMetrics) {
76+
const isZh = lang === 'zh';
77+
const tps = metrics.latencyMs > 0 ? ((metrics.outputTokens / metrics.latencyMs) * 1000).toFixed(1) : '0';
78+
79+
const colors: Record<AlertType, string> = { down: 'red', reminder: 'orange', recovery: 'green' };
80+
81+
const titles: Record<AlertType, string> = isZh
82+
? { down: '🚨 监控告警:服务异常', reminder: '⚠️ 监控提醒:服务仍异常', recovery: '✅ 监控恢复:服务已恢复' }
83+
: {
84+
down: '🚨 Monitor Alert: Service Down',
85+
reminder: '⚠️ Monitor Reminder: Still Down',
86+
recovery: '✅ Monitor Recovery: Service Restored',
87+
};
88+
89+
const providerLabel = isZh ? '服务商' : 'Provider';
90+
const modelLabel = isZh ? '模型' : 'Model';
91+
const latencyLabel = isZh ? '延迟' : 'Latency';
92+
const errorLabel = isZh ? '错误' : 'Error';
93+
const timeLabel = isZh ? '时间' : 'Time';
94+
95+
const elements = [
96+
{
97+
tag: 'div',
98+
text: {
99+
tag: 'lark_md',
100+
content: `**${providerLabel}:** ${target.providerName}\n**${modelLabel}:** ${target.modelName}`,
101+
},
102+
},
103+
];
104+
105+
if (type === 'recovery') {
106+
elements.push({
107+
tag: 'div',
108+
text: {
109+
tag: 'lark_md',
110+
content: `**${latencyLabel}:** ${metrics.latencyMs}ms | **TPS:** ${tps} | **TTFT:** ${metrics.ttftMs}ms`,
111+
},
112+
});
113+
} else {
114+
const details = [
115+
`**${latencyLabel}:** ${metrics.latencyMs}ms`,
116+
`**TTFT:** ${metrics.ttftMs}ms`,
117+
`**Tokens:** ${metrics.outputTokens}`,
118+
];
119+
if (metrics.errorMessage) details.push(`**${errorLabel}:** ${metrics.errorMessage}`);
120+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: details.join('\n') } });
121+
}
122+
123+
elements.push({
124+
tag: 'div',
125+
text: { tag: 'plain_text', content: `${timeLabel}: ${new Date().toISOString()}` },
126+
});
127+
128+
return { color: colors[type], title: titles[type], elements };
129+
}
130+
131+
/** Send alert to Feishu webhook */
132+
async function sendFeishuAlert(
133+
webhookUrl: string,
134+
secret: string | undefined,
135+
lang: 'en' | 'zh',
136+
type: AlertType,
137+
target: MonitorTarget,
138+
metrics: AlertMetrics,
139+
): Promise<void> {
140+
const { color, title, elements } = getAlertContent(lang, type, target, metrics);
141+
142+
const bodyStr = JSON.stringify({
143+
msg_type: 'interactive',
144+
card: {
145+
header: { title: { tag: 'plain_text', content: title }, template: color },
146+
elements,
147+
},
148+
});
149+
150+
const targetUrl = secret ? buildSignedUrl(webhookUrl, secret) : webhookUrl;
151+
const res = await fetch(targetUrl, {
152+
method: 'POST',
153+
headers: { 'Content-Type': 'application/json' },
154+
body: bodyStr,
155+
});
156+
157+
if (!res.ok) {
158+
const text = await res.text().catch(() => '');
159+
console.error(`[Alert] Feishu webhook failed (${res.status}): ${text}`);
160+
}
161+
}
162+
163+
/** Main entry: check and send alert if needed */
164+
export async function processAlert(
165+
target: MonitorTarget,
166+
currentStatus: HealthStatus,
167+
metrics: AlertMetrics,
168+
): Promise<void> {
169+
if (target.alertEnabled === false) return;
170+
171+
const config = monitorConfigStore.getConfig();
172+
const webhookUrl = config.alertWebhookUrl;
173+
if (!webhookUrl) return;
174+
175+
const reminderMinutes = config.alertReminderMinutes ?? 360;
176+
const decision = shouldSendAlert(target, currentStatus, reminderMinutes);
177+
if (!decision) return;
178+
179+
try {
180+
await sendFeishuAlert(
181+
webhookUrl,
182+
config.alertWebhookSecret || undefined,
183+
config.alertLanguage || 'en',
184+
decision.type,
185+
target,
186+
metrics,
187+
);
188+
monitorConfigStore.updateLastAlertAt(target.providerId, target.modelName);
189+
} catch (err) {
190+
console.error('[Alert] Failed to send notification:', err);
191+
}
192+
}

0 commit comments

Comments
 (0)