glad-web 1.0.36 → 1.0.38
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/lib/codex/structured-session.js +24 -1
- package/lib/commands/web.js +17 -0
- package/lib/config/manager.js +18 -0
- package/lib/notifications/message-formatter.js +94 -0
- package/lib/notifications/notification-service.js +143 -0
- package/lib/notifications/serverchan-client.js +58 -0
- package/lib/notifications/serverchan-settings-store.js +115 -0
- package/lib/server/routes/notifications.js +52 -0
- package/lib/session/session-manager.js +1 -0
- package/lib/web/claude.js +32 -8
- package/lib/web/core.js +7 -6
- package/lib/web/index.html +60 -6
- package/lib/web/notifications.js +162 -0
- package/lib/web/schedules.js +1 -1
- package/lib/web/styles.css +35 -4
- package/package.json +1 -1
|
@@ -183,6 +183,12 @@ function toolDetails(raw) {
|
|
|
183
183
|
};
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
function codexReconnectProgress(message) {
|
|
187
|
+
const match = String(message || '').match(/\bReconnecting(?:\.\.\.)?\s*(\d+)\s*\/\s*(\d+)\b/i);
|
|
188
|
+
if (!match) return null;
|
|
189
|
+
return { attempt: Number(match[1]), maximum: Number(match[2]) };
|
|
190
|
+
}
|
|
191
|
+
|
|
186
192
|
class CodexStructuredSession extends EventEmitter {
|
|
187
193
|
constructor({ id, tool, workingDir, name, logger, options = {} }) {
|
|
188
194
|
super();
|
|
@@ -203,6 +209,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
203
209
|
this.threadId = options.resume || null;
|
|
204
210
|
this.currentTurnId = null;
|
|
205
211
|
this.currentTurnStartedAt = null;
|
|
212
|
+
this.reconnectAbortTurnId = null;
|
|
206
213
|
this.threadTurns = new Map();
|
|
207
214
|
this.turnContexts = new Map();
|
|
208
215
|
this.providerItemContexts = new Map();
|
|
@@ -376,8 +383,14 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
376
383
|
for (const request of this.pendingRequests.values()) request.reject(new Error(`Codex app-server exited (${code})`));
|
|
377
384
|
this.pendingRequests.clear();
|
|
378
385
|
if (this.running && this.presentation === 'structured') {
|
|
386
|
+
const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
|
|
379
387
|
this.compacting = false;
|
|
380
388
|
this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
|
|
389
|
+
this.emitEvent({
|
|
390
|
+
type: 'runtime-disconnected',
|
|
391
|
+
activeTurn,
|
|
392
|
+
turnId: this.currentTurnId || null
|
|
393
|
+
});
|
|
381
394
|
this.setStatus('idle');
|
|
382
395
|
}
|
|
383
396
|
}
|
|
@@ -569,7 +582,15 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
569
582
|
return;
|
|
570
583
|
}
|
|
571
584
|
if (method === 'error') {
|
|
572
|
-
|
|
585
|
+
const message = params.error?.message || 'Codex reported an error.';
|
|
586
|
+
this.append({ kind: 'event', level: 'error', text: message });
|
|
587
|
+
const reconnect = codexReconnectProgress(message);
|
|
588
|
+
const turnId = String(params.turnId || this.currentTurnId || 'active');
|
|
589
|
+
if (params.willRetry && reconnect?.attempt === 4 && reconnect.maximum === 5
|
|
590
|
+
&& this.reconnectAbortTurnId !== turnId && this.abort('Aborted after Codex reconnect attempt 4/5.')) {
|
|
591
|
+
this.reconnectAbortTurnId = turnId;
|
|
592
|
+
this.emitEvent({ type: 'runtime-disconnected', activeTurn: true, turnId });
|
|
593
|
+
}
|
|
573
594
|
if (!params.willRetry) { this.compacting = false; this.setStatus('idle'); }
|
|
574
595
|
return;
|
|
575
596
|
}
|
|
@@ -976,10 +997,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
976
997
|
this.currentTurnId = started?.turn?.id || started?.turnId || this.currentTurnId;
|
|
977
998
|
return true;
|
|
978
999
|
} catch (error) {
|
|
1000
|
+
const failedAt = Date.now();
|
|
979
1001
|
this.currentTurnId = null;
|
|
980
1002
|
this.currentTurnStartedAt = null;
|
|
981
1003
|
this.setStatus('idle');
|
|
982
1004
|
this.append({ kind: 'event', level: 'error', text: `Unable to send message: ${error.message}` });
|
|
1005
|
+
this.emitEvent({ type: 'turn-failed', createdAt: failedAt });
|
|
983
1006
|
throw error;
|
|
984
1007
|
}
|
|
985
1008
|
}
|
package/lib/commands/web.js
CHANGED
|
@@ -46,6 +46,10 @@ const { getClaudeRuntimeConfig } = require('../claude/config');
|
|
|
46
46
|
const registerScheduleRoutes = require('../server/routes/schedules');
|
|
47
47
|
const registerWorkspaceRoutes = require('../server/routes/workspace');
|
|
48
48
|
const registerProviderRoutes = require('../server/routes/providers');
|
|
49
|
+
const registerNotificationRoutes = require('../server/routes/notifications');
|
|
50
|
+
const { ServerChanSettingsStore } = require('../notifications/serverchan-settings-store');
|
|
51
|
+
const ServerChanClient = require('../notifications/serverchan-client');
|
|
52
|
+
const NotificationService = require('../notifications/notification-service');
|
|
49
53
|
|
|
50
54
|
async function webCommand(options) {
|
|
51
55
|
const port = parseInt(options.port) || 3000;
|
|
@@ -90,6 +94,13 @@ async function webCommand(options) {
|
|
|
90
94
|
logger,
|
|
91
95
|
hasConnectedSessionClient
|
|
92
96
|
});
|
|
97
|
+
const serverChanSettings = new ServerChanSettingsStore();
|
|
98
|
+
const notificationService = new NotificationService({
|
|
99
|
+
sessionManager,
|
|
100
|
+
settingsStore: serverChanSettings,
|
|
101
|
+
channel: new ServerChanClient(),
|
|
102
|
+
logger
|
|
103
|
+
});
|
|
93
104
|
sessionManager.on('output', ({ sessionId, data }) => {
|
|
94
105
|
broadcastToSession(sessionId, { type: 'output', data });
|
|
95
106
|
});
|
|
@@ -128,6 +139,10 @@ async function webCommand(options) {
|
|
|
128
139
|
});
|
|
129
140
|
|
|
130
141
|
registerScheduleRoutes(app, { jobStore, jobRunner });
|
|
142
|
+
registerNotificationRoutes(app, {
|
|
143
|
+
settingsStore: serverChanSettings,
|
|
144
|
+
notificationService
|
|
145
|
+
});
|
|
131
146
|
|
|
132
147
|
// API: List all active sessions
|
|
133
148
|
app.get('/api/sessions', (req, res) => {
|
|
@@ -457,6 +472,7 @@ async function webCommand(options) {
|
|
|
457
472
|
'gitgraph.js',
|
|
458
473
|
'styles.css',
|
|
459
474
|
'core.js',
|
|
475
|
+
'notifications.js',
|
|
460
476
|
'claude.js',
|
|
461
477
|
'schedules.js',
|
|
462
478
|
'shell.js',
|
|
@@ -518,6 +534,7 @@ async function webCommand(options) {
|
|
|
518
534
|
|
|
519
535
|
process.on('SIGINT', () => {
|
|
520
536
|
schedulerService.stop();
|
|
537
|
+
notificationService.stop();
|
|
521
538
|
sessionManager.killAll();
|
|
522
539
|
process.exit(0);
|
|
523
540
|
});
|
package/lib/config/manager.js
CHANGED
|
@@ -8,6 +8,24 @@ const schema = {
|
|
|
8
8
|
type: 'string',
|
|
9
9
|
default: ''
|
|
10
10
|
},
|
|
11
|
+
serverChan: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
sendKey: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
default: ''
|
|
17
|
+
},
|
|
18
|
+
clientType: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
enum: ['wechat', 'pushdeer'],
|
|
21
|
+
default: 'wechat'
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
default: {
|
|
25
|
+
sendKey: '',
|
|
26
|
+
clientType: 'wechat'
|
|
27
|
+
}
|
|
28
|
+
},
|
|
11
29
|
version: {
|
|
12
30
|
type: 'string',
|
|
13
31
|
default: '1.0.0'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const TITLE_LABELS = {
|
|
4
|
+
approval: '待审批',
|
|
5
|
+
completed: '已完成',
|
|
6
|
+
failed: '执行失败',
|
|
7
|
+
disconnected: '连接中断',
|
|
8
|
+
test: '通知测试'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function compactText(value, maxLength = 20) {
|
|
12
|
+
const text = String(value || '').replace(/[\r\n]+/g, ' ').trim();
|
|
13
|
+
if (text.length <= maxLength) return text;
|
|
14
|
+
return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatLocalDateTime(value) {
|
|
18
|
+
const date = new Date(Number(value) || Date.now());
|
|
19
|
+
const pad = number => String(number).padStart(2, '0');
|
|
20
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
|
21
|
+
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatDuration(durationMs) {
|
|
25
|
+
const totalSeconds = Math.max(0, Math.round(Number(durationMs) / 1000));
|
|
26
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return '';
|
|
27
|
+
if (totalSeconds < 60) return `${totalSeconds}秒`;
|
|
28
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
29
|
+
const seconds = totalSeconds % 60;
|
|
30
|
+
return seconds ? `${minutes}分${seconds}秒` : `${minutes}分`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function escapeMarkdown(value) {
|
|
34
|
+
return String(value || '').replace(/([\\`*_{}\[\]()#+\-.!|>])/g, '\\$1');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function providerName(provider, session) {
|
|
38
|
+
if (provider === 'claude') return 'Claude';
|
|
39
|
+
if (provider === 'codex') return 'Codex';
|
|
40
|
+
return session?.tool?.displayName || 'AI';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sessionDirectory(session) {
|
|
44
|
+
return session?.workingDir || session?.workingDirectory || session?.ptyManager?.workingDir || process.cwd();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatNotification({ kind, provider, session, durationMs = null }, clientType = 'wechat') {
|
|
48
|
+
const name = String(session?.name || providerName(provider, session));
|
|
49
|
+
const directory = sessionDirectory(session);
|
|
50
|
+
const type = providerName(provider, session);
|
|
51
|
+
const title = `${TITLE_LABELS[kind] || '通知'}|${compactText(name)}`;
|
|
52
|
+
const details = [
|
|
53
|
+
['类型', type],
|
|
54
|
+
['会话', name],
|
|
55
|
+
['创建', formatLocalDateTime(session?.startTime)],
|
|
56
|
+
['目录', directory]
|
|
57
|
+
];
|
|
58
|
+
const duration = formatDuration(durationMs);
|
|
59
|
+
if (duration) details.push(['本轮耗时', duration]);
|
|
60
|
+
|
|
61
|
+
if (clientType === 'pushdeer') {
|
|
62
|
+
return {
|
|
63
|
+
title,
|
|
64
|
+
description: details
|
|
65
|
+
.map(([label, value]) => label === '目录'
|
|
66
|
+
? `**${label}:** \`${escapeMarkdown(value)}\``
|
|
67
|
+
: `**${label}:** ${escapeMarkdown(value)}`)
|
|
68
|
+
.join('\n\n')
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
title,
|
|
74
|
+
description: details.map(([label, value]) => `${label}:${value}`).join('\n\n')
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createTestSession(session = null) {
|
|
79
|
+
if (session) return session;
|
|
80
|
+
return {
|
|
81
|
+
name: 'Glad 测试会话',
|
|
82
|
+
startTime: Date.now(),
|
|
83
|
+
workingDir: path.resolve(process.cwd()),
|
|
84
|
+
tool: { displayName: 'Glad' }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
formatNotification,
|
|
90
|
+
createTestSession,
|
|
91
|
+
compactText,
|
|
92
|
+
formatLocalDateTime,
|
|
93
|
+
formatDuration
|
|
94
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const { formatNotification, createTestSession } = require('./message-formatter');
|
|
2
|
+
|
|
3
|
+
class NotificationService {
|
|
4
|
+
constructor({ sessionManager, settingsStore, channel, logger = console }) {
|
|
5
|
+
this.sessionManager = sessionManager;
|
|
6
|
+
this.settingsStore = settingsStore;
|
|
7
|
+
this.channel = channel;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.seenEvents = new Map();
|
|
10
|
+
this.handlers = {
|
|
11
|
+
claude: payload => this.handleProviderEvent('claude', payload),
|
|
12
|
+
codex: payload => this.handleProviderEvent('codex', payload),
|
|
13
|
+
exit: payload => this.clearSession(payload.sessionId)
|
|
14
|
+
};
|
|
15
|
+
sessionManager.on('claude-event', this.handlers.claude);
|
|
16
|
+
sessionManager.on('codex-event', this.handlers.codex);
|
|
17
|
+
sessionManager.on('exit', this.handlers.exit);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
stop() {
|
|
21
|
+
this.sessionManager.off('claude-event', this.handlers.claude);
|
|
22
|
+
this.sessionManager.off('codex-event', this.handlers.codex);
|
|
23
|
+
this.sessionManager.off('exit', this.handlers.exit);
|
|
24
|
+
this.seenEvents.clear();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getSessionState(sessionId) {
|
|
28
|
+
const session = this.sessionManager.get(sessionId);
|
|
29
|
+
if (!session) return null;
|
|
30
|
+
return {
|
|
31
|
+
enabled: Boolean(session.serverChanNotificationEnabled),
|
|
32
|
+
configured: this.settingsStore.getPublic().configured
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async setSessionEnabled(sessionId, enabled) {
|
|
37
|
+
const session = this.sessionManager.get(sessionId);
|
|
38
|
+
if (!session) {
|
|
39
|
+
const error = new Error('Session not found');
|
|
40
|
+
error.statusCode = 404;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (enabled && !this.settingsStore.getPublic().configured) {
|
|
44
|
+
const error = new Error('请先配置 Server酱');
|
|
45
|
+
error.statusCode = 409;
|
|
46
|
+
error.code = 'SERVERCHAN_NOT_CONFIGURED';
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
session.serverChanNotificationEnabled = Boolean(enabled);
|
|
50
|
+
if (enabled && session.pendingPermissions?.size > 0) {
|
|
51
|
+
await this.sendEvent({
|
|
52
|
+
kind: 'approval',
|
|
53
|
+
provider: session.kind === 'claude-structured' ? 'claude' : 'codex',
|
|
54
|
+
session
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return this.getSessionState(sessionId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
disableAllSessions() {
|
|
61
|
+
for (const session of this.sessionManager.sessions.values()) {
|
|
62
|
+
session.serverChanNotificationEnabled = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async sendTest(input = {}) {
|
|
67
|
+
const settings = this.settingsStore.resolve(input);
|
|
68
|
+
const session = createTestSession(
|
|
69
|
+
input.sessionId ? this.sessionManager.get(input.sessionId) : null
|
|
70
|
+
);
|
|
71
|
+
const provider = session?.kind === 'claude-structured'
|
|
72
|
+
? 'claude'
|
|
73
|
+
: session?.kind === 'codex-structured' ? 'codex' : null;
|
|
74
|
+
const message = formatNotification({ kind: 'test', provider, session }, settings.clientType);
|
|
75
|
+
await this.channel.send({ ...settings, ...message });
|
|
76
|
+
return { success: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
handleProviderEvent(provider, payload = {}) {
|
|
80
|
+
const { session, event, sessionId } = payload;
|
|
81
|
+
if (!session || !event || !session.serverChanNotificationEnabled) return;
|
|
82
|
+
|
|
83
|
+
if (event.type === 'permission-request') {
|
|
84
|
+
const requestId = event.request?.id || event.request?.toolUseId || 'pending';
|
|
85
|
+
if (!this.markSeen(sessionId, `approval:${requestId}`)) return;
|
|
86
|
+
this.sendEvent({ kind: 'approval', provider, session });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (event.type === 'runtime-disconnected') {
|
|
91
|
+
if (!event.activeTurn || !this.markSeen(sessionId, `disconnected:${event.turnId || 'active'}`)) return;
|
|
92
|
+
this.sendEvent({ kind: 'disconnected', provider, session });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (event.type === 'turn-failed') {
|
|
97
|
+
if (!this.markSeen(sessionId, `failed:${event.turnId || event.createdAt || 'start'}`)) return;
|
|
98
|
+
this.sendEvent({ kind: 'failed', provider, session, durationMs: event.durationMs });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const message = event.type === 'message' ? event.message : null;
|
|
103
|
+
if (!message || message.kind !== 'turn-end') return;
|
|
104
|
+
if (provider === 'codex' && message.threadId && session.threadId && message.threadId !== session.threadId) return;
|
|
105
|
+
|
|
106
|
+
const status = String(message.turnStatus || message.status || 'completed');
|
|
107
|
+
if (status === 'cancelled' || status === 'interrupted') return;
|
|
108
|
+
const turnId = message.turnId || message.id;
|
|
109
|
+
if (!this.markSeen(sessionId, `turn:${turnId}`)) return;
|
|
110
|
+
this.sendEvent({
|
|
111
|
+
kind: status === 'failed' ? 'failed' : 'completed',
|
|
112
|
+
provider,
|
|
113
|
+
session,
|
|
114
|
+
durationMs: message.durationMs
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
markSeen(sessionId, key) {
|
|
119
|
+
const seen = this.seenEvents.get(sessionId) || new Set();
|
|
120
|
+
if (seen.has(key)) return false;
|
|
121
|
+
seen.add(key);
|
|
122
|
+
if (seen.size > 100) seen.delete(seen.values().next().value);
|
|
123
|
+
this.seenEvents.set(sessionId, seen);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
clearSession(sessionId) {
|
|
128
|
+
this.seenEvents.delete(sessionId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async sendEvent(event) {
|
|
132
|
+
try {
|
|
133
|
+
const settings = this.settingsStore.get();
|
|
134
|
+
if (!settings.sendKey) return;
|
|
135
|
+
const message = formatNotification(event, settings.clientType);
|
|
136
|
+
await this.channel.send({ ...settings, ...message });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
this.logger.error?.(`[serverchan] notification failed: ${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = NotificationService;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const API_BASE = 'https://sctapi.ftqq.com';
|
|
2
|
+
|
|
3
|
+
function sanitizeServerChanError(payload, fallback) {
|
|
4
|
+
const message = payload && typeof payload === 'object'
|
|
5
|
+
? payload.message || payload.error || payload.data?.error
|
|
6
|
+
: '';
|
|
7
|
+
const text = String(message || fallback || 'Server酱发送失败').trim();
|
|
8
|
+
return text.slice(0, 300);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class ServerChanClient {
|
|
12
|
+
constructor({ fetchImpl = globalThis.fetch, timeoutMs = 10_000 } = {}) {
|
|
13
|
+
this.fetchImpl = fetchImpl;
|
|
14
|
+
this.timeoutMs = timeoutMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async send({ sendKey, title, description = '' }) {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
const response = await this.fetchImpl(`${API_BASE}/${encodeURIComponent(sendKey)}.send`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: {
|
|
24
|
+
'content-type': 'application/x-www-form-urlencoded'
|
|
25
|
+
},
|
|
26
|
+
body: new URLSearchParams({
|
|
27
|
+
title: String(title || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 64),
|
|
28
|
+
desp: String(description || '')
|
|
29
|
+
}),
|
|
30
|
+
signal: controller.signal
|
|
31
|
+
});
|
|
32
|
+
const text = await response.text();
|
|
33
|
+
let payload = null;
|
|
34
|
+
try {
|
|
35
|
+
payload = text ? JSON.parse(text) : null;
|
|
36
|
+
} catch (_) {
|
|
37
|
+
payload = null;
|
|
38
|
+
}
|
|
39
|
+
if (!response.ok || (payload && Number(payload.code) !== 0)) {
|
|
40
|
+
const error = new Error(sanitizeServerChanError(payload, `HTTP ${response.status}`));
|
|
41
|
+
error.statusCode = 502;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
return payload || { code: 0 };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error && error.name === 'AbortError') {
|
|
47
|
+
const timeoutError = new Error('Server酱请求超时');
|
|
48
|
+
timeoutError.statusCode = 504;
|
|
49
|
+
throw timeoutError;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = ServerChanClient;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const {
|
|
3
|
+
getConfig,
|
|
4
|
+
setConfig,
|
|
5
|
+
getConfigPath
|
|
6
|
+
} = require('../config/manager');
|
|
7
|
+
|
|
8
|
+
const CLIENT_TYPES = new Set(['wechat', 'pushdeer']);
|
|
9
|
+
|
|
10
|
+
function normalizeClientType(value) {
|
|
11
|
+
const clientType = String(value || 'wechat').trim().toLowerCase();
|
|
12
|
+
if (!CLIENT_TYPES.has(clientType)) {
|
|
13
|
+
const error = new Error('接收客户端必须是微信或 PushDeer');
|
|
14
|
+
error.statusCode = 400;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
return clientType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeSendKey(value) {
|
|
21
|
+
const sendKey = String(value || '').trim();
|
|
22
|
+
if (!sendKey || sendKey.length < 8 || sendKey.length > 512 || /\s/.test(sendKey)) {
|
|
23
|
+
const error = new Error('请输入有效的 Server酱 SendKey');
|
|
24
|
+
error.statusCode = 400;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
return sendKey;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function maskSendKey(sendKey) {
|
|
31
|
+
if (!sendKey) return '';
|
|
32
|
+
return `${sendKey.slice(0, Math.min(3, sendKey.length))}${'•'.repeat(10)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ServerChanSettingsStore {
|
|
36
|
+
constructor({
|
|
37
|
+
readConfig = getConfig,
|
|
38
|
+
writeConfig = setConfig,
|
|
39
|
+
configPath = getConfigPath,
|
|
40
|
+
chmod = fs.chmodSync
|
|
41
|
+
} = {}) {
|
|
42
|
+
this.readConfig = readConfig;
|
|
43
|
+
this.writeConfig = writeConfig;
|
|
44
|
+
this.configPath = configPath;
|
|
45
|
+
this.chmod = chmod;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get() {
|
|
49
|
+
const stored = this.readConfig('serverChan') || {};
|
|
50
|
+
return {
|
|
51
|
+
sendKey: String(stored.sendKey || '').trim(),
|
|
52
|
+
clientType: CLIENT_TYPES.has(stored.clientType) ? stored.clientType : 'wechat'
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getPublic() {
|
|
57
|
+
const settings = this.get();
|
|
58
|
+
return {
|
|
59
|
+
configured: Boolean(settings.sendKey),
|
|
60
|
+
maskedKey: maskSendKey(settings.sendKey),
|
|
61
|
+
clientType: settings.clientType
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
save(input = {}) {
|
|
66
|
+
const existing = this.get();
|
|
67
|
+
const sendKey = input.sendKey == null || String(input.sendKey).trim() === ''
|
|
68
|
+
? existing.sendKey
|
|
69
|
+
: normalizeSendKey(input.sendKey);
|
|
70
|
+
if (!sendKey) {
|
|
71
|
+
const error = new Error('请先填写 Server酱 SendKey');
|
|
72
|
+
error.statusCode = 400;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
const settings = {
|
|
76
|
+
sendKey,
|
|
77
|
+
clientType: normalizeClientType(input.clientType ?? existing.clientType)
|
|
78
|
+
};
|
|
79
|
+
this.writeConfig('serverChan', settings);
|
|
80
|
+
this.restrictConfigFile();
|
|
81
|
+
return this.getPublic();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
resolve(input = {}) {
|
|
85
|
+
const existing = this.get();
|
|
86
|
+
return {
|
|
87
|
+
sendKey: input.sendKey == null || String(input.sendKey).trim() === ''
|
|
88
|
+
? normalizeSendKey(existing.sendKey)
|
|
89
|
+
: normalizeSendKey(input.sendKey),
|
|
90
|
+
clientType: normalizeClientType(input.clientType ?? existing.clientType)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
clear() {
|
|
95
|
+
this.writeConfig('serverChan', { sendKey: '', clientType: 'wechat' });
|
|
96
|
+
this.restrictConfigFile();
|
|
97
|
+
return this.getPublic();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
restrictConfigFile() {
|
|
101
|
+
try {
|
|
102
|
+
const configPath = typeof this.configPath === 'function' ? this.configPath() : this.configPath;
|
|
103
|
+
if (configPath && fs.existsSync(configPath)) this.chmod(configPath, 0o600);
|
|
104
|
+
} catch (_) {
|
|
105
|
+
// Best effort: configuration remains usable on filesystems without chmod support.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
ServerChanSettingsStore,
|
|
112
|
+
normalizeClientType,
|
|
113
|
+
normalizeSendKey,
|
|
114
|
+
maskSendKey
|
|
115
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function statusCode(error) {
|
|
2
|
+
return Number(error?.statusCode) || 500;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function registerNotificationRoutes(app, {
|
|
6
|
+
settingsStore,
|
|
7
|
+
notificationService
|
|
8
|
+
}) {
|
|
9
|
+
app.get('/api/notifications/serverchan', (_req, res) => {
|
|
10
|
+
res.json(settingsStore.getPublic());
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.put('/api/notifications/serverchan', (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
res.json({ success: true, settings: settingsStore.save(req.body || {}) });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
res.status(statusCode(error)).json({ error: error.message });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
app.delete('/api/notifications/serverchan', (_req, res) => {
|
|
22
|
+
const settings = settingsStore.clear();
|
|
23
|
+
notificationService.disableAllSessions();
|
|
24
|
+
res.json({ success: true, settings });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.post('/api/notifications/serverchan/test', async (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
await notificationService.sendTest(req.body || {});
|
|
30
|
+
res.json({ success: true });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
res.status(statusCode(error)).json({ error: error.message });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.put('/api/sessions/:id/notifications/serverchan', async (req, res) => {
|
|
37
|
+
try {
|
|
38
|
+
const state = await notificationService.setSessionEnabled(
|
|
39
|
+
req.params.id,
|
|
40
|
+
Boolean(req.body?.enabled)
|
|
41
|
+
);
|
|
42
|
+
res.json({ success: true, state });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
res.status(statusCode(error)).json({
|
|
45
|
+
error: error.message,
|
|
46
|
+
...(error.code ? { code: error.code } : {})
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = registerNotificationRoutes;
|
|
@@ -69,6 +69,7 @@ class SessionManager extends EventEmitter {
|
|
|
69
69
|
workingDirectory: this.getSessionWorkingDirectory(session),
|
|
70
70
|
mode: ['claude-structured', 'codex-structured'].includes(session.kind) ? (session.presentation || 'structured') : 'terminal',
|
|
71
71
|
hasUnreadCompletion: Boolean(session.hasUnreadCompletion),
|
|
72
|
+
serverChanNotificationEnabled: Boolean(session.serverChanNotificationEnabled),
|
|
72
73
|
timedInputCount: session.timedInputs
|
|
73
74
|
? Array.from(session.timedInputs.values()).filter(item => item.sendAt > Date.now()).length
|
|
74
75
|
: 0
|
package/lib/web/claude.js
CHANGED
|
@@ -497,6 +497,24 @@
|
|
|
497
497
|
return html;
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
function parseMarkdownFenceOpener(line) {
|
|
501
|
+
const match = String(line || '').match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
|
|
502
|
+
if (!match) return null;
|
|
503
|
+
const marker = match[1];
|
|
504
|
+
const info = match[2].trim();
|
|
505
|
+
if (marker[0] === '`' && info.includes('`')) return null;
|
|
506
|
+
return {
|
|
507
|
+
character: marker[0],
|
|
508
|
+
length: marker.length,
|
|
509
|
+
language: info.split(/\s+/, 1)[0] || ''
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function isMarkdownFenceCloser(line, opener) {
|
|
514
|
+
const match = String(line || '').match(/^\s{0,3}(`+|~+)\s*$/);
|
|
515
|
+
return Boolean(match && match[1][0] === opener.character && match[1].length >= opener.length);
|
|
516
|
+
}
|
|
517
|
+
|
|
500
518
|
function splitMarkdownBlocks(markdown) {
|
|
501
519
|
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
|
|
502
520
|
const blocks = [];
|
|
@@ -506,17 +524,16 @@
|
|
|
506
524
|
i++;
|
|
507
525
|
continue;
|
|
508
526
|
}
|
|
509
|
-
const fence = lines[i]
|
|
527
|
+
const fence = parseMarkdownFenceOpener(lines[i]);
|
|
510
528
|
if (fence) {
|
|
511
|
-
const language = fence[1] || '';
|
|
512
529
|
const content = [];
|
|
513
530
|
i++;
|
|
514
|
-
while (i < lines.length &&
|
|
531
|
+
while (i < lines.length && !isMarkdownFenceCloser(lines[i], fence)) {
|
|
515
532
|
content.push(lines[i]);
|
|
516
533
|
i++;
|
|
517
534
|
}
|
|
518
535
|
if (i < lines.length) i++;
|
|
519
|
-
blocks.push({ type: 'code', language, content: content.join('\n') });
|
|
536
|
+
blocks.push({ type: 'code', language: fence.language, content: content.join('\n') });
|
|
520
537
|
continue;
|
|
521
538
|
}
|
|
522
539
|
if (/^\s*[-*_]{3,}\s*$/.test(lines[i])) {
|
|
@@ -541,13 +558,15 @@
|
|
|
541
558
|
continue;
|
|
542
559
|
}
|
|
543
560
|
if (/^\s*([-*+])\s+/.test(lines[i]) || /^\s*\d+\.\s+/.test(lines[i])) {
|
|
544
|
-
const
|
|
561
|
+
const orderedMatch = lines[i].match(/^\s*(\d+)\.\s+/);
|
|
562
|
+
const ordered = Boolean(orderedMatch);
|
|
563
|
+
const start = ordered ? Number(orderedMatch[1]) : null;
|
|
545
564
|
const items = [];
|
|
546
565
|
while (i < lines.length && (ordered ? /^\s*\d+\.\s+/.test(lines[i]) : /^\s*[-*+]\s+/.test(lines[i]))) {
|
|
547
566
|
items.push(lines[i].replace(ordered ? /^\s*\d+\.\s+/ : /^\s*[-*+]\s+/, ''));
|
|
548
567
|
i++;
|
|
549
568
|
}
|
|
550
|
-
blocks.push({ type: ordered ? 'ol' : 'ul', items });
|
|
569
|
+
blocks.push({ type: ordered ? 'ol' : 'ul', items, ...(ordered ? { start } : {}) });
|
|
551
570
|
continue;
|
|
552
571
|
}
|
|
553
572
|
if (/^\s*>\s?/.test(lines[i])) {
|
|
@@ -561,7 +580,12 @@
|
|
|
561
580
|
}
|
|
562
581
|
const paragraph = [];
|
|
563
582
|
while (i < lines.length && lines[i].trim()) {
|
|
564
|
-
if (
|
|
583
|
+
if (parseMarkdownFenceOpener(lines[i]) || /^\s{0,3}#{1,4}\s+/.test(lines[i]) || /^\s*([-*+])\s+/.test(lines[i]) || /^\s*\d+\.\s+/.test(lines[i])) break;
|
|
584
|
+
paragraph.push(lines[i]);
|
|
585
|
+
i++;
|
|
586
|
+
}
|
|
587
|
+
// Always make progress even if a future block detector and parser disagree.
|
|
588
|
+
if (!paragraph.length) {
|
|
565
589
|
paragraph.push(lines[i]);
|
|
566
590
|
i++;
|
|
567
591
|
}
|
|
@@ -580,7 +604,7 @@
|
|
|
580
604
|
if (block.type === 'hr') return '<hr>';
|
|
581
605
|
if (block.type === 'header') return `<h${block.level}>${inlineMarkdown(block.text)}</h${block.level}>`;
|
|
582
606
|
if (block.type === 'ul') return `<ul>${block.items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('')}</ul>`;
|
|
583
|
-
if (block.type === 'ol') return `<ol>${block.items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('')}</ol>`;
|
|
607
|
+
if (block.type === 'ol') return `<ol${block.start !== 1 ? ` start="${block.start}"` : ''}>${block.items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('')}</ol>`;
|
|
584
608
|
if (block.type === 'quote') return `<blockquote>${renderMarkdown(block.text)}</blockquote>`;
|
|
585
609
|
if (block.type === 'table') return renderMarkdownTable(block.rows);
|
|
586
610
|
return `<p>${inlineMarkdown(block.text).replace(/\n/g, '<br>')}</p>`;
|
package/lib/web/core.js
CHANGED
|
@@ -229,17 +229,18 @@
|
|
|
229
229
|
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
|
|
230
230
|
<p>${escapeHtml(s.tool)}</p>
|
|
231
231
|
<p>${new Date(s.startTime).toLocaleTimeString()}</p>
|
|
232
|
-
<div class="session-dir-row">
|
|
233
|
-
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
|
234
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
235
|
-
</button>
|
|
236
|
-
<p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
|
|
237
|
-
</div>
|
|
238
232
|
</div>
|
|
239
233
|
<div class="session-actions">
|
|
234
|
+
${renderServerChanSessionAction(s)}
|
|
240
235
|
<button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
|
|
241
236
|
<button class="icon-btn btn-delete" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
|
242
237
|
</div>
|
|
238
|
+
<div class="session-dir-row">
|
|
239
|
+
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
|
240
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
241
|
+
</button>
|
|
242
|
+
<p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
|
|
243
|
+
</div>
|
|
243
244
|
</div>`;
|
|
244
245
|
});
|
|
245
246
|
list.innerHTML = html;
|
package/lib/web/index.html
CHANGED
|
@@ -19,14 +19,21 @@
|
|
|
19
19
|
<div id="lobby">
|
|
20
20
|
<div class="header">
|
|
21
21
|
<h1><img class="header-logo" src="logo.svg" alt="">Glad</h1>
|
|
22
|
-
<div
|
|
23
|
-
<button class="btn
|
|
24
|
-
<button class="btn
|
|
22
|
+
<div class="header-actions">
|
|
23
|
+
<button class="header-action-btn" onclick="showToolModal()" title="New AI session"><span>+</span><span>Session</span></button>
|
|
24
|
+
<button class="header-action-btn" onclick="showScheduleModal()" title="New scheduled task"><span>+</span><span>Task</span></button>
|
|
25
|
+
<button id="app-settings-button" class="header-action-btn icon-only" type="button"
|
|
26
|
+
onclick="openSettings()" title="Settings" aria-label="Settings">
|
|
27
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
28
|
+
<circle cx="12" cy="12" r="3"></circle>
|
|
29
|
+
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21h-4v-.1A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3v-4h.1A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.1A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.1.37.31.7.6 1 .3.25.67.39 1.1.4h.1v4h-.1a1.7 1.7 0 0 0-1.7.6z"></path>
|
|
30
|
+
</svg>
|
|
31
|
+
</button>
|
|
25
32
|
</div>
|
|
26
33
|
</div>
|
|
27
34
|
<div class="lobby-tabs">
|
|
28
|
-
<button id="lobby-tab-sessions" class="lobby-tab active" onclick="switchLobbyTab('sessions')">Sessions</button>
|
|
29
|
-
<button id="lobby-tab-schedules" class="lobby-tab" onclick="switchLobbyTab('schedules')">
|
|
35
|
+
<button id="lobby-tab-sessions" class="lobby-tab active" onclick="switchLobbyTab('sessions')" title="AI sessions">Sessions</button>
|
|
36
|
+
<button id="lobby-tab-schedules" class="lobby-tab" onclick="switchLobbyTab('schedules')" title="Scheduled tasks">Tasks</button>
|
|
30
37
|
</div>
|
|
31
38
|
<div id="sessions-list">
|
|
32
39
|
<p style="color:#888; text-align:center; margin-top:50px;">Loading sessions...</p>
|
|
@@ -252,11 +259,57 @@
|
|
|
252
259
|
</div>
|
|
253
260
|
</div>
|
|
254
261
|
|
|
262
|
+
<!-- App Settings Modal -->
|
|
263
|
+
<div id="settings-modal-overlay" onclick="closeSettings(event)">
|
|
264
|
+
<div id="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title" onclick="event.stopPropagation()">
|
|
265
|
+
<div class="settings-modal-header">
|
|
266
|
+
<div>
|
|
267
|
+
<h2 id="settings-modal-title">Settings</h2>
|
|
268
|
+
<p>Manage Glad preferences and integrations.</p>
|
|
269
|
+
</div>
|
|
270
|
+
<button class="icon-btn settings-close" type="button" onclick="closeSettings()" aria-label="Close">×</button>
|
|
271
|
+
</div>
|
|
272
|
+
<section class="settings-section" aria-labelledby="notifications-settings-title">
|
|
273
|
+
<div class="settings-section-label" id="notifications-settings-title">Notifications</div>
|
|
274
|
+
<div class="settings-provider-header">
|
|
275
|
+
<div>
|
|
276
|
+
<h3>ServerChan</h3>
|
|
277
|
+
<p>Save the integration here, then enable notifications per chat using its bell.</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="form-field">
|
|
281
|
+
<label for="serverchan-client-type">Delivery</label>
|
|
282
|
+
<select id="serverchan-client-type">
|
|
283
|
+
<option value="wechat">WeChat</option>
|
|
284
|
+
<option value="pushdeer">PushDeer</option>
|
|
285
|
+
</select>
|
|
286
|
+
<div class="serverchan-help">This choice adjusts message formatting. Delivery is managed by ServerChan.</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="form-field">
|
|
289
|
+
<label for="serverchan-send-key">SendKey</label>
|
|
290
|
+
<input id="serverchan-send-key" type="password" autocomplete="off" placeholder="SCT...">
|
|
291
|
+
<div id="serverchan-key-hint" class="serverchan-help"></div>
|
|
292
|
+
</div>
|
|
293
|
+
<div id="serverchan-settings-status" class="serverchan-settings-status" aria-live="polite"></div>
|
|
294
|
+
<div class="serverchan-modal-actions">
|
|
295
|
+
<button id="serverchan-remove-btn" class="small-btn danger" type="button" onclick="removeServerChanSettings()">Remove</button>
|
|
296
|
+
<span class="serverchan-action-spacer"></span>
|
|
297
|
+
<button id="serverchan-save-btn" class="small-btn primary" type="button" onclick="saveServerChanSettings()">Save</button>
|
|
298
|
+
<button id="serverchan-test-btn" class="small-btn" type="button" onclick="testServerChanSettings()">Send Test</button>
|
|
299
|
+
</div>
|
|
300
|
+
</section>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
<div id="app-toast" role="status" aria-live="polite"></div>
|
|
304
|
+
|
|
255
305
|
<!-- Schedule Modal -->
|
|
256
306
|
<div id="schedule-modal-overlay" onclick="closeScheduleModal(event)" style="position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 10001; display: none; align-items: center; justify-content: center; padding: 16px;">
|
|
257
307
|
<div id="schedule-modal" onclick="event.stopPropagation()">
|
|
258
308
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:14px;">
|
|
259
|
-
<
|
|
309
|
+
<div>
|
|
310
|
+
<h2 id="schedule-modal-title" style="margin:0; font-size:20px;">Scheduled Task</h2>
|
|
311
|
+
<p class="schedule-modal-subtitle">Runs automatically at the selected time.</p>
|
|
312
|
+
</div>
|
|
260
313
|
<button class="icon-btn" onclick="closeScheduleModal()" style="color: var(--text-dim); font-size: 24px;">×</button>
|
|
261
314
|
</div>
|
|
262
315
|
<div class="form-field">
|
|
@@ -304,6 +357,7 @@
|
|
|
304
357
|
<script defer src="vendor/xterm-addon-fit.js"></script>
|
|
305
358
|
<script src="gitgraph.js"></script>
|
|
306
359
|
<script src="core.js"></script>
|
|
360
|
+
<script src="notifications.js"></script>
|
|
307
361
|
<script src="claude.js"></script>
|
|
308
362
|
<script src="schedules.js"></script>
|
|
309
363
|
<script src="shell.js"></script>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
let serverChanSettings = null;
|
|
2
|
+
let serverChanToastTimer = null;
|
|
3
|
+
|
|
4
|
+
function serverChanBellSvg() {
|
|
5
|
+
return '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"></path><path d="M10 21h4"></path></svg>';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function renderServerChanSessionAction(session) {
|
|
9
|
+
const enabled = Boolean(session.serverChanNotificationEnabled);
|
|
10
|
+
const title = enabled ? 'Disable ServerChan notifications for this chat' : 'Enable ServerChan notifications for this chat';
|
|
11
|
+
return `<button class="serverchan-toggle${enabled ? ' active' : ''}" type="button"
|
|
12
|
+
data-serverchan-session="${escapeHtml(session.id)}"
|
|
13
|
+
aria-label="${title}" title="${title}"
|
|
14
|
+
onclick="toggleServerChanSession('${session.id}', ${enabled ? 'false' : 'true'}, event)">
|
|
15
|
+
${serverChanBellSvg()}
|
|
16
|
+
</button>`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function showAppToast(message) {
|
|
20
|
+
const toast = document.getElementById('app-toast');
|
|
21
|
+
toast.textContent = message;
|
|
22
|
+
toast.classList.add('visible');
|
|
23
|
+
clearTimeout(serverChanToastTimer);
|
|
24
|
+
serverChanToastTimer = setTimeout(() => toast.classList.remove('visible'), 1800);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function toggleServerChanSession(sessionId, enabled, event) {
|
|
28
|
+
event?.stopPropagation();
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetchWithTimeout(`/api/sessions/${sessionId}/notifications/serverchan`, {
|
|
31
|
+
method: 'PUT',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ enabled })
|
|
34
|
+
}, 10000);
|
|
35
|
+
const data = await response.json();
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
if (data.code === 'SERVERCHAN_NOT_CONFIGURED') {
|
|
38
|
+
showAppToast('Configure ServerChan in Settings first');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(data.error || 'Could not update notifications');
|
|
42
|
+
}
|
|
43
|
+
await refreshSessionsNow();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
showAppToast(error.message || 'Could not update notifications');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function openSettings(event = null) {
|
|
50
|
+
event?.stopPropagation();
|
|
51
|
+
document.getElementById('settings-modal-overlay').style.display = 'flex';
|
|
52
|
+
setServerChanStatus('Loading configuration…');
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetchWithTimeout('/api/notifications/serverchan', {}, 10000);
|
|
55
|
+
const data = await response.json();
|
|
56
|
+
if (!response.ok) throw new Error(data.error || 'Could not load configuration');
|
|
57
|
+
serverChanSettings = data;
|
|
58
|
+
document.getElementById('serverchan-client-type').value = data.clientType || 'wechat';
|
|
59
|
+
const keyInput = document.getElementById('serverchan-send-key');
|
|
60
|
+
keyInput.value = '';
|
|
61
|
+
keyInput.placeholder = data.configured ? data.maskedKey : 'SCT...';
|
|
62
|
+
document.getElementById('serverchan-key-hint').textContent = data.configured
|
|
63
|
+
? `Saved: ${data.maskedKey}. Leave blank to keep the current SendKey.`
|
|
64
|
+
: 'The SendKey is stored only on this Glad host.';
|
|
65
|
+
document.getElementById('serverchan-remove-btn').style.display = data.configured ? 'inline-flex' : 'none';
|
|
66
|
+
setServerChanStatus(data.configured ? 'Configuration saved.' : 'ServerChan is not configured.');
|
|
67
|
+
} catch (error) {
|
|
68
|
+
setServerChanStatus(error.message || 'Could not load configuration', 'error');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function closeSettings(event = null) {
|
|
73
|
+
if (event && event.target.id !== 'settings-modal-overlay') return;
|
|
74
|
+
document.getElementById('settings-modal-overlay').style.display = 'none';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function currentServerChanForm() {
|
|
78
|
+
return {
|
|
79
|
+
sendKey: document.getElementById('serverchan-send-key').value.trim(),
|
|
80
|
+
clientType: document.getElementById('serverchan-client-type').value
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function setServerChanStatus(message, type = '') {
|
|
85
|
+
const status = document.getElementById('serverchan-settings-status');
|
|
86
|
+
status.textContent = message;
|
|
87
|
+
status.className = `serverchan-settings-status${type ? ` ${type}` : ''}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setServerChanBusy(busy) {
|
|
91
|
+
document.getElementById('serverchan-save-btn').disabled = busy;
|
|
92
|
+
document.getElementById('serverchan-test-btn').disabled = busy;
|
|
93
|
+
document.getElementById('serverchan-remove-btn').disabled = busy;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function saveServerChanSettings() {
|
|
97
|
+
setServerChanBusy(true);
|
|
98
|
+
setServerChanStatus('Saving…');
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetchWithTimeout('/api/notifications/serverchan', {
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify(currentServerChanForm())
|
|
104
|
+
}, 10000);
|
|
105
|
+
const data = await response.json();
|
|
106
|
+
if (!response.ok) throw new Error(data.error || 'Could not save configuration');
|
|
107
|
+
serverChanSettings = data.settings;
|
|
108
|
+
const keyInput = document.getElementById('serverchan-send-key');
|
|
109
|
+
keyInput.value = '';
|
|
110
|
+
keyInput.placeholder = data.settings.maskedKey;
|
|
111
|
+
document.getElementById('serverchan-key-hint').textContent =
|
|
112
|
+
`Saved: ${data.settings.maskedKey}. Leave blank to keep the current SendKey.`;
|
|
113
|
+
document.getElementById('serverchan-remove-btn').style.display = 'inline-flex';
|
|
114
|
+
setServerChanStatus('Configuration saved. Per-chat notification switches are unchanged.', 'success');
|
|
115
|
+
} catch (error) {
|
|
116
|
+
setServerChanStatus(error.message || 'Could not save configuration', 'error');
|
|
117
|
+
} finally {
|
|
118
|
+
setServerChanBusy(false);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function testServerChanSettings() {
|
|
123
|
+
setServerChanBusy(true);
|
|
124
|
+
setServerChanStatus('Sending test message…');
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetchWithTimeout('/api/notifications/serverchan/test', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify(currentServerChanForm())
|
|
130
|
+
}, 15000);
|
|
131
|
+
const data = await response.json();
|
|
132
|
+
if (!response.ok) throw new Error(data.error || 'Could not send test message');
|
|
133
|
+
setServerChanStatus('Test message sent. The test did not save configuration.', 'success');
|
|
134
|
+
} catch (error) {
|
|
135
|
+
setServerChanStatus(error.message || 'Could not send test message', 'error');
|
|
136
|
+
} finally {
|
|
137
|
+
setServerChanBusy(false);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function removeServerChanSettings() {
|
|
142
|
+
if (!confirm('Remove ServerChan configuration and disable notifications for every chat?')) return;
|
|
143
|
+
setServerChanBusy(true);
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetchWithTimeout('/api/notifications/serverchan', {
|
|
146
|
+
method: 'DELETE'
|
|
147
|
+
}, 10000);
|
|
148
|
+
const data = await response.json();
|
|
149
|
+
if (!response.ok) throw new Error(data.error || 'Could not remove configuration');
|
|
150
|
+
serverChanSettings = data.settings;
|
|
151
|
+
document.getElementById('serverchan-send-key').value = '';
|
|
152
|
+
document.getElementById('serverchan-send-key').placeholder = 'SCT...';
|
|
153
|
+
document.getElementById('serverchan-key-hint').textContent = 'The SendKey is stored only on this Glad host.';
|
|
154
|
+
document.getElementById('serverchan-remove-btn').style.display = 'none';
|
|
155
|
+
setServerChanStatus('Configuration removed. Notifications are disabled for every chat.', 'success');
|
|
156
|
+
await refreshSessionsNow();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
setServerChanStatus(error.message || 'Could not remove configuration', 'error');
|
|
159
|
+
} finally {
|
|
160
|
+
setServerChanBusy(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
package/lib/web/schedules.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
editingScheduleId = data.id || null;
|
|
36
36
|
editingSteps = (data.steps || []).map(step => ({ ...step }));
|
|
37
37
|
selectedWeekdays = [...(data.schedule.weekdays || [1, 2, 3, 4, 5])];
|
|
38
|
-
document.getElementById('schedule-modal-title').textContent = editingScheduleId ? 'Edit
|
|
38
|
+
document.getElementById('schedule-modal-title').textContent = editingScheduleId ? 'Edit Scheduled Task' : 'New Scheduled Task';
|
|
39
39
|
document.getElementById('schedule-name').value = data.name || 'Scheduled Task';
|
|
40
40
|
document.getElementById('schedule-cwd').value = data.target.workingDirectory || '';
|
|
41
41
|
document.getElementById('schedule-time').value = data.schedule.time || '09:00';
|
package/lib/web/styles.css
CHANGED
|
@@ -6,26 +6,56 @@
|
|
|
6
6
|
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
|
7
7
|
.header h1 { font-size: 28px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 10px; }
|
|
8
8
|
.header-logo { width: 32px; height: 32px; flex-shrink: 0; }
|
|
9
|
-
.
|
|
9
|
+
.header-actions { display: flex; align-items: center; gap: 5px; }
|
|
10
|
+
.header-action-btn { height: 36px; min-width: 36px; padding: 0 10px; border: 0; border-radius: 10px; background: var(--primary); color: #fff; display: inline-flex; align-items: center; justify-content: center; gap: 3px; font-size: 13px; font-weight: 750; line-height: 1; white-space: nowrap; cursor: pointer; flex-shrink: 0; }
|
|
11
|
+
.header-action-btn.icon-only { width: 36px; padding: 0; }
|
|
12
|
+
.header-action-btn:active { background: #0062cc; transform: scale(.97); }
|
|
10
13
|
.btn-retry { background: #333; color: #fff; border: none; padding: 8px 16px; border-radius: 20px; margin-top: 10px; cursor: pointer; }
|
|
11
|
-
.session-card { background: var(--card-bg); border-radius: 12px; padding: 16px; margin-bottom: 12px; display:
|
|
14
|
+
.session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center; transition: transform 0.1s; position: relative; }
|
|
12
15
|
.session-card:active { transform: scale(0.98); }
|
|
13
16
|
.completion-dot { width: 9px; height: 9px; border-radius: 50%; background: #ff3b30; flex-shrink: 0; }
|
|
14
17
|
.session-info { flex: 1; min-width: 0; }
|
|
15
18
|
.session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
|
|
16
19
|
.session-info p { margin: 0; font-size: 13px; color: var(--text-dim); }
|
|
17
20
|
.timer-count-badge { display: inline-flex; align-items: center; gap: 3px; color: #fff; background: rgba(0,122,255,0.22); border: 1px solid rgba(0,122,255,0.34); border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 800; flex-shrink: 0; }
|
|
18
|
-
.session-dir-row { display: flex; align-items: center; gap: 6px;
|
|
21
|
+
.session-dir-row { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; width: 100%; min-width: 0; }
|
|
19
22
|
.session-dir-row p { flex: 1; min-width: 0; font-family: monospace; overflow-wrap: anywhere; }
|
|
20
23
|
.copy-dir-btn { color: var(--text-dim); background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; width: 28px; height: 28px; padding: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
|
|
21
24
|
.copy-dir-btn:active { color: #fff; background: rgba(255,255,255,0.12); }
|
|
22
|
-
.session-actions { display: flex; gap: 12px; align-items: center;
|
|
25
|
+
.session-actions { display: flex; gap: 12px; align-items: center; }
|
|
26
|
+
.serverchan-toggle { width: 34px; height: 34px; padding: 0; border: 1px solid rgba(255,255,255,0.1); border-radius: 50%; background: rgba(255,255,255,0.05); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
27
|
+
.serverchan-toggle.active { color: #34c759; background: rgba(52,199,89,0.13); }
|
|
28
|
+
.serverchan-toggle:active { color: #fff; background: rgba(255,255,255,0.12); }
|
|
23
29
|
.btn-join { background: rgba(255,255,255,0.1); border: none; color: var(--primary); padding: 8px 14px; border-radius: 18px; font-weight: 600; font-size: 14px; cursor: pointer; }
|
|
24
30
|
.icon-btn { color: var(--text-dim); background: none; border: none; padding: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
|
25
31
|
.icon-btn:active { color: var(--text); }
|
|
26
32
|
.btn-delete { color: #ff3b30; }
|
|
27
33
|
#modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: none; align-items: center; justify-content: center; padding: 20px; }
|
|
28
34
|
#tool-modal { background: var(--card-bg); width: 100%; max-width: 400px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
|
|
35
|
+
#settings-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 10020; display: none; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; }
|
|
36
|
+
#settings-modal { background: var(--card-bg); width: 100%; max-width: 450px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); box-sizing: border-box; }
|
|
37
|
+
.settings-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
|
|
38
|
+
.settings-modal-header h2 { margin: 0; font-size: 20px; }
|
|
39
|
+
.settings-modal-header p { margin: 5px 0 0; color: var(--text-dim); font-size: 12px; line-height: 1.45; }
|
|
40
|
+
.settings-close { font-size: 25px; line-height: 1; }
|
|
41
|
+
.settings-section { padding-top: 14px; border-top: 1px solid rgba(255,255,255,0.09); }
|
|
42
|
+
.settings-section-label { margin-bottom: 12px; color: var(--text-dim); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
|
43
|
+
.settings-provider-header { margin-bottom: 16px; }
|
|
44
|
+
.settings-provider-header h3 { margin: 0; font-size: 16px; }
|
|
45
|
+
.settings-provider-header p { margin: 5px 0 0; color: var(--text-dim); font-size: 12px; line-height: 1.45; }
|
|
46
|
+
#settings-modal .form-field { margin-bottom: 14px; }
|
|
47
|
+
#settings-modal .form-field label { display: block; margin-bottom: 7px; color: #f5f5f7; font-size: 13px; font-weight: 650; }
|
|
48
|
+
#settings-modal input, #settings-modal select { width: 100%; min-height: 42px; padding: 9px 10px; box-sizing: border-box; border: 1px solid rgba(255,255,255,0.12); border-radius: 9px; background: rgba(255,255,255,0.08); color: #fff; font-size: 14px; outline: none; }
|
|
49
|
+
#settings-modal input:focus, #settings-modal select:focus { border-color: rgba(0,122,255,0.6); }
|
|
50
|
+
.serverchan-help { margin-top: 6px; color: var(--text-dim); font-size: 11px; line-height: 1.4; }
|
|
51
|
+
.serverchan-settings-status { min-height: 20px; margin: 4px 0 10px; color: var(--text-dim); font-size: 12px; }
|
|
52
|
+
.serverchan-settings-status.success { color: #34c759; }
|
|
53
|
+
.serverchan-settings-status.error { color: #ff6b61; }
|
|
54
|
+
.serverchan-modal-actions { display: flex; align-items: center; gap: 8px; }
|
|
55
|
+
.serverchan-action-spacer { flex: 1; }
|
|
56
|
+
#serverchan-remove-btn { display: none; }
|
|
57
|
+
#app-toast { position: fixed; left: 50%; bottom: max(24px, env(safe-area-inset-bottom)); z-index: 10100; max-width: calc(100vw - 32px); transform: translate(-50%, 18px); opacity: 0; pointer-events: none; padding: 9px 14px; border-radius: 999px; background: rgba(44,44,46,0.96); border: 1px solid rgba(255,255,255,0.12); color: #fff; font-size: 13px; box-shadow: 0 10px 28px rgba(0,0,0,0.35); transition: opacity .18s ease, transform .18s ease; }
|
|
58
|
+
#app-toast.visible { opacity: 1; transform: translate(-50%, 0); }
|
|
29
59
|
.tool-item { padding: 12px; border-bottom: 1px solid #333; cursor: pointer; display: flex; align-items: center; border-radius: 8px; margin-top: 4px; }
|
|
30
60
|
.tool-item:hover { background: rgba(255,255,255,0.05); }
|
|
31
61
|
.tool-icon { width: 32px; height: 32px; background: #333; border-radius: 8px; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
|
|
@@ -380,6 +410,7 @@
|
|
|
380
410
|
.weekday-btn { border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.05); color: var(--text-dim); border-radius: 8px; padding: 8px 0; font-size: 12px; font-weight: 700; cursor: pointer; }
|
|
381
411
|
.weekday-btn.active { border-color: var(--primary); background: rgba(0,122,255,0.25); color: #fff; }
|
|
382
412
|
#schedule-modal { background: var(--card-bg); width: 100%; max-width: 720px; max-height: 92dvh; overflow-y: auto; border-radius: 16px; padding: 18px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); box-sizing: border-box; }
|
|
413
|
+
.schedule-modal-subtitle { margin: 4px 0 0; color: var(--text-dim); font-size: 12px; line-height: 1.4; }
|
|
383
414
|
.step-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 10px; margin-bottom: 8px; background: rgba(255,255,255,0.04); }
|
|
384
415
|
.step-grid { display: grid; grid-template-columns: minmax(110px, 150px) 1fr auto; gap: 8px; align-items: center; }
|
|
385
416
|
@media (max-width: 640px) {
|