Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { randomUUID } from 'crypto';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from 'fs';
import { config, log } from './config.js';
import { getEffectiveProxy } from './dashboard/proxy-config.js';
import { getTierModels, getModelKeysByEnum, MODELS } from './models.js';
Expand Down Expand Up @@ -54,9 +54,12 @@ function saveAccounts() {
userStatus: a.userStatus || null,
userStatusLastFetched: a.userStatusLastFetched || 0,
}));
writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2));
const tempFile = ACCOUNTS_FILE + '.tmp';
writeFileSync(tempFile, JSON.stringify(data, null, 2));
renameSync(tempFile, ACCOUNTS_FILE);
} catch (e) {
log.error('Failed to save accounts:', e.message);
try { unlinkSync(ACCOUNTS_FILE + '.tmp'); } catch {}
}
}

Expand Down Expand Up @@ -552,7 +555,6 @@ export function getAccountList() {
lastUsed: a.lastUsed ? new Date(a.lastUsed).toISOString() : null,
addedAt: new Date(a.addedAt).toISOString(),
keyPrefix: a.apiKey.slice(0, 8) + '...',
apiKey: a.apiKey,
tier: a.tier || 'unknown',
capabilities: a.capabilities || {},
lastProbed: a.lastProbed || 0,
Expand Down
7 changes: 7 additions & 0 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,11 @@ export class WindsurfClient {

return new Promise((resolve, reject) => {
const chunks = [];
let done = false;

grpcStream(this.port, this.csrfToken, `${LS_SERVICE}/RawGetChatMessage`, body, {
onData: (payload) => {
if (done) return;
try {
const parsed = parseRawResponse(payload);
if (parsed.text) {
Expand All @@ -80,6 +82,7 @@ export class WindsurfClient {
const err = new Error(parsed.text.trim());
// Mark model-level errors so they don't count against the account
err.isModelError = /permission_denied|failed_precondition/.test(parsed.text);
done = true;
reject(err);
return;
}
Expand All @@ -91,10 +94,14 @@ export class WindsurfClient {
}
},
onEnd: () => {
if (done) return;
done = true;
onEnd?.(chunks);
resolve(chunks);
},
onError: (err) => {
if (done) return;
done = true;
onError?.(err);
reject(err);
},
Expand Down
4 changes: 4 additions & 0 deletions src/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ export class StreamingFrameParser {

/** Drain all complete frames. Returns [{ flags, isEndStream, payload }]. */
drain() {
const MAX_FRAME_SIZE = 16 * 1024 * 1024; // 16 MB
const frames = [];
while (this.buffer.length >= 5) {
const len = this.buffer.readUInt32BE(1);
if (len > MAX_FRAME_SIZE) {
throw new Error(`Frame size ${len} exceeds maximum ${MAX_FRAME_SIZE}`);
}
if (this.buffer.length < 5 + len) break;

const flags = this.buffer[0];
Expand Down
8 changes: 5 additions & 3 deletions src/dashboard/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,12 @@ export async function handleDashboardApi(method, subpath, body, req, res) {
dirtyFiles: dirty.split('\n').slice(0, 20),
});
}
await runShell(`git fetch origin ${before.branch || 'master'}`);
await runShell(`git reset --hard origin/${before.branch || 'master'}`);
const safeBranch = /^[\w.\-\/]+$/.test(before.branch) ? before.branch : 'master';
await runShell(`git fetch origin ${safeBranch}`);
await runShell(`git reset --hard origin/${safeBranch}`);
}
const pullCmd = `git pull origin ${before.branch || 'master'} --ff-only 2>&1`;
const safeBranch = /^[\w.\-\/]+$/.test(before.branch) ? before.branch : 'master';
const pullCmd = `git pull origin ${safeBranch} --ff-only 2>&1`;
const pull = dirty ? 'hard-reset applied' : await runShell(pullCmd);
const after = await gitStatus();
const changed = before.commit !== after.commit;
Expand Down
72 changes: 47 additions & 25 deletions src/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2133,7 +2133,7 @@ <h3>控制台登录</h3>
const blockedCount = (a.blockedModels || []).length;
const availCount = tierModels.length - blockedCount;
const capsHtml = tierModels.length
? `<button class="btn btn-ghost btn-xs" style="min-width:96px" onclick="App.openBlockedModal('${a.id}')" title="编辑该账号可用模型">
? `<button class="btn btn-ghost btn-xs" style="min-width:96px" onclick="App.openBlockedModal('${this.esc(a.id)}')" title="编辑该账号可用模型">
<span style="color:var(--success,#10b981);font-weight:600">${availCount}</span>
<span style="color:var(--text-dim)">/${tierModels.length}</span>
${blockedCount > 0 ? `<span class="badge warn" style="margin-left:4px">-${blockedCount}</span>` : ''}
Expand Down Expand Up @@ -2197,7 +2197,7 @@ <h3>控制台登录</h3>
<td>${this.esc(a.email)}</td>
<td>
<div style="display:flex;flex-direction:column;gap:2px">
<span class="tier ${tier}" style="cursor:pointer" title="${this.esc(tierTooltip)}" onclick="App.overrideTier('${a.id}','${tier}')">${tierLabel[tier] || tier}${a.tierManual ? ' ✎' : ''}</span>
<span class="tier ${this.esc(tier)}" style="cursor:pointer" title="${this.esc(tierTooltip)}" onclick="App.overrideTier('${this.esc(a.id)}','${this.esc(tier)}')">${tierLabel[tier] || this.esc(tier)}${a.tierManual ? ' ✎' : ''}</span>
${tierSubline ? `<span class="text-xs text-dim" style="font-size:10px">${this.esc(tierSubline)}</span>` : ''}
</div>
</td>
Expand All @@ -2212,13 +2212,13 @@ <h3>控制台登录</h3>
</td>
<td class="nowrap">
<div class="btn-group">
<button class="btn btn-ghost btn-xs" onclick="App.probeAccount('${a.id}')" title="探测能力">探测</button>
<button class="btn btn-ghost btn-xs" onclick="App.probeAccount('${this.esc(a.id)}')" title="探测能力">探测</button>
<button class="btn btn-ghost btn-xs" onclick="App.refreshCredits('${a.id}')" title="刷新余额">余额</button>
${a.status === 'active'
? `<button class="btn btn-ghost btn-xs" onclick="App.toggleAccount('${a.id}','disabled')">停用</button>`
: `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${a.id}','active')">启用</button>`}
? `<button class="btn btn-ghost btn-xs" onclick="App.toggleAccount('${this.esc(a.id)}','disabled')">停用</button>`
: `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${this.esc(a.id)}','active')">启用</button>`}
<button class="btn btn-ghost btn-xs" onclick="App.resetErrors('${a.id}')">重置</button>
<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.deleteAccount('${a.id}')">删除</button>
<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.deleteAccount('${this.esc(a.id)}')">删除</button>
</div>
</td>
</tr>`;
Expand Down Expand Up @@ -2494,7 +2494,7 @@ <h3>控制台登录</h3>
if (current.length > 0) {
document.getElementById('model-list-current').innerHTML = `
<div class="text-xs text-muted" style="margin-bottom:6px">当前清单 (${current.length})</div>
<div class="model-chips">${current.map(m => `<span class="model-chip selected">${m}<span class="remove" onclick="App.removeModelFromList('${m}')">×</span></span>`).join('')}</div>`;
<div class="model-chips">${current.map(m => `<span class="model-chip selected">${this.esc(m)}<span class="remove" onclick="App.removeModelFromList('${this.esc(m)}')">×</span></span>`).join('')}</div>`;
} else {
document.getElementById('model-list-current').innerHTML = '<div class="text-xs text-dim">清单为空</div>';
}
Expand Down Expand Up @@ -2524,7 +2524,7 @@ <h3>控制台登录</h3>
<div class="provider-label">${prov}</div>
<div class="model-chips">${models.map(m => {
const inList = list.includes(m.id);
return `<span class="model-chip ${inList ? 'selected' : ''}" onclick="App.toggleModelInList('${m.id}')">${m.name}</span>`;
return `<span class="model-chip ${inList ? 'selected' : ''}" onclick="App.toggleModelInList('${this.esc(m.id)}')">${this.esc(m.name)}</span>`;
}).join('')}</div>
</div>
`).join('') || '<div class="text-sm text-dim">没有符合的模型</div>';
Expand Down Expand Up @@ -2577,11 +2577,11 @@ <h3>控制台登录</h3>
const p = pa[a.id];
return `<tr>
<td>${this.esc(a.email)} <code class="text-xs">${a.id}</code></td>
<td>${p ? `<code>${p.type}://${p.username ? p.username + '@' : ''}${p.host}:${p.port}</code>` : '<span class="text-sm text-dim">无(使用全局)</span>'}</td>
<td>${p ? `<code>${this.esc(p.type)}://${p.username ? this.esc(p.username) + '@' : ''}${this.esc(p.host)}:${this.esc(String(p.port))}</code>` : '<span class="text-sm text-dim">无(使用全局)</span>'}</td>
<td class="nowrap">
<div class="btn-group">
<button class="btn btn-outline btn-xs" onclick="App.editAccountProxy('${a.id}','${this.esc(a.email)}')">配置</button>
${p ? `<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.clearAccountProxy('${a.id}')">清除</button>` : ''}
<button class="btn btn-outline btn-xs" onclick="App.editAccountProxy('${this.esc(a.id)}','${this.esc(a.email)}')">配置</button>
${p ? `<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.clearAccountProxy('${this.esc(a.id)}')">清除</button>` : ''}
</div>
</td>
</tr>`;
Expand Down Expand Up @@ -2647,19 +2647,41 @@ <h3>控制台登录</h3>
loadLogs() {
this.logEntries = [];
document.getElementById('log-container').innerHTML = '';
// EventSource can't set custom headers, so pass the dashboard password
// via query string (same secret, only transmitted over same-origin).
const qs = this.password ? `?pwd=${encodeURIComponent(this.password)}` : '';
this.sseConn = new EventSource('/dashboard/api/logs/stream' + qs);
this.sseConn.onmessage = (e) => {
try {
const entry = JSON.parse(e.data);
this.logEntries.push(entry);
if (this.logEntries.length > 500) this.logEntries.shift();
this.renderLogEntry(entry);
} catch {}
};
this.sseConn.onerror = () => {};

// Use fetch-based SSE to send password via header instead of URL
const headers = { 'Accept': 'text/event-stream' };
if (this.password) headers['X-Dashboard-Password'] = this.password;

fetch('/dashboard/api/logs/stream', { headers })
.then(response => {
if (!response.ok) throw new Error('SSE connection failed');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

const processChunk = ({ done, value }) => {
if (done) return;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();

for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const entry = JSON.parse(line.slice(6));
this.logEntries.push(entry);
if (this.logEntries.length > 500) this.logEntries.shift();
this.renderLogEntry(entry);
} catch {}
}
}
return reader.read().then(processChunk);
};

this.sseConn = { close: () => reader.cancel() };
return reader.read().then(processChunk);
})
.catch(() => {});
},

renderLogEntry(entry) {
Expand Down Expand Up @@ -2961,7 +2983,7 @@ <h3>控制台登录</h3>
<td class="text-sm">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '-'}</td>
<td class="nowrap">
<div class="btn-group">
${a.status === 'error' ? `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${a.id}','active');setTimeout(()=>App.loadBans(),500)">重新启用</button>` : ''}
${a.status === 'error' ? `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${this.esc(a.id)}','active');setTimeout(()=>App.loadBans(),500)">重新启用</button>` : ''}
${a.errorCount > 0 ? `<button class="btn btn-outline btn-xs" onclick="App.resetErrors('${a.id}');setTimeout(()=>App.loadBans(),500)">重置错误</button>` : ''}
</div>
</td>
Expand Down
9 changes: 7 additions & 2 deletions src/dashboard/model-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { log } from '../config.js';

const ACCESS_FILE = join(process.cwd(), 'model-access.json');

Expand All @@ -19,12 +20,16 @@ try {
if (existsSync(ACCESS_FILE)) {
Object.assign(_config, JSON.parse(readFileSync(ACCESS_FILE, 'utf-8')));
}
} catch {}
} catch (e) {
log.error('Failed to load model-access.json:', e.message);
}

function save() {
try {
writeFileSync(ACCESS_FILE, JSON.stringify(_config, null, 2));
} catch {}
} catch (e) {
log.error('Failed to save model-access.json:', e.message);
}
}

export function getModelAccessConfig() {
Expand Down
9 changes: 7 additions & 2 deletions src/dashboard/proxy-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { log } from '../config.js';

const PROXY_FILE = join(process.cwd(), 'proxy.json');

Expand All @@ -18,12 +19,16 @@ try {
if (existsSync(PROXY_FILE)) {
Object.assign(_config, JSON.parse(readFileSync(PROXY_FILE, 'utf-8')));
}
} catch {}
} catch (e) {
log.error('Failed to load proxy.json:', e.message);
}

function save() {
try {
writeFileSync(PROXY_FILE, JSON.stringify(_config, null, 2));
} catch {}
} catch (e) {
log.error('Failed to save proxy.json:', e.message);
}
}

export function getProxyConfig() {
Expand Down
Loading