coding-tool-x 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +599 -0
- package/LICENSE +21 -0
- package/README.md +439 -0
- package/bin/ctx.js +8 -0
- package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
- package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
- package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
- package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
- package/dist/web/assets/Home-38JTUlYt.js +1 -0
- package/dist/web/assets/Home-CjupSEWE.css +1 -0
- package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
- package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
- package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
- package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
- package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
- package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
- package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
- package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
- package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
- package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
- package/dist/web/assets/icons-DRrXwWZi.js +1 -0
- package/dist/web/assets/index-CetESrXw.css +1 -0
- package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
- package/dist/web/assets/markdown-BfC0goYb.css +10 -0
- package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
- package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
- package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
- package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/index.html +20 -0
- package/dist/web/logo.png +0 -0
- package/docs/bannel.png +0 -0
- package/docs/home.png +0 -0
- package/docs/logo.png +0 -0
- package/docs/model-redirection.md +251 -0
- package/docs/multi-channel-load-balancing.md +249 -0
- package/package.json +80 -0
- package/src/commands/channels.js +551 -0
- package/src/commands/cli-type.js +101 -0
- package/src/commands/daemon.js +365 -0
- package/src/commands/doctor.js +333 -0
- package/src/commands/export-config.js +205 -0
- package/src/commands/list.js +222 -0
- package/src/commands/logs.js +261 -0
- package/src/commands/plugin.js +585 -0
- package/src/commands/port-config.js +135 -0
- package/src/commands/proxy-control.js +264 -0
- package/src/commands/proxy.js +152 -0
- package/src/commands/resume.js +137 -0
- package/src/commands/search.js +190 -0
- package/src/commands/security.js +37 -0
- package/src/commands/stats.js +398 -0
- package/src/commands/switch.js +48 -0
- package/src/commands/toggle-proxy.js +247 -0
- package/src/commands/ui.js +99 -0
- package/src/commands/update.js +97 -0
- package/src/commands/workspace.js +454 -0
- package/src/config/default.js +69 -0
- package/src/config/loader.js +149 -0
- package/src/config/model-metadata.js +167 -0
- package/src/config/model-metadata.json +125 -0
- package/src/config/model-pricing.js +35 -0
- package/src/config/paths.js +190 -0
- package/src/index.js +680 -0
- package/src/plugins/constants.js +15 -0
- package/src/plugins/event-bus.js +54 -0
- package/src/plugins/manifest-validator.js +129 -0
- package/src/plugins/plugin-api.js +128 -0
- package/src/plugins/plugin-installer.js +601 -0
- package/src/plugins/plugin-loader.js +229 -0
- package/src/plugins/plugin-manager.js +170 -0
- package/src/plugins/registry.js +152 -0
- package/src/plugins/schema/plugin-manifest.json +115 -0
- package/src/reset-config.js +94 -0
- package/src/server/api/agents.js +826 -0
- package/src/server/api/aliases.js +36 -0
- package/src/server/api/channels.js +368 -0
- package/src/server/api/claude-hooks.js +480 -0
- package/src/server/api/codex-channels.js +417 -0
- package/src/server/api/codex-projects.js +104 -0
- package/src/server/api/codex-proxy.js +195 -0
- package/src/server/api/codex-sessions.js +483 -0
- package/src/server/api/codex-statistics.js +57 -0
- package/src/server/api/commands.js +482 -0
- package/src/server/api/config-export.js +212 -0
- package/src/server/api/config-registry.js +357 -0
- package/src/server/api/config-sync.js +155 -0
- package/src/server/api/config-templates.js +248 -0
- package/src/server/api/config.js +521 -0
- package/src/server/api/convert.js +260 -0
- package/src/server/api/dashboard.js +142 -0
- package/src/server/api/env.js +144 -0
- package/src/server/api/favorites.js +77 -0
- package/src/server/api/gemini-channels.js +366 -0
- package/src/server/api/gemini-projects.js +91 -0
- package/src/server/api/gemini-proxy.js +173 -0
- package/src/server/api/gemini-sessions.js +376 -0
- package/src/server/api/gemini-statistics.js +57 -0
- package/src/server/api/health-check.js +31 -0
- package/src/server/api/mcp.js +399 -0
- package/src/server/api/opencode-channels.js +419 -0
- package/src/server/api/opencode-projects.js +99 -0
- package/src/server/api/opencode-proxy.js +207 -0
- package/src/server/api/opencode-sessions.js +327 -0
- package/src/server/api/opencode-statistics.js +57 -0
- package/src/server/api/plugins.js +463 -0
- package/src/server/api/pm2-autostart.js +269 -0
- package/src/server/api/projects.js +124 -0
- package/src/server/api/prompts.js +279 -0
- package/src/server/api/proxy.js +306 -0
- package/src/server/api/security.js +53 -0
- package/src/server/api/sessions.js +514 -0
- package/src/server/api/settings.js +142 -0
- package/src/server/api/skills.js +570 -0
- package/src/server/api/statistics.js +238 -0
- package/src/server/api/ui-config.js +64 -0
- package/src/server/api/workspaces.js +456 -0
- package/src/server/codex-proxy-server.js +681 -0
- package/src/server/dev-server.js +26 -0
- package/src/server/gemini-proxy-server.js +610 -0
- package/src/server/index.js +422 -0
- package/src/server/opencode-proxy-server.js +4771 -0
- package/src/server/proxy-server.js +669 -0
- package/src/server/services/agents-service.js +1137 -0
- package/src/server/services/alias.js +71 -0
- package/src/server/services/channel-health.js +234 -0
- package/src/server/services/channel-scheduler.js +240 -0
- package/src/server/services/channels.js +447 -0
- package/src/server/services/codex-channels.js +705 -0
- package/src/server/services/codex-config.js +90 -0
- package/src/server/services/codex-parser.js +322 -0
- package/src/server/services/codex-sessions.js +936 -0
- package/src/server/services/codex-settings-manager.js +619 -0
- package/src/server/services/codex-speed-test-template.json +24 -0
- package/src/server/services/codex-statistics-service.js +161 -0
- package/src/server/services/commands-service.js +574 -0
- package/src/server/services/config-export-service.js +1165 -0
- package/src/server/services/config-registry-service.js +828 -0
- package/src/server/services/config-sync-manager.js +941 -0
- package/src/server/services/config-sync-service.js +504 -0
- package/src/server/services/config-templates-service.js +913 -0
- package/src/server/services/enhanced-cache.js +196 -0
- package/src/server/services/env-checker.js +409 -0
- package/src/server/services/env-manager.js +436 -0
- package/src/server/services/favorites.js +165 -0
- package/src/server/services/format-converter.js +620 -0
- package/src/server/services/gemini-channels.js +459 -0
- package/src/server/services/gemini-config.js +73 -0
- package/src/server/services/gemini-sessions.js +689 -0
- package/src/server/services/gemini-settings-manager.js +263 -0
- package/src/server/services/gemini-statistics-service.js +157 -0
- package/src/server/services/health-check.js +85 -0
- package/src/server/services/mcp-client.js +790 -0
- package/src/server/services/mcp-service.js +1732 -0
- package/src/server/services/model-detector.js +1245 -0
- package/src/server/services/network-access.js +80 -0
- package/src/server/services/opencode-channels.js +366 -0
- package/src/server/services/opencode-gateway-adapters.js +1168 -0
- package/src/server/services/opencode-gateway-converter.js +639 -0
- package/src/server/services/opencode-sessions.js +931 -0
- package/src/server/services/opencode-settings-manager.js +478 -0
- package/src/server/services/opencode-statistics-service.js +161 -0
- package/src/server/services/plugins-service.js +1268 -0
- package/src/server/services/prompts-service.js +534 -0
- package/src/server/services/proxy-runtime.js +79 -0
- package/src/server/services/repo-scanner-base.js +708 -0
- package/src/server/services/request-logger.js +130 -0
- package/src/server/services/response-decoder.js +21 -0
- package/src/server/services/security-config.js +131 -0
- package/src/server/services/session-cache.js +127 -0
- package/src/server/services/session-converter.js +577 -0
- package/src/server/services/sessions.js +900 -0
- package/src/server/services/settings-manager.js +163 -0
- package/src/server/services/skill-service.js +1482 -0
- package/src/server/services/speed-test.js +1146 -0
- package/src/server/services/statistics-service.js +1043 -0
- package/src/server/services/ui-config.js +132 -0
- package/src/server/services/workspace-service.js +830 -0
- package/src/server/utils/pricing.js +73 -0
- package/src/server/websocket-server.js +513 -0
- package/src/ui/menu.js +139 -0
- package/src/ui/prompts.js +100 -0
- package/src/utils/format.js +43 -0
- package/src/utils/port-helper.js +108 -0
- package/src/utils/session.js +240 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { loadConfig } = require('../../config/loader');
|
|
2
|
+
const { resolveModelMetadata } = require('../../config/model-metadata');
|
|
3
|
+
|
|
4
|
+
function normalizeModelId(model) {
|
|
5
|
+
return String(model || '').trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getModelMetadataOverride(overrides, model) {
|
|
9
|
+
if (!overrides || typeof overrides !== 'object') return null;
|
|
10
|
+
const modelId = normalizeModelId(model);
|
|
11
|
+
if (!modelId) return null;
|
|
12
|
+
|
|
13
|
+
let directMatch = null;
|
|
14
|
+
for (const [id, override] of Object.entries(overrides)) {
|
|
15
|
+
if (normalizeModelId(id) === modelId) {
|
|
16
|
+
directMatch = override;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (directMatch) return directMatch;
|
|
21
|
+
|
|
22
|
+
let bestMatch = null;
|
|
23
|
+
let bestLen = 0;
|
|
24
|
+
for (const [id, override] of Object.entries(overrides)) {
|
|
25
|
+
const key = normalizeModelId(id);
|
|
26
|
+
if (!key) continue;
|
|
27
|
+
if (modelId.startsWith(key) || key.startsWith(modelId)) {
|
|
28
|
+
if (key.length > bestLen) {
|
|
29
|
+
bestLen = key.length;
|
|
30
|
+
bestMatch = override;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return bestMatch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveMetadataPricing(model) {
|
|
38
|
+
if (!model) return null;
|
|
39
|
+
|
|
40
|
+
const builtInPricing = resolveModelMetadata(model)?.pricing || null;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const config = loadConfig();
|
|
44
|
+
const override = getModelMetadataOverride(config.modelMetadataOverrides, model);
|
|
45
|
+
const overridePricing = override?.pricing || null;
|
|
46
|
+
if (!builtInPricing && !overridePricing) return null;
|
|
47
|
+
return {
|
|
48
|
+
...(builtInPricing || {}),
|
|
49
|
+
...(overridePricing || {})
|
|
50
|
+
};
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error('[Pricing] Failed to load model metadata overrides:', err);
|
|
53
|
+
return builtInPricing;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolvePricing(_toolKey, modelPricing = {}, defaultPricing = {}) {
|
|
58
|
+
return { ...defaultPricing, ...(modelPricing || {}) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveModelPricing(_toolKey, model, fallbackPricing = {}, defaultPricing = {}) {
|
|
62
|
+
const pricingFromMetadata = resolveMetadataPricing(model);
|
|
63
|
+
return {
|
|
64
|
+
...defaultPricing,
|
|
65
|
+
...(fallbackPricing || {}),
|
|
66
|
+
...(pricingFromMetadata || {})
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
resolvePricing,
|
|
72
|
+
resolveModelPricing
|
|
73
|
+
};
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { loadConfig } = require('../config/loader');
|
|
7
|
+
const {
|
|
8
|
+
normalizeAddress,
|
|
9
|
+
isLoopbackAddress,
|
|
10
|
+
isLoopbackRequest
|
|
11
|
+
} = require('./services/network-access');
|
|
12
|
+
|
|
13
|
+
const MAX_PERSISTED_LOGS = 500;
|
|
14
|
+
|
|
15
|
+
function getMaxLogsLimit() {
|
|
16
|
+
try {
|
|
17
|
+
const config = loadConfig();
|
|
18
|
+
const limit = parseInt(config.maxLogs, 10);
|
|
19
|
+
if (!Number.isFinite(limit)) {
|
|
20
|
+
return 100;
|
|
21
|
+
}
|
|
22
|
+
return Math.min(Math.max(limit, 50), MAX_PERSISTED_LOGS);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error('Failed to load log limit from config:', err);
|
|
25
|
+
return 100;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let wss = null;
|
|
30
|
+
let wsClients = new Set();
|
|
31
|
+
let websocketOptions = {
|
|
32
|
+
host: '127.0.0.1'
|
|
33
|
+
};
|
|
34
|
+
const HISTORY_CHUNK_SIZE = 50;
|
|
35
|
+
|
|
36
|
+
function sendPersistedLogsInChunks(ws, logs) {
|
|
37
|
+
let index = 0;
|
|
38
|
+
|
|
39
|
+
const sendChunk = () => {
|
|
40
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const end = Math.min(index + HISTORY_CHUNK_SIZE, logs.length);
|
|
45
|
+
for (let i = index; i < end; i++) {
|
|
46
|
+
ws.send(JSON.stringify(logs[i]));
|
|
47
|
+
}
|
|
48
|
+
index = end;
|
|
49
|
+
|
|
50
|
+
if (index < logs.length) {
|
|
51
|
+
setImmediate(sendChunk);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
setImmediate(sendChunk);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseHostHeader(hostHeader) {
|
|
59
|
+
const value = String(hostHeader || '').trim();
|
|
60
|
+
if (!value) {
|
|
61
|
+
return { hostname: '', port: '' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (value.startsWith('[')) {
|
|
65
|
+
const closingBracket = value.indexOf(']');
|
|
66
|
+
if (closingBracket > 0) {
|
|
67
|
+
const hostname = value.slice(1, closingBracket);
|
|
68
|
+
const rest = value.slice(closingBracket + 1);
|
|
69
|
+
const port = rest.startsWith(':') ? rest.slice(1) : '';
|
|
70
|
+
return { hostname, port };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const separator = value.lastIndexOf(':');
|
|
75
|
+
if (separator > -1 && value.indexOf(':') === separator) {
|
|
76
|
+
return {
|
|
77
|
+
hostname: value.slice(0, separator),
|
|
78
|
+
port: value.slice(separator + 1)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { hostname: value, port: '' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function defaultPortForProtocol(protocol) {
|
|
86
|
+
if (protocol === 'https:') {
|
|
87
|
+
return '443';
|
|
88
|
+
}
|
|
89
|
+
return '80';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isAllowedWebSocketOrigin(req) {
|
|
93
|
+
if (!req || !req.headers) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const originHeader = req.headers.origin;
|
|
98
|
+
if (!originHeader) {
|
|
99
|
+
// 非浏览器客户端通常不会携带 Origin,仅允许本机来源
|
|
100
|
+
return isLoopbackRequest(req);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let originUrl;
|
|
104
|
+
try {
|
|
105
|
+
originUrl = new URL(originHeader);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (originUrl.protocol !== 'http:' && originUrl.protocol !== 'https:') {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const requestHost = parseHostHeader(req.headers.host);
|
|
115
|
+
const requestProtocol = req.socket && req.socket.encrypted ? 'https:' : 'http:';
|
|
116
|
+
const requestHostname = normalizeAddress(requestHost.hostname).toLowerCase();
|
|
117
|
+
const requestPort = requestHost.port || defaultPortForProtocol(requestProtocol);
|
|
118
|
+
|
|
119
|
+
const originHostname = normalizeAddress(originUrl.hostname).toLowerCase();
|
|
120
|
+
const originPort = originUrl.port || defaultPortForProtocol(originUrl.protocol);
|
|
121
|
+
|
|
122
|
+
if (!requestHostname || !originHostname) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 同源直接放行
|
|
127
|
+
if (originHostname === requestHostname && originPort === requestPort) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 允许本机开发代理(例如 Vite 5000 -> 19999)
|
|
132
|
+
if (isLoopbackRequest(req) && isLoopbackAddress(originHostname) && isLoopbackAddress(requestHostname)) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function installOriginGuard(server) {
|
|
140
|
+
if (!server || typeof server.shouldHandle !== 'function') {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const originalShouldHandle = server.shouldHandle.bind(server);
|
|
145
|
+
server.shouldHandle = (req) => {
|
|
146
|
+
if (!originalShouldHandle(req)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const allowed = isAllowedWebSocketOrigin(req);
|
|
151
|
+
if (!allowed) {
|
|
152
|
+
const origin = req.headers.origin || 'unknown';
|
|
153
|
+
const clientIp = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : 'unknown';
|
|
154
|
+
console.warn(`[WebSocket] Rejected connection from ${clientIp}, origin: ${origin}`);
|
|
155
|
+
}
|
|
156
|
+
return allowed;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 日志持久化文件路径
|
|
161
|
+
function getLogsFilePath() {
|
|
162
|
+
const ccToolDir = path.join(os.homedir(), '.cc-tool');
|
|
163
|
+
if (!fs.existsSync(ccToolDir)) {
|
|
164
|
+
fs.mkdirSync(ccToolDir, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
return path.join(ccToolDir, 'proxy-logs.json');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getTodayRange() {
|
|
170
|
+
const start = new Date();
|
|
171
|
+
start.setHours(0, 0, 0, 0);
|
|
172
|
+
const startMs = start.getTime();
|
|
173
|
+
const endMs = startMs + 24 * 60 * 60 * 1000;
|
|
174
|
+
return { startMs, endMs };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function inferSource(log) {
|
|
178
|
+
if (log.source) {
|
|
179
|
+
return log.source;
|
|
180
|
+
}
|
|
181
|
+
if (log.toolType) {
|
|
182
|
+
if (log.toolType.includes('codex')) return 'codex';
|
|
183
|
+
if (log.toolType.includes('gemini')) return 'gemini';
|
|
184
|
+
}
|
|
185
|
+
if (typeof log.model === 'string') {
|
|
186
|
+
const model = log.model.toLowerCase();
|
|
187
|
+
if (model.includes('gemini')) return 'gemini';
|
|
188
|
+
if (model.includes('gpt') || model.includes('o1') || model.includes('o3')) return 'codex';
|
|
189
|
+
if (model.includes('claude')) return 'claude';
|
|
190
|
+
}
|
|
191
|
+
if (typeof log.action === 'string') {
|
|
192
|
+
if (log.action.includes('codex')) return 'codex';
|
|
193
|
+
if (log.action.includes('gemini')) return 'gemini';
|
|
194
|
+
}
|
|
195
|
+
if (log.channelType === 'codex' || log.channelType === 'gemini') {
|
|
196
|
+
return log.channelType;
|
|
197
|
+
}
|
|
198
|
+
return 'claude';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function filterTodayLogs(logs) {
|
|
202
|
+
const { startMs, endMs } = getTodayRange();
|
|
203
|
+
return logs.filter(log => {
|
|
204
|
+
let ts = log.timestamp;
|
|
205
|
+
if (typeof ts !== 'number' || !Number.isFinite(ts)) {
|
|
206
|
+
if (typeof log.timestamp === 'string') {
|
|
207
|
+
const parsed = Date.parse(log.timestamp);
|
|
208
|
+
if (Number.isFinite(parsed)) {
|
|
209
|
+
ts = parsed;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (typeof ts !== 'number' || !Number.isFinite(ts)) {
|
|
214
|
+
// 无法解析时间戳,默认为当前时间(视作今日日志)
|
|
215
|
+
ts = Date.now();
|
|
216
|
+
}
|
|
217
|
+
log.timestamp = ts;
|
|
218
|
+
log.source = inferSource(log);
|
|
219
|
+
return ts >= startMs && ts < endMs;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function enforcePerSourceLimit(logs) {
|
|
224
|
+
const limit = getMaxLogsLimit();
|
|
225
|
+
if (!limit || limit <= 0) {
|
|
226
|
+
return logs;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const counts = {};
|
|
230
|
+
const retained = [];
|
|
231
|
+
|
|
232
|
+
for (let i = logs.length - 1; i >= 0; i--) {
|
|
233
|
+
const log = logs[i];
|
|
234
|
+
const src = log.source || 'claude';
|
|
235
|
+
counts[src] = (counts[src] || 0) + 1;
|
|
236
|
+
if (counts[src] <= limit) {
|
|
237
|
+
retained.push(log);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return retained.reverse();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 加载持久化的日志
|
|
245
|
+
function loadPersistedLogs() {
|
|
246
|
+
try {
|
|
247
|
+
const logsFile = getLogsFilePath();
|
|
248
|
+
if (fs.existsSync(logsFile)) {
|
|
249
|
+
const data = fs.readFileSync(logsFile, 'utf8');
|
|
250
|
+
const logs = enforcePerSourceLimit(filterTodayLogs(JSON.parse(data)));
|
|
251
|
+
return Array.isArray(logs) ? logs : [];
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error('Failed to load persisted logs:', err);
|
|
255
|
+
}
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 保存日志到文件
|
|
260
|
+
function saveLogsToFile(logs) {
|
|
261
|
+
try {
|
|
262
|
+
const logsFile = getLogsFilePath();
|
|
263
|
+
// 只保留最新的 MAX_PERSISTED_LOGS 条,且仅保存今日日志
|
|
264
|
+
const todayLogs = enforcePerSourceLimit(filterTodayLogs(logs));
|
|
265
|
+
const logsToSave = todayLogs.slice(-MAX_PERSISTED_LOGS);
|
|
266
|
+
fs.writeFileSync(logsFile, JSON.stringify(logsToSave, null, 2), 'utf8');
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error('Failed to save logs to file:', err);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 内存中的日志缓存
|
|
273
|
+
let logsCache = [];
|
|
274
|
+
|
|
275
|
+
// 启动 WebSocket 服务器(附加到现有的 HTTP 服务器)
|
|
276
|
+
function startWebSocketServer(httpServer, options = {}) {
|
|
277
|
+
if (wss) {
|
|
278
|
+
console.log('WebSocket server already running');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
websocketOptions = {
|
|
283
|
+
host: options.host || '127.0.0.1'
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// 加载持久化的日志到缓存
|
|
287
|
+
logsCache = loadPersistedLogs();
|
|
288
|
+
const counts = logsCache.reduce((acc, log) => {
|
|
289
|
+
const source = log.source || 'unknown';
|
|
290
|
+
acc[source] = (acc[source] || 0) + 1;
|
|
291
|
+
return acc;
|
|
292
|
+
}, {});
|
|
293
|
+
console.log(`📝 Loaded ${logsCache.length} persisted logs today ->`, counts);
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
// 如果传入的是 HTTP server,则附加到该服务器;否则创建独立的 WebSocket 服务器
|
|
297
|
+
if (httpServer) {
|
|
298
|
+
wss = new WebSocket.Server({
|
|
299
|
+
server: httpServer,
|
|
300
|
+
path: '/ws' // 指定 WebSocket 路径
|
|
301
|
+
});
|
|
302
|
+
installOriginGuard(wss);
|
|
303
|
+
console.log(`✅ WebSocket server attached to HTTP server at /ws`);
|
|
304
|
+
} else {
|
|
305
|
+
// 创建独立的 WebSocket 服务器,使用配置的 webUI 端口
|
|
306
|
+
const config = loadConfig();
|
|
307
|
+
const port = config.ports?.webUI || 19999;
|
|
308
|
+
wss = new WebSocket.Server({
|
|
309
|
+
port,
|
|
310
|
+
path: '/ws'
|
|
311
|
+
});
|
|
312
|
+
installOriginGuard(wss);
|
|
313
|
+
console.log(`✅ WebSocket server started on ws://127.0.0.1:${port}/ws`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
wss.on('connection', (ws, req) => {
|
|
317
|
+
const clientIp = req.socket.remoteAddress;
|
|
318
|
+
console.log(`[WebSocket] New connection from ${clientIp}`);
|
|
319
|
+
|
|
320
|
+
wsClients.add(ws);
|
|
321
|
+
|
|
322
|
+
// 标记客户端存活
|
|
323
|
+
ws.isAlive = true;
|
|
324
|
+
// 发送历史日志给新连接的客户端
|
|
325
|
+
if (logsCache.length > 0) {
|
|
326
|
+
sendPersistedLogsInChunks(ws, logsCache);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 响应 pong 消息
|
|
330
|
+
ws.on('pong', () => {
|
|
331
|
+
ws.isAlive = true;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// 响应客户端的心跳 ping
|
|
335
|
+
ws.on('ping', () => {
|
|
336
|
+
ws.pong();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
ws.on('close', () => {
|
|
340
|
+
wsClients.delete(ws);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
ws.on('error', (error) => {
|
|
344
|
+
console.error('WebSocket error:', error);
|
|
345
|
+
wsClients.delete(ws);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// 心跳检测:每 30 秒 ping 一次所有客户端
|
|
350
|
+
const heartbeatInterval = setInterval(() => {
|
|
351
|
+
wsClients.forEach(ws => {
|
|
352
|
+
if (ws.isAlive === false) {
|
|
353
|
+
// 客户端没有响应 pong,断开连接
|
|
354
|
+
console.log('❌ WebSocket client timeout, terminating');
|
|
355
|
+
wsClients.delete(ws);
|
|
356
|
+
return ws.terminate();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 标记为未响应,等待 pong
|
|
360
|
+
ws.isAlive = false;
|
|
361
|
+
ws.ping();
|
|
362
|
+
});
|
|
363
|
+
}, 30000);
|
|
364
|
+
|
|
365
|
+
// 保存 interval 以便停止时清除
|
|
366
|
+
wss.heartbeatInterval = heartbeatInterval;
|
|
367
|
+
|
|
368
|
+
wss.on('error', (error) => {
|
|
369
|
+
console.error('WebSocket server error:', error);
|
|
370
|
+
if (error.code === 'EADDRINUSE') {
|
|
371
|
+
console.error(chalk.red('\n❌ WebSocket 端口已被占用'));
|
|
372
|
+
console.error(chalk.yellow('\n💡 请检查端口配置\n'));
|
|
373
|
+
wss = null;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
} catch (error) {
|
|
377
|
+
console.error('Failed to start WebSocket server:', error);
|
|
378
|
+
wss = null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 停止 WebSocket 服务器
|
|
383
|
+
function stopWebSocketServer() {
|
|
384
|
+
if (!wss) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 清除心跳定时器
|
|
389
|
+
if (wss.heartbeatInterval) {
|
|
390
|
+
clearInterval(wss.heartbeatInterval);
|
|
391
|
+
wss.heartbeatInterval = null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 关闭所有客户端连接
|
|
395
|
+
wsClients.forEach(client => {
|
|
396
|
+
client.close();
|
|
397
|
+
});
|
|
398
|
+
wsClients.clear();
|
|
399
|
+
|
|
400
|
+
// 关闭服务器
|
|
401
|
+
wss.close(() => {
|
|
402
|
+
console.log('✅ WebSocket server stopped');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
wss = null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 广播日志消息
|
|
409
|
+
function broadcastLog(logData) {
|
|
410
|
+
const timestamp = typeof logData.timestamp === 'number' ? logData.timestamp : Date.now();
|
|
411
|
+
const payload = {
|
|
412
|
+
...logData,
|
|
413
|
+
timestamp
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
payload.source = payload.source || inferSource(payload);
|
|
417
|
+
|
|
418
|
+
// 添加到缓存
|
|
419
|
+
logsCache.push(payload);
|
|
420
|
+
logsCache = enforcePerSourceLimit(filterTodayLogs(logsCache));
|
|
421
|
+
|
|
422
|
+
if (logsCache.length > MAX_PERSISTED_LOGS) {
|
|
423
|
+
logsCache = logsCache.slice(-MAX_PERSISTED_LOGS);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 保存到文件
|
|
427
|
+
saveLogsToFile(logsCache);
|
|
428
|
+
|
|
429
|
+
// 广播给所有连接的客户端
|
|
430
|
+
if (wss && wsClients.size > 0) {
|
|
431
|
+
const message = JSON.stringify(payload);
|
|
432
|
+
|
|
433
|
+
wsClients.forEach(client => {
|
|
434
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
435
|
+
client.send(message);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 清空所有日志
|
|
442
|
+
function clearAllLogs() {
|
|
443
|
+
logsCache = [];
|
|
444
|
+
saveLogsToFile([]);
|
|
445
|
+
console.log('✅ All logs cleared');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 去掉敏感字段
|
|
449
|
+
function sanitizeChannel(channel) {
|
|
450
|
+
if (!channel || typeof channel !== 'object') {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
const { apiKey, ...rest } = channel;
|
|
454
|
+
return rest;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function sanitizeChannels(channels) {
|
|
458
|
+
if (!Array.isArray(channels)) {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
return channels.map(channel => sanitizeChannel(channel)).filter(Boolean);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 广播代理状态更新
|
|
465
|
+
function broadcastProxyState(source, proxyStatus = {}, activeChannel = null, channels = []) {
|
|
466
|
+
const stateUpdate = {
|
|
467
|
+
type: 'proxy-state',
|
|
468
|
+
source, // 'claude', 'codex', or 'gemini'
|
|
469
|
+
proxy: proxyStatus,
|
|
470
|
+
activeChannel: sanitizeChannel(activeChannel),
|
|
471
|
+
channels: sanitizeChannels(channels),
|
|
472
|
+
timestamp: Date.now()
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
if (wss && wsClients.size > 0) {
|
|
476
|
+
const message = JSON.stringify(stateUpdate);
|
|
477
|
+
|
|
478
|
+
wsClients.forEach(client => {
|
|
479
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
480
|
+
client.send(message);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// 广播调度状态更新(实时并发信息)
|
|
487
|
+
function broadcastSchedulerState(source, schedulerState) {
|
|
488
|
+
const stateUpdate = {
|
|
489
|
+
type: 'scheduler-state',
|
|
490
|
+
source, // 'claude', 'codex', or 'gemini'
|
|
491
|
+
scheduler: schedulerState,
|
|
492
|
+
timestamp: Date.now()
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
if (wss && wsClients.size > 0) {
|
|
496
|
+
const message = JSON.stringify(stateUpdate);
|
|
497
|
+
|
|
498
|
+
wsClients.forEach(client => {
|
|
499
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
500
|
+
client.send(message);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
module.exports = {
|
|
507
|
+
startWebSocketServer,
|
|
508
|
+
stopWebSocketServer,
|
|
509
|
+
broadcastLog,
|
|
510
|
+
clearAllLogs,
|
|
511
|
+
broadcastProxyState,
|
|
512
|
+
broadcastSchedulerState
|
|
513
|
+
};
|